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    /// A NOMATCH raised by a typeset-family command's words BEFORE its first
91    /// postassign, set aside by BUILTIN_TYPESET_POSTASSIGNS_BEGIN and put
92    /// back by BUILTIN_TYPESET_POSTASSIGNS_END.
93    static TYPESET_WORD_GLOB_FAILED: Cell<bool> = const { Cell::new(false) };
94    /// Number of postassign words of the typeset-family command about to
95    /// dispatch, set by BUILTIN_TYPESET_POSTASSIGNS_BEGIN. C keeps those in
96    /// the command's `assigns` list, not `args` (c:Src/parse.c:2003-2008),
97    /// so they never become `$_` (c:Src/exec.c:3546).
98    static TYPESET_POSTASSIGN_COUNT: Cell<Option<usize>> = const { Cell::new(None) };
99
100    /// Set by BUILTIN_TYPESET_POSTASSIGNS_END when a NOMATCH fired in one of
101    /// the postassigns. Consumed by `dispatch_builtin`.
102    static TYPESET_POSTASSIGN_GLOB_FAILED: Cell<bool> = const { Cell::new(false) };
103
104    /// Port of C's `FILE *xtrerr` xtrace stream (Src/exec.c:81). C builds
105    /// each trace line in this stdio buffer — `printprompt4` does
106    /// `fprintf(xtrerr, …)`, then args via `fputs`/`fputc` — and
107    /// `fflush(xtrerr)` writes the WHOLE line to stderr in one syscall
108    /// (makecline c:2122-2123, addvars c:2588, condition c:1372). That
109    /// single flush is exactly why a forked pipeline stage's trace line
110    /// reaches the shared stderr fd atomically and never interleaves with
111    /// a concurrent stage. zshrs previously emitted PS4 and the command
112    /// text as separate `eprint!` writes, which raced under load. Model
113    /// the FILE buffer as this thread-local String (a forked child owns
114    /// its own copy); `xtrerr_fputs` appends, `xtrerr_flush` does the
115    /// single write.
116    static XTRERR: RefCell<String> = const { RefCell::new(String::new()) };
117
118    /// !!! WARNING: RUST-ONLY HELPER !!! `(scope depth, newxtrerr fd)` for
119    /// each simple command whose redirect scope took a copy of stderr for
120    /// xtrace (c:Src/exec.c:3766-3772). C holds newxtrerr in an
121    /// `execcmd_exec` local and closes it on the way out (c:4445-4452);
122    /// zshrs closes it when that redirect scope ends.
123    static XTRERR_COPIES: RefCell<Vec<(usize, i32)>> = const { RefCell::new(Vec::new()) };
124
125    /// Stack of (RETFLAG, BREAKS, CONTFLAG, EXIT_PENDING) tuples saved
126    /// at try-block exit so the always-arm body can run cleanly even
127    /// when the try-block fired `return` / `break` / `continue` /
128    /// `exit`. Restored right before the post-always re-jump so the
129    /// escape resumes propagation past the construct.
130    /// c:Src/exec.c WC_TRYBLOCK — zsh's wordcode walker handles this
131    /// inline; the zshrs port lifts it into a paired SET / RESTORE
132    /// pair around the always-arm.
133    /// Tuple: `(retflag, breaks, contflag, exit_pending, try_errflag,
134    /// try_interrupt)`. The last two are c:Src/loop.c:762-763's
135    /// `save_try_errflag` / `save_try_interrupt` — the ENCLOSING try
136    /// block's values, restored at c:778-779 so nested
137    /// `{…} always {…}` constructs don't leak `$TRY_BLOCK_ERROR`
138    /// outward.
139    static TRY_ESCAPE_SAVE: RefCell<Vec<(i32, i32, i32, i32, i64, i64)>> =
140        const { RefCell::new(Vec::new()) };
141    /// Re-entry guard for BUILTIN_DEBUG_TRAP. While the DEBUG trap
142    /// body is running, the per-statement DEBUG_TRAP dispatch in the
143    /// trap body must NOT re-fire (otherwise infinite recursion +
144    /// stack overflow). zsh's in_trap counter at Src/signals.c
145    /// serves the same purpose.
146    static DEBUG_TRAP_REENTRY: Cell<bool> = const { Cell::new(false) };
147    /// Stack of (saved_stdout, saved_stderr) tuples pushed by
148    /// `cmd_subst` around its nested-VM run. RUST-ONLY: zsh forks
149    /// each cmdsub so trap output during the cmdsub naturally
150    /// lands on the PARENT's stdout. zshrs's in-process cmdsub
151    /// dups fd 1 → pipe, so a trap firing during cmdsub would
152    /// emit into the captured value. Traps consult this stack
153    /// to route their body output to the topmost saved_stdout
154    /// instead of the cmdsub's fd 1. Bug #56 in docs/BUGS.md.
155    pub static CMDSUBST_OUTER_FDS: RefCell<Vec<(i32, i32)>> =
156        const { RefCell::new(Vec::new()) };
157    /// c:Src/exec.c:5025 getproc (PATH_DEV_FD branch) — the parent
158    /// keeps the `>(cmd)` pipe WRITE end open under `/dev/fd/N`,
159    /// parks it in the job's filelist (`fdtable[fd] =
160    /// FDT_PROC_SUBST; addfilelist(NULL, fd)`), and deletefilelist
161    /// closes it when the consuming job finishes — that close is
162    /// what lets the `>(cmd)` child's reader see EOF. zshrs runs
163    /// commands in-process, so the equivalent is: record
164    /// `(scope_depth, fd)` here and drain after the consuming
165    /// command (external exec or builtin dispatch) completes.
166    static PSUB_PENDING_FDS: RefCell<Vec<(usize, i32)>> = const { RefCell::new(Vec::new()) };
167    /// `(scope_depth, path)` for `=(cmd)` temp files, unlinked at the same
168    /// job-end boundary as the pending fds (c:Src/jobs.c deletefilelist).
169    static PSUB_PENDING_FILES: RefCell<Vec<(usize, String)>> = const { RefCell::new(Vec::new()) };
170    /// Scope depth for PSUB_PENDING_FDS tagging. Incremented around
171    /// nested execution contexts (cmd-subst bodies, shell-function
172    /// bodies) so a command running INSIDE the nested context only
173    /// drains its own `>(cmd)` fds, never the enclosing command's
174    /// (e.g. `tee >(wc) $(print x)` — print must not close tee's
175    /// fd). Mirrors C's per-job filelist ownership.
176    static PSUB_SCOPE_DEPTH: Cell<usize> = const { Cell::new(0) };
177    /// Forked `<(cmd)`/`>(cmd)` child pids awaiting reap. Drained
178    /// non-blockingly (WNOHANG) by note_psub_child so proc-sub children
179    /// don't accumulate as zombies across a shell session.
180    static PSUB_CHILDREN: RefCell<Vec<i32>> = const { RefCell::new(Vec::new()) };
181}
182
183/// Record a proc-sub child pid and best-effort reap any already-exited
184/// proc-sub children (WNOHANG). Non-blocking: still-running children
185/// stay parked and get reaped on a later call.
186pub(crate) fn note_psub_child(pid: i32) {
187    if pid <= 0 {
188        return;
189    }
190    PSUB_CHILDREN.with(|v| {
191        let mut v = v.borrow_mut();
192        v.push(pid);
193        v.retain(|&p| {
194            let mut status = 0;
195            // WNOHANG: reap if exited, else keep parked.
196            let r = unsafe { libc::waitpid(p, &mut status, libc::WNOHANG) };
197            // r == p → reaped; r == 0 → still running (keep); r < 0 →
198            // already gone/not ours (drop).
199            r == 0
200        });
201    });
202}
203
204/// Port of `fputs(s, xtrerr)` / `fprintf(xtrerr, "%s", s)` (Src/exec.c):
205/// append `s` to the buffered xtrace line. Nothing reaches stderr until
206/// [`xtrerr_flush`] (the port of `fflush(xtrerr)`) writes the line whole.
207pub(crate) fn xtrerr_fputs(s: &str) {
208    XTRERR.with(|b| b.borrow_mut().push_str(s));
209}
210
211/// Port of `fflush(xtrerr)` (Src/exec.c:1373/2123/2596): write the
212/// buffered xtrace line to stderr in ONE `write` and clear the buffer, so
213/// the line lands on the shared fd atomically (no interleaving across
214/// concurrent pipeline stages).
215pub(crate) fn xtrerr_flush() {
216    XTRERR.with(|b| {
217        let mut buf = b.borrow_mut();
218        if !buf.is_empty() {
219            // c:Src/utils.c:1714 — the line goes to `xtrerr`, which is
220            // stderr unless a simple command's redirect scope holds a copy
221            // of the pre-redirection stderr (c:Src/exec.c:3766-3772).
222            let fd = crate::ported::utils::xtrerr.load(std::sync::atomic::Ordering::Relaxed);
223            // c:Src/exec.c:2061 — the pieces were written with `quotedzputs(s, xtrerr)`,
224            // which emits METAFIED text un-metafied (a lone high byte from
225            // `mb_niceformat` under a single-byte locale is one byte on the wire).
226            let bytes = crate::ported::utils::unmetafy_str(&buf);
227            if fd == libc::STDERR_FILENO {
228                use std::io::Write;
229                let _ = std::io::stderr().write_all(&bytes);
230            } else {
231                let _ = crate::ported::utils::write_loop(fd, &bytes);
232            }
233            buf.clear();
234        }
235    });
236}
237
238/// RAII guard bumping the psub scope depth — see PSUB_SCOPE_DEPTH.
239pub(crate) struct PsubScope;
240
241impl PsubScope {
242    pub(crate) fn enter() -> Self {
243        PSUB_SCOPE_DEPTH.with(|d| d.set(d.get() + 1));
244        PsubScope
245    }
246}
247
248impl Drop for PsubScope {
249    fn drop(&mut self) {
250        PSUB_SCOPE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
251    }
252}
253
254/// RAII guard bumping `$ZSH_SUBSHELL` for the duration of an
255/// in-process command substitution.
256///
257/// c:Src/exec.c:1161 — entersubsh() does `zsh_subshell++;` and zsh's
258/// cmdsub FORKS, so the increment dies with the child and the parent's
259/// value is untouched. zshrs runs cmdsubs in-process on a nested VM,
260/// so the visible param must be bumped on entry and restored on exit.
261/// Writes paramtab u_val directly because ZSH_SUBSHELL is PM_READONLY
262/// (same bypass pattern as the subshell-builtin bump below); also
263/// mirrors into ported::exec::zsh_subshell so exec.c:4376-style
264/// `forked | zsh_subshell` reads agree.
265pub(crate) struct CmdSubstSubshellBump {
266    saved_val: i64,
267    saved_str: Option<String>,
268}
269
270impl CmdSubstSubshellBump {
271    pub(crate) fn enter() -> Self {
272        let mut saved_val = 0i64;
273        let mut saved_str = None;
274        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
275            if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
276                saved_val = pm.u_val;
277                saved_str = pm.u_str.clone();
278                pm.u_val = saved_val + 1;
279                pm.u_str = Some((saved_val + 1).to_string());
280                pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
281            }
282        }
283        crate::ported::exec::zsh_subshell.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
284        CmdSubstSubshellBump {
285            saved_val,
286            saved_str,
287        }
288    }
289}
290
291impl Drop for CmdSubstSubshellBump {
292    fn drop(&mut self) {
293        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
294            if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
295                pm.u_val = self.saved_val;
296                pm.u_str = self.saved_str.take();
297            }
298        }
299        crate::ported::exec::zsh_subshell.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
300    }
301}
302
303/// `addfilelist(nam, 0)` (c:Src/exec.c:4960) for an `=(cmd)` temp file:
304/// park it for the job-end unlink in `close_pending_psub_fds`, tagged with
305/// the current scope depth.
306pub(crate) fn psub_pending_file_add(nam: &str) {
307    let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
308    PSUB_PENDING_FILES.with(|v| v.borrow_mut().push((depth, nam.to_string())));
309}
310
311/// Port of deletefilelist() from Src/jobs.c (the `>(cmd)` fd arm):
312/// closes every pending proc-subst write end created at or inside
313/// the current scope depth, exactly when C deletes the consuming
314/// job's filelist (getproc parks the fd there via
315/// `addfilelist(NULL, fd)`, Src/exec.c:5025+).
316fn close_pending_psub_fds() {
317    close_pending_psub_from(PSUB_SCOPE_DEPTH.with(|d| d.get()));
318}
319
320/// c:Src/jobs.c:1452-1460 cleanfilelists — at shell exit EVERY job's
321/// filelist is deleted, whichever scope parked it, so the pending `>(cmd)`
322/// fds and `=(cmd)` temp files of all depths go. Called from
323/// `jobs::cleanfilelists`: these lists stand in for the jobtab filelists
324/// (see PSUB_PENDING_FDS).
325pub(crate) fn cleanfilelists_pending() {
326    close_pending_psub_from(0);
327}
328
329/// The drain behind both: everything parked at scope `depth` or deeper.
330fn close_pending_psub_from(depth: usize) {
331    PSUB_PENDING_FDS.with(|v| {
332        v.borrow_mut().retain(|&(d, fd)| {
333            if d >= depth {
334                unsafe { libc::close(fd) };
335                false
336            } else {
337                true
338            }
339        });
340    });
341    // c:Src/jobs.c deletefilelist — `=(cmd)` temp files are unlinked at the
342    // same job-end boundary. `f==(print x); [[ -f $f ]]` is false after the
343    // command line ends.
344    PSUB_PENDING_FILES.with(|v| {
345        v.borrow_mut().retain(|(d, path)| {
346            if *d >= depth {
347                let _ = fs::remove_file(path);
348                false
349            } else {
350                true
351            }
352        });
353    });
354}
355
356/// RAII drain guard — instantiated at the top of the consuming-
357/// command paths (dispatch_builtin, ZshrsHost::exec) so the pending
358/// `>(cmd)` write ends close on every exit path once the command
359/// finished, exactly when C's job filelist would be deleted.
360struct PsubFdGuard;
361
362impl Drop for PsubFdGuard {
363    fn drop(&mut self) {
364        close_pending_psub_fds();
365    }
366}
367
368/// Peek the outermost cmdsub-saved (stdout, stderr) fds, if any.
369/// Returns None when no cmdsub is currently capturing. Used by the
370/// trap dispatcher in `src/ported/signals.rs::dotrap` to route trap
371/// body output to the parent's real stdout (matching zsh's forked
372/// cmdsub behaviour) instead of the cmdsub's pipe-bound fd 1.
373/// Bug #56 in docs/BUGS.md.
374/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
375///
376/// C keeps a shell function in exactly ONE place — the `Shfunc` node in
377/// `shfunctab` — so `dosavetrap` copies a `TRAP<SIG>` function by
378/// duplicating that node (`Src/signals.c:638-656`) and `endtrapscope`
379/// puts it back with a single `shfunctab->addnode` (c:929-931).
380///
381/// zshrs splits one function across FIVE stores: the `shfunc` node in
382/// `shfunctab` (metadata + body text) plus the executor's
383/// `functions_compiled` (the fusevm `Chunk` that actually runs),
384/// `function_source`, `function_line_base` and `function_def_file`.
385/// Snapshotting only the hashtable node therefore restores the outer
386/// function's *metadata* while the inner function's *bytecode* keeps
387/// running. `FuncSnapshot` captures all five so the C-level "copy the
388/// node" / "add the node back" steps are faithful in behaviour.
389#[derive(Default)]
390pub struct FuncSnapshot {
391    pub shf: Option<crate::ported::zsh_h::shfunc>,
392    pub chunk: Option<fusevm::Chunk>,
393    pub source: Option<String>,
394    pub line_base: Option<i64>,
395    pub def_file: Option<Option<String>>,
396}
397
398impl FuncSnapshot {
399    /// True when `name` had no definition at snapshot time — restoring
400    /// such a snapshot deletes the function everywhere.
401    pub fn is_empty(&self) -> bool {
402        self.shf.is_none() && self.chunk.is_none() && self.source.is_none()
403    }
404}
405
406/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
407/// Capture every store that holds `name`'s definition. See
408/// [`FuncSnapshot`].
409pub fn snapshot_function(name: &str) -> FuncSnapshot {
410    let shf = crate::ported::hashtable::shfunctab_lock()
411        .read()
412        .ok()
413        .and_then(|t| t.get(name).cloned());
414    let (chunk, source, line_base, def_file) = try_with_executor(|exec| {
415        (
416            exec.functions_compiled.get(name).cloned(),
417            exec.function_source.get(name).cloned(),
418            exec.function_line_base.get(name).copied(),
419            exec.function_def_file.get(name).cloned(),
420        )
421    })
422    .unwrap_or((None, None, None, None));
423    FuncSnapshot {
424        shf,
425        chunk,
426        source,
427        line_base,
428        def_file,
429    }
430}
431
432/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
433/// Put a [`FuncSnapshot`] back under `name`. An empty snapshot (the
434/// function did not exist when it was taken) removes it from every
435/// store, mirroring C's `removehashnode(shfunctab, …)`.
436pub fn restore_function(name: &str, snap: FuncSnapshot) {
437    if let Ok(mut t) = crate::ported::hashtable::shfunctab_lock().write() {
438        match snap.shf {
439            Some(shf) => {
440                t.add(shf);
441            }
442            None => {
443                t.remove(name);
444            }
445        }
446    }
447    let _ = try_with_executor(|exec| {
448        match snap.chunk {
449            Some(c) => {
450                exec.functions_compiled.insert(name.to_string(), c);
451            }
452            None => {
453                exec.functions_compiled.remove(name);
454            }
455        }
456        match snap.source {
457            Some(s) => {
458                exec.function_source.insert(name.to_string(), s);
459            }
460            None => {
461                exec.function_source.remove(name);
462            }
463        }
464        match snap.line_base {
465            Some(n) => {
466                exec.function_line_base.insert(name.to_string(), n);
467            }
468            None => {
469                exec.function_line_base.remove(name);
470            }
471        }
472        match snap.def_file {
473            Some(f) => {
474                exec.function_def_file.insert(name.to_string(), f);
475            }
476            None => {
477                exec.function_def_file.remove(name);
478            }
479        }
480    });
481}
482
483pub fn cmdsubst_outer_stdout() -> Option<i32> {
484    CMDSUBST_OUTER_FDS.with(|s| s.borrow().last().map(|(o, _)| *o))
485}
486
487thread_local! {
488    /// The pipeline fds a stage still has to install onto 0/1, as
489    /// `(input, output)` with -1 meaning "leave this fd alone".
490    ///
491    /// c:Src/exec.c:3720-3724 — the pipe's read/write ends are the
492    /// FIRST entries of the command's multio table:
493    ///     /* Add pipeline input/output to mnodes */
494    ///     if (input)  addfd(forked, save, mfds, 0, input, 0, NULL);
495    ///     if (output) addfd(forked, save, mfds, 1, output, 1, NULL);
496    /// and that runs AFTER prefork (c:3304) + globlist (c:3702) have
497    /// expanded the command's argument words. So a `$(...)` inside a
498    /// stage's ARGS reads the shell's original fd 0, not the pipe:
499    /// `print -rl -- c a b | print -r -- "[$(cat)]"` prints `[]`.
500    /// (The stage's own fork at c:3000 happens before the expansion,
501    /// which is why `${x::=v}` in a non-last stage doesn't survive —
502    /// but the fds are still installed after it.)
503    ///
504    /// zshrs's [`BUILTIN_RUN_PIPELINE`] forks per stage, so it parks
505    /// the stage's fds here instead of dup2'ing them itself, and the
506    /// compiled stage chunk installs them via
507    /// [`BUILTIN_PIPE_FDS_INSTALL`] at the C-faithful point: after the
508    /// arg-word ops, before the redirect scope
509    /// (compile_zsh.rs::emit_stage_fds_install). A compound stage
510    /// (`{ … }`, `( … )`, a function) installs at chunk entry — its
511    /// body legitimately reads the pipe.
512    static PENDING_STAGE_FDS: std::cell::Cell<(i32, i32)> = const { std::cell::Cell::new((-1, -1)) };
513}
514
515/// Park the current stage's `(input, output)` pipe fds for the stage
516/// chunk's `BUILTIN_PIPE_FDS_INSTALL` to pick up. Returns the previous
517/// value so a nested pipeline (`print -- "$(a | b)" | c`) can restore
518/// the outer stage's still-uninstalled fds when it finishes.
519fn stage_fds_park(input: i32, output: i32) -> (i32, i32) {
520    PENDING_STAGE_FDS.with(|c| c.replace((input, output)))
521}
522
523/// Take (and clear) the parked stage fds.
524fn stage_fds_take() -> (i32, i32) {
525    PENDING_STAGE_FDS.with(|c| c.replace((-1, -1)))
526}
527
528// Thread-local pointer to the current ShellExecutor.
529// Set before VM execution, cleared after. Used by builtin handlers.
530thread_local! {
531    static CURRENT_EXECUTOR: RefCell<Option<*mut ShellExecutor>> = const { RefCell::new(None) };
532    /// The installed session executor, registered by
533    /// `exec::install_session_executor`. Lets [`with_session_context`]
534    /// establish a VM execution context for STARTUP work that runs
535    /// before the loop's first `execode` enters one (rc-file sourcing in
536    /// `run_init_scripts`, c:1914). Mirrors exec.rs's own
537    /// `SESSION_EXECUTOR`; kept here so the context helper lives next to
538    /// `ExecutorContext`/`CURRENT_EXECUTOR`.
539    static SESSION_EXECUTOR_PTR: std::cell::Cell<Option<*mut ShellExecutor>> = const { std::cell::Cell::new(None) };
540    /// GLOB_ASSIGN eligibility carrier. Set true by BUILTIN_MARK_GLOB_ELIGIBLE
541    /// (emitted by the compiler ONLY when a scalar-assignment RHS carries an
542    /// UNQUOTED glob token — Star/Quest/Inbrack), read+cleared by the next
543    /// BUILTIN_SET_VAR. Matches C zsh's `GLOB_ASSIGN` (Src/exec.c:2554): only
544    /// a literal unquoted glob pattern in the wordcode is globbed; values from
545    /// `$param` / `$(cmd)` / quoted strings are NOT (verified against zsh).
546    /// The runtime SET_VAR value arrives untokenized (the compiler DQ-wraps to
547    /// suppress compile-time globbing), so quoting can no longer be recovered
548    /// from the value bytes — this flag carries the compile-time decision.
549    static SET_VAR_GLOB_ELIGIBLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
550    /// c:Src/exec.c:3088-3096 — the next builtin dispatch comes from a
551    /// WC_TYPESET command, which looks the builtin up with `getnode2`
552    /// (DISABLED ignored). Set by BUILTIN_TYPESET_RESWD, consumed by
553    /// dispatch_builtin.
554    ///
555    /// !!! WARNING: RUST-ONLY CARRIER !!! C knows `type == WC_TYPESET` in the
556    /// same function that looks the builtin up; here the compile-time fact has
557    /// to cross from the marker op to the dispatch op.
558    static TYPESET_RESWD_DISPATCH: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
559    /// c:Src/exec.c:4147-4154 — `addvars(state, varspc, flags); if (errflag)
560    /// { …; lastval = 1; fixfds(save); goto done; }`: an expansion error in
561    /// the prefix assignments of `X=… cmd` skips `cmd`. Set by
562    /// BUILTIN_SEAL_INLINE_ENV (the point right after those assignments),
563    /// consumed by the dispatch that would run the command, and cleared by
564    /// BUILTIN_END_INLINE_ENV so it never outlives the command.
565    ///
566    /// !!! WARNING: RUST-ONLY CARRIER !!! C tests errflag inline between
567    /// addvars and the dispatch in one function; here those are separate
568    /// VM ops, and errflag alone cannot tell this skip from an error raised
569    /// by the command words, which C handles at c:3760 instead.
570    static PREFIX_ASSIGN_FAILED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
571    /// The command word of the `X=… cmd` whose prefix assignments are
572    /// expanding right now: Some between BUILTIN_PREFIX_ASSIGNS_START and
573    /// BUILTIN_SEAL_INLINE_ENV, "" when the word is an expansion that only
574    /// BUILTIN_EXEC_DYNAMIC resolves.
575    ///
576    /// !!! WARNING: RUST-ONLY CARRIER !!! C decides whether to fork
577    /// (c:Src/exec.c:3719-3730) before addvars runs, so a `${name?msg}` in
578    /// the assignments already knows which process it is in
579    /// (c:Src/subst.c:3371 `mypid == getpid()`). zshrs expands them in the
580    /// shell before the dispatch op resolves the command.
581    static PREFIX_ASSIGN_CMD: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
582    /// A `${name?msg}` in those assignments owes c:Src/subst.c:3375's
583    /// `zexit(1)` unless the command turns out to be forked; see
584    /// `prefix_assign_defer_exit`.
585    static PREFIX_ASSIGN_EXIT_PENDING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
586    /// Set by BUILTIN_EXEC_DYNAMIC_REDIRS, taken by the next
587    /// BUILTIN_EXEC_DYNAMIC: that command had redirections.
588    static EXEC_DYNAMIC_REDIRS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
589    /// Facts about the next command that only its EXTERNAL spawn acts on; an
590    /// external spawn consumes them, any other command clears them.
591    /// EXEC_CARRIER_DASH — c:Src/exec.c:772-776, the `-` precommand
592    /// (BINF_DASH), set by BUILTIN_EXEC_DASH: argv[0] is prefixed with `-`.
593    /// EXEC_CARRIER_FORKED — c:Src/exec.c:3063/4369, set by
594    /// BUILTIN_EXEC_FORKED_SIMPLE: the child forked for this command execs it.
595    ///
596    /// !!! WARNING: RUST-ONLY CARRIER !!! C accumulates BINF_DASH into
597    /// `cflags` in the same function that calls `execute()`; here the
598    /// compile-time fact crosses from the marker op to the spawn.
599    static EXEC_DASH: std::cell::Cell<u8> = const { std::cell::Cell::new(0) };
600    /// c:Src/exec.c:3536-3538 — the job text of the simple command about to
601    /// dispatch (BUILTIN_JOB_TEXT). The foreground external wait takes it;
602    /// a builtin or function dispatch drops it.
603    ///
604    /// !!! WARNING: RUST-ONLY CARRIER !!! C computes `text` in execcmd_exec,
605    /// the function that also forks and waits.
606    static JOB_TEXT: std::cell::RefCell<Option<fusevm::Value>> = const { std::cell::RefCell::new(None) };
607    /// zshrs-only: the alias the command about to dispatch was written as, with
608    /// the number of words in its body (BUILTIN_ALIAS_ORIGIN). `call_function`
609    /// takes it, so an `intercept` on the alias name can fire.
610    ///
611    /// !!! WARNING: RUST-ONLY CARRIER !!! No C counterpart.
612    static ALIAS_ORIGIN: std::cell::RefCell<Option<(String, usize, String)>> = const { std::cell::RefCell::new(None) };
613    /// Counts sublists (bumped by BUILTIN_STMT_PROLOGUE_FAST). Together with
614    /// REDIR_SCOPE_OPENED it tells a builtin whether the redirect scope on
615    /// top of the stack belongs to its own command.
616    ///
617    /// !!! WARNING: RUST-ONLY CARRIER !!! C's `execcmd_exec` holds the
618    /// command's `save[]` array locally and tests `save[1] == -2`
619    /// (c:Src/exec.c:4300); zshrs keeps one shared scope stack for simple
620    /// and compound commands alike.
621    static SUBLIST_SERIAL: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
622    /// `(SUBLIST_SERIAL, stack depth)` recorded when the newest redirect
623    /// scope opened. A simple command opens its scope right before its own
624    /// dispatch, inside its sublist; a compound command's body starts a new
625    /// sublist first.
626    static REDIR_SCOPE_OPENED: std::cell::Cell<(u64, usize)> = const { std::cell::Cell::new((0, 0)) };
627    /// Per open redirect scope (index = depth - 1): whether ERRFLAG_ERROR
628    /// was clear when the scope opened. c:Src/exec.c:3637-3643 aborts on an
629    /// argument-expansion errflag BEFORE the redirect loop, so an errflag
630    /// first seen while a redirection is applied came from that
631    /// redirection's own target (xpandredir). See redir_target_expansion_failed.
632    static REDIR_SCOPE_ERRFLAG_CLEAN: std::cell::RefCell<Vec<bool>> =
633        const { std::cell::RefCell::new(Vec::new()) };
634    /// c:Src/exec.c:3775-3777 — `if (input) addfd(forked, save, mfds, 0,
635    /// input, 0, NULL);` seeds `mfds[0]` with the pipeline input BEFORE the
636    /// stage command's redirect list is walked, so an input redirection
637    /// joins the pipe as a second multio member (`cat o1 | cat <o2` reads
638    /// both). BUILTIN_PIPE_FDS_INSTALL records the sublist serial it
639    /// installed the pipe in; the redirect scope that opens in the same
640    /// sublist (the stage command's own) turns it into PIPE_INPUT_SCOPE.
641    ///
642    /// !!! WARNING: RUST-ONLY CARRIER !!! C does both steps inside one
643    /// execcmd_exec call; zshrs splits them across two VM ops.
644    static PIPE_INPUT_INSTALLED: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
645    /// Redirect-stack depth of the scope whose fd-0 redirection must keep
646    /// the pipeline input as its first multio member; consumed by the first
647    /// such redirection.
648    static PIPE_INPUT_SCOPE: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
649}
650
651/// Register the session executor pointer (called from
652/// `install_session_executor`). See [`with_session_context`].
653pub fn register_session_executor(exec: &mut ShellExecutor) {
654    SESSION_EXECUTOR_PTR.with(|c| c.set(Some(exec as *mut ShellExecutor)));
655}
656
657/// Run `f` with the registered session executor established as
658/// `CURRENT_EXECUTOR`, so code reaching the live executor via
659/// `try_with_executor` works even when no per-command `execode` context
660/// is active yet.
661///
662/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
663/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
664/// first `execode`. Without an active context those sourced bodies
665/// `try_with_executor` → `None` → no-op, so the shell silently ignored
666/// the user's dotfiles. The scope is entered once around the startup
667/// sourcing window and dropped before the loop begins — deliberately NOT
668/// Run `f` with the registered session executor established as
669/// `CURRENT_EXECUTOR`, so code reaching the live executor via
670/// `try_with_executor` works even when no per-command `execode` context
671/// is active yet.
672///
673/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
674/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
675/// first `execode`. Without an active context those sourced bodies
676/// `try_with_executor` → `None` → no-op, so the shell silently ignored
677/// the user's dotfiles. The scope is entered once around the startup
678/// sourcing window and dropped before the loop begins.
679///
680/// `execute_script_zsh_pipeline` now carries the same session-executor
681/// rung of its own (exec.rs), since `execstring` callers that fire
682/// BETWEEN commands — `checksched`, `dotrap` — have no active context
683/// either and were silently dropping their command. That rung is reached
684/// only when `CURRENT_EXECUTOR` is unset, so a nested command
685/// substitution (which always runs inside a chunk, and therefore inside
686/// an `ExecutorContext`) takes the active-executor path as before and
687/// never re-enters the session executor.
688pub fn with_session_context<R>(f: impl FnOnce() -> R) -> R {
689    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
690    match ptr {
691        // SAFETY: set by install_session_executor to an executor that
692        // outlives the single-threaded interactive session.
693        Some(ptr) => {
694            let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
695            f()
696        }
697        None => f(),
698    }
699}
700
701/// Merge finished background-compinit results into shell state, callable
702/// from any site (active VM context first, session executor otherwise —
703/// the ZLE completion path runs OUTSIDE a VM frame, where
704/// `try_with_executor` alone is None and $_comps stayed empty forever).
705pub fn drain_compinit_bg_hook() {
706    if try_with_executor(|exec| exec.drain_compinit_bg()).is_some() {
707        return;
708    }
709    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
710    if let Some(ptr) = ptr {
711        // SAFETY: per with_session_context.
712        let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
713        unsafe { (*ptr).drain_compinit_bg() }
714    }
715}
716
717/// Run the body of a PLAIN sourced file through the per-command loop —
718/// `ShellExecutor::execute_script_per_command`, the port of the
719/// `switch (loop(0, 0))` arm of C's `source()` (c:Src/init.c:1625-1641).
720///
721/// `bin_dot` (`src/ported/builtin.rs`, C's `Src/builtin.c:6118`
722/// `ret = source(enam = buf);`) is the only caller. It lives HERE rather
723/// than beside the other executor accessors in `src/ported/exec.rs`
724/// because everything under `src/ported/` is a line-by-line port and
725/// `build.rs` rejects a `fn` there with no C counterpart — this bridge
726/// shim has none, since C's `source()` reaches its interpreter through
727/// plain globals where Rust needs an explicit executor handle.
728///
729/// Executor ladder, in the order the sibling wrappers in `exec.rs` use:
730/// the innermost ACTIVE executor when one is in scope — inside
731/// `$(source f)` that is the sub-VM that owns the capture, which is why
732/// the substitution still collects the file's output — and the installed
733/// session executor otherwise, for a `source` that runs before the main
734/// loop's first `execode` has entered a context. `Ok(0)` with neither,
735/// matching `exec::execute_script`'s no-executor return.
736pub fn source_file_per_command(src: &str) -> Result<i32, String> {
737    if let Some(r) = try_with_executor(|exec| exec.execute_script_per_command(src)) {
738        return r;
739    }
740    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
741    match ptr {
742        // SAFETY: per with_session_context.
743        Some(ptr) => {
744            let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
745            unsafe { (*ptr).execute_script_per_command(src) }
746        }
747        None => Ok(0),
748    }
749}
750
751/// Run the deparsed text of an ALREADY-COMPILED `.zwc` program on the live
752/// executor — the compiled arm of `source()`, `execode(prog, 1, 0,
753/// "filecode")` (c:Src/init.c:1621).
754///
755/// Same executor resolution as [`source_file_per_command`]: the live
756/// executor when one is in scope, the installed session executor otherwise,
757/// `Ok(0)` with neither. See
758/// [`crate::vm_helper::ShellExecutor::execute_zwc_program`] for why the plain
759/// script entry point is wrong here.
760pub fn execute_zwc_program(src: &str) -> Result<i32, String> {
761    if let Some(r) = try_with_executor(|exec| exec.execute_zwc_program(src)) {
762        return r;
763    }
764    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
765    match ptr {
766        // SAFETY: per with_session_context.
767        Some(ptr) => {
768            let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
769            unsafe { (*ptr).execute_zwc_program(src) }
770        }
771        None => Ok(0),
772    }
773}
774
775/// RAII guard that sets/clears the thread-local executor pointer.
776///
777/// Idempotent: calling `enter` when a context is already active is a no-op
778/// for the entry side, and the guard's drop only clears the thread-local if
779/// *this* call was the one that set it. Nested `execute_command` invocations
780/// (e.g. from inside a builtin handler) reuse the outer pointer instead of
781/// stomping it.
782pub(crate) struct ExecutorContext {
783    we_set_it: bool,
784}
785
786impl ExecutorContext {
787    pub(crate) fn enter(executor: &mut ShellExecutor) -> Self {
788        let we_set_it = CURRENT_EXECUTOR.with(|cell| {
789            let mut slot = cell.borrow_mut();
790            if slot.is_some() {
791                false
792            } else {
793                *slot = Some(executor as *mut ShellExecutor);
794                true
795            }
796        });
797        ExecutorContext { we_set_it }
798    }
799}
800
801impl Drop for ExecutorContext {
802    fn drop(&mut self) {
803        if self.we_set_it {
804            CURRENT_EXECUTOR.with(|cell| {
805                *cell.borrow_mut() = None;
806            });
807        }
808    }
809}
810
811/// Access the current executor from a builtin handler.
812/// # Safety
813/// Only call this from within a VM execution context (after ExecutorContext::enter).
814#[inline]
815pub(crate) fn with_executor<F, R>(f: F) -> R
816where
817    F: FnOnce(&mut ShellExecutor) -> R,
818{
819    CURRENT_EXECUTOR.with(|cell| {
820        let ptr = cell
821            .borrow()
822            .expect("with_executor called outside VM context");
823        // SAFETY: The pointer is valid for the duration of VM execution,
824        // and we're single-threaded within the executor.
825        let executor = unsafe { &mut *ptr };
826        f(executor)
827    })
828}
829
830/// Non-panicking variant of [`with_executor`]: runs `f` against the
831/// current executor and returns `Some(result)`, or `None` when no
832/// executor is in scope (`CURRENT_EXECUTOR` unset — e.g. unit tests /
833/// compsys contexts with no fusevm bridge running).
834///
835/// This is the primitive the `crate::ported::exec` accessor wrappers
836/// (array/assoc/dispatch_function_call/execute_script/...) use to
837/// reach the live executor while preserving the exact "no executor →
838/// fall back to the direct param table / default value" semantics that
839/// the deleted `exec_hooks` OnceLock layer encoded via its
840/// "is-the-hook-installed?" check. `CURRENT_EXECUTOR` being set is the
841/// faithful equivalent of "the bridge installed the hooks".
842#[inline]
843pub(crate) fn try_with_executor<F, R>(f: F) -> Option<R>
844where
845    F: FnOnce(&mut ShellExecutor) -> R,
846{
847    CURRENT_EXECUTOR.with(|cell| {
848        let ptr = (*cell.borrow())?;
849        // SAFETY: same contract as with_executor — the pointer is valid
850        // for the duration of VM execution and access is single-threaded.
851        let executor = unsafe { &mut *ptr };
852        Some(f(executor))
853    })
854}
855
856/// Look up a canonical builtin by name in `BUILTINS` and dispatch
857/// via `execbuiltin` (Src/builtin.c:250). NO shadow check — calls the
858/// builtin even if a user function with the same name exists. Used by
859/// the `builtin foo` prefix opcode (which explicitly bypasses function
860/// lookup per zsh semantics) and by internal call sites where shadowing
861/// is unwanted. For zsh's normal name-resolution order (function shadows
862/// builtin), use `dispatch_builtin` instead.
863/// Shell-identifier prefix for diagnostic lines. Reads the canonical
864/// scriptname (`zsh` in `--zsh` parity mode, `zshrs` otherwise) so a
865/// single helper replaces hardcoded `"zshrs:"` literals across the
866/// file's eprintln paths.
867fn shname() -> String {
868    crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string())
869}
870
871/// c:Src/subst.c:505-507 + Src/exec.c:3378-3380 — per-command
872/// CSH_NULL_GLOB outcome check. During this command's word expansion
873/// `expand_glob` accumulated `badcshglob |= 1` per failed glob and
874/// `|= 2` per successful one (Src/glob.c:1871-1875). Exactly 1 —
875/// failures and no successes — is the csh-style error: `no match`,
876/// command skipped, status 1. Any other value (0 = no globs, 2/3 =
877/// at least one matched) is silent. Always resets the counter for
878/// the next command (C resets at prefork entry, subst.rs:1307).
879/// Returns true when the error fired; callers mirror their
880/// glob_failed handling (builtins leave ERRFLAG_ERROR set so the
881/// script aborts, externals clear it so the next sublist runs —
882/// verified against zsh 5.9.1).
883/// Restore the user's GLOB_SUBST after a `${~spec}` carrier flip
884/// (see subst::TILDE_GLOBSUBST_CARRIER). Runs at the same
885/// command-dispatch boundaries that consume glob_failed /
886/// badcshglob — by then every glob op of the current word pipeline
887/// has read the carrier.
888pub(crate) fn consume_tilde_globsubst_carrier() {
889    // The default-word report belongs to the word pipeline just finished too.
890    crate::ported::subst::DEFAULT_WORD_GLOBSUBST_OFF.with(|c| c.set(false));
891    crate::ported::subst::TILDE_GLOBSUBST_CARRIER.with(|c| {
892        if let Some(saved) = c.take() {
893            crate::ported::options::opt_state_set("globsubst", saved);
894        }
895    });
896}
897
898/// Pop `argc` stack slots for a whole-array assignment: the LAST popped
899/// (deepest pushed) is the param name, the rest are the values in stack
900/// order, with any `Value::Array` flattened to its elements. Mirrors the
901/// Flatten an array-assignment RHS value into scalar strings, descending
902/// through nested `Value::Array`s. zsh arrays are always flat, so recursion
903/// only collapses the wrapper layers the compiler introduces — in particular
904/// the single `Value::Array` built by `Op::MakeArray` for `arr=(...)` literals
905/// (used to dodge `CallBuiltin`'s u8 argc cap), whose own elements may
906/// themselves be arrays from an unquoted `$other_array` expansion. A
907/// one-level flatten would stringify those inner arrays into a single element.
908fn flatten_array_value(v: Value, out: &mut Vec<String>) {
909    match v {
910        Value::Array(items) => {
911            for it in items.iter() {
912                flatten_array_value(it.clone(), out);
913            }
914        }
915        other => out.push(other.to_str()),
916    }
917}
918
919/// c:Src/exec.c:2595-2598, 2605-2608, 2620-2623 (addvars) — `if (errflag)
920/// { state->pc = opc; return; }` after ecgetlist, prefork and globlist. An
921/// array assignment whose element expansion failed (`x=( *(zz) )`, a NOMATCH)
922/// never reaches assignaparam, so the parameter keeps its old type and value.
923/// Shared gate of BUILTIN_SET_ARRAY / BUILTIN_APPEND_ARRAY.
924fn array_assignment_expansion_failed() -> bool {
925    (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
926        & crate::ported::zsh_h::ERRFLAG_ERROR)
927        != 0
928        || with_executor(|exec| exec.current_command_glob_failed.get())
929}
930
931/// pop/flatten prologue of BUILTIN_SET_ARRAY / BUILTIN_APPEND_ARRAY.
932fn pop_array_args_with_name(vm: &mut fusevm::VM, argc: u8) -> (String, Vec<String>) {
933    let n = argc as usize;
934    let mut popped: Vec<Value> = Vec::with_capacity(n);
935    for _ in 0..n {
936        popped.push(vm.pop());
937    }
938    popped.reverse();
939    let name = popped.pop().map(|v| v.to_str()).unwrap_or_default();
940    let mut values: Vec<String> = Vec::new();
941    for v in popped {
942        flatten_array_value(v, &mut values);
943    }
944    (name, values)
945}
946
947/// c:Src/exec.c:3785-3796 — the redirect loop runs `xpandredir(fn, redir)`
948/// on each target and then `if (errflag) { closemnodes(mfds); fixfds(save);
949/// execerr(); }`: an expansion error in a target (`> $((1/0))`,
950/// `> ${u?msg}`, a NULL_GLOB `> nx(N)`) is a failed redirection. zshrs
951/// expands the target before the redirect op runs, so the op sees the
952/// errflag: when it was clear as this command's redirect scope opened, mark
953/// the redirection failed and skip it. The redirect_failed gates then treat
954/// it like any other failure — for an external command, which C forks before
955/// this loop (c:3719), only the child dies (call_function).
956/// !!! RUST-ONLY HELPER: C tests errflag inline in the redirect loop.
957fn redir_target_expansion_failed(exec: &mut ShellExecutor) -> bool {
958    if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
959        & crate::ported::zsh_h::ERRFLAG_ERROR)
960        == 0
961    {
962        return false;
963    }
964    let depth = exec.redirect_scope_stack.len();
965    let clean_at_open = depth > 0
966        && REDIR_SCOPE_ERRFLAG_CLEAN.with(|v| v.borrow().get(depth - 1).copied().unwrap_or(false));
967    if clean_at_open {
968        exec.redirect_failed = true;
969    }
970    clean_at_open
971}
972
973/// Asked by paramsubst's `${name?msg}` arms before they take
974/// c:Src/subst.c:3375's `zexit(1)`: true when the expansion belongs to the
975/// prefix assignments of `X=… cmd`, whose process is not known yet. The exit
976/// is then owed (PREFIX_ASSIGN_EXIT_PENDING) and `settle_prefix_assign_exit`
977/// pays it once the command word resolves.
978pub(crate) fn prefix_assign_defer_exit() -> bool {
979    let assigning = PREFIX_ASSIGN_CMD.with(|c| c.borrow().is_some());
980    if assigning {
981        PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.set(true));
982    }
983    assigning
984}
985
986/// Pays a deferred `${name?msg}` exit once `name` (the expanded command word,
987/// "" for none) is known. c:Src/exec.c:3719-3730 forks an external command
988/// before c:4358 runs its prefix assignments, so the error ends only that
989/// child (c:3377 `_exit(1)`, c:4359 `if (errflag) _exit(1)`): the shell sees
990/// status 1, which the dispatch's PREFIX_ASSIGN_FAILED arm reports. A shell
991/// function, a builtin or no command at all runs the assignments in the
992/// shell (c:4157), which exits there (c:3375).
993fn settle_prefix_assign_exit(name: &str) {
994    if !PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.replace(false)) {
995        return;
996    }
997    let forked = !name.is_empty()
998        && !with_executor(|exec| exec.function_exists(name))
999        && !crate::ported::builtin::createbuiltintable().contains_key(name);
1000    if !forked {
1001        crate::ported::builtin::STOPMSG.store(1, std::sync::atomic::Ordering::Relaxed); // c:3374
1002        // !!! DASH-FAMILY GATE (dash_mode::fatal_error_status): same as the
1003        // paramsubst arms. !!!
1004        crate::ported::builtin::zexit(
1005            crate::extensions::dash_mode::fatal_error_status().unwrap_or(1),
1006            crate::ported::zsh_h::ZEXIT_NORMAL,
1007        ); // c:3375
1008    }
1009}
1010
1011thread_local! {
1012    /// Set by BUILTIN_CMD_NAME_GLOB_MARK: the command-name word failed to glob.
1013    static CMD_NAME_GLOB_FAILED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1014}
1015
1016/// Consume the command-name-glob-failed mark.
1017fn take_cmd_name_glob_failed() -> bool {
1018    CMD_NAME_GLOB_FAILED.with(|c| c.replace(false))
1019}
1020
1021/// c:Src/exec.c:3719 + 3755-3763 — an external command is forked
1022/// (execcmd_fork) BEFORE `globlist(args, 0)`, so a NOMATCH (or the
1023/// CSH_NULL_GLOB `no match`, c:Src/subst.c:505-507) raised while globbing
1024/// its arguments ends the CHILD with status 1; the shell's errflag stays
1025/// clean and the next command runs (`rm *nomatch; print after` prints the
1026/// error, then `after`). zshrs globs in the shell, so every path that runs
1027/// a name zsh would fork for calls this first: `Some(1)` means skip the
1028/// command, with this command's ERRFLAG_ERROR taken back out.
1029fn external_glob_failure_status() -> Option<i32> {
1030    consume_tilde_globsubst_carrier();
1031    let failed = with_executor(|exec| exec.current_command_glob_failed.replace(false))
1032        || consume_badcshglob();
1033    if !failed {
1034        return None;
1035    }
1036    if take_cmd_name_glob_failed() {
1037        // c:Src/exec.c:3350-3354 + 3757-3760 — the name was globbed in the
1038        // shell, so errflag stays set and the list ends.
1039        with_executor(|exec| exec.set_last_status(1));
1040        return Some(1);
1041    }
1042    crate::ported::utils::errflag.fetch_and(
1043        !crate::ported::zsh_h::ERRFLAG_ERROR,
1044        std::sync::atomic::Ordering::Relaxed,
1045    );
1046    with_executor(|exec| exec.set_last_status(1));
1047    Some(1)
1048}
1049
1050fn consume_badcshglob() -> bool {
1051    let v = crate::ported::glob::BADCSHGLOB.swap(0, std::sync::atomic::Ordering::Relaxed);
1052    if v == 1 {
1053        crate::ported::utils::zerr("no match"); // c:Src/subst.c:507
1054        true
1055    } else {
1056        false
1057    }
1058}
1059
1060/// Map a builtin name to the zsh module that owns it, IFF zsh does
1061/// not auto-load that builtin on first use. Used by
1062/// `dispatch_builtin_raw` to gate `--zsh` mode dispatch behind
1063/// `zmodload`, mirroring `zsh -fc <name>` returning 127 for these
1064/// names without an explicit module load.
1065///
1066/// Returns `Some(module_name)` if `name` belongs to a non-auto-load
1067/// module per the per-module `Src/Modules/<x>.c` `bintab[]` plus
1068/// the auto-load flag set at module-build time. `None` for core
1069/// builtins and for auto-loaded module builtins (sched, log, echotc,
1070/// echoti, zformat, zparseopts, zregexparse, zstyle, strftime,
1071/// private, vared, zle, bindkey, comp*) which work without zmodload.
1072fn module_bound_builtin_module(name: &str) -> Option<&'static str> {
1073    match name {
1074        "zftp" => Some("zsh/zftp"),
1075        "zsocket" => Some("zsh/net/socket"),
1076        "ztcp" => Some("zsh/net/tcp"),
1077        "zstat" => Some("zsh/stat"),
1078        "zselect" => Some("zsh/zselect"),
1079        "zpty" => Some("zsh/zpty"),
1080        "zprof" => Some("zsh/zprof"),
1081        "zsystem" | "syserror" => Some("zsh/system"),
1082        "clone" => Some("zsh/clone"),
1083        "zcurses" => Some("zsh/curses"),
1084        "ztie" | "zuntie" | "zgdbmpath" => Some("zsh/db/gdbm"),
1085        "pcre_compile" | "pcre_match" | "pcre_study" => Some("zsh/pcre"),
1086        "example" => Some("zsh/example"),
1087        "cap" | "getcap" | "setcap" => Some("zsh/cap"),
1088        "zgetattr" | "zsetattr" | "zdelattr" | "zlistattr" => Some("zsh/attr"),
1089        // c:Src/Modules/datetime.c — `strftime` is registered via
1090        // partab[] when zsh/datetime loads. Verified by
1091        // `zsh -fc 'strftime -s s %Y 0'` → 127 "command not found".
1092        "strftime" => Some("zsh/datetime"),
1093        _ => None,
1094    }
1095}
1096
1097/// Lookup half of [`try_run_registered_builtin`]: the handler for a
1098/// zshrs-original opcode builtin NAME, or `None`. Split from the run half so
1099/// the `builtin` precommand walk can ask whether the name exists before the
1100/// redirections open (c:Src/exec.c:3489 `builtintab->getnode`) without
1101/// running the command.
1102///
1103/// !!! Keep in sync with the matching `vm.register_builtin(...)` closures in
1104/// `register_builtins`: both must route a name to the same executor method.
1105fn registered_builtin(name: &str) -> Option<fn(&[String]) -> i32> {
1106    let f: fn(&[String]) -> i32 = match name {
1107        "ai" => |argv| with_executor(|e| e.builtin_ai(argv)),
1108        "async" => |argv| with_executor(|e| e.builtin_async(argv)),
1109        "await" => |argv| with_executor(|e| e.builtin_await(argv)),
1110        "barrier" => |argv| with_executor(|e| e.builtin_barrier(argv)),
1111        "peach" => |argv| with_executor(|e| e.builtin_peach(argv)),
1112        "pmap" => |argv| with_executor(|e| e.builtin_pmap(argv)),
1113        "pgrep" => |argv| with_executor(|e| e.builtin_pgrep(argv)),
1114        "intercept" => |argv| with_executor(|e| e.builtin_intercept(argv)),
1115        "intercept_proceed" => |argv| with_executor(|e| e.builtin_intercept_proceed(argv)),
1116        "doctor" => |argv| with_executor(|e| e.builtin_doctor(argv)),
1117        "dbview" => |argv| with_executor(|e| e.builtin_dbview(argv)),
1118        "profile" => |argv| with_executor(|e| e.builtin_profile(argv)),
1119        "provenance" => |argv| with_executor(|e| e.builtin_provenance(argv)),
1120        "zbanner" => |argv| with_executor(|e| e.builtin_zbanner(argv)),
1121        "caller" => |argv| with_executor(|e| e.builtin_caller(argv)),
1122        "help" => |argv| with_executor(|e| e.builtin_help(argv)),
1123        "cdreplay" => |argv| with_executor(|e| e.builtin_cdreplay(argv)),
1124        "zsleep" => |argv| crate::extensions::ext_builtins::zsleep(argv),
1125        _ => return None,
1126    };
1127    Some(f)
1128}
1129
1130/// Whether NAME is a zshrs-original opcode builtin or an enabled
1131/// host-registered native command: the names [`try_run_registered_builtin`]
1132/// would run.
1133pub(crate) fn is_registered_builtin(name: &str) -> bool {
1134    registered_builtin(name).is_some() || crate::native_cmds::is_enabled(name)
1135}
1136
1137/// Whether NAME is an autoload stub that has not been loaded yet: an enabled
1138/// shfunctab entry with PM_UNDEFINED set (c:Src/zsh.h:1895). Loading clears
1139/// the flag (c:Src/exec.c:5790, c:5815); a function defined in the shell
1140/// never carries it. Gates the native zmv/zcp/zln/zcalc ports, which stand
1141/// in for the fpath load of the stock autoload functions only.
1142fn autoload_stub_pending(name: &str) -> bool {
1143    crate::ported::hashtable::shfunctab_lock()
1144        .read()
1145        .ok()
1146        .and_then(|t| t.get(name).map(|f| (f.node.flags as u32 & crate::ported::zsh_h::PM_UNDEFINED as u32) != 0))
1147        .unwrap_or(false)
1148}
1149
1150/// Whether a shell function named NAME shadows a builtin of that name at
1151/// command dispatch (c:Src/exec.c:3485-3489: `shfunctab->getnode` before
1152/// `builtintab->getnode`). `getnode` skips DISABLED nodes (c:Src/hashtable.c:239), so a function
1153/// switched off with `disable -f NAME` does not shadow (Bug #221 in
1154/// docs/BUGS.md). `functions_compiled` keeps the body regardless of the
1155/// DISABLED flag, hence the shfunctab probe first.
1156fn user_fn_shadows_builtin(name: &str) -> bool {
1157    let disabled = crate::ported::hashtable::shfunctab_lock()
1158        .read()
1159        .ok()
1160        .and_then(|t| {
1161            let entry = t.get_including_disabled(name)?;
1162            Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
1163        })
1164        .unwrap_or(false);
1165    !disabled && with_executor(|exec| exec.functions_compiled.contains_key(name))
1166}
1167
1168/// The builtin table as the `builtin` precommand modifier consults it
1169/// (c:Src/exec.c:3489 `builtintab->getnode(builtintab, cmdarg)`): the ported
1170/// table minus DISABLED nodes (`getnode` skips them, so `disable typeset;
1171/// builtin typeset` is "no such builtin"), plus the zshrs builtins that live
1172/// outside it: the daemon `z*` family and the opcode builtins above.
1173pub(crate) fn builtin_prefix_finds(name: &str) -> bool {
1174    if crate::daemon::builtins::is_zshrs_builtin(name) {
1175        return true;
1176    }
1177    let disabled = crate::ported::builtin::BUILTINS_DISABLED
1178        .lock()
1179        .map(|s| s.contains(name))
1180        .unwrap_or(false);
1181    (crate::ported::builtin::createbuiltintable().contains_key(name) && !disabled)
1182        || is_registered_builtin(name)
1183}
1184
1185/// Run a daemon `z*` builtin (zcache, zd, zjob, …) by name. They are
1186/// dispatched by name instead of living in builtintab, so each resolver that
1187/// can land on a builtin name — literal head, `$var` head, `builtin NAME` —
1188/// routes them through here. `None` when `name` is not one of them.
1189fn run_daemon_builtin(name: &str, rest: &[String]) -> Option<i32> {
1190    if !crate::daemon::builtins::is_zshrs_builtin(name) {
1191        return None;
1192    }
1193    let argv: Vec<String> = std::iter::once(name.to_string())
1194        .chain(rest.iter().cloned())
1195        .collect();
1196    Some(crate::daemon::builtins::try_dispatch(name, &argv).unwrap_or(1))
1197}
1198
1199/// Dispatch a zshrs-ORIGINAL builtin by NAME, argv-style. These are
1200/// registered as fusevm opcodes in [`register_builtins`] (async, doctor,
1201/// peach, …), so a *literal* name compiles to `CallBuiltin` and runs. But
1202/// they are absent from the static `BUILTINS` port table and the merged
1203/// `builtintab`, so when the command name is resolved only at run time —
1204/// `$var` indirection, `builtin NAME` — the ported command-resolution path
1205/// never finds them and reports "command not found" / "no such builtin",
1206/// even though `whence` (correctly) calls them builtins. The
1207/// `register_builtins` closures use the VM only to pop args and then call an
1208/// executor method, so the identical dispatch works here from any parent-side
1209/// resolver that has an executor — no VM re-entry (which would alias the
1210/// running `&mut VM`). Returns `None` for a name that is not one of them, so
1211/// the caller falls through to external lookup.
1212///
1213/// !!! Keep in sync with the matching `vm.register_builtin(...)` closures in
1214/// `register_builtins`: both must route a name to the same executor method.
1215pub(crate) fn try_run_registered_builtin(name: &str, argv: &[String]) -> Option<i32> {
1216    if let Some(run) = registered_builtin(name) {
1217        return Some(run(argv));
1218    }
1219    // Host-registered native commands (`extensions/native_cmds.rs`): the
1220    // fat binary's sibling runtimes — `git` (zvcs), `arb` (arblang),
1221    // `stryke` (strykelang) in the zshrs-native build. Unknown here in the
1222    // thin shell, where the table is empty and this falls through to
1223    // `None` exactly as before.
1224    //
1225    // Reached from the two places that ask "is this a builtin?": the
1226    // pre-PATH arm of the ZshrsHost dispatch (after functions and after
1227    // builtintab, so a user `git()` still shadows it) and the forced
1228    // `builtin NAME` precommand. `command git` consults neither, so the
1229    // escape hatch to the `git` on PATH is untouched.
1230    //
1231    // The registry's contract is full argv — argv[0] is the command name
1232    // as invoked, which zvcs needs for its `git-<verb>` dashed form and
1233    // for its `zvcs: <command>: <reason>` diagnostics — while every arm
1234    // above takes the operands alone, so the name is spliced back on here.
1235    if !crate::native_cmds::is_enabled(name) {
1236        return None;
1237    }
1238    let full: Vec<String> = std::iter::once(name.to_string())
1239        .chain(argv.iter().cloned())
1240        .collect();
1241    crate::native_cmds::dispatch(name, &full)
1242}
1243
1244/// `command -v` / `command -V`: c:Src/exec.c:3149-3157 — `pushnode(preargs,
1245/// "command"); hn = &commandbn.node; is_builtin = 1;`, i.e. bin_whence with
1246/// the BIN_COMMAND funcid. `post` is the flag words then the names; `dash_p`
1247/// says a `-p` was among the flags.
1248fn command_whence(post: &[String], dash_p: bool) -> i32 {
1249    let mut ops = options {
1250        ind: [0u8; MAX_OPS],
1251        args: Vec::new(),
1252        argscount: 0,
1253        argsalloc: 0,
1254    };
1255    let mut name_pos = 0usize;
1256    let mut flag_byte = b'v';
1257    for (i, a) in post.iter().enumerate() {
1258        if a.starts_with('-') && a.len() >= 2 {
1259            let body = &a.as_bytes()[1..];
1260            if body.contains(&b'V') {
1261                flag_byte = b'V';
1262            }
1263            name_pos = i + 1;
1264        } else {
1265            name_pos = i;
1266            break;
1267        }
1268    }
1269    ops.ind[flag_byte as usize] = 1;
1270    // c:Src/builtin.c:4157 + c:4165-4167 — bin_whence reads `-p` off
1271    // `ops` twice: to prefer a builtin over an external for
1272    // `command -p[vV]`, and as `findcmd`'s `default_path` argument.
1273    if dash_p {
1274        ops.ind[b'p' as usize] = 1;
1275    }
1276    let whence_args: Vec<String> = post[name_pos..].to_vec();
1277    crate::ported::builtin::bin_whence(
1278        "command",
1279        &whence_args,
1280        &ops,
1281        crate::ported::hashtable_h::BIN_COMMAND,
1282    )
1283}
1284
1285/// Whether the redirect scope on top of the stack was opened by the
1286/// command now dispatching — its OWN redirections — rather than by an
1287/// enclosing compound command. C asks the same of the command's local
1288/// `redir` list / `save[]` array (c:Src/exec.c:3651, c:4300).
1289/// !!! RUST-ONLY — no C counterpart.
1290pub(crate) fn own_redirect_scope(exec: &ShellExecutor) -> bool {
1291    let depth = exec.redirect_scope_stack.len();
1292    depth > 0 && REDIR_SCOPE_OPENED.with(|c| c.get()) == (SUBLIST_SERIAL.with(|c| c.get()), depth)
1293}
1294
1295pub(crate) fn dispatch_builtin_raw(name: &str, args: Vec<String>) -> i32 {
1296    // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
1297    // Native p10k engine intercept (src/extensions/p10k): sourcing
1298    // powerlevel10k.zsh-theme activates the Rust segment engine
1299    // instead of executing the ~13k-line zsh theme. The user's
1300    // `.p10k.zsh` CONFIG is NOT intercepted — it sources normally so
1301    // its POWERLEVEL9K_* typesets land in the paramtab, which the
1302    // engine reads live at every render. Placed here (the chokepoint
1303    // every builtin route funnels through) so `source`, `.`, and
1304    // `builtin source` all hit it.
1305    if matches!(name, "source" | ".") {
1306        if let Some(status) = crate::p10k::maybe_intercept_theme_source(&args) {
1307            // Register a `p10k` stub function so `${+functions[p10k]}`
1308            // guards in .zshrc templates stay truthy. The body forwards
1309            // to the bridge-intercepted `zshrs-p10k-api` name so
1310            // `p10k segment` (custom-segment protocol) and the other
1311            // API subcommands reach the native engine (p10k_api).
1312            try_with_executor(|exec| {
1313                let _ = exec.execute_script("function p10k() { zshrs-p10k-api \"$@\" }");
1314            });
1315            return status;
1316        }
1317    }
1318    // Native p10k API dispatch — the `p10k` stub function forwards
1319    // here (see the theme intercept above). Must run before the
1320    // generic builtintab lookup: the name is not a real builtin.
1321    if let Some(status) = crate::p10k::maybe_intercept_command(name, &args) {
1322        return status;
1323    }
1324    // c:Src/exec.c:3674-3682 — a builtin runs in the current shell.
1325    crate::exec_jobs::mark_thisjob_cursh(true);
1326    // c:Src/exec.c:2700-2717 — `private` is an autoloaded builtin in
1327    // zsh (autofeature b:private of zsh/param/private): first use
1328    // runs ensurefeature → require_module → load_module → boot_,
1329    // marking the module MOD_INIT_B (what `zmodload -e` reads) and
1330    // installing the wrap_private FuncWrap (param_private.c:712).
1331    // doshfunc gates the wrapper dispatch on that load state, so
1332    // this require_module is what activates private scoping. The
1333    // raw dispatcher is the chokepoint every builtin route funnels
1334    // through; require_module is idempotent after the first call
1335    // (needs_load checks MOD_INIT_B).
1336    if name == "private" {
1337        if let Ok(mut tab) = crate::ported::module::MODULESTAB.lock() {
1338            let _ = crate::ported::module::require_module(
1339                &mut tab,
1340                "zsh/param/private",
1341                None,
1342                0,
1343                false,
1344            );
1345            // c:2710 ensurefeature
1346        }
1347    }
1348    // c:Src/Modules/param_private.c:682-685 setup_ — loading
1349    // zsh/param/private REPLACES the `local` builtintab node's
1350    // handlerfunc + optstr with bin_private's ("Even more horrible
1351    // hack"), so once the module is loaded `local` IS bin_private: it
1352    // accepts the -P/-Pa private-scope flags, and without -P delegates
1353    // to bin_typeset, which already treats `local` and `private`
1354    // identically (is_locallike, builtin.rs:3666). Replicate the swap by
1355    // routing `local` through the `private` node only after the module
1356    // is loaded — before then, `local -P` still errors "bad option: -P"
1357    // exactly like stock zsh. The `private` node carries the augmented
1358    // optstr (with P) that the `local` node lacks.
1359    if name == "local"
1360        && crate::ported::module::MODULESTAB
1361            .lock()
1362            .map(|t| t.is_bound("zsh/param/private"))
1363            .unwrap_or(false)
1364    {
1365        // c:Src/Modules/param_private.c:683-685 — the swap copies EXACTLY
1366        // two fields:
1367        //     ((Builtin)hn)->handlerfunc = bintab[0].handlerfunc;
1368        //     ((Builtin)hn)->optstr = bintab[0].optstr;
1369        // `defopts` is NOT copied, so the `local` node keeps its own
1370        // (NULL) defaults while `private`'s node keeps `"P"`. That is
1371        // what makes `local x` delegate straight to bin_typeset
1372        // (c:225-229 `if (!OPT_ISSET(ops, 'P'))`) while `private x`
1373        // opens a private scope. Dispatching `local` through the
1374        // `private` NODE inherited defopts="P", so every `local NAME`
1375        // ran the private-promotion path: `() { local h=scalar;
1376        // private -A h }` reported "can't change type of private param"
1377        // where zsh reports "can't change scope of existing param"
1378        // (V10private.ztst:13), and `local` silently made privates.
1379        // zshrs's builtintab maps to `&'static builtin` rows in an
1380        // immutable static, so mirror C's field swap on a private
1381        // static copy of the `local` node instead of mutating the table.
1382        static LOCAL_AS_PRIVATE: std::sync::LazyLock<crate::ported::zsh_h::builtin> =
1383            std::sync::LazyLock::new(|| crate::ported::zsh_h::builtin {
1384                // c:683 `save_local = *(Builtin)hn;` — start from the
1385                // real `local` row so name/flags/minargs/maxargs/funcid/
1386                // defopts all stay `local`'s.
1387                node: crate::ported::zsh_h::hashnode {
1388                    next: None,
1389                    nam: "local".to_string(),
1390                    flags: (crate::ported::zsh_h::BINF_PLUSOPTS
1391                        | crate::ported::zsh_h::BINF_MAGICEQUALS
1392                        | crate::ported::zsh_h::BINF_PSPECIAL
1393                        | crate::ported::zsh_h::BINF_ASSIGN) as i32,
1394                },
1395                // c:684 — handlerfunc from bintab[0] (bin_private).
1396                handlerfunc: Some(
1397                    crate::ported::modules::param_private::bin_private
1398                        as crate::ported::zsh_h::HandlerFunc,
1399                ),
1400                minargs: 0,
1401                maxargs: -1,
1402                funcid: 0,
1403                // c:685 — optstr from bintab[0] (private's, which adds `P`).
1404                // Same string as the `private` row in builtin.rs (5.9.2's `p`).
1405                optstr: Some("AE:%F:%HL:%PR:%TUZ:%ahi:%lmnprtux".to_string()),
1406                // c:683-685 — NOT copied: `local` keeps its own defaults.
1407                defopts: None,
1408            });
1409        let bn_ptr = &*LOCAL_AS_PRIVATE as *const _ as *mut _;
1410        return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
1411    }
1412    // c:Bugs #475/#504/#555 — bash-only builtins (`mapfile`,
1413    // `readarray`, `compopt`) should emit "command not found" in
1414    // `--zsh` mode matching zsh's external-command-lookup miss.
1415    // The per-opcode closures for caller/help/complete/compgen
1416    // already gate via IS_ZSH_MODE at their registration sites;
1417    // names without dedicated opcodes (compopt/mapfile/readarray)
1418    // route through this generic builtintab lookup and need the
1419    // gate here.
1420    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
1421        && matches!(name, "compopt" | "mapfile" | "readarray")
1422    {
1423        eprintln!("zsh:1: command not found: {}", name);
1424        let _ = args;
1425        return 127;
1426    }
1427    // c:Src/exec.c:2700-2724 resolvebuiltin — autoloaded-builtin stub
1428    // (registered by `zmodload -ab MOD NAME`, Src/module.c:426
1429    // add_autobin) fires on first use: ensurefeature loads the owning
1430    // module, then dispatch proceeds against the real builtin. Must
1431    // run BEFORE the module-bound 127 gate below — `zmodload -ab
1432    // zsh/zselect zselect; zselect` previously died there with
1433    // `command not found` because the gate only checked is_loaded,
1434    // never the autoload ledger.
1435    if let Some(rc) = crate::ported::exec::resolvebuiltin(name) {
1436        if rc != 0 {
1437            // Load failed or feature undefined — diagnostics already
1438            // printed (load_module zwarn / resolvebuiltin zerr).
1439            // C's execbuiltin head returns 1 (Src/builtin.c:264-267).
1440            return 1;
1441        }
1442        // Module loaded — fall through; the is_loaded gates below now
1443        // pass and the normal dispatch chain runs the real builtin.
1444    }
1445    // c:Src/Modules/<mod>.c boot_/setup_ chain — module-bound builtins
1446    // (zftp, zsocket, ztcp, zstat, etc.) are only registered into
1447    // `builtintab` when their module is loaded via `zmodload`. In
1448    // zsh `-fc` (the parity test harness's invocation), the modules
1449    // are NOT pre-loaded, so each name reports "command not found"
1450    // with exit 127. zshrs intentionally pre-loads all module bintabs
1451    // in `createbuiltintable` (builtin.rs:131-152) for the default
1452    // mode so users can call these without `zmodload`; that auto-load
1453    // diverges from zsh's gate behavior. Match zsh's stance only when
1454    // the user explicitly asked for parity via `--zsh`.
1455    //
1456    // The list is the union of builtins from modules that zsh does
1457    // NOT auto-load (verified via `zsh -fc <name>` returning 127):
1458    //   zsh/zftp          → zftp
1459    //   zsh/net/socket    → zsocket
1460    //   zsh/net/tcp       → ztcp
1461    //   zsh/stat          → zstat (NOT `stat`; that name resolves to
1462    //                              /bin/stat on PATH per zsh's setup)
1463    //   zsh/zselect       → zselect
1464    //   zsh/zpty          → zpty
1465    //   zsh/zprof         → zprof
1466    //   zsh/system        → zsystem, syserror
1467    //   zsh/clone         → clone
1468    //   zsh/curses        → zcurses
1469    //   zsh/db/gdbm       → ztie, zuntie, zgdbmpath
1470    //   zsh/pcre          → pcre_compile, pcre_match, pcre_study
1471    //   zsh/example       → example
1472    //   zsh/cap           → cap, getcap, setcap
1473    //   zsh/attr          → zgetattr, zsetattr, zdelattr, zlistattr
1474    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
1475        && module_bound_builtin_module(name)
1476            .map(|m| {
1477                !crate::ported::module::MODULESTAB
1478                    .lock()
1479                    .map(|t| t.is_loaded(m))
1480                    .unwrap_or(false)
1481            })
1482            .unwrap_or(false)
1483    {
1484        eprintln!("zsh:1: command not found: {}", name);
1485        let _ = args;
1486        return 127;
1487    }
1488    // c:Src/Modules/files.c:806-824 — zsh/files registers `chmod`,
1489    // `chown`, `chgrp`, `ln`, `mkdir`, `mv`, `rm`, `rmdir`, `sync`
1490    // (plus their `zf_*` aliases) into builtintab on module load.
1491    // Without an explicit `zmodload zsh/files`, zsh resolves the
1492    // names through PATH lookup — `zsh -fc 'chmod +x f'` runs
1493    // `/bin/chmod`, whose argv-parser accepts symbolic modes like
1494    // `+x` that bin_chmod's octal-only parser rejects with
1495    // "invalid mode `+x'". The shadow-aware wrapper at
1496    // `dispatch_builtin` (line 438) already has this gate, but the
1497    // direct `dispatch_builtin_raw` path used by fusevm's
1498    // CallBuiltin opcode bypasses it. Mirror the gate here so the
1499    // low-level dispatch matches C's PATH-fall-through behavior.
1500    // The gate is NOT emulation-mode dependent. C has no `zsh/files`
1501    // builtins in `builtintab` until the module is loaded, in ANY mode, so a
1502    // bare `rm`/`mv`/`chmod` falls through to PATH — `chmod +x FILE` runs
1503    // /bin/chmod and succeeds. Conditioning this on `IS_ZSH_MODE` meant the
1504    // native binary answered `chmod +x` from `bin_chmod`'s octal-only parser
1505    // ("invalid mode `+x'"), and `rm -s` / `mv -s` from the module's argument
1506    // parser instead of the system tool's. `dispatch_builtin` at :1087 already
1507    // gates unconditionally; this low-level `CallBuiltin` path did not.
1508    if files_builtin_runs_from_path(name) {
1509        // PATH lookup uses the LITERAL name: bare `mkdir` finds
1510        // /bin/mkdir; a `zf_*` alias finds nothing and exits 127 —
1511        // matching zsh -fc `zf_mkdir d` → "command not found:
1512        // zf_mkdir" (the aliases exist ONLY in the loaded module's
1513        // builtintab, Src/Modules/files.c:816-824; PATH has no
1514        // /bin/zf_rm). The previous zf_-strip silently ran the
1515        // system binary instead.
1516        if let Some(status) = external_glob_failure_status() {
1517            return status;
1518        }
1519        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1520        return status;
1521    }
1522    // c:Src/Modules/stat.c:637-638 — zsh/stat registers BOTH `stat`
1523    // and `zstat`. `zstat` is in the module_bound 127-gate above (no
1524    // /usr/bin/zstat exists), but the bare `stat` name must FALL
1525    // THROUGH to PATH when zsh/stat isn't loaded — zsh -fc
1526    // 'stat -f %Lp f' runs /usr/bin/stat, while bin_stat's parser
1527    // rejects stat(1) flags ("bad option: -c"). Same fall-through
1528    // shape as the zsh/files gate above.
1529    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
1530        && name == "stat"
1531        && !crate::ported::module::MODULESTAB
1532            .lock()
1533            .map(|t| t.is_loaded("zsh/stat"))
1534            .unwrap_or(false)
1535    {
1536        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1537        return status;
1538    }
1539    // c:Src/exec.c:3050-3068 — builtin lookup hits `builtintab` (the
1540    // merged table containing module-provided builtins). The previous
1541    // port walked only the core `BUILTINS` slice, so per-module
1542    // entries like `log` (Src/Modules/watch.c:693 `BUILTIN("log", …,
1543    // bin_log, …)`) were registered into builtintab via
1544    // createbuiltintable but never reached at dispatch — `log` fell
1545    // through to PATH and ran `/usr/bin/log`. Bug #72 in docs/BUGS.md.
1546    let tab = crate::ported::builtin::createbuiltintable();
1547    if let Some(bn_static) = tab.get(name) {
1548        let bn_ptr = *bn_static as *const _ as *mut _;
1549        return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
1550    }
1551    1
1552}
1553
1554/// Shadow-aware dispatch matching zsh's name-resolution order:
1555/// alias → reserved word → **function (shadows builtin)** → builtin →
1556/// external. All `BUILTIN_X` opcode handlers route through here so a
1557/// user-defined `cd () { … }` (or `r`, `fc`, `which`, … anything in
1558/// fusevm's name→opcode map) takes precedence over the C builtin —
1559/// matching `Src/exec.c:execcmd_exec`'s dispatch at c:3050-3068.
1560/// Without this, compile-time builtin resolution silently ignored
1561/// user wrappers (e.g. ZPWR's `cd () { builtin cd "$@"; … }`).
1562/// True for builtins that are bound by zsh/files's boot_/setup_
1563/// chain (Src/Modules/files.c:806-824). These are the bare-name
1564/// `mkdir`/`rm`/`mv`/`ln`/`chmod`/`chown`/`chgrp`/`sync`/`rmdir`
1565/// AND their `zf_*` aliases at c:816-824. Without explicit
1566/// `zmodload zsh/files`, the names fall through to PATH lookup
1567/// (zsh's `type rm` reports `/bin/rm`). Bug #28.
1568fn module_gated_files_builtin(name: &str) -> bool {
1569    matches!(
1570        name,
1571        "mkdir"
1572            | "rmdir"
1573            | "rm"
1574            | "mv"
1575            | "ln"
1576            | "chmod"
1577            | "chown"
1578            | "chgrp"
1579            | "sync"
1580            | "zf_mkdir"
1581            | "zf_rmdir"
1582            | "zf_rm"
1583            | "zf_mv"
1584            | "zf_ln"
1585            | "zf_chmod"
1586            | "zf_chown"
1587            | "zf_chgrp"
1588            | "zf_sync"
1589    )
1590}
1591
1592/// A zsh/files name while the module is not loaded: C has no such entry in
1593/// `builtintab` then, so the name runs from PATH like any external.
1594fn files_builtin_runs_from_path(name: &str) -> bool {
1595    module_gated_files_builtin(name)
1596        && !crate::ported::module::MODULESTAB
1597            .lock()
1598            .map(|t| t.is_loaded("zsh/files"))
1599            .unwrap_or(false)
1600}
1601
1602thread_local! {
1603    /// Set when a command-word glob (BUILTIN_GLOB_EXPAND / BUILTIN_GLOBLIST)
1604    /// is what raised errflag, so the abort takes C's globlist status
1605    /// (c:Src/exec.c:3760 `lastval = 1`) rather than the prefork one
1606    /// (c:3523 `if (!lastval) lastval = 1`).
1607    ///
1608    /// !!! WARNING: RUST-ONLY CARRIER !!! C knows which of the two checks
1609    /// it is at; zshrs expands and globs each word in one pass and reaches
1610    /// a single gate at dispatch.
1611    static GLOB_WORD_ERRFLAG: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1612}
1613
1614/// Run a command-word glob and note whether it raised errflag.
1615fn glob_word_noting_errflag(raw: Value, skip_glob: bool) -> Value {
1616    use std::sync::atomic::Ordering;
1617    let before = crate::ported::utils::errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR;
1618    let out = glob_expand_word_value(raw, skip_glob);
1619    if before == 0
1620        && crate::ported::utils::errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR != 0
1621    {
1622        GLOB_WORD_ERRFLAG.with(|c| c.set(true));
1623    }
1624    out
1625}
1626
1627/// Glob one BUILTIN_EXPAND_TEXT mode-10 result inside BUILTIN_GLOBLIST.
1628/// EXPAND_TEXT left the glob-eligible words tokenized (their `haswilds`
1629/// test, c:Src/glob.c:1230, ran on the tokenized word) and every other word
1630/// untokenized, so the token test picks the same words out again; noglob was
1631/// already applied there. The glob call and the empty-result shapes are the
1632/// ones mode 0 uses.
1633fn glob_tokenized_word(raw: Value, noglob: bool) -> Value {
1634    use std::sync::atomic::Ordering;
1635    let words: Vec<String> = match &raw {
1636        Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
1637        other => vec![other.to_str()],
1638    };
1639    let mut parts: Vec<String> = Vec::with_capacity(words.len());
1640    for w in words {
1641        // c:Src/glob.c:1872 — NO_GLOB leaves the word literal; a word that came
1642        // back tokenized only for its deferred filesub reaches here too.
1643        // c:Src/glob.c:1230 — `unset(EXECOPT)` is part of the same early return.
1644        if noglob
1645            || !crate::ported::zsh_h::isset(crate::ported::zsh_h::EXECOPT)
1646            || !crate::ported::pattern::haswilds(&w)
1647        {
1648            // c:Src/glob.c:1232 — zglob untokenizes a word it does not glob; a
1649            // deferred `=cmd` / `~user` that filesub left alone (NOMATCH off)
1650            // still carries its token.
1651            parts.push(crate::ported::lex::untokenize(&w).to_string());
1652            continue;
1653        }
1654        let ef = || crate::ported::utils::errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR;
1655        let before = ef();
1656        let globbed = with_executor(|exec| exec.expand_glob(&w));
1657        if before == 0 && ef() != 0 {
1658            GLOB_WORD_ERRFLAG.with(|c| c.set(true)); // c:Src/exec.c:3760-3761
1659        }
1660        parts.extend(globbed);
1661    }
1662    if parts.len() == 1 {
1663        Value::str(parts.into_iter().next().unwrap_or_default())
1664    } else {
1665        Value::array(parts.into_iter().map(Value::str).collect())
1666    }
1667}
1668
1669/// The value of a word whose prefork an `(e)` NULL stopped
1670/// (c:Src/subst.c:142-147): each node left in the list, rendered with its quote
1671/// tokens (c:Src/exec.c:2134 untokenize), one word per node.
1672fn stopped_nodes_value(nodes: Vec<String>) -> Value {
1673    let mut words: Vec<Value> = nodes
1674        .iter()
1675        .map(|n| Value::str(crate::ported::lex::untokenize_ztokens(n)))
1676        .collect();
1677    match words.len() {
1678        0 => Value::str(String::new()),
1679        1 => words.remove(0),
1680        _ => Value::array(words),
1681    }
1682}
1683
1684/// Run the filesub BUILTIN_EXPAND_TEXT mode 10 deferred on one word's
1685/// elements (c:Src/subst.c:178-182, `flags` 0 for a command argument).
1686fn filesub_deferred_word(raw: Value) -> Value {
1687    let expand = |s: String| -> String {
1688        if s.starts_with(crate::ported::zsh_h::Tilde) || s.starts_with(crate::ported::zsh_h::Equals) {
1689            crate::ported::subst::filesub(&s, 0)
1690        } else {
1691            s
1692        }
1693    };
1694    match raw {
1695        Value::Array(items) => {
1696            Value::array(items.iter().map(|v| Value::str(expand(v.to_str()))).collect())
1697        }
1698        other => Value::str(expand(other.to_str())),
1699    }
1700}
1701
1702/// Status for a command whose words raised errflag, stored into LASTVAL:
1703/// c:Src/exec.c:3760-3761 `lastval = 1` after a globlist error, else
1704/// c:3523-3524 `if (!lastval) lastval = 1` after a prefork error.
1705fn words_errflag_status() -> i32 {
1706    use std::sync::atomic::Ordering;
1707    let status = if GLOB_WORD_ERRFLAG.with(|c| c.replace(false)) {
1708        1 // c:3761
1709    } else {
1710        match crate::ported::builtin::LASTVAL.load(Ordering::Relaxed) {
1711            0 => 1, // c:3524
1712            lastval => lastval,
1713        }
1714    };
1715    crate::ported::builtin::LASTVAL.store(status, Ordering::Relaxed);
1716    status
1717}
1718
1719thread_local! {
1720    /// c:Src/subst.c:142-147 / c:326-327 — paramsubst returned NULL (a failed
1721    /// `(e)` re-lex), so prefork stops: the current word ends at the `$`
1722    /// (c:1878) and the later words of the same list stay unexpanded. Read,
1723    /// never cleared, by BUILTIN_PREFORK_CUT_CHECK; cleared at the sublist
1724    /// prologue and when the command dispatches.
1725    ///
1726    /// !!! WARNING: RUST-ONLY CARRIER !!! C's NULL return unwinds prefork's
1727    /// loop in one step; zshrs expands a command's words across many VM ops.
1728    static PREFORK_CUT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1729}
1730
1731/// Take `subst::PARAMSUBST_NULL` right after a direct paramsubst call and
1732/// turn it into the PREFORK_CUT carrier.
1733fn take_paramsubst_null() -> bool {
1734    let null_return = crate::ported::subst::PARAMSUBST_NULL.with(|c| c.replace(false));
1735    if null_return {
1736        PREFORK_CUT.with(|c| c.set(true));
1737    }
1738    null_return
1739}
1740
1741/// Consume the `-` precommand carrier (see BUILTIN_EXEC_DASH).
1742pub(crate) fn take_exec_dash() -> bool {
1743    take_exec_carrier() & EXEC_CARRIER_DASH != 0
1744}
1745
1746/// `EXEC_DASH` bit: the `-` precommand (c:Src/exec.c:772-776).
1747pub(crate) const EXEC_CARRIER_DASH: u8 = 1;
1748/// `EXEC_DASH` bit: exec in the already-forked child (c:Src/exec.c:4369).
1749pub(crate) const EXEC_CARRIER_FORKED: u8 = 2;
1750/// `EXEC_DASH` bit: the exec replaces the shell itself, so SHLVL drops first
1751/// (exec_shlvl_decrement).
1752pub(crate) const EXEC_CARRIER_SHLVL: u8 = 4;
1753
1754/// c:Src/exec.c:3717 — `nsigtrapped || havefiles() || fdtable_flocks`: what
1755/// stops an exiting list's last command from exec'ing in place. nsigtrapped
1756/// counts real traps only (c:Src/signals.c:725 — an ignored signal is not
1757/// counted); zshrs keeps string traps in traps_table as well as sigtrapped.
1758fn fake_exec_blocked() -> bool {
1759    use crate::ported::zsh_h::{ZSIG_FUNC, ZSIG_TRAPPED};
1760    let trapped = crate::ported::signals::sigtrapped
1761        .lock()
1762        .map(|t| t.iter().any(|&st| st & (ZSIG_TRAPPED | ZSIG_FUNC) != 0))
1763        .unwrap_or(true)
1764        || crate::ported::builtin::traps_table()
1765            .lock()
1766            .map(|t| t.values().any(|body| !body.is_empty()))
1767            .unwrap_or(true);
1768    let files = crate::ported::jobs::JOBTAB
1769        .get()
1770        .and_then(|tab| tab.lock().ok().map(|jt| crate::ported::jobs::havefiles(&jt)))
1771        .unwrap_or(false);
1772    trapped
1773        || files
1774        || crate::ported::utils::FDTABLE_FLOCKS.load(std::sync::atomic::Ordering::Relaxed) != 0
1775}
1776
1777/// c:Src/exec.c:4332-4336 —
1778/// ```c
1779/// if (!subsh) {
1780///     /* for either implicit or explicit "exec", decrease $SHLVL
1781///      * as we're now done as a shell */
1782///     if (!forked)
1783///         setiparam("SHLVL", --shlvl);
1784/// ```
1785/// For an exec that replaces the shell itself (explicit `exec`, or the fake
1786/// exec of the `-c` string's last command): the command inherits one level
1787/// LESS than the shell counted for itself — it is taking the shell's place,
1788/// not nesting inside it.
1789///
1790/// C gets the environment update for free: SHLVL is PM_EXPORTED (from
1791/// `addenv` in createparamtable, c:Src/params.c:951), and `setiparam` on an
1792/// exported parameter runs setnumvalue → setstrvalue(NULL) → export_param →
1793/// addenv (c:2872, c:2841, c:2672). zshrs's `setnumvalue`
1794/// (src/ported/params.rs:5588-5595) has no `setstrvalue(v, NULL)` tail, so
1795/// the paramtab write alone would leave the environment holding the
1796/// pre-decrement number and the exec'd command would read one too high.
1797/// Publish it explicitly.
1798pub(crate) fn exec_shlvl_decrement() {
1799    let cur = crate::ported::params::getsparam("SHLVL")
1800        .and_then(|s| s.parse::<i64>().ok())
1801        .unwrap_or(1);
1802    crate::ported::params::setiparam("SHLVL", cur - 1); // c:4336
1803    crate::ported::params::addenv("SHLVL", &(cur - 1).to_string()); // c:2672
1804}
1805
1806/// Take the job text of the command being dispatched (see JOB_TEXT).
1807pub(crate) fn take_job_text() -> Option<String> {
1808    JOB_TEXT.with(|t| t.borrow_mut().take()).map(|v| v.to_str())
1809}
1810
1811/// Consume every external-spawn carrier bit (see EXEC_DASH).
1812pub(crate) fn take_exec_carrier() -> u8 {
1813    EXEC_DASH.with(|c| c.replace(0))
1814}
1815
1816pub(crate) fn dispatch_builtin(name: &str, args: Vec<String>) -> i32 {
1817    // c:Src/exec.c:772-776 — BINF_DASH only changes an external's argv[0].
1818    take_exec_dash();
1819    take_job_text();
1820    PREFORK_CUT.with(|c| c.set(false)); // the words are complete
1821
1822    // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close any
1823    // `>(cmd)` write ends owned by this command once it finishes
1824    // (drops on every return path below).
1825    let _psub_fds = PsubFdGuard;
1826    // c:Src/exec.c — when any redirect in the current scope failed
1827    // (e.g. noclobber blocked a `>` overwrite), zsh refuses to
1828    // execute the command and exits with status 1. The Rust port
1829    // still applied the command (writing to the /dev/null sink
1830    // installed by host_apply_redirect's noclobber arm) so the
1831    // success status overwrote the intended 1. Short-circuit here
1832    // for builtins (the external-exec equivalent lives in
1833    // ZshrsHost::exec).
1834    let redir_failed = with_executor(|exec| {
1835        let f = exec.redirect_failed;
1836        exec.redirect_failed = false;
1837        f
1838    });
1839    if redir_failed {
1840        // c:Src/exec.c:4367-4386 — POSIX special-builtin escalation:
1841        // a failed redirect on a PSPECIAL builtin (set, readonly,
1842        // typeset, ...) under POSIX_BUILTINS is FATAL in a
1843        // non-interactive shell (`exit(1)` at c:4383). The `command`
1844        // prefix resets this (BINF_COMMAND, c:4369) — that path
1845        // dispatches through bin_command, not here.
1846        if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
1847            && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
1848            && builtin_is_pspecial(name)
1849        {
1850            use std::sync::atomic::Ordering;
1851            crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
1852            crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
1853        }
1854        // c:Src/exec.c:252-256 execerr — `redir_err = lastval = 1;`. The
1855        // redirect arm already stored it, but the statement prologue between
1856        // the Redirect op and this call (BUILTIN_XTRACE_ARGS) copies the VM's
1857        // stale status back over LASTVAL, and the errflag abort that follows
1858        // exits with LASTVAL: `print hi <&""` exited 0 where zsh exits 1.
1859        // !!! DASH-FAMILY GATE !!! dash reports a redirection failure as 2.
1860        let st = crate::extensions::dash_mode::fatal_error_status().unwrap_or(1);
1861        crate::ported::builtin::LASTVAL.store(st, std::sync::atomic::Ordering::Relaxed);
1862        return st;
1863    }
1864    // c:Src/glob.c:1876-1880 NOMATCH path — when expand_glob() failed
1865    // on a no-match glob, zsh aborts the simple command after zerr()
1866    // printed "no matches found". In C, this works because zerr()
1867    // sets ERRFLAG_ERROR (Src/utils.c) and execcmd_exec()
1868    // (Src/exec.c:3050+) checks errflag before invoking the builtin
1869    // table. Rust's builtin dispatch doesn't sit on the same errflag
1870    // gate, so we explicitly consume the per-command glob-fail cell
1871    // and short-circuit with status 1. Mirrors the external-path
1872    // guard at host_exec_external (line 5167). Without this:
1873    // `echo /never/*` would print empty (silently rolled back to ""
1874    // by the empty glob expansion). Parity bug #13.
1875    //
1876    // A zsh/files name that runs from PATH (the arm below) is an external:
1877    // its glob failure ends the forked child, not the shell.
1878    if files_builtin_runs_from_path(name) {
1879        if let Some(status) = external_glob_failure_status() {
1880            return status;
1881        }
1882    }
1883    consume_tilde_globsubst_carrier();
1884    let glob_failed = with_executor(|exec| {
1885        let f = exec.current_command_glob_failed.get();
1886        exec.current_command_glob_failed.set(false); // c:1879 cleanup
1887        f
1888    });
1889    let postassign_glob_failed = TYPESET_POSTASSIGN_GLOB_FAILED.with(|c| c.replace(false));
1890    if postassign_glob_failed && !glob_failed {
1891        // c:Src/exec.c:4167-4285 — a typeset-family command's postassigns
1892        // (every word from its first `NAME=…` on, c:Src/parse.c:1986-1989 and
1893        // c:2008-2050) are globbed inside the builtin branch, after the
1894        // command words: `globlist(…); if (errflag) { …; break; }`. Then
1895        // c:4287 `if (!errflag) { … lastval = ret; }` skips the builtin
1896        // WITHOUT storing a status, so `lastval` keeps the previous command's
1897        // value (`print hi; local -a a=(zzq*)` exits 0, after `false` 1)
1898        // while ERRFLAG_ERROR still ends the script. A command word globbed
1899        // before them fails at c:3755-3763 instead, with `lastval = 1`.
1900        return crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
1901    }
1902    if glob_failed {
1903        // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH zerr sets
1904        // ERRFLAG_ERROR (via utils.c:184). For a BUILTIN command the
1905        // expansion runs IN the shell process, so errflag stays set
1906        // and the rest of the input aborts (zsh -fc 'echo /nope_*;
1907        // echo after' prints nothing after the error — verified
1908        // against zsh 5.9). The continue-after-nomatch behaviour
1909        // belongs ONLY to externals: C forks BEFORE expansion there,
1910        // so the child's zerr can't touch the parent's errflag (zsh
1911        // -fc 'ls /nope_*; echo after' prints `after`) — that path's
1912        // clear lives in fn exec / execute_external. Leave
1913        // ERRFLAG_ERROR set here; BUILTIN_ERREXIT_CHECK trigger 4
1914        // aborts the remaining script at the next command boundary.
1915        return 1; // c:1880 — command aborted, status 1
1916    }
1917    // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the NOMATCH
1918    // gate above: all of this command's globs failed silently (words
1919    // dropped, badcshglob accumulated 1s and no 2s) → `no match`,
1920    // skip the builtin, status 1. Like the NOMATCH path, ERRFLAG
1921    // from zerr stays set for builtins so the rest of the script
1922    // aborts (zsh -fc 'setopt cshnullglob; print *nope* x; print
1923    // after' prints only the error — verified zsh 5.9.1).
1924    if consume_badcshglob() {
1925        // c:Src/exec.c:3380 — `lastval = 1;` so the shell's final
1926        // exit status reflects the aborted command.
1927        with_executor(|exec| exec.set_last_status(1));
1928        return 1;
1929    }
1930    // c:Src/exec.c:4162-4295 — assignment-builtin (BINF_ASSIGN family:
1931    // typeset / declare / local / export / readonly / integer / float /
1932    // private) whose `name=value` postassign arg raised errflag while
1933    // its RHS was preforked (PREFORK_ASSIGN, c:4239-4245) — the classic
1934    // case is a math error in `typeset -F fv=$((1/0))`. The postassign
1935    // loop `break`s on errflag (c:4243) and then `if (!errflag)
1936    // execbuiltin(...)` (c:4287) SKIPS the builtin entirely, so `lastval`
1937    // is left UNCHANGED from before the command (0 fresh, 1 after
1938    // `false`). This differs from a PLAIN assignment `x=$((1/0))`, which
1939    // goes through execsimple c:1375 `lv = errflag ? errflag : cmdoutval`
1940    // → 1, and from a NON-assign builtin `print $((1/0))`, whose main
1941    // args-prefork errflag lands on c:3760 `lastval = 1`. Only the
1942    // assignment-BUILTIN postassign path preserves the prior status.
1943    // Mirror it here: the fusevm reg_passthru dispatch still calls us
1944    // with errflag set (unlike C's pre-invoke gate), so consume that
1945    // state and return the prior LASTVAL instead of running the builtin.
1946    {
1947        use std::sync::atomic::Ordering;
1948        let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
1949        let ef = live & crate::ported::zsh_h::ERRFLAG_ERROR;
1950        let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
1951        // Only the SOFT recoverable error (math failure like `$((1/0))`,
1952        // ERRFLAG_ERROR without ERRFLAG_HARD) preserves the prior status
1953        // per c:4287. A HARD error (`${var?msg}`, which c:Src/subst.c
1954        // OR's ERRFLAG_HARD onto errflag) is a script-abort that yields
1955        // status 1 regardless of the prior status — leave that to the
1956        // normal dispatch/abort path below (which returns 1 and keeps
1957        // ERRFLAG_HARD set for the downstream errexit gate).
1958        if ef != 0 && hard == 0 && builtin_is_assign_family(name) {
1959            // c:4287 — execbuiltin skipped; lastval unchanged.
1960            return crate::ported::builtin::LASTVAL.load(Ordering::Relaxed);
1961        }
1962        // c:Src/exec.c:3523-3525 — an expansion error in the command words
1963        // (prefork, c:3357-3359) ends the command before anything runs:
1964        //     if (errflag) { if (!lastval) lastval = 1; … return; }
1965        // so the previous command's non-zero status survives
1966        // (`nosuchcmd; print ${.bad}` exits 127). A glob failure took the
1967        // c:3760 `lastval = 1` return above; a HARD error keeps status 1.
1968        if ef != 0 && hard == 0 {
1969            return words_errflag_status();
1970        }
1971    }
1972    if let Some(status) = user_fn_override_unadvised(name, &args) {
1973        // c:Src/jobs.c:1748 waitonejob — canonical single-command
1974        // pipestats update via the no-procs else-branch.
1975        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1976        crate::ported::jobs::waitonejob(-1);
1977        return status;
1978    }
1979    // c:Src/builtin.c:587 + Src/exec.c:3056 — a builtin disabled via
1980    // `disable <name>` has its `DISABLED` flag set in `builtintab`;
1981    // `builtintab->getnode` (the DISABLED-filtering accessor) returns
1982    // NULL for it at lookup time, so execcmd_exec falls through to
1983    // PATH lookup and runs the external. The Rust port stores the
1984    // disabled set in `BUILTINS_DISABLED`; the previous dispatcher
1985    // only checked the immutable `createbuiltintable` HashMap which
1986    // never reflects disablement — so `disable echo; echo hi` kept
1987    // running the bin_echo builtin. Bug #106 in docs/BUGS.md.
1988    //
1989    // dispatch_builtin (the high-level wrapper used by the BUILTIN_*
1990    // opcode handlers and reg_passthru! callsites) is the correct
1991    // gate: `dispatch_builtin_raw` is the low-level entry point
1992    // used by `bin_builtin` itself which MUST bypass the disabled
1993    // set (man zshbuiltins: `builtin name` runs the builtin
1994    // regardless of disable state). Place the check here so the
1995    // bypass path stays clean.
1996    // c:Src/exec.c:3088-3096 — a WC_TYPESET command finds the builtin with
1997    // `getnode2`, which ignores DISABLED: `disable typeset` leaves the
1998    // reserved-word form running the builtin.
1999    let typeset_reswd = TYPESET_RESWD_DISPATCH.with(|c| c.replace(false));
2000    let disabled = !typeset_reswd
2001        && crate::ported::builtin::BUILTINS_DISABLED
2002            .lock()
2003            .map(|s| s.contains(name))
2004            .unwrap_or(false);
2005    if disabled {
2006        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
2007        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
2008        crate::ported::jobs::waitonejob(-1);
2009        return status;
2010    }
2011    // c:Src/Modules/files.c:806-814 — `mkdir`, `rm`, `mv`, `ln`, `chmod`,
2012    // `chown`, `chgrp`, `sync`, `rmdir` are bound by the `zsh/files`
2013    // module's boot_/setup_ chain. Without explicit `zmodload zsh/files`,
2014    // these bare names fall through to PATH (`/bin/rm`, `/usr/bin/chmod`,
2015    // etc.) in zsh; `type rm` reports `rm is /bin/rm`. The `zf_*`
2016    // aliases (`zf_rm`, `zf_chmod`, …) are bound by the same module
2017    // and gated the same way. Bug #28 in docs/BUGS.md.
2018    if files_builtin_runs_from_path(name) {
2019        // PATH lookup uses the literal name. In --zsh parity mode
2020        // `zf_rm` must 127 like zsh -fc (no /bin/zf_rm); default
2021        // zshrs mode keeps the convenience zf_-strip so the alias
2022        // still reaches the system binary.
2023        let path_name = if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2024            name
2025        } else {
2026            name.strip_prefix("zf_").unwrap_or(name)
2027        };
2028        let status =
2029            with_executor(|exec| exec.execute_external(path_name, &args, &[])).unwrap_or(127);
2030        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
2031        crate::ported::jobs::waitonejob(-1);
2032        return status;
2033    }
2034    // c:Src/exec.c:3997 `int q = queue_signal_level();`
2035    // c:Src/exec.c:4231 `dont_queue_signals();`
2036    // c:Src/exec.c:4243 `restore_queue_signals(q);`
2037    //
2038    // C runs EVERY builtin with signal queueing switched OFF. Two
2039    // consequences the zshrs port was missing:
2040    //
2041    //   1. `dont_queue_signals()` DRAINS the pending queue (it calls
2042    //      run_queued_signals()), so a signal that arrived while an
2043    //      enclosing scope held queue_signals() — doshfunc holds one
2044    //      for the whole call, c:Src/exec.c:5835 — fires its trap at
2045    //      the NEXT command boundary rather than at function exit.
2046    //   2. While the builtin runs, queueing stays off, so a signal the
2047    //      builtin sends to itself (`kill -USR1 $$`) dispatches the
2048    //      trap synchronously inside the builtin — which is why zsh
2049    //      prints pre/trap/post for
2050    //      `f() { print pre; kill -USR1 $$; print post }`.
2051    //
2052    // Without this bracket every trap raised inside a function was
2053    // deferred to the enclosing unqueue_signals() (i.e. script end).
2054    // c:Src/exec.c:3546 — `setunderscore((args && nonempty(args)) ?
2055    // ((char *) getdata(lastnode(args))) : "");`. execcmd_exec sets `$_`
2056    // to the last word of the command it is ABOUT to run — after the
2057    // words were expanded, before the builtin/external executes — so a
2058    // builtin that READS `_` at run time (`typeset -p _`, `${(P)…}`,
2059    // `$parameters[_]`) sees its own last argument, not the previous
2060    // command's. zshrs only did this for a handful of builtins (echo,
2061    // print, true, false, `:`) and for the external/function paths;
2062    // every reg_passthru! builtin was left reading the stale value.
2063    // C's `args` list carries argv[0], so a bare `cat` sets `_=cat` —
2064    // hence the fallback to `name` when there are no arguments.
2065    // c:Src/parse.c:2003-2008 — under the TYPESET reserved word the words
2066    // from the first `NAME=…` on are postassigns, not args: `local b c=2`
2067    // sets `_=b`, `typeset -g a=1` sets `_=-g`.
2068    let n_post = TYPESET_POSTASSIGN_COUNT.with(|c| c.take()).filter(|_| typeset_reswd).unwrap_or(0);
2069    let n_args = args.len().saturating_sub(n_post);
2070    let underscore = args[..n_args].last().cloned().unwrap_or_else(|| name.to_string()); // c:3546
2071    crate::ported::params::set_zunderscore(std::slice::from_ref(&underscore)); // c:3546
2072    let q = crate::ported::signals_h::queue_signal_level(); // c:3997
2073    crate::ported::signals_h::dont_queue_signals(); // c:4231
2074    // c:Src/exec.c:4300 `save[1] == -2` — this command's own redirections
2075    // did not touch fd 1.
2076    let stdout_unredirected = with_executor(|exec| {
2077        !(own_redirect_scope(exec)
2078            && exec
2079                .redirect_scope_stack
2080                .last()
2081                .is_some_and(|top| top.iter().any(|&(fd, _)| fd == 1)))
2082    });
2083    let ret = dispatch_builtin_raw(name, args);
2084    // c:Src/exec.c:4298-4305
2085    //     fflush(stdout);
2086    //     if (save[1] == -2) {
2087    //         if (ferror(stdout)) {
2088    //             zwarn("write error: %e", errno);
2089    //             clearerr(stdout);
2090    //         }
2091    //     } else
2092    //         clearerr(stdout);
2093    let ferror = crate::stdout_ferror::take_stdout_ferror();
2094    if ferror != 0 && stdout_unredirected {
2095        crate::ported::utils::zwarn(&format!(
2096            "write error: {}",
2097            crate::ported::utils::zsh_errno_msg(ferror)
2098        ));
2099    }
2100    // c:Src/exec.c:4289-4293 — "In case of interruption assume builtin
2101    // status is less useful than what interrupt set":
2102    //     if (!(errflag & ERRFLAG_INT)) lastval = ret;
2103    // The SIGINT handler wrote `lastval = 128 + SIGINT` (c:Src/signals.c:463)
2104    // and a forced INT-trap return wrote its own value (c:Src/signals.c:1202),
2105    // both into LASTVAL. Hand that back, so the VM's trailing SetStatus
2106    // publishes it instead of the interrupted builtin's 0:
2107    //   zsh -f -i -c 'kill -INT $$'   → exit 130 (zshrs exited 0)
2108    let status = if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2109        & crate::ported::zsh_h::ERRFLAG_INT)
2110        != 0
2111    {
2112        crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed)
2113    } else {
2114        ret
2115    };
2116    crate::ported::signals_h::restore_queue_signals(q); // c:4243
2117                                                        // c:Src/jobs.c:1748 waitonejob — canonical single-command pipestats update.
2118    crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
2119    crate::ported::jobs::waitonejob(-1);
2120    // c:Src/exec.c:4367-4386 — done: tail. A PSPECIAL builtin that
2121    // raised errflag under POSIX_BUILTINS exits the non-interactive
2122    // shell with status 1 ("hard error in POSIX" — e.g. bin_dot's
2123    // zerrnam at Src/builtin.c:6133). Arm the deferred-exit pair so
2124    // the next ERREXIT_CHECK unwinds; EXIT_VAL=1 matches C's
2125    // hardcoded exit(1), NOT the builtin's own status (dot returns
2126    // 127 but POSIX exits 1).
2127    if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
2128        && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
2129        && builtin_is_pspecial(name)
2130        && (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2131            & crate::ported::zsh_h::ERRFLAG_ERROR)
2132            != 0
2133    {
2134        use std::sync::atomic::Ordering;
2135        crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
2136        crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
2137    }
2138    status
2139}
2140
2141/// c:Src/zsh.h:1467 BINF_PSPECIAL — true when `name` is a POSIX
2142/// special builtin per the canonical builtin table flags
2143/// (Src/builtin.c:48-129: `.`, `:`, break, continue, declare, eval,
2144/// exit, export, float, integer, local, readonly, return, set,
2145/// shift, source, times, trap, typeset, unset).
2146fn builtin_is_pspecial(name: &str) -> bool {
2147    crate::ported::builtin::createbuiltintable()
2148        .get(name)
2149        .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_PSPECIAL) != 0)
2150        .unwrap_or(false)
2151}
2152
2153/// c:Src/zsh.h:1486 BINF_ASSIGN — the assignment-builtin family
2154/// (typeset / declare / local / export / readonly / integer / float /
2155/// private). Their `name=value` args are handled as postassigns
2156/// (c:Src/exec.c:4162-4295), whose errflag-abort skips execbuiltin and
2157/// preserves the prior `lastval`. Read the flag straight from the
2158/// builtin table (same pattern as `builtin_is_pspecial`).
2159fn builtin_is_assign_family(name: &str) -> bool {
2160    crate::ported::builtin::createbuiltintable()
2161        .get(name)
2162        .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_ASSIGN) != 0)
2163        .unwrap_or(false)
2164}
2165
2166// The former `install_exec_hooks()` fn-pointer registry is gone. Code
2167// under `src/ported/` now reaches `ShellExecutor` operations
2168// (array/assoc storage, script eval, function dispatch, command
2169// substitution) through the `crate::ported::exec::*` accessor wrappers,
2170// which resolve the live executor via `try_with_executor`
2171// (`CURRENT_EXECUTOR`). The bridge lives in exec.rs — the sanctioned
2172// fusevm-access exception — per `feedback_no_exec_script_from_ported` /
2173// `feedback_no_shellexecutor_in_ported`.
2174
2175/// The outcome of c:`Src/subst.c:3914-3927`, the JOIN half of the
2176/// join-then-split block: either the vector was collapsed onto a scalar
2177/// (`isarr = 0`) or it survived as an array (`isarr` still non-zero).
2178enum JoinC3914 {
2179    /// c:3917 / c:3925 — `val = sepjoin(aval, …, 1); isarr = 0;`
2180    Joined(String),
2181    /// Neither arm fired; `aval` reaches c:3931 with `isarr` non-zero, and
2182    /// that gate (`force_split && !isarr`) then keeps the split off too.
2183    Elements(Vec<String>),
2184}
2185
2186/// Port of the JOIN half of c:`Src/subst.c:3914-3927`, for an ARRAY-shaped
2187/// UNQUOTED expansion whose `spbreak` is non-zero — i.e. c:3912's gate is open
2188/// and c:3913's `force_split` is 1 (the `ssub` callers join at c:4226 instead
2189/// and never reach here).
2190///
2191/// ```c
2192///     if (isarr || quoted_array_with_offset) {
2193///         if (nojoin == 0 || sep) {
2194///             val = sepjoin(aval, sep, 1);
2195///             isarr = 0;
2196///         } else if (force_split &&
2197///                    (spsep || nojoin == 2 || (!ifs && isarr < 0))) {
2198///             val = sepjoin(aval, (nojoin == 1 ? NULL : spsep), 1);
2199///             isarr = 0;
2200///         }
2201///     }
2202/// ```
2203///
2204/// `sep`/`spsep` are the `(j:…:)` / `(s:…:)` separators, which never reach the
2205/// compiler fast paths (a flagged expansion goes to `paramsubst`), so the two
2206/// live discriminators are `nojoin` and whether IFS is UNSET or merely EMPTY:
2207///
2208/// * c:1819 / c:2569 — `nojoin = !(ifs && *ifs)`, i.e. 1 when IFS is unset OR
2209///   set to the empty string, 0 for any non-empty IFS.
2210/// * c:3030-3032 — `if (isarr) { if (nojoin) isarr = -1; }`. This is why a
2211///   `[*]` splice and a `[@]` splice behave IDENTICALLY here: whenever `nojoin`
2212///   is set they are BOTH `isarr < 0`, and when it is clear c:3916 joins them
2213///   both. The `[@]`-vs-`[*]` distinction only survives into the QUOTED arm at
2214///   c:3033, which this block is never reached with.
2215///
2216/// So: a non-empty IFS joins on `IFS[0]`; an UNSET IFS joins on `sepjoin`'s
2217/// default `" "` (c:`Src/utils.c:3936-3945`); an EMPTY IFS joins on nothing at
2218/// all and the element vector survives.
2219fn join_c3914(elems: Vec<String>, ifs: Option<&str>) -> JoinC3914 {
2220    // c:1819 — `!(ifs && *ifs)`.
2221    let nojoin = !matches!(ifs, Some(s) if !s.is_empty());
2222    // c:Src/utils.c:3936-3945 `sepjoin(s, NULL, heap)` — the separator is the
2223    // first metachar of `ifs`, except that a NULL `ifs` (unset) or one whose
2224    // first byte is already a space takes the literal `" "` fast path.
2225    let sep = match ifs {
2226        Some(s) if !s.starts_with(' ') => s.chars().next().map(String::from).unwrap_or_default(),
2227        _ => " ".to_string(),
2228    };
2229    // c:3916 `nojoin == 0`, then c:3919-3925's `(!ifs && isarr < 0)`.
2230    if !nojoin || ifs.is_none() {
2231        JoinC3914::Joined(elems.join(&sep))
2232    } else {
2233        JoinC3914::Elements(elems)
2234    }
2235}
2236
2237/// Port of c:`Src/subst.c:3932` `aval = sepsplit(val, spsep, 0, 1)` with
2238/// `spsep == NULL` (so c:`Src/utils.c:3971-3972` falls through to
2239/// `spacesplit(s, allownull=0, …)`), followed by c:`Src/subst.c:184-187`'s
2240/// empty-word removal.
2241///
2242/// `spacesplit` distinguishes the two kinds of empty field, and that is the
2243/// whole reason this cannot be a plain `split().filter(non-empty)`:
2244///
2245/// * An empty field delimited by IFS-NON-whitespace comes back as `nulstring`
2246///   (c:`Src/subst.c:36` `char nulstring[] = {Nularg, '\0'};`, emitted at
2247///   c:`Src/utils.c:3732` and c:3752). That word is one byte long, so prefork's
2248///   `uremnode` at c:186 keeps it and `remnulargs` at c:169 turns it into `""`.
2249///   `IFS=:; a=(x '' y)` therefore splits `x::y` back to THREE words.
2250/// * A field left by a skipped run of IFS-WHITESPACE is a real `""`
2251///   (c:`Src/utils.c:3734` / c:3757) and c:186 DOES delete it — unless the
2252///   caller is attaching it to neighbouring text, which is what `keep_empties`
2253///   (the `BUILTIN_FORCE_SPLIT` argc contract) says.
2254/// c:Src/glob.c:3663 `remnulargs` for a finished word: remove the c:36
2255/// `nulstring` markers a deferring word kept through its segments (see
2256/// BUILTIN_FORCE_SPLIT and `paramsubst_to_value_pf`), once the end-of-word drop
2257/// has decided which empty nodes survive. A marker-free value is returned as is.
2258/// argc bits the compiler adds to the segment path's end-of-word drop
2259/// (BUILTIN_ARRAY_DROP_EMPTY / BUILTIN_WORD_ELIDE_EMPTY) when the word's first
2260/// or last segment is a quoted empty literal (`""`, `''`). c:Src/subst.c:36 —
2261/// the lexer leaves its Dnull/Snull in the word, so the node that literal is
2262/// glued to is non-empty at c:183 and survives: the first node for a leading
2263/// literal, the last for a trailing one (c:4366-4437).
2264pub const WORD_DROP_KEEPS_FIRST: u8 = 4;
2265/// See [`WORD_DROP_KEEPS_FIRST`].
2266pub const WORD_DROP_KEEPS_LAST: u8 = 8;
2267
2268/// Whether node `i` (of `0..=last`) is anchored by a quoted empty literal.
2269fn word_drop_keeps(argc: u8, i: usize, last: usize) -> bool {
2270    (i == 0 && argc & WORD_DROP_KEEPS_FIRST != 0) || (i == last && argc & WORD_DROP_KEEPS_LAST != 0)
2271}
2272
2273fn strip_nulstring_markers(v: Value) -> Value {
2274    let nul = crate::ported::zsh_h::Nularg;
2275    match v {
2276        Value::Array(items) if items.iter().any(|x| x.as_str_cow().contains(nul)) => Value::array(
2277            items
2278                .iter()
2279                .map(|x| Value::str(x.as_str_cow().replace(nul, "")))
2280                .collect(),
2281        ),
2282        Value::Str(s) if s.contains(nul) => Value::str(s.replace(nul, "")),
2283        other => other,
2284    }
2285}
2286
2287fn sepsplit_c3932(s: &str, keep_empties: bool) -> Vec<String> {
2288    let raw = crate::ported::utils::sepsplit(s, None, false);
2289    let nulstring = crate::ported::zsh_h::Nularg.to_string();
2290    let mut out: Vec<String> = Vec::with_capacity(raw.len());
2291    for w in raw {
2292        if w == nulstring {
2293            out.push(String::new());
2294        } else if w.is_empty() {
2295            if keep_empties {
2296                out.push(String::new());
2297            }
2298        } else {
2299            out.push(w);
2300        }
2301    }
2302    out
2303}
2304
2305/// c:`Src/subst.c:3932-3938` for a bare read: `sepsplit` plus the 0/1/N shape.
2306/// Inside a word that opened with `BUILTIN_WORD_DEFER_EMPTIES`, c:184-187 runs on
2307/// the FINISHED node (after c:4366-4437 glue the affixes on), so the truly empty
2308/// edge fields go out raw for the end-of-word drop to judge:
2309/// `setopt shwordsplit; a=(a b ''); print -l x${a}y` is `xa` `b` `y`.
2310fn split_words_c3932(val: &str) -> Value {
2311    if crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get()) > 0 {
2312        return splice_words_value(crate::ported::utils::sepsplit(val, None, false));
2313    }
2314    splice_words_value(sepsplit_c3932(val, false))
2315}
2316
2317/// c:`Src/subst.c:3933-3938` — how a split (or untouched) word vector reaches
2318/// the caller: nothing at all, a SCALAR for a single field, an array otherwise.
2319///
2320/// ```c
2321///     if (!aval || !aval[0]) val = dupstring("");
2322///     else if (!aval[1])     val = aval[0];
2323///     else                   isarr = nojoin ? 1 : 2;
2324/// ```
2325fn splice_words_value(words: Vec<String>) -> Value {
2326    match words.len() {
2327        0 => Value::array(Vec::new()),
2328        1 => Value::str(words.into_iter().next().unwrap()),
2329        _ => Value::array(words.into_iter().map(Value::str).collect()),
2330    }
2331}
2332
2333/// c:`Src/subst.c:184-187` — prefork's `uremnode` deletes an UNQUOTED word that
2334/// came out empty. Applied to an element vector that c:3912's gate left alone.
2335fn prefork_c184_drop_empty(elems: Vec<String>) -> Vec<String> {
2336    elems.into_iter().filter(|e| !e.is_empty()).collect()
2337}
2338
2339/// Read a parameter as `paramsubst` does at c:`Src/subst.c:2916-2917`, which is
2340/// where both halves of the value come from:
2341///
2342/// ```c
2343///     if ((isarr = (v->scanflags & SCANPM_ISVAR_AT) ? -1
2344///                : v->scanflags ? 1 : 0)) {
2345///         … aval = getarrvalue(v);
2346///     } else {
2347///         … val = getstrvalue(v);
2348/// ```
2349///
2350/// The `bool` is that `isarr != 0` test — whether `fetchvalue` handed back an
2351/// ARRAY (or a hash, whose values `getarrvalue` enumerates) rather than a
2352/// scalar. Every caller needs it, because c:3914's join arm and c:3931's split
2353/// arm are mutually exclusive on exactly that bit.
2354///
2355/// A scalar comes back as a one-element vector so the two shapes share a type;
2356/// the `bool` is what tells them apart (a genuine one-element ARRAY is not the
2357/// same thing, though c:3899-3906 goes on to treat it as a scalar for the
2358/// join/split block — that rule belongs to the caller, not here).
2359fn param_value_elems_c2916(exec: &mut ShellExecutor, name: &str) -> (Vec<String>, bool) {
2360    if let Some(v) = crate::dash_mode::bash_special_array(name) {
2361        // bash `"${PIPESTATUS[*]}"` / `"${FUNCNAME[*]}"` join.
2362        return (v, true);
2363    }
2364    if name == "@" || name == "*" || name == "argv" {
2365        // c:Src/params.c:392,393,430 — the three IPDEF9 rows over `pparams`.
2366        return (exec.pparams(), true);
2367    }
2368    if let Some(assoc_map) = exec.assoc(name) {
2369        // c:Src/params.c — assoc-splat values for `"${h[@]}"` / `"${h[*]}"`.
2370        // Bug #109 in docs/BUGS.md.
2371        return (assoc_map.values().cloned().collect::<Vec<_>>(), true);
2372    }
2373    if let Some(arr) = exec.array(name) {
2374        // bash sparse arrays: `"${a[*]}"` joins only LIVE elements, dropping
2375        // hole slots. No-op in --zsh (no holes tracked).
2376        return (crate::bash_arrays::compact(name, arr), true);
2377    }
2378    if let Some(arr) = crate::ported::subst::arrays_get(name) {
2379        // c:Src/Modules/parameter.c:2239-2291 partab[] — the PM_ARRAY magic
2380        // specials (reswords/patchars/dis_*/…) are getfn-backed, so
2381        // `getaparam`'s `pm->u.arr` read (behind `exec.array`) comes back NULL
2382        // and the join fell through to the scalar fallback, which is empty.
2383        // Same omission the `[@]` splat had.
2384        return (arr, true);
2385    }
2386    if let Some(map) = crate::ported::subst::assoc_get(name) {
2387        // c:Src/Modules/parameter.c:2235-2298 partab[] — the PM_HASHED magic
2388        // assocs (aliases/functions/options/…) live behind a scanfn+getfn pair,
2389        // not in the executor's assoc storage, so `exec.assoc` above misses
2390        // them and `${aliases[*]}` joined an empty scalar where zsh joins the
2391        // alias VALUES.
2392        return (map.values().cloned().collect::<Vec<_>>(), true);
2393    }
2394    (vec![exec.get_variable(name)], false) // c:2927 getstrvalue
2395}
2396
2397/// Register all zsh builtins with the VM.
2398/// c:Src/exec.c:4473-4509 (`save_params`) — the per-name body of the loop,
2399/// run for a prefix assignment while its inline-env frame is still recording.
2400/// Shared by the scalar (BUILTIN_SET_VAR) and array (BUILTIN_SET_ARRAY)
2401/// assignment builtins: C walks every WC_ASSIGN in the chain regardless of
2402/// its type (c:4510-4511 advances over WC_ASSIGN_SCALAR and array words alike).
2403fn save_inline_prefix_param(exec: &mut ShellExecutor, name: &str) {
2404    // c:Src/exec.c:4475-4478 (54941) — `ss = itype_end(s, INAMESPC, 0);
2405    // slen = *ss == '[' || *ss == Inbrack ? ss - s : strlen(s);`: a
2406    // subscripted assignment (`H[2,4]=oo f`) saves its base parameter.
2407    let name = match name.find('[') {
2408        Some(i) => name[..i].to_string(),
2409        None => name.to_string(),
2410    };
2411    let prev_env = env::var(&name).ok();
2412    // c:Src/exec.c:4476 — `pm = paramtab->getnode(paramtab, s)`.
2413    // c:4491-4493 — an existing name is SNAPSHOTTED, not removed:
2414    // `tpm = zshcalloc(...); tpm->node.nam = ztrdup(pm->node.nam);
2415    //  copyparam(tpm, pm, 0);` — the WHOLE Param, type flags and
2416    // value union together. Saving only `getsparam(name)` (a
2417    // scalar string) is what destroyed every non-scalar: an array
2418    // came back as the IFS-joined string retyped to PM_SCALAR, and
2419    // an association came back as None — the scalar getter has
2420    // nothing to read for a PM_HASHED param — so the restore took
2421    // the "did not exist" arm and unset it outright.
2422    let pm_snapshot: Option<crate::ported::zsh_h::param> = {
2423        let live = crate::ported::params::paramtab()
2424            .read()
2425            .ok()
2426            .and_then(|t| t.get(&name).map(|p| (**p).clone()));
2427        // c:4480 / c:4494 — three arms, and the third saves
2428        // nothing: a PM_SPECIAL that is also PM_READONLY leaves
2429        // `tpm` NULL and only joins the remove list (c:4504).
2430        let fakecopy = live.as_ref().map(|p| {
2431            (p.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) != 0
2432        });
2433        let live = match (live, fakecopy) {
2434            (Some(p), Some(true))
2435                if (p.node.flags & crate::ported::zsh_h::PM_READONLY as i32) != 0 =>
2436            {
2437                None
2438            }
2439            (other, _) => other,
2440        };
2441        live.map(|mut tpm| {
2442            // c:Src/params.c:1273-1291 — copyparam reads the value
2443            // back through the type's GSU getfn rather than trusting
2444            // the union slot it already holds, because a special's or
2445            // a tied scalar's value does not live there.
2446            match crate::ported::zsh_h::PM_TYPE(tpm.node.flags as u32) {
2447                crate::ported::zsh_h::PM_INTEGER => {
2448                    // c:1279 — `tpm->u.val = pm->gsu.i->getfn(pm);`
2449                    // A special's getfn (`uidgetfn`, `gidgetfn`, …) does not
2450                    // read `u.val`, which `getiparam` returns for any
2451                    // PM_INTEGER node: `UID=$UID cmd` saved 0 and the restore
2452                    // ran `setuid(0)`. zshrs reaches those getfns by name.
2453                    tpm.u_val = crate::ported::params::lookup_special_var(&name)
2454                        .and_then(|v| v.parse::<i64>().ok())
2455                        .unwrap_or_else(|| crate::ported::params::getiparam(&name));
2456                }
2457                crate::ported::zsh_h::PM_EFLOAT
2458                | crate::ported::zsh_h::PM_FFLOAT => {
2459                    // c:1283 — `tpm->u.dval = pm->gsu.f->getfn(pm);`
2460                    tpm.u_dval = crate::ported::params::getnparam(&name).1;
2461                }
2462                crate::ported::zsh_h::PM_ARRAY => {
2463                    // c:1286 — `tpm->u.arr = zarrdup(pm->gsu.a->getfn(pm));`
2464                    tpm.u_arr = crate::ported::params::getaparam(&name);
2465                }
2466                crate::ported::zsh_h::PM_HASHED => {
2467                    // c:1289 — `tpm->u.hash = copyparamtable(...)`.
2468                    // See `hashed_snapshot` below for where the pairs go.
2469                }
2470                _ => {
2471                    // c:1276 — `tpm->u.str = ztrdup(pm->gsu.s->getfn(pm));`
2472                    tpm.u_str = crate::ported::params::getsparam(&name);
2473                }
2474            }
2475            // c:1269-1272 / c:1301-1302 — the `!fakecopy` postlude:
2476            // the copy is going back into the parameter table as a
2477            // REAL parameter, so it stops being special and gets the
2478            // standard get/set vtable for its type. That is what
2479            // makes a user's `typeset -T PAIR pair` scalar come back
2480            // as a plain scalar holding the joined string instead of
2481            // re-reading the partner array the prefix assignment
2482            // just overwrote.
2483            if fakecopy == Some(false) {
2484                tpm.node.flags &= !(crate::ported::zsh_h::PM_SPECIAL as i32); // c:1271
2485                crate::ported::params::assigngetset(&mut tpm); // c:1302
2486            }
2487            tpm
2488        })
2489    };
2490    // !!! RUST-ONLY FIELD — THE SECOND HALF OF c:1289 !!!
2491    // C keeps an association's pairs in `pm->u.hash`, so
2492    // `copyparam`'s `copyparamtable` copy travels inside the Param
2493    // above and `restore_params`' `tpm->gsu.h->setfn(tpm, pm->u.hash)`
2494    // (c:4561) puts them back. zshrs keeps them in the name-keyed
2495    // `paramtab_hashed_storage` instead, OUTSIDE the Param —
2496    // `arrhashsetfn`, the only writer of whole associations, never
2497    // populates `u_hash` at all (the same structural mismatch
2498    // `stdunsetfn` documents at params.rs:11044). So the pairs have
2499    // to be carried alongside the node and put back alongside it.
2500    let hashed_snapshot = if pm_snapshot.as_ref().is_some_and(|p| {
2501        crate::ported::zsh_h::PM_TYPE(p.node.flags as u32)
2502            == crate::ported::zsh_h::PM_HASHED
2503    }) {
2504        crate::ported::params::paramtab_hashed_storage()
2505            .lock()
2506            .ok()
2507            .and_then(|m| m.get(&name).cloned())
2508    } else {
2509        None
2510    };
2511    exec.inline_env_stack
2512        .last_mut()
2513        .unwrap()
2514        .saved
2515        .push(crate::vm_helper::SavedInlineParam {
2516            name: name.clone(),
2517            pm: pm_snapshot,
2518            hashed: hashed_snapshot,
2519            prev_env,
2520        });
2521}
2522
2523pub(crate) fn register_builtins(vm: &mut fusevm::VM) {
2524    // src/ported/ reaches the live executor (param store, function
2525    // dispatch, nested script/cmdsubst exec) through the
2526    // `crate::ported::exec::*` accessor wrappers, which read
2527    // `CURRENT_EXECUTOR` via `try_with_executor`. No install step is
2528    // needed: the executor is in scope for the duration of any VM run
2529    // (set by `ExecutorContext::enter`), so the wrappers resolve it
2530    // directly. (Replaces the former `exec_hooks` OnceLock fn-ptr
2531    // registry, now deleted.)
2532    // Engage fusevm's tiered JIT (block + tracing) so hot, fully-eligible
2533    // numeric chunks run in native code and — with the `jit-disk-cache`
2534    // feature (on by default) — persist that native code to
2535    // `~/.cache/fusevm-jit`, letting repeated zsh invocations skip Cranelift
2536    // codegen. fusevm gates the JIT on per-chunk eligibility and warms up by
2537    // an invocation threshold, falling back to the interpreter for any chunk
2538    // it cannot compile (e.g. host-builtin/`Extended` command dispatch), so
2539    // enabling it here never changes observable behaviour — it only caches
2540    // the numeric hot path. Idempotent: re-enabling on each VM is a no-op.
2541    vm.enable_tracing_jit();
2542    // Macro for builtins that user functions are allowed to shadow.
2543    // zsh dispatch order is alias → function → builtin; without the
2544    // try_user_fn_override probe a `cat() { ... }; cat` would silently
2545    // run the C builtin and ignore the user function.
2546    macro_rules! reg_overridable {
2547        ($vm:expr, $id:expr, $name:literal, $method:ident) => {
2548            $vm.register_builtin($id, |vm, argc| {
2549                let args = pop_args(vm, argc);
2550                // c:Src/exec.c getproc + Src/jobs.c deletefilelist —
2551                // close `>(cmd)` write ends owned by this command
2552                // once it finishes (shadows bypass dispatch_builtin
2553                // and ZshrsHost::exec, so they need their own guard:
2554                // `tee >(wc -c) </dev/null` left wc blocked).
2555                let _psub_fds = PsubFdGuard;
2556                if let Some(s) = try_user_fn_override($name, &args) {
2557                    return Value::Status(s);
2558                }
2559                // c:Src/exec.c — redirect failure in the current
2560                // scope means the command must NOT run. coreutils
2561                // shadows (cat / head / tail / etc.) take a separate
2562                // dispatch path from dispatch_builtin, so they need
2563                // their own gate. Without this `cat <&3` after a
2564                // closed-fd diagnostic still ran the shadow and
2565                // overwrote $? from the forced 1.
2566                let redir_failed = with_executor(|exec| {
2567                    let f = exec.redirect_failed;
2568                    exec.redirect_failed = false;
2569                    f
2570                });
2571                // c:Src/exec.c:4343-4350 — the prefix assignments of an
2572                // external run in the forked child (`addvars(…); if
2573                // (errflag) _exit(1);`): `UID=0 date` is skipped with
2574                // status 1 and the shell's errflag stays clean.
2575                let prefix_failed = PREFIX_ASSIGN_FAILED.with(|c| c.replace(false));
2576                if redir_failed || prefix_failed {
2577                    // c:Src/exec.c:3719 forks the external command these
2578                    // shadows stand in for BEFORE the redirection loop at
2579                    // c:3785, so a redirection `zerr` (`cat <&""` → "file
2580                    // number expected") lands in the child: the shell only
2581                    // sees status 1 (c:252-256 execerr in the child) and runs
2582                    // the next command. Same rule as call_function's arm.
2583                    let bits = if prefix_failed {
2584                        crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD
2585                    } else {
2586                        crate::ported::zsh_h::ERRFLAG_ERROR
2587                    };
2588                    crate::ported::utils::errflag.fetch_and(
2589                        !bits,
2590                        std::sync::atomic::Ordering::Relaxed,
2591                    );
2592                    // A NOMATCH / NULL_GLOB failure in the redirect target's
2593                    // filename generation belongs to the same child.
2594                    let _ = external_glob_failure_status();
2595                    // dash: a failed redirection is status 2 (dash_mode::fatal_error_status).
2596                    let st = if prefix_failed {
2597                        1
2598                    } else {
2599                        crate::extensions::dash_mode::fatal_error_status().unwrap_or(1)
2600                    };
2601                    crate::ported::builtin::LASTVAL.store(st, std::sync::atomic::Ordering::Relaxed);
2602                    return Value::Status(st);
2603                }
2604                // A failed argument glob fails in that forked child too.
2605                if let Some(status) = external_glob_failure_status() {
2606                    return Value::Status(status);
2607                }
2608                // `[builtins].coreutils_shadows = off` in
2609                // ~/.zshrs/zshrs.toml (or `ZSHRS_NO_COREUTILS_SHADOWS=1`
2610                // env override) bypasses the in-process shadow and
2611                // fork-execs the real /bin/X. Safety valve for any
2612                // script that hits an edge-case divergence between
2613                // the zshrs shadow and system coreutils. Cached
2614                // after first call, so the hot path is one atomic
2615                // load per shadowed-builtin invocation.
2616                // c:Src/exec.c:3545-3547 — these shadows stand in for
2617                // EXTERNAL commands (`cat`, `head`, …), which in zsh reach
2618                // execcmd_exec and set `$_` to the command's last word
2619                // before running. The in-process shadow below bypasses
2620                // dispatch_builtin AND execute_external_bg, so without this
2621                // `cat f; print $_` reported the PREVIOUS command's last
2622                // argument.
2623                {
2624                    let last = args.last().cloned().unwrap_or_else(|| $name.to_string());
2625                    crate::ported::params::set_zunderscore(std::slice::from_ref(&last));
2626                    // c:3546
2627                }
2628                // With the shadow off (always under `--zsh`) the name is the
2629                // external command it stands in for, so it takes the external
2630                // path: c:Src/exec.c:4369 `execute()` replaces a child already
2631                // forked for it (`sleep 5 &`, a pipeline stage) instead of
2632                // forking again, and a signal sent to the job reaches the
2633                // command itself (`kill -HUP %1` is `hangup`, not `exit 1`).
2634                if !crate::daemon_presence::coreutils_shadows_enabled() {
2635                    return Value::Status(with_executor(|exec| {
2636                        exec.execute_external($name, &args, &[]).unwrap_or(127)
2637                    }));
2638                }
2639                let status = with_executor(|exec| exec.$method(&args));
2640                Value::Status(status)
2641            });
2642        };
2643    }
2644
2645    // Pure-passthru builtin: pops args, routes to canonical
2646    // `dispatch_builtin(name, args)` (which goes via execbuiltin →
2647    // BUILTINS[name] → bin_X). No pre/post bridge work. Used by
2648    // ~25 handlers that were 4-line copy-paste boilerplate.
2649    macro_rules! reg_passthru {
2650        ($vm:expr, $id:expr, $name:literal) => {
2651            $vm.register_builtin($id, |vm, argc| {
2652                let args = pop_args(vm, argc);
2653                // function > builtin: a same-named user function wins over
2654                // the builtin on the normal (CallBuiltin) invocation path.
2655                // The compiler's `user_function_shadow` already routes the
2656                // same-compile-unit case through CallFunction; this probe
2657                // extends that to the cross-unit / interactive case (define
2658                // `zstyle() { … }` on one line, call it on the next). The
2659                // forced `builtin NAME` / `command NAME` paths dispatch
2660                // through their own handlers, not this one, so they still
2661                // reach the builtin as required.
2662                if let Some(s) = try_user_fn_override($name, &args) {
2663                    return Value::Status(s);
2664                }
2665                Value::Status(dispatch_builtin($name, args))
2666            });
2667        };
2668    }
2669
2670    // zshrs-original extension builtins (async / peach / doctor / …) that
2671    // route to an ExecutorContext method. Like `reg_overridable!`, they
2672    // probe `try_user_fn_override` FIRST so a user function of the same
2673    // name wins — zsh's alias → function → builtin dispatch order. Without
2674    // the probe, `doctor() { … }; doctor` silently ran the builtin and
2675    // ignored the function (function > builtin violated for these).
2676    macro_rules! reg_ext_overridable {
2677        ($vm:expr, $id:expr, $name:literal, $method:ident) => {
2678            $vm.register_builtin($id, |vm, argc| {
2679                let args = pop_args(vm, argc);
2680                if let Some(s) = try_user_fn_override($name, &args) {
2681                    return Value::Status(s);
2682                }
2683                Value::Status(with_executor(|exec| exec.$method(&args)))
2684            });
2685        };
2686    }
2687
2688    // Core builtins
2689    vm.register_builtin(BUILTIN_CD, |vm, argc| {
2690        let args = pop_args(vm, argc);
2691        if let Some(s) = try_user_fn_override("cd", &args) {
2692            return Value::Status(s);
2693        }
2694        // c:Src/builtin.c:1258 — the chpwd hook runs inside cd_new_pwd.
2695        Value::Status(dispatch_builtin("cd", args))
2696    });
2697
2698    vm.register_builtin(BUILTIN_PWD, |vm, argc| {
2699        let args = pop_args(vm, argc);
2700        if let Some(s) = try_user_fn_override("pwd", &args) {
2701            return Value::Status(s);
2702        }
2703        // Route through the canonical execbuiltin path so the `rLP`
2704        // optstr at BUILTINS["pwd"] is parsed into `ops`.
2705        let status = dispatch_builtin("pwd", args);
2706        Value::Status(status)
2707    });
2708
2709    vm.register_builtin(BUILTIN_ECHO, |vm, argc| {
2710        let args = pop_args(vm, argc);
2711        if let Some(s) = try_user_fn_override("echo", &args) {
2712            return Value::Status(s);
2713        }
2714        // Update `$_` to the last arg before running. C zsh sets
2715        // zunderscore in execcmd_exec for every simple command,
2716        // including builtins.
2717        crate::ported::params::set_zunderscore(&args);
2718        let status = dispatch_builtin("echo", args);
2719        Value::Status(status)
2720    });
2721
2722    vm.register_builtin(BUILTIN_PRINT, |vm, argc| {
2723        let args = pop_args(vm, argc);
2724        if let Some(s) = try_user_fn_override("print", &args) {
2725            return Value::Status(s);
2726        }
2727        crate::ported::params::set_zunderscore(&args);
2728        let status = dispatch_builtin("print", args);
2729        Value::Status(status)
2730    });
2731
2732    reg_passthru!(vm, BUILTIN_PRINTF, "printf");
2733    reg_passthru!(vm, BUILTIN_EXPORT, "export");
2734    reg_passthru!(vm, BUILTIN_UNSET, "unset");
2735    // `source` (Src/builtin.c c:116) wired to bin_dot via BUILTINS.
2736    reg_passthru!(vm, BUILTIN_SOURCE, "source");
2737    reg_passthru!(vm, BUILTIN_DOT, ".");
2738    reg_passthru!(vm, BUILTIN_LOGOUT, "logout");
2739
2740    vm.register_builtin(BUILTIN_EXIT, |vm, argc| {
2741        let args = pop_args(vm, argc);
2742        // AOP intercepts (zshrs extension). A bare `exit` reads the status of
2743        // the previous command, which the advice must not disturb.
2744        if let Some(done) = advise_keeping_status(vm, "exit", &args) {
2745            return done;
2746        }
2747        let status = dispatch_builtin("exit", args);
2748        Value::Status(status)
2749    });
2750
2751    vm.register_builtin(BUILTIN_RETURN, |vm, argc| {
2752        let args = pop_args(vm, argc);
2753        // AOP intercepts (zshrs extension). A bare `return` reads the status of
2754        // the previous command, which the advice must not disturb.
2755        if let Some(done) = advise_keeping_status(vm, "return", &args) {
2756            return done;
2757        }
2758        // zsh: bare `return` (no arg) returns with the status of
2759        // the most recently executed command — `false; return`
2760        // returns 1, not 0. Direct port of zsh's bin_break/RETURN.
2761        // The executor's `last_status` is stale here (synced at
2762        // statement boundaries, not after each VM op), so read
2763        // the live `vm.last_status` instead.
2764        let live_status = vm.last_status;
2765        let status = {
2766            // Sync canonical LASTVAL to the VM's view BEFORE
2767            // bin_break("return") reads it for the no-arg fallback.
2768            with_executor(|exec| exec.set_last_status(live_status));
2769            dispatch_builtin("return", args)
2770        };
2771        Value::Status(status)
2772    });
2773
2774    vm.register_builtin(BUILTIN_TRUE, |vm, argc| {
2775        let args = pop_args(vm, argc);
2776        if let Some(s) = try_user_fn_override("true", &args) {
2777            return Value::Status(s);
2778        }
2779        // c:Src/exec.c:1257 — zsh sets `zunderscore` AT THE END of
2780        // each command (the `if (!noerrs)` block runs `zsfree(prev_argv0); …;
2781        // zunderscore = …`). For no-arg `true`, $_ becomes the
2782        // command name itself. Set DIRECTLY (not via pending_underscore)
2783        // so the NEXT command's argv-expansion of `$_` reads "true",
2784        // not the stale prior value — pending_underscore is consumed
2785        // by pop_args which runs AFTER argv expansion, too late.
2786        // c:Src/exec.c:1257 — `zunderscore = …` at end-of-command.
2787        // With args, $_ = args.last(). Without args, $_ = command name.
2788        // Write DIRECTLY to the canonical zunderscore static (the
2789        // underscoregetfn at params.rs:7003 reads from there); the
2790        // paramtab "_" slot is shadowed by lookup_special_var so
2791        // set_scalar on it has no effect on `$_` reads.
2792        if args.is_empty() {
2793            crate::ported::params::set_zunderscore(&["true".to_string()]);
2794        } else {
2795            crate::ported::params::set_zunderscore(&args);
2796        }
2797        // Route through canonical execbuiltin so PS4 xtrace fires
2798        // via the c:442 printprompt4 path.
2799        Value::Status(dispatch_builtin("true", args))
2800    });
2801    vm.register_builtin(BUILTIN_FALSE, |vm, argc| {
2802        let args = pop_args(vm, argc);
2803        if let Some(s) = try_user_fn_override("false", &args) {
2804            return Value::Status(s);
2805        }
2806        // Direct set; see BUILTIN_TRUE above for rationale.
2807        if args.is_empty() {
2808            crate::ported::params::set_zunderscore(&["false".to_string()]);
2809        } else {
2810            crate::ported::params::set_zunderscore(&args);
2811        }
2812        // Route through canonical execbuiltin — see BUILTIN_TRUE
2813        // above for the same rationale (xtrace + fast-path removal).
2814        let status = dispatch_builtin("false", args);
2815        Value::Status(status)
2816    });
2817    vm.register_builtin(BUILTIN_COLON, |vm, argc| {
2818        let args = pop_args(vm, argc);
2819        // AOP intercepts (zshrs extension): this opcode builtin has no
2820        // user-function probe, so its advice runs here.
2821        if let Some(status) = run_command_intercepts(":", &args) {
2822            return Value::Status(status);
2823        }
2824        // Direct set; see BUILTIN_TRUE above for rationale.
2825        if args.is_empty() {
2826            crate::ported::params::set_zunderscore(&[":".to_string()]);
2827        } else {
2828            crate::ported::params::set_zunderscore(&args);
2829        }
2830        let status = dispatch_builtin(":", args);
2831        Value::Status(status)
2832    });
2833
2834    // `[ … ]` — c:Src/builtin.c:7231 `bin_test` with BIN_BRACKET. See
2835    // BUILTIN_TEST_BRACKET.
2836    vm.register_builtin(BUILTIN_TEST_BRACKET, |vm, argc| {
2837        let args = pop_args(vm, argc);
2838        // AOP intercepts (zshrs extension): this opcode builtin has no
2839        // user-function probe, so its advice runs here.
2840        if let Some(status) = run_command_intercepts("[", &args) {
2841            return Value::Status(status);
2842        }
2843        Value::Status(dispatch_builtin("[", args))
2844    });
2845    vm.register_builtin(BUILTIN_TEST, |vm, argc| {
2846        let args = pop_args(vm, argc);
2847        // AOP intercepts (zshrs extension): this opcode builtin has no
2848        // user-function probe, so its advice runs here.
2849        if let Some(status) = run_command_intercepts("test", &args) {
2850            return Value::Status(status);
2851        }
2852        // c:Src/builtin.c:7231 `bin_test` with funcid BIN_TEST. The `[`
2853        // spelling compiles to BUILTIN_TEST_BRACKET, so this slot is only ever
2854        // `test`. It used to guess the spelling from a trailing `]`, which ran
2855        // `test a ]` as `[ a ]` (status 0) where zsh reports "condition
2856        // expected: a" (status 2), and ran `[ a` as `test a`.
2857        Value::Status(dispatch_builtin("test", args))
2858    });
2859
2860    // Variable declaration. `local` (Src/builtin.c bin_local) handles
2861    // the scope chain (`pm->old = oldpm` at Src/params.c:1137 inside
2862    // createparam, `pm->level = locallevel` at Src/builtin.c:2576).
2863    // `typeset` / `declare` are aliases — fusevm maps both to
2864    // BUILTIN_TYPESET; compile_zsh special-cases `declare` to keep
2865    // the `declare:` error prefix.
2866    reg_passthru!(vm, BUILTIN_LOCAL, "local");
2867    reg_passthru!(vm, BUILTIN_TYPESET, "typeset");
2868
2869    reg_passthru!(vm, BUILTIN_DECLARE, "declare");
2870    reg_passthru!(vm, BUILTIN_READONLY, "readonly");
2871    reg_passthru!(vm, BUILTIN_INTEGER, "integer");
2872    reg_passthru!(vm, BUILTIN_FLOAT, "float");
2873    reg_passthru!(vm, BUILTIN_READ, "read");
2874    // c:Bug #504 — fusevm reserves BUILTIN_MAPFILE for the bash
2875    // mapfile/readarray builtins. Neither exists in zsh; in --zsh
2876    // parity mode the dispatch must emit "command not found" + rc=127
2877    // matching zsh's external-command-lookup miss. The previous wiring
2878    // left BUILTIN_MAPFILE unregistered, so fusevm's VM treated the op
2879    // as a no-op rc=0 — `mapfile` (and `readarray`) silently succeeded
2880    // in --zsh mode. The host gate in `dispatch_builtin_raw` never
2881    // fired because the compile path emitted `Op::CallBuiltin(31, ..)`
2882    // directly. Register the slot so the gate runs (or a future
2883    // non-zsh mode can wire in a real impl).
2884    vm.register_builtin(fusevm::shell_builtins::BUILTIN_MAPFILE, |vm, argc| {
2885        let args = pop_args(vm, argc);
2886        // AOP intercepts (zshrs extension): this opcode builtin has no
2887        // user-function probe, so its advice runs here.
2888        if let Some(status) = run_command_intercepts("mapfile", &args) {
2889            return Value::Status(status);
2890        }
2891        // The fusevm name→id map collapses both `mapfile` and
2892        // `readarray` to the same opcode; pick the right diagnostic
2893        // by sniffing the user's actual invocation. The xtrace ARGS
2894        // push earlier records the cmd-prefix as the bottom of the
2895        // popped argv, but `args` here excludes the prefix — so we
2896        // can't recover the user-typed name from the stack. Default
2897        // to `mapfile` (the more-common spelling); both produce
2898        // identical diagnostics in any case.
2899        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2900            eprintln!("zsh:1: command not found: mapfile");
2901            let _ = args;
2902            return Value::Status(127);
2903        }
2904        // Non-zsh modes (bash drop-in): mapfile / readarray reads lines
2905        // from stdin (or `-u fd`) into an array. Handled by the ported
2906        // bash builtin in ext_builtins.
2907        Value::Status(crate::extensions::ext_builtins::readarray(&args))
2908    });
2909    reg_passthru!(vm, BUILTIN_BREAK, "break");
2910    reg_passthru!(vm, BUILTIN_CONTINUE, "continue");
2911    reg_passthru!(vm, BUILTIN_SHIFT, "shift");
2912
2913    vm.register_builtin(BUILTIN_EVAL, |vm, argc| {
2914        // Direct port of `bin_eval(UNUSED(char *nam), char **argv, UNUSED(Options ops), UNUSED(int func))` body from Src/builtin.c:6151:
2915        //   `if (!*argv) return 0;`
2916        //   `prog = parse_string(zjoin(argv, ' ', 1), 1);`
2917        //   `execode(prog, 1, 0, "eval");`
2918        // The execode invocation lives here (not in the canonical
2919        // free-fn) because it must run through the bytecode VM's
2920        // current executor — the same VM that's mid-dispatch.
2921        let mut args = pop_args(vm, argc);
2922        // AOP intercepts (zshrs extension): this opcode builtin has no
2923        // user-function probe, so its advice runs here.
2924        if let Some(status) = run_command_intercepts("eval", &args) {
2925            return Value::Status(status);
2926        }
2927        // c:Src/builtin.c:407-411 — generic `--` end-of-options
2928        // strip applied by `execbuiltin` for builtins that have
2929        // NULL optstr AND no BINF_HANDLES_OPTS. `eval` qualifies
2930        // (Src/builtin.c:65 `BUILTIN("eval", BINF_PSPECIAL, ...,
2931        // NULL, NULL)`). The BUILTIN_EVAL fast-path bypasses
2932        // execbuiltin, so we mirror the strip inline. Bug #319.
2933        if args.first().is_some_and(|s| s == "--") {
2934            args.remove(0);
2935        }
2936        if args.is_empty() {
2937            return Value::Status(0); // c:6160
2938        }
2939        let src = args.join(" "); // c:6166
2940                                  // c:Src/builtin.c:6164-6165 — `if (!ineval) scriptname =
2941                                  // "(eval)";`. Diagnostics emitted while the eval body runs
2942                                  // (command-not-found, parse errors, etc.) use scriptname as
2943                                  // the source-context prefix. Without setting it here the
2944                                  // BUILTIN_EVAL fast-path leaked the outer "zsh" prefix
2945                                  // through, breaking the `(eval):N:` convention zsh uses
2946                                  // for in-eval errors. Bug #420.
2947                                  // c:Src/builtin.c:6209 — `execode(prog, 1, 0, "eval");`. execode
2948                                  // (c:Src/exec.c:1245-1266) APPENDS its context argument to
2949                                  // `zsh_eval_context` for the duration of the body, so code inside
2950                                  // `eval` sees `cmdarg:eval` (and `cmdarg:shfunc:eval` when the eval is
2951                                  // in a function). zshrs pushed "shfunc" and, since #1065, "cmdsubst",
2952                                  // but never "eval". Popped on every return path by the guard, matching
2953                                  // execode's stack discipline. Bug #1065 (eval leg).
2954        let _eval_ctx_guard = crate::ported::exec::EvalContextFrame::push("eval");
2955        // c:Src/builtin.c:6163-6178 — `eval` pushes a funcstack frame named
2956        // "(eval)" (tp = FS_EVAL), gated on `ineval = !isset(EVALLINENO)` /
2957        // `if (!ineval)` — i.e. pushed when EVAL_LINENO is SET, which is the
2958        // zsh default. zshrs already set `scriptname = "(eval)"` (below) but
2959        // never pushed the frame, so `eval '…${#funcstack}'` reported 0 where
2960        // zsh reports 1, and inside a function `${(j:,:)funcstack}` was `f`
2961        // rather than `(eval),f`. Both shells already agreed under
2962        // `unsetopt evallineno` (no frame), so the option gate is load-bearing
2963        // and is mirrored here. Popped on every return path by the guard.
2964        // Bug #1066.
2965        // The push itself is the canonical port (`EvalFuncstackFrame::push`,
2966        // exec.rs, c:6155-6193) — shared with `eval_string`, which the
2967        // compsys `_dispatch` port uses for its `eval "$comp"` sites so both
2968        // eval entry points produce a byte-identical `(eval)` frame. It was
2969        // inline here and set `lineno`/`flineno` to 0 with no `filename`,
2970        // which C computes at c:6161 / c:6174-6188 — so `$functrace` read
2971        // `<caller>:0` and `$funcfiletrace` lost the defining file.
2972        let _eval_fs_guard = crate::ported::exec::EvalFuncstackFrame::push();
2973        let oscriptname = crate::ported::utils::scriptname_get();
2974        crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
2975        // Recursion backstop — c:Src/jobs.c:1878-1884. zsh caps eval recursion
2976        // via its job table (every eval'd pipeline grabs a job slot; the table
2977        // caps at MAX_MAXJOBS → "job table full or recursion limit exceeded").
2978        // The fusevm runtime allocates no job per pipeline, and nested evals
2979        // push no funcstack frame (INEVAL suppression, c:6164), so eval nesting
2980        // is invisible to both the job table AND FUNCNEST/FUNCSTACK — runaway
2981        // `eval`-string recursion overflowed the 256 MB main-thread stack →
2982        // uncatchable SIGBUS. Track eval re-entry depth (the Rust proxy for
2983        // held job slots) and refuse at the same MAX_MAXJOBS ceiling.
2984        let eval_depth = crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| {
2985            let v = d.get() + 1;
2986            d.set(v);
2987            v
2988        });
2989        let mut status = if eval_depth >= crate::ported::jobs::MAX_MAXJOBS {
2990            crate::ported::utils::zerr("job table full or recursion limit exceeded");
2991            1
2992        } else {
2993            with_executor(|exec| {
2994                // c:6175 execode — no end-of-script hooks (c:Src/builtin.c:6213)
2995                exec.execute_string_without_exit_hooks(&src).unwrap_or(1)
2996            })
2997        };
2998        crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
2999        // c:Src/builtin.c:6211-6212 — `if (errflag && !lastval)
3000        //   lastval = errflag;`
3001        // c:Src/builtin.c:6221 — `errflag &= ~ERRFLAG_ERROR;`
3002        // eval is a CONTAINMENT boundary: an error inside the eval
3003        // body (readonly reassign, bad assoc set, ${unset?msg}, …)
3004        // breaks the eval body's lists via errflag, then eval clears
3005        // the flag and returns lastval, and the CALLER's next list
3006        // runs. zsh 5.9: `eval 'assoc=(odd)'; echo "after $?"`
3007        // prints `after 1` in -c, script, and stdin contexts.
3008        {
3009            use std::sync::atomic::Ordering;
3010            let ef = crate::ported::utils::errflag.load(Ordering::Relaxed)
3011                & crate::ported::zsh_h::ERRFLAG_ERROR;
3012            if ef != 0 && status == 0 {
3013                status = ef; // c:6212 lastval = errflag
3014            }
3015            crate::ported::utils::errflag
3016                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
3017            // The NOMATCH cell is the Rust half of that error state (set
3018            // beside `zerr` for c:Src/glob.c:1877). The builtin dispatcher
3019            // reads it BEFORE the next command runs, so one left over from
3020            // `eval 'x=( *nomatch )'` skipped the caller's next command.
3021            with_executor(|exec| exec.current_command_glob_failed.set(false));
3022        }
3023        crate::ported::utils::set_scriptname(oscriptname);
3024        Value::Status(status)
3025    });
3026
3027    // `builtin foo args…`: precmd-modifier that forces builtin dispatch,
3028    // bypassing alias AND function lookup. Without this, `builtin cd /`
3029    // inside a user `cd () { … }` wrapper recurses (real-world ZPWR pattern).
3030    // Handler pops argc args from the stack, treats args[0] as the builtin
3031    // name, and dispatches the rest via `dispatch_builtin` → `execbuiltin`
3032    // → `bin_*` directly. No function/alias lookup happens.
3033    vm.register_builtin(BUILTIN_BUILTIN, |vm, argc| {
3034        let args = pop_args(vm, argc);
3035        // c:Src/exec.c:3483-3487 — the precommand-modifier walk checks
3036        // `shfunctab` for the command word BEFORE `builtintab`, and only
3037        // skips that check once a prefix has already been consumed
3038        // (`cflags & (BINF_BUILTIN|BINF_COMMAND)`). So on the FIRST word a
3039        // shell function literally named `builtin` shadows the builtin —
3040        // `builtin() { ... }; builtin whence -va x` runs the function.
3041        // zshrs resolves `builtin` at bytecode-compile time (no function
3042        // table yet), so the shadow test has to happen here, at dispatch.
3043        if let Some(status) = try_user_fn_override("builtin", &args) {
3044            return Value::Status(status);
3045        }
3046        // c:Src/exec.c:3760-3763 — `if (errflag) { lastval = 1; goto err; }`
3047        // after argument expansion: a bad glob qualifier or pattern in the
3048        // words (`builtin print x(a)` "number expected") skips the builtin
3049        // with status 1 and leaves errflag set, so the script ends. This
3050        // handler is reached without dispatch_builtin's gates, and ran the
3051        // builtin, which reported 0.
3052        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3053            & crate::ported::zsh_h::ERRFLAG_ERROR)
3054            != 0
3055        {
3056            // c:3523-3525 / c:3760-3762 — see words_errflag_status.
3057            return Value::Status(words_errflag_status());
3058        }
3059        // c:Src/exec.c:3104-3146 — the precommand walk strips a `-` word after
3060        // `builtin` too (it is itself a BINF_PREFIX node, c:Src/builtin.c:42);
3061        // when nothing is left, c:3380-3406 is the empty-command return and
3062        // `builtin -` succeeds without running anything.
3063        let mut full = Vec::with_capacity(args.len() + 1);
3064        full.push("builtin".to_string());
3065        full.extend(args.iter().cloned());
3066        if crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE)
3067            .is_empty_command
3068        {
3069            return Value::Status(0);
3070        }
3071        let Some((name, rest)) = args.split_first() else {
3072            // `builtin` with no args → list builtins (zsh emits nothing,
3073            // exit 0). Match that behavior; the BIN_BUILTIN bin_* in C
3074            // does the same default-list-nothing.
3075            return Value::Status(0);
3076        };
3077        // zshrs extension builtins (daemon z* family: zd, zcache, zjob,
3078        // …) are dispatched by name via try_dispatch instead of living
3079        // in builtintab — but they ARE builtins, so the `builtin`
3080        // precommand must reach them (`builtin zd ping` errored
3081        // "no such builtin: zd" while bare `zd ping` worked).
3082        if let Some(status) = run_daemon_builtin(name, rest) {
3083            return Value::Status(status);
3084        }
3085        // c:Src/exec.c:3435-3436 — `builtin NAME` with NAME not in
3086        // builtintab emits `zwarn("no such builtin: %s", cmdarg)`
3087        // and returns 1. zshrs's dispatch_builtin_raw bare-returned 1
3088        // silently. Probe the table here so the diagnostic fires
3089        // before dispatch.
3090        let tab = crate::ported::builtin::createbuiltintable();
3091        // c:Src/exec.c:3489-3499 — `builtintab->getnode` skips DISABLED
3092        // entries, so `disable typeset; builtin typeset x=1` is "no such
3093        // builtin: typeset" with status 1, like a name that was never a builtin.
3094        let disabled = crate::ported::builtin::BUILTINS_DISABLED
3095            .lock()
3096            .map(|s| s.contains(name.as_str()))
3097            .unwrap_or(false);
3098        if !tab.contains_key(name.as_str()) || disabled {
3099            // zshrs-original opcode builtins (async, doctor, peach, …) aren't
3100            // in builtintab; `builtin NAME` must still reach them.
3101            if let Some(status) = try_run_registered_builtin(name, rest) {
3102                return Value::Status(status);
3103            }
3104            // c:Src/exec.c:3436 — `zwarn("no such builtin: %s", cmdarg);`.
3105            // Route through the ported `zwarn` rather than formatting the
3106            // prefix by hand: zwarn emits zsh's `zsh:LINE:` prefix, and the
3107            // hand-rolled `eprintln!` here printed `zshrs:1:` instead. Of the
3108            // twelve error shapes probed this was the ONLY one carrying the
3109            // wrong prefix — the ported twin at exec.rs:9878 already used
3110            // zwarn correctly, so this was a reimplementation shadowing a
3111            // faithful port (same shape as #1027 / #1031 / #1044 / #1050).
3112            // Bug #1063.
3113            crate::ported::utils::zwarn(&format!("no such builtin: {}", name));
3114            return Value::Status(1);
3115        }
3116        // `builtin foo` MUST bypass function shadow — that's the whole
3117        // point of the prefix. Use the _raw helper, not the shadow-aware
3118        // one. Without this, `cd () { builtin cd "$@"; }` recurses.
3119        // c:Src/exec.c:3484 — BINF_BUILTIN suppresses the shfunctab probe.
3120        // `bin_compadd` runs that probe itself, so publish the modifier across
3121        // the call (see `ForcedBuiltinGuard`).
3122        let _forced = crate::ported::zle::complete::ForcedBuiltinGuard::enter();
3123        Value::Status(dispatch_builtin_raw(name, rest.to_vec()))
3124    });
3125
3126    // `command foo args…` — BINF_COMMAND prefix (Src/builtin.c:44). Zsh
3127    // semantic: bypass alias+function lookup, search builtin then $PATH.
3128    // Without this, `cd () { command cd "$@" }` would re-invoke the user
3129    // wrapper (same root cause as the `builtin` bug). Flags `-p`/`-v`/`-V`
3130    // route to bin_whence with BIN_COMMAND funcid; bare `command foo`
3131    // dispatches builtin if present, else external (no fork — direct
3132    // spawn via execute_external since zshrs is non-forking).
3133    // BUILTIN_COMMAND — `command [-p] [-v|-V] cmd args…` BIN_PREFIX
3134    // (Src/builtin.c:45). PURE PASSTHRU: prepend "command" and hand
3135    // to `exec::execcmd_compile_head` (the fusevm-bytecode-time head
3136    // resolver mirroring `Src/exec.c::execcmd_exec` precommand-modifier
3137    // walk at c:3104-3187). That helper already does the -p / -v / -V
3138    // option parsing, surfaces `has_command_vv` for the whence
3139    // redirect, and reports the dispatch shape (is_builtin vs external).
3140    vm.register_builtin(BUILTIN_COMMAND, |vm, argc| {
3141        let args = pop_args(vm, argc);
3142        // c:Src/exec.c:3483-3487 — same shfunctab-before-builtintab rule
3143        // as BUILTIN_BUILTIN above: a shell function named `command`
3144        // shadows the `command` precommand modifier on the first word.
3145        if let Some(status) = try_user_fn_override("command", &args) {
3146            return Value::Status(status);
3147        }
3148        let mut full = Vec::with_capacity(args.len() + 1);
3149        full.push("command".to_string());
3150        full.extend(args.clone());
3151        let dispatch =
3152            crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
3153        let post = &full[dispatch.precmd_skip..];
3154        // c:Src/exec.c:3212-3214 — `} else if (has_p) { /* Use default path */
3155        // use_defpath = 1; … }`. `-p` is a flag carried down to the exec
3156        // (c:4369 `execute(args, cflags, use_defpath)`) and to whence
3157        // (c:Src/builtin.c:4165-4167 `findcmd(*argv, 1, func == BIN_COMMAND &&
3158        // OPT_ISSET(ops,'p'))`), where the compiled-in DEFAULT_PATH
3159        // (configure.ac:1954, `getconf _CS_PATH` at build time) is searched
3160        // directly. C never assigns to `$path` for this.
3161        //
3162        // command's OWN options end at the first non-flag arg —
3163        // everything after the command name belongs to IT. The
3164        // previous `.any()` scan over ALL args stole `-p` from
3165        // `command mkdir -p DIR` (zconvey.plugin.zsh:44), stripping
3166        // the flag before /bin/mkdir ran → "File exists" errors on
3167        // every re-source.
3168        let mut lead = 0usize;
3169        let mut dash_p = false;
3170        let mut kept_flags: Vec<String> = Vec::new();
3171        for a in post.iter() {
3172            let s = a.as_str();
3173            if s == "--" {
3174                lead += 1;
3175                break;
3176            }
3177            if s.starts_with('-')
3178                && s.len() >= 2
3179                && s[1..].chars().all(|c| c == 'p' || c == 'v' || c == 'V')
3180            {
3181                if s.contains('p') {
3182                    dash_p = true;
3183                }
3184                // -v / -V drive the whence-style lookup downstream —
3185                // keep them in post (only the PATH-reset `p` is
3186                // consumed here).
3187                let rest: String = s[1..].chars().filter(|c| *c != 'p').collect();
3188                if !rest.is_empty() {
3189                    kept_flags.push(format!("-{}", rest));
3190                }
3191                lead += 1;
3192                continue;
3193            }
3194            break;
3195        }
3196        // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash's `command` knows only
3197        // -p -v -V; any other `-x` word is "Illegal option" (status 2), not a
3198        // command name.
3199        if crate::dash_mode::dash_faithful() {
3200            if let Some(bad) = post[lead..]
3201                .first()
3202                .filter(|s| s.len() >= 2 && s.starts_with('-') && *s != "--")
3203                .and_then(|s| s[1..].chars().find(|c| !matches!(c, 'p' | 'v' | 'V')))
3204            {
3205                crate::ported::utils::zwarnnam("command", &format!("Illegal option -{bad}"));
3206                return Value::Status(2);
3207            }
3208        }
3209        let mut post: Vec<String> = {
3210            let mut v = kept_flags;
3211            v.extend(post[lead..].iter().cloned());
3212            v
3213        };
3214        // c:Src/exec.c:3176-3177 — `BINF_COMMAND` arm strips a single
3215        // leading `--` end-of-options marker.
3216        // `execcmd_compile_head` (src/ported/exec.rs:1042) performs
3217        // this removal on its LOCAL `preargs` Vec but doesn't surface
3218        // the modified args; the caller still sees `--` in `full` and
3219        // tried to dispatch it as the command name. Bug #251. Mirror
3220        // the C strip here so `command -- echo hi` and
3221        // `command -p -- echo hi` route correctly.
3222        if let Some(first) = post.first() {
3223            if first == "--" {
3224                post.remove(0);
3225            }
3226        }
3227        let post = post.as_slice();
3228        // The previous implementation spelled `-p` as a temporary `$PATH`
3229        // reassignment around the call, seeded by forking `getconf PATH`.
3230        // Three things were wrong with that and all three are gone now:
3231        // every `$PATH` write empties `cmdnamtab` (c:Src/params.c pathsetfn),
3232        // so the entry `execcmd_exec` had just made for the command was wiped
3233        // on restore and `hash` came back empty where the oracle lists the
3234        // command; the child process inherited the DEFAULT_PATH in its
3235        // environment instead of the caller's `$PATH`; and a shell whose point
3236        // is not forking paid a fork per `command -p` for a value the C build
3237        // bakes in at configure time.
3238        // c:Src/exec.c:3760-3763 — `if (errflag) { lastval = 1; goto err; }`
3239        // after argument expansion. The in-shell forms (`command -v`, and a
3240        // builtin under POSIX_BUILTINS) keep errflag, so the script ends; the
3241        // external form clears it below, as its forked child would.
3242        let expansion_error = (crate::ported::utils::errflag
3243            .load(std::sync::atomic::Ordering::Relaxed)
3244            & crate::ported::zsh_h::ERRFLAG_ERROR)
3245            != 0;
3246        if expansion_error && (dispatch.has_command_vv || dispatch.is_builtin) {
3247            // c:3523-3525 / c:3760-3762 — see words_errflag_status.
3248            return Value::Status(words_errflag_status());
3249        }
3250        if dispatch.has_command_vv {
3251            return Value::Status(command_whence(post, dash_p));
3252        }
3253        if dispatch.is_empty_command {
3254            return Value::Status(0);
3255        }
3256        let Some((name, rest)) = post.split_first() else {
3257            return Value::Status(0);
3258        };
3259        // c:Src/exec.c:3275-3278 — `execcmd_compile_head` cleared
3260        // hn for the BINF_COMMAND + !POSIXBUILTINS case, surfacing
3261        // is_builtin=false. Run as external. Under POSIXBUILTINS
3262        // dispatch.is_builtin would be true; honour it.
3263        let n = name.clone();
3264        let r = rest.to_vec();
3265        if dispatch.is_builtin
3266            && crate::ported::builtin::BUILTINS
3267                .iter()
3268                .any(|b| b.node.nam == n.as_str())
3269        {
3270            // c:Src/exec.c:3484 — BINF_COMMAND suppresses it too.
3271            let _forced = crate::ported::zle::complete::ForcedBuiltinGuard::enter();
3272            return Value::Status(dispatch_builtin_raw(&n, r));
3273        }
3274        // c:Src/exec.c:3755-3763 — `globlist(args, 0); if (errflag) { lastval
3275        // = 1; goto err; }`. Without POSIX_BUILTINS `command NAME` is always
3276        // an external, and C forks before that globlist, so a NOMATCH error
3277        // skips the command in the child while the parent's errflag stays
3278        // clean: `command ls zzq*; print $?` prints the error, then 1. Here
3279        // the words were expanded in the shell, so drop the failed command
3280        // and its ERRFLAG_ERROR the way the external dispatcher does. Any
3281        // other expansion error (`command print x(a)` "number expected")
3282        // takes the same path.
3283        let glob_failed = with_executor(|exec| {
3284            let f = exec.current_command_glob_failed.get();
3285            exec.current_command_glob_failed.set(false);
3286            f
3287        });
3288        // A prefork error (c:3357-3359, a bad `${…}` or math) happens BEFORE
3289        // that fork, in the shell: c:3523-3525 keeps a non-zero lastval and
3290        // errflag ends the list (`command print ${.bad}` after `(exit 5)`
3291        // exits 5). Only a globlist error belongs to the forked child.
3292        if expansion_error && !glob_failed && !GLOB_WORD_ERRFLAG.with(|c| c.get()) {
3293            return Value::Status(words_errflag_status());
3294        }
3295        GLOB_WORD_ERRFLAG.with(|c| c.set(false));
3296        if (glob_failed || expansion_error) && take_cmd_name_glob_failed() {
3297            with_executor(|exec| exec.set_last_status(1));
3298            return Value::Status(1);
3299        }
3300        if glob_failed || expansion_error {
3301            crate::ported::utils::errflag.fetch_and(
3302                !crate::ported::zsh_h::ERRFLAG_ERROR,
3303                std::sync::atomic::Ordering::Relaxed,
3304            );
3305            with_executor(|exec| exec.set_last_status(1));
3306            return Value::Status(1);
3307        }
3308        // c:Src/exec.c:3719 forks the external command before the redirection
3309        // loop at c:3785, so a redirection that failed (`command cat <&""` →
3310        // "file number expected") fails in the child: the command never runs
3311        // and the shell continues with status 1. The redirect arm only
3312        // records the failure; without consuming it here `command cat` ran
3313        // the real cat on the untouched stdin and blocked.
3314        let redir_failed = with_executor(|exec| {
3315            let f = exec.redirect_failed;
3316            exec.redirect_failed = false;
3317            f
3318        });
3319        if redir_failed {
3320            crate::ported::utils::errflag.fetch_and(
3321                !crate::ported::zsh_h::ERRFLAG_ERROR,
3322                std::sync::atomic::Ordering::Relaxed,
3323            );
3324            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
3325            return Value::Status(1);
3326        }
3327        // c:Src/exec.c:3275-3278 — `command NAME` asks for the thing on
3328        // `PATH`, not the in-process one. The host-registered native commands
3329        // (`extensions/native_cmds.rs`) are caught inside `execute_external`,
3330        // which is this very call, so they are marked as explicitly forced
3331        // past for its duration — matching what `command cat` already does to
3332        // the coreutils shadow.
3333        let _forced = crate::native_cmds::force_external();
3334        // c:Src/exec.c:4369 — `execute(args, cflags, use_defpath)`. The `-p`
3335        // flag is the third argument, not a mutation of the environment.
3336        Value::Status(
3337            with_executor(|exec| {
3338                if dash_p {
3339                    exec.execute_external_defpath(&n, &r, &[])
3340                } else {
3341                    exec.execute_external(&n, &r, &[])
3342                }
3343            })
3344            .unwrap_or(127),
3345        )
3346    });
3347
3348    // `exec cmd args…` — BINF_EXEC prefix (Src/builtin.c:45). Zsh
3349    // semantic: replace the current shell process with `cmd`. On Unix
3350    // this is `execvp(2)`; the call only returns on error. zshrs is
3351    // non-forking, so the shell process IS the calling process —
3352    // execvp here directly replaces it. Options `-a name` (override
3353    // argv[0]), `-c` (clean env), `-l` (login shell — prepend `-`)
3354    // ported minimally; advanced redirect-only `exec >file` is handled
3355    // upstream by compile_zsh and never reaches this handler.
3356    vm.register_builtin(BUILTIN_EXEC, |vm, argc| {
3357        let args = pop_args(vm, argc);
3358        // AOP intercepts (zshrs extension). A bare `exec` reads the status of
3359        // the previous command, which the advice must not disturb.
3360        if let Some(done) = advise_keeping_status(vm, "exec", &args) {
3361            return done;
3362        }
3363        // c:Src/exec.c:3029-3278 — the precommand walk, `exec` included:
3364        // its options (c:3178-3274, `-a`/`-c`/`-l` and the "exec requires
3365        // a command to execute" / "unknown exec flag" errors), then any
3366        // further BINF_PREFIX word (`command`, `builtin`, `exec`, `-`) whose
3367        // flags join cflags with BINF_EXEC still set (c:3116-3118). So
3368        // `exec command X` runs the external X in the shell's place and
3369        // `exec builtin X` the builtin. Under POSIX_BUILTINS the walk stops
3370        // after `exec`'s options (c:3098-3103): the name is a command.
3371        let mut full = Vec::with_capacity(args.len() + 1);
3372        full.push("exec".to_string());
3373        full.extend(args);
3374        let walk = crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
3375        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3376            & crate::ported::zsh_h::ERRFLAG_ERROR)
3377            != 0
3378        {
3379            vm.last_status = 1; // c:3205 `lastval = 1;`
3380            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
3381            return Value::Status(1); // c:3203-3207 / c:3230-3234 / c:3249-3256 `goto done`
3382        }
3383        if walk.has_command_vv {
3384            // c:3149-3157 — whence, then the exec'd shell is gone.
3385            let post = &walk.preargs[walk.precmd_skip..];
3386            let dash_p = post.iter().take_while(|a| a.starts_with('-')).any(|a| a.contains('p'));
3387            let status = command_whence(post, dash_p);
3388            let _ = std::io::stdout().flush();
3389            std::process::exit(status);
3390        }
3391        if walk.is_empty_command {
3392            // `exec` with no command + no redirects = no-op success
3393            // (c:3365-3406; the redirect-only form never reaches here).
3394            return Value::Status(0);
3395        }
3396        let args: Vec<String> = walk.preargs[walk.precmd_skip..].to_vec();
3397        let cmd = args[0].clone();
3398        if (walk.cflags & crate::ported::zsh_h::BINF_BUILTIN) != 0 && !walk.is_builtin {
3399            // c:3490-3492 — `zwarn("no such builtin: %s", cmdarg); lastval = 1;`
3400            crate::ported::utils::zwarn(&format!("no such builtin: {}", cmd));
3401            return Value::Status(1);
3402        }
3403        let login = (walk.cflags & crate::ported::zsh_h::BINF_DASH) != 0; // c:3246
3404        let clean_env = (walk.cflags & crate::ported::zsh_h::BINF_CLEARENV) != 0; // c:3243
3405        let argv0_override = walk.exec_argv0.clone(); // c:3263-3274
3406        // c:4369 — `execute(args, cflags, use_defpath)`: `exec command -p X`.
3407        let use_defpath = walk.use_defpath;
3408        let prog = if use_defpath && !cmd.contains('/') {
3409            crate::ported::exec::search_defpath(&cmd, libc::PATH_MAX as usize).unwrap_or_else(|| cmd.clone())
3410        } else {
3411            cmd.clone()
3412        };
3413        let rest: Vec<String> = args[1..].to_vec();
3414        let display_argv0 = match argv0_override {
3415            Some(a) => a,
3416            None => {
3417                if login {
3418                    format!("-{}", cmd)
3419                } else {
3420                    cmd.clone()
3421                }
3422            }
3423        };
3424        // c:Src/exec.c:3468/3582 — execcmd bails out before running anything
3425        // once a redirection has failed: the failure calls zerr, which sets
3426        // errflag, and both bail-outs test it. `exec` is not exempt, so
3427        //     exec ls 3>&98; print after
3428        // in zsh reports the bad fd, does NOT run ls, and the SHELL SURVIVES
3429        // to run `print after`. zshrs consumed the flag in
3430        // BUILTIN_EXEC_PERM_REDIRS (returning status 1) but then dispatched
3431        // the command regardless — replacing the shell with it, so anything
3432        // after the exec never ran, and `exec 99>&98` reported a spurious
3433        // `command not found: 99` for the leftover fd word.
3434        if with_executor(|exec| {
3435            let f = exec.redirect_failed;
3436            exec.redirect_failed = false;
3437            f
3438        }) {
3439            vm.last_status = 1;
3440            return Value::Status(1);
3441        }
3442
3443        // c:Src/exec.c::execcmd — `exec funcname` runs the function
3444        // in-process as the shell's last act, then exits with the
3445        // function's status. zsh's dispatcher falls through from the
3446        // BINF_EXEC prefix into the normal Builtin/External/Function
3447        // resolution and only execvp's if the target ISN'T a
3448        // function. Bug #101 in docs/BUGS.md: zshrs's exec went
3449        // straight to execvp and errored `not found` for shell
3450        // functions.
3451        //
3452        // For both subshell and top-level contexts: dispatch through
3453        // the function/builtin lookup first; only fall through to
3454        // execvp/spawn if the name isn't shell-resolvable.
3455        // c:Src/exec.c:3098-3103 — `else if (isset(POSIXBUILTINS) &&
3456        // (cflags & BINF_EXEC)) break;` "POSIX doesn't allow "exec" to
3457        // operate on builtins or shell functions": the walk stops before
3458        // the shfunctab and builtintab lookups, so the name is searched
3459        // as an external command. A walked chain has already resolved the
3460        // head (c:3104-3108 skips functions after `builtin` / `command`).
3461        let has_user_fn = walk.is_shfunc; // c:3104-3108
3462        if has_user_fn {
3463            let status =
3464                with_executor(|exec| exec.dispatch_function_call(&cmd, &rest).unwrap_or(127));
3465            // Top-level `exec funcname` — exit the shell with the
3466            // function's status (mirrors C's "exec replaces shell as
3467            // last act"). Subshell `(exec funcname)` — return through
3468            // the EXIT_PENDING path so the subshell body aborts and
3469            // the parent resumes via subshell_end.
3470            let in_subshell_now = exec_in_process_subshell();
3471            if in_subshell_now {
3472                crate::ported::builtin::EXIT_VAL
3473                    .store(status, std::sync::atomic::Ordering::Relaxed);
3474                crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
3475                return Value::Status(status);
3476            }
3477            std::process::exit(status);
3478        }
3479        // c:Src/exec.c — builtin path: `exec builtin` runs the
3480        // builtin in-process and exits.
3481        let bn_in_tab = walk.is_builtin; // c:3120-3129
3482        if bn_in_tab {
3483            let status = dispatch_builtin_raw(&cmd, rest.clone());
3484            let in_subshell_now = exec_in_process_subshell();
3485            if in_subshell_now {
3486                crate::ported::builtin::EXIT_VAL
3487                    .store(status, std::sync::atomic::Ordering::Relaxed);
3488                crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
3489                return Value::Status(status);
3490            }
3491            std::process::exit(status);
3492        }
3493        // c:Src/exec.c — `exec` inside a subshell (`(exec cmd)`)
3494        // replaces ONLY the subshell child process; the parent shell
3495        // continues. C zsh always forks for `(...)`, so the actual
3496        // execvp lands in the forked child. zshrs runs subshells via
3497        // a snapshot/restore pattern in the SAME process — calling
3498        // execvp here would replace the parent too. Bug #94 in
3499        // docs/BUGS.md.
3500        //
3501        // Detect subshell context via the non-empty
3502        // `subshell_snapshots` stack. When in a subshell: spawn the
3503        // command as a child, wait for it, then signal the subshell
3504        // body to abort (return Status(N) and the caller's
3505        // subshell_end will pop the snapshot and resume the parent).
3506        let in_subshell = exec_in_process_subshell();
3507        if in_subshell && !clean_env {
3508            let status = exec_execute(&args, walk.exec_argv0.clone(), login, use_defpath, false);
3509            crate::ported::builtin::EXIT_VAL.store(status, std::sync::atomic::Ordering::Relaxed);
3510            crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
3511            return Value::Status(status);
3512        }
3513        if in_subshell {
3514            // c:Src/exec.c:4352 `closem(FDT_INTERNAL, 0)` before execve.
3515            crate::lowfd::cloexec_internal_fds();
3516            let mut command = std::process::Command::new(&prog);
3517            command.arg0(&display_argv0);
3518            command.args(&rest);
3519            if clean_env {
3520                command.env_clear();
3521            }
3522            // Queue signals across spawn+wait so the SIGCHLD reaper
3523            // can't reap this child before child.wait() does — see
3524            // ForegroundWaitGuard.
3525            let _wait_guard = ForegroundWaitGuard::enter();
3526            let status = match command.spawn() {
3527                Ok(mut child) => match child.wait() {
3528                    Ok(s) => crate::exec_jobs::wait_status_val(s),
3529                    Err(_) => 127,
3530                },
3531                Err(e) => {
3532                    // c:Src/exec.c:797 — `zerr("%e: %s", lerrno, arg0)`
3533                    //                     when arg0 contains `/`.
3534                    // c:872-876 — when arg0 has no `/` (PATH search
3535                    //              path), C tracks the "good" errno
3536                    //              via `isgooderr`; if all PATH entries
3537                    //              were ENOENT-not-good, eno stays 0
3538                    //              and C emits `command not found: %s`
3539                    //              instead of strerror.
3540                    // %e expands to strerror(errno) with the first
3541                    // letter lowercased (unless errno == EIO; see
3542                    // Src/utils.c:362-368). `zerr` prepends the
3543                    // scriptname:lineno: prefix — matching zsh's
3544                    // canonical `zsh:N: <errmsg>: <cmd>` pattern.
3545                    // Previously emitted `zshrs: exec: {}: not found`
3546                    // (wrong prefix, hardcoded message, missing
3547                    // lineno). Bug #140 in docs/BUGS.md.
3548                    let errno = e.raw_os_error().unwrap_or(libc::ENOENT);
3549                    let has_slash = cmd.contains('/');
3550                    if !has_slash && errno == libc::ENOENT {
3551                        // c:876 — PATH search exhausted with no good
3552                        // errno → `command not found: arg0`.
3553                        crate::ported::utils::zerr(&format!("command not found: {}", crate::ported::utils::nicedupstring(&cmd)));
3554                    } else {
3555                        let mut errmsg = crate::ported::compat::strerror(errno);
3556                        if errno != libc::EIO {
3557                            if let Some(c) = errmsg.chars().next() {
3558                                errmsg = format!(
3559                                    "{}{}",
3560                                    c.to_ascii_lowercase(),
3561                                    &errmsg[c.len_utf8()..]
3562                                );
3563                            }
3564                        }
3565                        crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
3566                    }
3567                    // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
3568                    if errno == libc::EACCES || errno == libc::ENOEXEC {
3569                        126
3570                    } else {
3571                        127
3572                    }
3573                }
3574            };
3575            // Mark the subshell as exec-replaced so subsequent body
3576            // commands skip — mirrors the post-execvp "child process
3577            // is gone" reality in C. EXIT_PENDING + EXIT_VAL drive
3578            // the next ERREXIT_CHECK to unwind to the subshell-end
3579            // patch.
3580            crate::ported::builtin::EXIT_VAL.store(status, std::sync::atomic::Ordering::Relaxed);
3581            crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
3582            return Value::Status(status);
3583        }
3584        // Both guards of exec_shlvl_decrement hold here: the in-subshell
3585        // form returned at the branch above (so `subsh` is false), and this
3586        // call replaces THIS process rather than a child (so `forked` is
3587        // false).
3588        exec_shlvl_decrement();
3589        // c:Src/exec.c:4369 — `execute(args, cflags, use_defpath)` in this
3590        // very process, exec'ing in place (see exec_execute). `exec -c`
3591        // keeps the Command route below: C hands it `blank_env` (c:778-779),
3592        // which carries no `_` either.
3593        if !clean_env {
3594            std::process::exit(exec_execute(&args, walk.exec_argv0.clone(), login, use_defpath, true));
3595        }
3596        let mut command = std::process::Command::new(&prog);
3597        command.arg0(&display_argv0);
3598        command.args(&rest);
3599        if clean_env {
3600            command.env_clear();
3601        }
3602        use std::os::unix::process::CommandExt;
3603        // `exec` returns the OS error iff exec(2) failed; on success
3604        // it never returns. Match zsh: print the error to stderr with
3605        // the `exec` prefix and exit 127 (cmd not found) or 126 (not
3606        // executable).
3607        // c:Src/exec.c:4352 `closem(FDT_INTERNAL, 0)` before execve.
3608        crate::lowfd::cloexec_internal_fds();
3609        let err = command.exec();
3610        // c:Src/exec.c:797 / c:872-876 — same format as in-subshell
3611        // branch. arg0-has-/ → `<strerror>: <cmd>`; arg0-no-/ +
3612        // ENOENT → `command not found: <cmd>`. Lowercase strerror
3613        // first letter unless EIO. Bug #140 in docs/BUGS.md.
3614        let errno = err.raw_os_error().unwrap_or(libc::ENOENT);
3615        let has_slash = cmd.contains('/');
3616        if !has_slash && errno == libc::ENOENT {
3617            crate::ported::utils::zerr(&format!("command not found: {}", crate::ported::utils::nicedupstring(&cmd)));
3618        } else {
3619            let mut errmsg = crate::ported::compat::strerror(errno);
3620            if errno != libc::EIO {
3621                if let Some(c) = errmsg.chars().next() {
3622                    errmsg = format!("{}{}", c.to_ascii_lowercase(), &errmsg[c.len_utf8()..]);
3623                }
3624            }
3625            crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
3626        }
3627        // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
3628        let code = if errno == libc::EACCES || errno == libc::ENOEXEC {
3629            126
3630        } else {
3631            127
3632        };
3633        std::process::exit(code);
3634    });
3635
3636    reg_passthru!(vm, BUILTIN_LET, "let");
3637
3638    // Job control
3639    reg_passthru!(vm, BUILTIN_JOBS, "jobs");
3640    reg_passthru!(vm, BUILTIN_FG, "fg");
3641    reg_passthru!(vm, BUILTIN_BG, "bg");
3642    reg_passthru!(vm, BUILTIN_KILL, "kill");
3643    reg_passthru!(vm, BUILTIN_DISOWN, "disown");
3644    reg_passthru!(vm, BUILTIN_WAIT, "wait");
3645    reg_passthru!(vm, BUILTIN_SUSPEND, "suspend");
3646
3647    // History — `fc` / `history` / `r` all route to `bin_fc` (zsh
3648    // registers them as aliases of the same builtin per Src/builtin.c).
3649    reg_passthru!(vm, BUILTIN_FC, "fc");
3650    reg_passthru!(vm, BUILTIN_HISTORY, "history");
3651    reg_passthru!(vm, BUILTIN_R, "r");
3652
3653    // Aliases — alias is `BINF_MAGICEQUALS` per Src/builtin.c:50.
3654    // c:Src/exec.c:3298-3304 — when a builtin has BINF_MAGICEQUALS,
3655    // execcmd_exec sets esprefork = PREFORK_TYPESET and calls
3656    // `prefork(args, esprefork, NULL)` on the argv. prefork (subst.c:
3657    // 100) drives `filesub` on each word (c:133), which (c:677-686)
3658    // looks for the assignment Equals and runs `filesubstr` on the
3659    // VALUE side. That's how `alias bad===` triggers equalsubstr's
3660    // "= not found" via the inner Equals after the first `=`.
3661    //
3662    // The fusevm dispatch path doesn't go through execcmd_exec, so
3663    // BUILTIN_ALIAS previously passed args straight to bin_alias with
3664    // no expansion — `alias x=~/foo` stored literal `~/foo` (no tilde
3665    // expand), `alias bad===` stored a broken entry without firing
3666    // the "= not found" diagnostic. The prefork(PREFORK_TYPESET) runs
3667    // per arg word via BUILTIN_MAGIC_EQUALS_PREFORK ops that
3668    // compile_simple emits BEFORE the redirect scope opens (matching
3669    // c:3304 prefork-before-addfd order), so the dispatch here is a
3670    // plain passthrough — re-running prefork would double-fire the
3671    // "= not found" diagnostic.
3672    reg_passthru!(vm, BUILTIN_ALIAS, "alias");
3673    // c:Src/exec.c:3298-3304 — per-word magic-equals prefork; see the
3674    // const doc at BUILTIN_MAGIC_EQUALS_PREFORK. prefork's filesub
3675    // trigger (subst.c:678 `strchr(*namptr+1, Equals)`) looks for the
3676    // EQUALS TOKEN, not literal `=`. The fusevm path delivers args
3677    // already-untokenized, so re-tokenize each element via
3678    // `shtokenize` (the same call C's lexer makes implicitly when
3679    // assembling the word) so prefork sees Equals tokens at `=`
3680    // boundaries and Tilde tokens at `~` starts. After prefork
3681    // expands, untokenize for storage.
3682    vm.register_builtin(BUILTIN_MAGIC_EQUALS_PREFORK, |vm, _argc| {
3683        let raw = vm.pop();
3684        let inputs: Vec<String> = match raw {
3685            Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
3686            other => vec![other.to_str()],
3687        };
3688        let mut as_linklist: crate::ported::linklist::LinkList<String> = Default::default();
3689        for s in &inputs {
3690            let mut tokd = s.clone();
3691            crate::ported::glob::shtokenize(&mut tokd);
3692            as_linklist.push_back(tokd);
3693        }
3694        let mut rf = 0i32;
3695        crate::ported::subst::prefork(
3696            &mut as_linklist,
3697            crate::ported::zsh_h::PREFORK_TYPESET,
3698            &mut rf,
3699        );
3700        let mut expanded: Vec<String> = Vec::with_capacity(inputs.len());
3701        while let Some(s) = as_linklist.pop_front() {
3702            expanded.push(crate::ported::lex::untokenize(&s).to_string());
3703        }
3704        if expanded.len() == 1 {
3705            Value::str(expanded.into_iter().next().unwrap())
3706        } else {
3707            Value::array(expanded.into_iter().map(Value::str).collect())
3708        }
3709    });
3710
3711    // Options. `setopt` (BIN_SETOPT=0) / `unsetopt` (BIN_UNSETOPT=1)
3712    // share bin_setopt (options.c:580) — funcid bit discriminates
3713    // the polarity via BUILTINS table entries.
3714    reg_passthru!(vm, BUILTIN_SET, "set");
3715    reg_passthru!(vm, BUILTIN_SETOPT, "setopt");
3716    reg_passthru!(vm, BUILTIN_UNSETOPT, "unsetopt");
3717
3718    vm.register_builtin(BUILTIN_SHOPT, |vm, argc| {
3719        let args = pop_args(vm, argc);
3720        // AOP intercepts (zshrs extension): this opcode builtin has no
3721        // user-function probe, so its advice runs here.
3722        if let Some(status) = run_command_intercepts("shopt", &args) {
3723            return Value::Status(status);
3724        }
3725        // `shopt` is a BASH builtin; no shell in the zsh family has it
3726        // (c:Src/builtin.c:40-137 `builtins[]` has no `shopt` row), so
3727        // outside bash drop-in mode the name must resolve through PATH
3728        // like any other unknown command — `zsh -fc shopt` →
3729        // `zsh:1: command not found: shopt`, rc 127.
3730        //
3731        // compile_zsh:3109 already forces `builtin_id = None` for the
3732        // literal name so the compile-time `CallBuiltin` fast path is
3733        // skipped, but that guard alone stopped working: the emitted
3734        // `Op::CallFunction` reaches fusevm's run-time
3735        // `VM::run_builtin_by_name` (fusevm-0.23.0/src/vm.rs:3348 →
3736        // :979) once the host's `call_function` answers None, and THAT
3737        // resolves the name straight back to this registered handler.
3738        // mapfile / readarray / compopt survive only because their
3739        // opcodes gate on the mode inside the handler; do the same
3740        // here. Mirrors the BUILTIN_COMPGEN / BUILTIN_COMPLETE gates
3741        // below, including their user-function precedence: a shell
3742        // function named `shopt` (bashcompinit-style shims define one)
3743        // still wins over the fall-through.
3744        if !crate::extensions::dash_mode::bash_mode() {
3745            if crate::ported::utils::getshfunc("shopt").is_some() {
3746                let status = with_executor(|exec| exec.dispatch_function_call("shopt", &args))
3747                    .unwrap_or(127);
3748                return Value::Status(status);
3749            }
3750            // PATH lookup with the literal name, so the diagnostic and
3751            // the status are the shell's own external-miss ones
3752            // (`zsh:1:` under --zsh, `zshrs:1:` natively) rather than a
3753            // second hand-rolled spelling of them.
3754            let status =
3755                with_executor(|exec| exec.execute_external("shopt", &args, &[])).unwrap_or(127);
3756            return Value::Status(status);
3757        }
3758        let status = crate::extensions::ext_builtins::shopt(&args);
3759        Value::Status(status)
3760    });
3761
3762    reg_passthru!(vm, BUILTIN_EMULATE, "emulate");
3763    reg_passthru!(vm, BUILTIN_GETOPTS, "getopts");
3764    reg_passthru!(vm, BUILTIN_AUTOLOAD, "autoload");
3765    reg_passthru!(vm, BUILTIN_FUNCTIONS, "functions");
3766    reg_passthru!(vm, BUILTIN_TRAP, "trap");
3767    reg_passthru!(vm, BUILTIN_DIRS, "dirs");
3768    // pushd / popd dispatch through canonical bin_cd via execbuiltin
3769    // — the BUILTINS table at src/ported/builtin.rs:9298 wires
3770    // `pushd` to bin_cd with funcid=BIN_PUSHD, and `popd` similarly
3771    // with BIN_POPD. Without these reg_passthru lines the fusevm
3772    // BUILTIN_PUSHD/POPD opcodes had no handler installed, so the
3773    // emitted CallBuiltin(110, …) silently returned a no-op and the
3774    // dirstack/$dirstack/pwd all stayed unchanged.
3775    reg_passthru!(vm, BUILTIN_PUSHD, "pushd");
3776    reg_passthru!(vm, BUILTIN_POPD, "popd");
3777    // type / whence / where / which all route through `bin_whence`
3778    // (canonical port at `src/ported/builtin.rs:3734` of
3779    // `Src/builtin.c:3975`). Each gets its own opcode so funcid +
3780    // defopts come from the BUILTINS table entry — execbuiltin
3781    // applies them correctly via the module-level dispatch_builtin.
3782    reg_passthru!(vm, BUILTIN_WHENCE, "whence");
3783    reg_passthru!(vm, BUILTIN_TYPE, "type");
3784    reg_passthru!(vm, BUILTIN_WHICH, "which");
3785    reg_passthru!(vm, BUILTIN_WHERE, "where");
3786    reg_passthru!(vm, BUILTIN_HASH, "hash");
3787    reg_passthru!(vm, BUILTIN_REHASH, "rehash");
3788
3789    // `unhash`/`unalias`/`unfunction` share `bin_unhash` (Src/builtin.c
3790    // c:4350) but each carries its own funcid (BIN_UNHASH /
3791    // BIN_UNALIAS / BIN_UNFUNCTION) in the BUILTINS table.
3792    reg_passthru!(vm, BUILTIN_UNHASH, "unhash");
3793    vm.register_builtin(BUILTIN_UNALIAS, |vm, argc| {
3794        let args = pop_args(vm, argc);
3795        // AOP intercepts (zshrs extension): this opcode builtin has no
3796        // user-function probe, so its advice runs here.
3797        if let Some(status) = run_command_intercepts("unalias", &args) {
3798            return Value::Status(status);
3799        }
3800        Value::Status(dispatch_builtin("unalias", args))
3801    });
3802    vm.register_builtin(BUILTIN_UNFUNCTION, |vm, argc| {
3803        let args = pop_args(vm, argc);
3804        Value::Status(dispatch_builtin("unfunction", args))
3805    });
3806
3807    // Completion
3808    vm.register_builtin(BUILTIN_COMPGEN, |vm, argc| {
3809        let args = pop_args(vm, argc);
3810        // c:Bug #475/#555 — `compgen` is a bash-only builtin. In
3811        // `--zsh` mode emit "command not found" matching zsh's
3812        // external-command lookup miss — UNLESS a user FUNCTION of
3813        // that name exists: zsh has no such builtin, so bashcompinit's
3814        // `compgen() {...}` definition wins the dispatch there. The
3815        // unconditional 127 shadowed it and broke every
3816        // bashcompinit-style completion file (zsh-more-completions
3817        // _msync/_gocomplete/_qshell/_cw), spraying "command not
3818        // found: complete" at every deferred compinit load.
3819        // c:Src/exec.c:3105-3110 — a shell FUNCTION is consulted before the
3820        // builtin table, UNCONDITIONALLY. This check used to sit INSIDE the
3821        // `IS_ZSH_MODE` arm below, so in NATIVE mode zshrs's own extension
3822        // builtin ran and a user function of the same name was ignored.
3823        //
3824        // Live cost: `bashcompinit` defines shell functions named `complete`
3825        // and `compgen`, so in native mode `complete -F _fn cmd` silently
3826        // registered NOTHING and returned 0:
3827        //     zsh -f          _comps[zzbashc]=[_bash_complete -F _zzfn]
3828        //     zshrs -f        _comps[zzbashc]=[]            <- BUG
3829        //     zshrs --zsh -f  _comps[zzbashc]=[_bash_complete -F _zzfn]
3830        // That breaks every bashcompinit-bridged completer on the host.
3831        //
3832        // `try_user_fn_override` is the shape the correct registrations
3833        // already use — `zsleep` (below) and `compdef` — which is why
3834        // `compdef` alone always worked.
3835        if let Some(status) = try_user_fn_override("compgen", &args) {
3836            return Value::Status(status);
3837        }
3838        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
3839            // No user function, and `compgen` is a bash-only builtin, so in a
3840            // zsh-emulation shell it genuinely does not exist.
3841            eprintln!("zsh:1: command not found: compgen");
3842            let _ = args;
3843            return Value::Status(127);
3844        }
3845        let status = with_executor(|exec| exec.builtin_compgen(&args));
3846        Value::Status(status)
3847    });
3848
3849    vm.register_builtin(BUILTIN_COMPLETE, |vm, argc| {
3850        let args = pop_args(vm, argc);
3851        // c:Bug #475 — `complete` is a bash-only builtin. Same gate +
3852        // user-function precedence as BUILTIN_COMPGEN above.
3853        // Same unconditional function-before-builtin rule as BUILTIN_COMPGEN
3854        // above (c:Src/exec.c:3105-3110); this check was inside the mode gate
3855        // too, so `bashcompinit`'s `complete` function lost to the extension
3856        // builtin in native mode.
3857        if let Some(status) = try_user_fn_override("complete", &args) {
3858            return Value::Status(status);
3859        }
3860        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
3861            eprintln!("zsh:1: command not found: complete");
3862            let _ = args;
3863            return Value::Status(127);
3864        }
3865        let status = with_executor(|exec| exec.builtin_complete(&args));
3866        Value::Status(status)
3867    });
3868
3869    reg_passthru!(vm, BUILTIN_COMPADD, "compadd");
3870    reg_passthru!(vm, BUILTIN_COMPSET, "compset");
3871
3872    // See the const's doc comment for the contract. Stack (bottom→top):
3873    // base, e1, …, eN — argc = N + 1.
3874    // See the consts' doc comments.
3875    vm.register_builtin(BUILTIN_TYPESET_POSTASSIGNS_BEGIN, |vm, argc| {
3876        let n_post = (argc == 1).then(|| vm.pop().to_int().max(0) as usize);
3877        TYPESET_POSTASSIGN_COUNT.with(|c| c.set(n_post));
3878        let word_failed = with_executor(|exec| exec.current_command_glob_failed.replace(false));
3879        TYPESET_WORD_GLOB_FAILED.with(|c| c.set(word_failed));
3880        Value::Int(0)
3881    });
3882    // See BUILTIN_TYPESET_RESWD.
3883    vm.register_builtin(BUILTIN_TYPESET_RESWD, |_vm, _argc| {
3884        TYPESET_RESWD_DISPATCH.with(|c| c.set(true));
3885        Value::Int(0)
3886    });
3887    // See BUILTIN_EXEC_DASH.
3888    vm.register_builtin(BUILTIN_EXEC_DASH, |_vm, _argc| {
3889        EXEC_DASH.with(|c| c.set(c.get() | EXEC_CARRIER_DASH));
3890        Value::Int(0)
3891    });
3892    // See BUILTIN_EXECPLINE_CHILD_BLOCK / _UNBLOCK.
3893    // The depth argument is absent from chunks compiled before the job frame
3894    // was added (the autoload cache); those keep the signal half only.
3895    vm.register_builtin(BUILTIN_EXECPLINE_CHILD_BLOCK, |vm, argc| {
3896        crate::ported::signals_h::child_block(); // c:Src/exec.c:1748
3897        // argc 2: the pipeline is a lone current-shell compound command.
3898        let cursh = argc == 2 && vm.pop().to_int() != 0;
3899        if argc >= 1 {
3900            let depth = vm.pop().to_int() as u32;
3901            let frame = vm as *const fusevm::VM as usize;
3902            crate::exec_jobs::execpline_slot_open(frame, depth); // c:Src/exec.c:1656-1666
3903            if cursh {
3904                crate::exec_jobs::mark_thisjob_cursh(false); // c:Src/exec.c:3674-3680
3905            }
3906        }
3907        Value::Int(0)
3908    });
3909    vm.register_builtin(BUILTIN_EXECPLINE_CHILD_UNBLOCK, |vm, argc| {
3910        if argc == 1 {
3911            let depth = vm.pop().to_int() as u32;
3912            let frame = vm as *const fusevm::VM as usize;
3913            crate::exec_jobs::execpline_slot_close(frame, depth); // c:Src/jobs.c:1754, exec.c:1981
3914        }
3915        crate::ported::signals_h::child_unblock(); // c:Src/exec.c:2017
3916        Value::Int(0)
3917    });
3918    // See BUILTIN_ALIAS_ORIGIN.
3919    vm.register_builtin(BUILTIN_ALIAS_ORIGIN, |vm, _argc| {
3920        let command = vm.pop().to_str();
3921        let body_words = vm.pop().to_int().max(0) as usize;
3922        let alias = vm.pop().to_str();
3923        ALIAS_ORIGIN.with(|a| *a.borrow_mut() = Some((alias, body_words, command)));
3924        Value::Int(0)
3925    });
3926    // See BUILTIN_JOB_TEXT.
3927    vm.register_builtin(BUILTIN_JOB_TEXT, |vm, _argc| {
3928        let text = vm.pop();
3929        JOB_TEXT.with(|t| *t.borrow_mut() = Some(text));
3930        Value::Int(0)
3931    });
3932    // See BUILTIN_EXEC_FORKED_SIMPLE.
3933    vm.register_builtin(BUILTIN_EXEC_FORKED_SIMPLE, |vm, argc| {
3934        let mode = if argc >= 1 { vm.pop().to_int() } else { 0 };
3935        let exiting_tail = mode != 0;
3936        let this_vm = vm as *const fusevm::VM as usize;
3937        if mode == 2 {
3938            // The `-c` string's last command (compile_zsh exiting_tail_mode
3939            // 2): the shell exits after it, so it is exec'd in place under
3940            // the same conditions, and not being forked it takes SHLVL down
3941            // (c:Src/exec.c:4334-4336).
3942            if !fake_exec_blocked() {
3943                EXEC_DASH.with(|c| c.set(c.get() | EXEC_CARRIER_FORKED | EXEC_CARRIER_SHLVL));
3944            }
3945            return Value::Int(0);
3946        }
3947        if exiting_tail {
3948            // c:Src/exec.c:3715-3718 — the fake exec of an exiting list's
3949            // last command needs `last1 == 1` (the compiler's mark, in a
3950            // child that was forked) and no traps, no files for the job to
3951            // clean up and no flock()s to keep: `nsigtrapped || havefiles()
3952            // || fdtable_flocks` otherwise forks as usual.
3953            if FORKED_SIMPLE_VM.with(|f| f.get()) != this_vm || fake_exec_blocked() {
3954                return Value::Int(0);
3955            }
3956        }
3957        if FORKED_SIMPLE_VM.with(|f| f.replace(0)) == this_vm {
3958            EXEC_DASH.with(|c| c.set(c.get() | EXEC_CARRIER_FORKED));
3959        }
3960        Value::Int(0)
3961    });
3962    // See BUILTIN_PREFORK_CUT_CHECK.
3963    vm.register_builtin(BUILTIN_PREFORK_CUT_CHECK, |_vm, _argc| {
3964        // c:Src/subst.c:118-121 — prefork's word loop also returns once
3965        // errflag is set, before expanding the next word.
3966        let errflag_set = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3967            & crate::ported::zsh_h::ERRFLAG_ERROR
3968            != 0;
3969        Value::Bool(PREFORK_CUT.with(|c| c.get()) || errflag_set)
3970    });
3971    // See BUILTIN_GLOBLIST. c:Src/subst.c:488-498 globlist:
3972    //     badcshglob = 0;
3973    //     for (node = firstnode(list); !errflag && node; node = next)
3974    //         { next = nextnode(node); zglob(list, node, nocheck); }
3975    // `badcshglob` stays with the per-word glob path (glob_expand_word_value
3976    // feeds consume_badcshglob), so only the loop shape changes here.
3977    vm.register_builtin(BUILTIN_GLOBLIST, |vm, argc| {
3978        let text_mask = vm.pop().to_int() as u64;
3979        let mask = vm.pop().to_int() as u64;
3980        let n = (argc as usize).saturating_sub(2);
3981        let mut words: Vec<Value> = (0..n).map(|_| vm.pop()).collect();
3982        words.reverse();
3983        let noglob =
3984            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
3985        let errflag_set = || {
3986            (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3987                & crate::ported::zsh_h::ERRFLAG_ERROR)
3988                != 0
3989        };
3990        // c:Src/subst.c:165-191 — prefork's second pass: filesub on each word
3991        // in order, returning at the first error, before globlist ever runs.
3992        // Only EXPAND_TEXT mode-10 words deferred theirs (see that arm).
3993        for (i, word) in words.iter_mut().enumerate() {
3994            if errflag_set() {
3995                break; // c:187-190
3996            }
3997            // c:178 — `if (unset(SHFILEEXPANSION))`: under SH_FILE_EXPANSION
3998            // the file expansion already ran in the first pass (c:122-138),
3999            // BEFORE substitution, so a `~` that arrived through a GLOB_SUBST
4000            // expansion stays literal (`emulate sh; v='~/x'; print $v`).
4001            if i < 64
4002                && text_mask & (1u64 << i) != 0
4003                && !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHFILEEXPANSION)
4004            {
4005                *word = filesub_deferred_word(std::mem::replace(word, Value::Int(0)));
4006            }
4007        }
4008        let mut out: Vec<Value> = Vec::with_capacity(n);
4009        for (i, word) in words.into_iter().enumerate() {
4010            let stopped = errflag_set(); // c:494 `!errflag`
4011            let bit = if i < 64 { 1u64 << i } else { 0 };
4012            if stopped || (mask | text_mask) & bit == 0 {
4013                out.push(word);
4014            } else if text_mask & bit != 0 {
4015                out.push(glob_tokenized_word(word, noglob)); // c:495 zglob
4016            } else {
4017                out.push(glob_word_noting_errflag(word, noglob)); // c:495 zglob
4018            }
4019        }
4020        for v in out {
4021            vm.push(v);
4022        }
4023        Value::Int(0)
4024    });
4025    vm.register_builtin(BUILTIN_TYPESET_POSTASSIGNS_END, |_vm, _argc| {
4026        let word_failed = TYPESET_WORD_GLOB_FAILED.with(|c| c.replace(false));
4027        let postassign_failed =
4028            with_executor(|exec| exec.current_command_glob_failed.replace(word_failed));
4029        TYPESET_POSTASSIGN_GLOB_FAILED.with(|c| c.set(postassign_failed));
4030        Value::Int(0)
4031    });
4032
4033    vm.register_builtin(BUILTIN_TYPESET_PAREN_PACK, |vm, argc| {
4034        let mut vals: Vec<Value> = Vec::with_capacity(argc as usize);
4035        for _ in 0..argc {
4036            vals.push(vm.pop());
4037        }
4038        vals.reverse();
4039        let mut it = vals.into_iter();
4040        let mut out = it.next().map(|v| v.to_str()).unwrap_or_default();
4041        for v in it {
4042            match v {
4043                // Array → splice items as separate elements (splat);
4044                // empty array contributes nothing (empty elision).
4045                Value::Array(items) => {
4046                    for item in items.iter() {
4047                        out.push('\u{1f}');
4048                        out.push_str(&item.to_str());
4049                    }
4050                }
4051                other => {
4052                    out.push('\u{1f}');
4053                    out.push_str(&other.to_str());
4054                }
4055            }
4056        }
4057        Value::str(out)
4058    });
4059
4060    vm.register_builtin(BUILTIN_TYPESET_PAREN_CLOSE, |vm, _argc| {
4061        let base = vm.pop().to_str();
4062        Value::str(format!("{}\u{1f})", base))
4063    });
4064
4065    vm.register_builtin(BUILTIN_COMPDEF, |vm, argc| {
4066        let args = pop_args(vm, argc);
4067        // AOP intercepts (zshrs extension): this opcode builtin has no
4068        // user-function probe, so its advice runs here.
4069        if let Some(status) = run_command_intercepts("compdef", &args) {
4070            return Value::Status(status);
4071        }
4072        // ACTUALLY A ZSH FUNCTION: compdef is defined by `compinit`, it is
4073        // never a builtin. Without the completion system set up it is
4074        // command-not-found (127) in every mode — `zsh -f; compdef` prints
4075        // "command not found: compdef". A user/compsys `compdef` FUNCTION
4076        // (autoload compinit → compinit defines compdef) wins and runs the
4077        // fast native impl; otherwise it's command-not-found. Previously the
4078        // extension builtin ran in native mode (bare `compdef` → "I need
4079        // arguments"), diverging from zsh.
4080        // compinit installs a `compdef` function stub (see
4081        // NATIVE_COMPDEF_MARKER) purely so `${+functions[compdef]}` is
4082        // true; route that exact body to the fast native impl instead of
4083        // dispatching the stub. A genuine user/compsys compdef function
4084        // (any other body) still wins via try_user_fn_override below.
4085        let is_native_stub = crate::ported::hashtable::shfunctab_lock()
4086            .read()
4087            .ok()
4088            .and_then(|t| t.get("compdef").and_then(|shf| shf.body.clone()))
4089            .map(|b| b.trim() == crate::extensions::ext_builtins::NATIVE_COMPDEF_MARKER)
4090            .unwrap_or(false);
4091        if is_native_stub {
4092            return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
4093        }
4094        if let Some(s) = try_user_fn_override("compdef", &args) {
4095            return Value::Status(s);
4096        }
4097        if with_executor(|exec| exec.function_exists("compdef")) {
4098            return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
4099        }
4100        eprintln!("zsh:1: command not found: compdef");
4101        Value::Status(127)
4102    });
4103
4104    vm.register_builtin(BUILTIN_COMPINIT, |vm, argc| {
4105        let args = pop_args(vm, argc);
4106        // AOP intercepts (zshrs extension): this opcode builtin has no
4107        // user-function probe, so its advice runs here.
4108        if let Some(status) = run_command_intercepts("compinit", &args) {
4109            return Value::Status(status);
4110        }
4111        // ACTUALLY A ZSH FUNCTION: compinit is a contrib FUNCTION (autoloaded
4112        // from $fpath), never a builtin. Without `autoload -Uz compinit` it is
4113        // command-not-found
4114        // (127) in every mode — `zsh -f; compinit` prints
4115        // "command not found: compinit". zshrs previously ran its builtin
4116        // unconditionally, so bare `compinit` succeeded. Gate on a compinit
4117        // function entry existing (which `autoload -Uz compinit` creates);
4118        // once the user has autoloaded/defined it, run zshrs's implementation.
4119        if !with_executor(|exec| exec.function_exists("compinit")) {
4120            eprintln!("zsh:1: command not found: compinit");
4121            let _ = args;
4122            return Value::Status(127);
4123        }
4124        Value::Status(with_executor(|exec| exec.builtin_compinit(&args)))
4125    });
4126
4127    reg_ext_overridable!(vm, BUILTIN_CDREPLAY, "cdreplay", builtin_cdreplay);
4128
4129    // Zsh-specific
4130    reg_passthru!(vm, BUILTIN_ZSTYLE, "zstyle");
4131    reg_passthru!(vm, BUILTIN_ZMODLOAD, "zmodload");
4132    reg_passthru!(vm, BUILTIN_BINDKEY, "bindkey");
4133    reg_passthru!(vm, BUILTIN_ZLE, "zle");
4134    reg_passthru!(vm, BUILTIN_VARED, "vared");
4135    reg_passthru!(vm, BUILTIN_ZCOMPILE, "zcompile");
4136    reg_passthru!(vm, BUILTIN_ZFORMAT, "zformat");
4137    reg_passthru!(vm, BUILTIN_ZPARSEOPTS, "zparseopts");
4138    reg_passthru!(vm, BUILTIN_ZREGEXPARSE, "zregexparse");
4139
4140    // Resource limits
4141    reg_passthru!(vm, BUILTIN_ULIMIT, "ulimit");
4142    reg_passthru!(vm, BUILTIN_LIMIT, "limit");
4143    reg_passthru!(vm, BUILTIN_UNLIMIT, "unlimit");
4144    reg_passthru!(vm, BUILTIN_UMASK, "umask");
4145
4146    // Misc
4147    reg_passthru!(vm, BUILTIN_TIMES, "times");
4148
4149    vm.register_builtin(BUILTIN_CALLER, |vm, argc| {
4150        let args = pop_args(vm, argc);
4151        // AOP intercepts (zshrs extension): this opcode builtin has no
4152        // user-function probe, so its advice runs here.
4153        if let Some(status) = run_command_intercepts("caller", &args) {
4154            return Value::Status(status);
4155        }
4156        // c:Bug #475 — `caller` is a bash-only builtin. In `--zsh`
4157        // mode emit the canonical "command not found" diagnostic
4158        // and rc=127 matching zsh's external-command-lookup miss.
4159        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
4160            eprintln!("zsh:1: command not found: caller");
4161            let _ = args;
4162            return Value::Status(127);
4163        }
4164        Value::Status(with_executor(|exec| exec.builtin_caller(&args)))
4165    });
4166
4167    vm.register_builtin(BUILTIN_HELP, |vm, argc| {
4168        let args = pop_args(vm, argc);
4169        // c:Bug #475 — `help` is a bash-only builtin. Same gate as
4170        // BUILTIN_CALLER above.
4171        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
4172            eprintln!("zsh:1: command not found: help");
4173            let _ = args;
4174            return Value::Status(127);
4175        }
4176        Value::Status(with_executor(|exec| exec.builtin_help(&args)))
4177    });
4178
4179    reg_passthru!(vm, BUILTIN_ENABLE, "enable");
4180    reg_passthru!(vm, BUILTIN_DISABLE, "disable");
4181    reg_passthru!(vm, BUILTIN_TTYCTL, "ttyctl");
4182    reg_passthru!(vm, BUILTIN_SYNC, "sync");
4183    reg_passthru!(vm, BUILTIN_MKDIR, "mkdir");
4184    reg_passthru!(vm, BUILTIN_STRFTIME, "strftime");
4185
4186    vm.register_builtin(BUILTIN_ZSLEEP, |vm, argc| {
4187        let args = pop_args(vm, argc);
4188        // function > builtin: a user `zsleep() { … }` wins.
4189        if let Some(s) = try_user_fn_override("zsleep", &args) {
4190            return Value::Status(s);
4191        }
4192        Value::Status(crate::extensions::ext_builtins::zsleep(&args))
4193    });
4194
4195    reg_passthru!(vm, BUILTIN_ZSYSTEM, "zsystem");
4196
4197    // PCRE
4198    reg_passthru!(vm, BUILTIN_PCRE_COMPILE, "pcre_compile");
4199    reg_passthru!(vm, BUILTIN_PCRE_MATCH, "pcre_match");
4200    reg_passthru!(vm, BUILTIN_PCRE_STUDY, "pcre_study");
4201
4202    // Database (GDBM)
4203    reg_passthru!(vm, BUILTIN_ZTIE, "ztie");
4204    reg_passthru!(vm, BUILTIN_ZUNTIE, "zuntie");
4205    reg_passthru!(vm, BUILTIN_ZGDBMPATH, "zgdbmpath");
4206
4207    // Prompt
4208    //
4209    // ACTUALLY ZSH FUNCTIONS: `promptinit` is Functions/Prompts/promptinit,
4210    // and `prompt` is a function that file defines — neither is a builtin,
4211    // but fusevm's name table hands both an opcode. Resolve them the way
4212    // execcmd does a name that is not in builtintab (c:Src/exec.c:3484-3488
4213    // shfunctab probe, else the command search): run the shell function,
4214    // or fall to the external lookup and its "command not found".
4215    //
4216    // A native stand-in used to answer both names: a hard-coded theme list
4217    // (one of them, `minimal`, not shipped by 5.9) in place of promptinit's
4218    // `$^fpath/prompt_*_setup(N)` scan, so a user's own themes were never
4219    // listed, and a `prompt` that shadowed the real function even after
4220    // promptinit had defined it — `prompt -l` printed one theme per line,
4221    // `prompt -h THEME` never showed the theme's help, and `prompt adam1`
4222    // assigned a canned PS1 without running prompt_adam1_setup.
4223    vm.register_builtin(BUILTIN_PROMPTINIT, |vm, argc| {
4224        let args = pop_args(vm, argc);
4225        if let Some(s) = try_user_fn_override("promptinit", &args) {
4226            return Value::Status(s);
4227        }
4228        let argv: Vec<String> = std::iter::once("promptinit".to_string()).chain(args).collect();
4229        Value::Status(with_executor(|exec| exec.host_exec_external(&argv)))
4230    });
4231
4232    vm.register_builtin(BUILTIN_PROMPT, |vm, argc| {
4233        let args = pop_args(vm, argc);
4234        if let Some(s) = try_user_fn_override("prompt", &args) {
4235            return Value::Status(s);
4236        }
4237        let argv: Vec<String> = std::iter::once("prompt".to_string()).chain(args).collect();
4238        Value::Status(with_executor(|exec| exec.host_exec_external(&argv)))
4239    });
4240
4241    // Async / Parallel (zshrs extensions) — all overridable by a
4242    // same-named user function (function > builtin).
4243    reg_ext_overridable!(vm, BUILTIN_ASYNC, "async", builtin_async);
4244    reg_ext_overridable!(vm, BUILTIN_AWAIT, "await", builtin_await);
4245    reg_ext_overridable!(vm, BUILTIN_PMAP, "pmap", builtin_pmap);
4246    reg_ext_overridable!(vm, BUILTIN_PGREP, "pgrep", builtin_pgrep);
4247    reg_ext_overridable!(vm, BUILTIN_PEACH, "peach", builtin_peach);
4248    reg_ext_overridable!(vm, BUILTIN_BARRIER, "barrier", builtin_barrier);
4249
4250    // `ai` — zshrs-original LLM builtin (`src/extensions/ai.rs`).
4251    // fusevm's name registry maps `"ai"` to `BUILTIN_AI` (264), so the
4252    // compiler emits `CallBuiltin(BUILTIN_AI)` for a literal `ai`. The VM
4253    // silently skips an unregistered id: without this line `ai` is a no-op.
4254    reg_ext_overridable!(vm, BUILTIN_AI, "ai", builtin_ai);
4255
4256    // Intercept (AOP)
4257    reg_ext_overridable!(vm, BUILTIN_INTERCEPT, "intercept", builtin_intercept);
4258    reg_ext_overridable!(
4259        vm,
4260        BUILTIN_INTERCEPT_PROCEED,
4261        "intercept_proceed",
4262        builtin_intercept_proceed
4263    );
4264
4265    // Debug / Profile
4266    reg_ext_overridable!(vm, BUILTIN_DOCTOR, "doctor", builtin_doctor);
4267    reg_ext_overridable!(vm, BUILTIN_DBVIEW, "dbview", builtin_dbview);
4268    reg_ext_overridable!(vm, BUILTIN_PROFILE, "profile", builtin_profile);
4269    reg_ext_overridable!(vm, BUILTIN_PROVENANCE, "provenance", builtin_provenance);
4270
4271    reg_passthru!(vm, BUILTIN_ZPROF, "zprof");
4272
4273    // ═══════════════════════════════════════════════════════════════════════
4274    // Coreutils builtins (anti-fork, gated by !posix_mode)
4275    //
4276    // All of these are routinely wrapped by user functions in real
4277    // dotfiles (zpwr, oh-my-zsh, etc.) — `cat() { ... }`, `ls() { ... }`,
4278    // `find() { ... }`. Each handler MUST consult try_user_fn_override
4279    // first (via reg_overridable!) so the user definition wins, matching
4280    // zsh's alias → function → builtin dispatch order.
4281    // ═══════════════════════════════════════════════════════════════════════
4282
4283    reg_overridable!(vm, BUILTIN_CAT, "cat", builtin_cat);
4284    reg_overridable!(vm, BUILTIN_HEAD, "head", builtin_head);
4285    reg_overridable!(vm, BUILTIN_TAIL, "tail", builtin_tail);
4286    reg_overridable!(vm, BUILTIN_WC, "wc", builtin_wc);
4287    reg_overridable!(vm, BUILTIN_BASENAME, "basename", builtin_basename);
4288    reg_overridable!(vm, BUILTIN_DIRNAME, "dirname", builtin_dirname);
4289    reg_overridable!(vm, BUILTIN_TOUCH, "touch", builtin_touch);
4290    reg_overridable!(vm, BUILTIN_REALPATH, "realpath", builtin_realpath);
4291    reg_overridable!(vm, BUILTIN_SORT, "sort", builtin_sort);
4292    reg_overridable!(vm, BUILTIN_FIND, "find", builtin_find);
4293    reg_overridable!(vm, BUILTIN_UNIQ, "uniq", builtin_uniq);
4294    reg_overridable!(vm, BUILTIN_CUT, "cut", builtin_cut);
4295    reg_overridable!(vm, BUILTIN_TR, "tr", builtin_tr);
4296    reg_overridable!(vm, BUILTIN_SEQ, "seq", builtin_seq);
4297    reg_overridable!(vm, BUILTIN_REV, "rev", builtin_rev);
4298    reg_overridable!(vm, BUILTIN_TEE, "tee", builtin_tee);
4299    reg_overridable!(vm, BUILTIN_SLEEP, "sleep", builtin_sleep);
4300    reg_overridable!(vm, BUILTIN_WHOAMI, "whoami", builtin_whoami);
4301    reg_overridable!(vm, BUILTIN_ID, "id", builtin_id);
4302
4303    reg_overridable!(vm, BUILTIN_HOSTNAME, "hostname", builtin_hostname);
4304    reg_overridable!(vm, BUILTIN_UNAME, "uname", builtin_uname);
4305    reg_overridable!(vm, BUILTIN_DATE, "date", builtin_date);
4306    reg_overridable!(vm, BUILTIN_MKTEMP, "mktemp", builtin_mktemp);
4307    // `cp` — zshrs extension (NOT in upstream zsh; upstream's
4308    // zsh/files module ships `ln`/`mv`/`rm`/`chmod`/`chown` but no
4309    // `cp`). In-process implementation in
4310    // `ext_builtins::cp_impl` — recursive copy with -r/-R, -f, -i,
4311    // -n, -p (chown + utimensat), -v. ID 263 is the first slot
4312    // past fusevm's built-in range (260-262) and before BUILTIN_MAX
4313    // (280).
4314    /// `BUILTIN_CP` constant.
4315    pub const BUILTIN_CP: u16 = 263;
4316    reg_overridable!(vm, BUILTIN_CP, "cp", builtin_cp);
4317
4318    // Pipeline execution — bytecode-native fork-per-stage. Pops N sub-chunk
4319    // indices, forks N children with stdin/stdout wired through N-1 pipes,
4320    // each child runs its stage's compiled bytecode and exits. Parent waits
4321    // and returns the last stage's status.
4322    //
4323    // Caveats: post-fork in a multi-threaded program, only async-signal-safe
4324    // ops are POSIX-safe. We violate this (running the bytecode VM after fork
4325    // touches mutexes like REGEX_CACHE). In practice, most pipeline stages
4326    // don't touch shared mutex state — externals fork/exec away, builtins do
4327    // pure I/O. Risks are bounded; if a stage does touch a held mutex, the
4328    // child deadlocks.
4329    vm.register_builtin(BUILTIN_RUN_PIPELINE, |vm, argc| {
4330        let n = argc as usize;
4331        if n == 0 {
4332            return Value::Status(0);
4333        }
4334
4335        // c:Src/exec.c — every pipeline stage forks from the current
4336        // shell state, so each stage observes the pre-pipeline $? until
4337        // it runs its own command. Stage sub-VMs start fresh with
4338        // last_status=0, so seed them with the parent's lastval; without
4339        // this `false; echo $? | cat` prints 0 instead of zsh's 1.
4340        let parent_status = vm.last_status;
4341
4342        // Pop N sub-chunk indices (LIFO → reverse to stage order)
4343        let mut indices: Vec<u16> = Vec::with_capacity(n);
4344        for _ in 0..n {
4345            indices.push(vm.pop().to_int() as u16);
4346        }
4347        indices.reverse();
4348
4349        // Clone each stage's sub-chunk
4350        let stages: Vec<fusevm::Chunk> = indices
4351            .iter()
4352            .filter_map(|&i| vm.chunk.sub_chunks.get(i as usize).cloned())
4353            .collect();
4354        if stages.len() != n {
4355            return Value::Status(1);
4356        }
4357
4358        // Single stage — no pipe, just run inline
4359        if n == 1 {
4360            let stage = stages.into_iter().next().unwrap();
4361            crate::fusevm_disasm::maybe_print_stdout("pipeline:single", &stage);
4362            let mut stage_vm = fusevm::VM::new(stage);
4363            stage_vm.last_status = parent_status;
4364            register_builtins(&mut stage_vm);
4365            let _ = stage_vm.run();
4366            return Value::Status(stage_vm.last_status);
4367        }
4368
4369        // A `time`d pipeline: record each forked stage for dumptime (see
4370        // TimedPipeline). Taken, so a pipeline nested in a stage runs untimed.
4371        let mut timed = TIMED_PIPELINE
4372            .with(|t| t.borrow_mut().take())
4373            .filter(|t| t.texts.len() == n);
4374        let mut child_started: Vec<std::time::Instant> = Vec::with_capacity(n - 1);
4375
4376        // c:Src/exec.c:1748 — `child_block();` before any stage is forked,
4377        // held through the in-shell last stage and the stage waits below,
4378        // released at c:2017. See ChildBlockSpan.
4379        #[cfg(unix)]
4380        let _child_block = ChildBlockSpan::enter();
4381
4382        // Build N-1 pipes
4383        let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(n - 1);
4384        for _ in 0..n - 1 {
4385            let mut fds = [0i32; 2];
4386            if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
4387                // Cleanup any pipes we already created
4388                for (r, w) in &pipes {
4389                    unsafe {
4390                        libc::close(*r);
4391                        libc::close(*w);
4392                    }
4393                }
4394                return Value::Status(1);
4395            }
4396            pipes.push((fds[0], fds[1]));
4397        }
4398
4399        // zsh runs the LAST stage of a pipeline in the CURRENT shell
4400        // (not a forked child) so a trailing `read x` keeps its
4401        // assignment in the parent. Other shells (bash) fork every
4402        // stage. Honor zsh by leaving stage N-1 inline. Forks the
4403        // first N-1 stages with fork(); runs the last in this process
4404        // with stdin dup2'd to the last pipe's read end and stdout
4405        // restored after.
4406        let last_idx = n - 1;
4407        let stages_vec: Vec<fusevm::Chunk> = stages.into_iter().collect();
4408
4409        let mut child_pids: Vec<libc::pid_t> = Vec::with_capacity(n - 1);
4410        for (i, chunk) in stages_vec.iter().take(last_idx).enumerate() {
4411            match unsafe { libc::fork() } {
4412                -1 => {
4413                    // fork failed — kill any children we already started
4414                    for pid in &child_pids {
4415                        unsafe { libc::kill(*pid, libc::SIGTERM) };
4416                    }
4417                    for (r, w) in &pipes {
4418                        unsafe {
4419                            libc::close(*r);
4420                            libc::close(*w);
4421                        }
4422                    }
4423                    return Value::Status(1);
4424                }
4425                0 => {
4426                    forked_child_subsh_levels(); // c:Src/exec.c:1221
4427                    EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
4428                    // Reset SIGPIPE to default so a broken-pipe write
4429                    // kills the child cleanly instead of triggering a
4430                    // Rust println! panic. The parent shell ignores
4431                    // SIGPIPE so it can handle EPIPE itself, but child
4432                    // pipeline stages should die quietly when their
4433                    // downstream stage closes early (e.g. `seq | head -3`).
4434                    unsafe {
4435                        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
4436                    }
4437                    // c:Src/exec.c — pipeline children are forked
4438                    // subshells; their EXIT trap context is reset so
4439                    // the parent's `trap '...' EXIT` doesn't fire when
4440                    // the child exits. Mirror by dropping EXIT from
4441                    // the inherited traps_table inside the child.
4442                    // c:Src/exec.c:2917-2918 — a forked PIPELINE stage enters
4443                    // the subshell with ESUB_KEEPTRAP:
4444                    //     if ((type != WC_SUBSH) && !(how & Z_ASYNC))
4445                    //         flags |= ESUB_KEEPTRAP;
4446                    // so entersubsh's c:1127 reset loop is SKIPPED and the
4447                    // stage keeps the parent's traps. Only the EXIT trap goes,
4448                    // so the parent's `trap '…' EXIT` does not fire when the
4449                    // stage exits. Applying the full reset here instead was
4450                    // wrong: it wiped the inherited-SIGQUIT record and the
4451                    // parent's other trap flags inside every pipeline stage.
4452                    //
4453                    // Drop it from BOTH stores, since a body-less entry lives
4454                    // only in sigtrapped and the `trap` listing now reads it
4455                    // (c:Src/builtin.c:7358-7361); clearing just the body left
4456                    // `trap | grep -c EXIT` reporting the stale flag.
4457                    if let Ok(mut tt) = crate::ported::builtin::traps_table().lock() {
4458                        tt.remove("EXIT");
4459                    }
4460                    if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
4461                        if let Some(slot) = st.get_mut(crate::ported::signals_h::SIGEXIT as usize) {
4462                            *slot = 0;
4463                        }
4464                    }
4465                    // c:Src/exec.c:2862 → 1219 — pipeline children run
4466                    // entersubsh with ESUB_PGRP, which clears the job
4467                    // table (clearjobtab, Src/jobs.c:1780). Without
4468                    // this, `sleep 5 & jobs -p | wc -l` reports 1 in
4469                    // the forked stage where zsh reports 0. The fork
4470                    // already copy-isolates the statics, so mutating
4471                    // them here can't leak to the parent.
4472                    with_executor(|exec| {
4473                        let monitor =
4474                            crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
4475                        crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
4476                    });
4477                    // c:Src/exec.c:1153-1154 — the same entersubsh call sets
4478                    // `subsh = 1` in the forked stage. PRINT_EXIT_VALUE reads
4479                    // it (c:4309 `&& !subsh`), which is why zsh prints nothing
4480                    // for the failing stage of `false | true`.
4481                    crate::ported::exec::subsh.store(1, std::sync::atomic::Ordering::Relaxed);
4482                    *crate::ported::jobs::THISJOB
4483                        .get_or_init(|| std::sync::Mutex::new(-1))
4484                        .lock()
4485                        .unwrap() = -1;
4486                    // c:Src/exec.c:3720-3724 — the stage's own fds go
4487                    // onto 0/1 only AFTER its argument words have been
4488                    // expanded (prefork c:3304 / globlist c:3702), so
4489                    // park them and let the stage chunk's
4490                    // BUILTIN_PIPE_FDS_INSTALL do the dup2 at the
4491                    // C-faithful point. `print -rl -- c a b |
4492                    // print -r -- "[$(cat)]" | cat` therefore prints
4493                    // `[]` — the middle stage's `$(cat)` reads the
4494                    // shell's stdin, not the pipe.
4495                    let in_fd = if i > 0 { pipes[i - 1].0 } else { -1 };
4496                    let out_fd = pipes[i].1;
4497                    // (Pipe-output MULTIOS marking — c:Src/exec.c:3724 —
4498                    // is emitted INTO the stage chunk by compile_pipe
4499                    // via BUILTIN_PIPE_OUTPUT_MARK, gated on the stage's
4500                    // top-level command actually carrying redirects, so
4501                    // a nested `{ echo a > f; } | cat` body redirect
4502                    // does not wrongly join the pipe.)
4503                    // Close every pipe fd this stage doesn't need. The
4504                    // two it does keep are closed by the install op
4505                    // right after their dup2.
4506                    for (r, w) in &pipes {
4507                        unsafe {
4508                            if *r != in_fd && *r != out_fd {
4509                                libc::close(*r);
4510                            }
4511                            if *w != in_fd && *w != out_fd {
4512                                libc::close(*w);
4513                            }
4514                        }
4515                    }
4516                    stage_fds_park(in_fd, out_fd);
4517
4518                    // Run this stage's bytecode on a fresh VM
4519                    crate::fusevm_disasm::maybe_print_stdout(
4520                        &format!("pipeline:child:stage:{i}"),
4521                        chunk,
4522                    );
4523                    let mut stage_vm = fusevm::VM::new(chunk.clone());
4524                    stage_vm.last_status = parent_status;
4525                    register_builtins(&mut stage_vm);
4526                    let _ = stage_vm.run();
4527                    // Flush any buffered output before exiting
4528                    let _ = std::io::stdout().flush();
4529                    let _ = std::io::stderr().flush();
4530                    std::process::exit(stage_vm.last_status);
4531                }
4532                pid => {
4533                    child_pids.push(pid);
4534                    child_started.push(std::time::Instant::now()); // c:Src/jobs.c:1480 bgtime
4535                }
4536            }
4537        }
4538
4539        // Parent runs the LAST stage inline. Save stdin, park the last
4540        // pipe's read end for the chunk's BUILTIN_PIPE_FDS_INSTALL
4541        // (c:Src/exec.c:3722 `addfd(..., 0, input, 0, NULL)` — after
4542        // the stage's args are expanded, so `… | print -r -- "[$(cat)]"`
4543        // has its `$(cat)` read the shell's stdin, not the pipe), run
4544        // the chunk, restore stdin. Close every other pipe fd so the
4545        // producer side gets EOF when the last upstream stage exits.
4546        // Shell-internal save — keep it out of the script's fd range (movefd,
4547        // c:Src/exec.c:2425).
4548        let saved_stdin = unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD_CLOEXEC, 10) };
4549        let last_in_fd = if last_idx > 0 {
4550            pipes[last_idx - 1].0
4551        } else {
4552            -1
4553        };
4554        // Close all pipe fds in the parent except the one the last
4555        // stage still has to install. (Children already have their own
4556        // copies; the install op closes the read end after its dup2.)
4557        for (r, w) in &pipes {
4558            unsafe {
4559                if *r != last_in_fd {
4560                    libc::close(*r);
4561                }
4562                libc::close(*w);
4563            }
4564        }
4565        let outer_stage_fds = stage_fds_park(last_in_fd, -1);
4566
4567        // Run the last stage's bytecode on a sub-VM with the host wired up.
4568        // By default (zsh semantics) the sub-VM runs IN THIS PROCESS so the
4569        // last stage's reads/assignments update the parent's state directly
4570        // (`echo x | read v` sets $v; `cmd | mapfile arr` sets arr).
4571        //
4572        // !!! BASH-MODE GATE !!! bash forks EVERY pipeline stage (unless
4573        // `shopt -s lastpipe`), so the last stage runs in a SUBSHELL and its
4574        // variable/array assignments do NOT persist — `echo x | read v; echo
4575        // $v` prints an empty line, `cmd | mapfile arr` leaves arr unset.
4576        // Fork the last stage under `--bash` to match. The parent's existing
4577        // `stage_fds_take()` below closes its `last_in_fd` copy; the forked
4578        // child inherits the parked pipe fd and installs it onto stdin, and
4579        // the writer stages (already forked) supply its input.
4580        //
4581        // c:Src/exec.c:3042-3043 — zsh itself forks the last stage of a
4582        // multi-stage pipeline under sh emulation:
4583        //     if ((how & Z_ASYNC) || output ||
4584        //         (last1 == 2 && input && EMULATION(EMULATE_SH))) {
4585        //         ... execcmd_fork(...)
4586        // so `emulate sh -c 'echo x | read v; echo $v'` prints an empty
4587        // line. ksh emulation keeps the in-shell last stage.
4588        let mut last_proc: Option<crate::ported::zsh_h::process> = None;
4589        // The pdksh line (mksh / pdksh) forks the last stage too: `mksh -c 'echo x |
4590        // read v; echo $v'` prints an empty line. ksh93 keeps it in the shell.
4591        // `shopt -s lastpipe` keeps bash's last stage in the current shell too.
4592        let last_stage_status = if (crate::dash_mode::bash_mode()
4593            && crate::dash_mode::bash_shopt_get("lastpipe") != Some(true))
4594            || crate::dash_mode::pdksh_family()
4595            || (crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_SH)
4596                && !crate::dash_mode::bash_mode())
4597        {
4598            let last_chunk = stages_vec.into_iter().last().unwrap();
4599            crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
4600            match unsafe { libc::fork() } {
4601                -1 => 1,
4602                0 => {
4603                    forked_child_subsh_levels(); // c:Src/exec.c:1221
4604                    EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
4605                    // Subshell child: run the last stage, then _exit with its
4606                    // status. Reset SIGPIPE + drop the EXIT trap like the
4607                    // other pipeline children above.
4608                    unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL) };
4609                    if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
4610                        t.remove("EXIT");
4611                    }
4612                    let mut stage_vm = fusevm::VM::new(last_chunk);
4613                    stage_vm.last_status = parent_status;
4614                    register_builtins(&mut stage_vm);
4615                    stage_vm.set_shell_host(Box::new(ZshrsHost));
4616                    let _ = stage_vm.run();
4617                    let st = stage_vm.last_status;
4618                    let _ = std::io::stdout().flush();
4619                    let _ = std::io::stderr().flush();
4620                    unsafe { libc::_exit(st) };
4621                }
4622                pid => {
4623                    let started = std::time::Instant::now();
4624                    // Same EINTR retry as the stage reap loop below.
4625                    let waited = wait4_eintr(pid);
4626                    // c:Src/exec.c:3042-3043 — this last stage forked, so it
4627                    // is a process of a timed job whatever its command is.
4628                    if let (Some(t), Some((status, ru))) = (timed.as_ref(), waited.as_ref()) {
4629                        last_proc = Some(timed_proc(pid, &t.texts[n - 1], *status, ru, started));
4630                    }
4631                    match waited.map(|(status, _)| status) {
4632                        Some(status) if libc::WIFEXITED(status) => libc::WEXITSTATUS(status),
4633                        Some(status) if libc::WIFSIGNALED(status) => 128 + libc::WTERMSIG(status),
4634                        Some(_) => 1,
4635                        None => 0,
4636                    }
4637                }
4638            }
4639        } else {
4640            let last_chunk = stages_vec.into_iter().last().unwrap();
4641            crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
4642            // A timed pipeline's in-shell last stage is a process of the job
4643            // only when it forks (TimedPipeline::last_forks); its times are
4644            // those of the externals it waits for (FG_CHILD_TIMES). Not a
4645            // RUSAGE_CHILDREN delta: the reaper on another thread can collect
4646            // a forked stage inside this window and its usage would land here.
4647            let last_timed = timed.as_ref().filter(|t| t.last_forks).map(|_| {
4648                FG_CHILD_TIMES.with(|c| c.set(Some((0, 0))));
4649                std::time::Instant::now()
4650            });
4651            let mut stage_vm = fusevm::VM::new(last_chunk);
4652            stage_vm.last_status = parent_status;
4653            register_builtins(&mut stage_vm);
4654            stage_vm.set_shell_host(Box::new(ZshrsHost));
4655            // The externals this stage waits for are not the job's process
4656            // group leader (the first stage is), so they skip update_job's
4657            // foreground tail; see exec_jobs::foreground_job_signalled.
4658            crate::exec_jobs::NOT_JOB_LEADER.with(|d| d.set(d.get() + 1));
4659            let _ = stage_vm.run();
4660            crate::exec_jobs::NOT_JOB_LEADER.with(|d| d.set(d.get() - 1));
4661            let _ = std::io::stdout().flush();
4662            let _ = std::io::stderr().flush();
4663            if let (Some(t), Some(started)) = (timed.as_ref(), last_timed) {
4664                let (utime, stime) = FG_CHILD_TIMES.with(|c| c.take()).unwrap_or((0, 0));
4665                let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
4666                ru.ru_utime = usec_timeval(utime);
4667                ru.ru_stime = usec_timeval(stime);
4668                let status = (stage_vm.last_status & 0xff) << 8; // W_EXITCODE
4669                last_proc = Some(timed_proc(0, &t.texts[n - 1], status, &ru, started));
4670            }
4671            stage_vm.last_status
4672        };
4673
4674        // Reclaim the read end if the stage chunk never reached its
4675        // install op (an expansion error aborted it, or the stage was
4676        // a shape that dispatches without one), then restore the outer
4677        // stage's still-pending fds for a nested pipeline.
4678        let (leftover_in, _) = stage_fds_take();
4679        if leftover_in >= 0 {
4680            unsafe { libc::close(leftover_in) };
4681        }
4682        stage_fds_park(outer_stage_fds.0, outer_stage_fds.1);
4683
4684        // Restore stdin
4685        if saved_stdin >= 0 {
4686            unsafe {
4687                libc::dup2(saved_stdin, libc::STDIN_FILENO);
4688                libc::close(saved_stdin);
4689            }
4690        }
4691
4692        // Wait for all forked stages, capture per-stage statuses for PIPESTATUS.
4693        let mut pipestatus: Vec<i32> = Vec::with_capacity(n);
4694        for (i, pid) in child_pids.into_iter().enumerate() {
4695            // EINTR retry: the SIGCHLD handler interrupts this wait and
4696            // leaves `status` untouched, which used to read back as a
4697            // clean exit 0 for every forked stage. See waitpid_eintr.
4698            let waited = match timed.as_mut() {
4699                Some(t) => wait4_eintr(pid).map(|(status, ru)| {
4700                    let started = child_started.get(i).copied().unwrap_or_else(std::time::Instant::now);
4701                    t.procs.push(timed_proc(pid, &t.texts[i], status, &ru, started));
4702                    status
4703                }),
4704                None => waitpid_eintr(pid),
4705            };
4706            if i == 0 {
4707                // c:Src/jobs.c:497-498 — `if (pn->pid == jn->gleader) status =
4708                // pn->status;`: the first stage leads the job's process group,
4709                // and its status is the one update_job's foreground tail reads.
4710                if let Some(status) = waited {
4711                    crate::exec_jobs::foreground_job_signalled(status);
4712                }
4713            }
4714            let s = match waited {
4715                Some(status) if libc::WIFEXITED(status) => libc::WEXITSTATUS(status),
4716                Some(status) if libc::WIFSIGNALED(status) => 128 + libc::WTERMSIG(status),
4717                Some(_) => 1,
4718                // Unreapable (ECHILD — the handler got there first).
4719                // Nothing better to report than success; the stage's
4720                // real status is gone.
4721                None => 0,
4722            };
4723            pipestatus.push(s);
4724        }
4725        // Append the in-parent last-stage status so `pipestatus` ends
4726        // with N entries (one per stage).
4727        pipestatus.push(last_stage_status);
4728        if let Some(mut t) = timed {
4729            t.procs.extend(last_proc);
4730            TIMED_PIPELINE.with(|slot| *slot.borrow_mut() = Some(t));
4731        }
4732        // Pipeline exit status: by default, the LAST stage's status.
4733        // With `setopt pipefail` (or `set -o pipefail`), use the
4734        // first non-zero stage status (so failures earlier in the
4735        // pipeline propagate even if the last stage succeeded).
4736        let pipefail_on = with_executor(|exec| opt_state_get("pipefail").unwrap_or(false));
4737        let last_status = if pipefail_on {
4738            pipestatus
4739                .iter()
4740                .copied()
4741                .rfind(|&s| s != 0)
4742                .or_else(|| pipestatus.last().copied())
4743                .unwrap_or(0)
4744        } else {
4745            *pipestatus.last().unwrap_or(&0)
4746        };
4747
4748        // c:Src/params.c:265,438 — only `pipestatus` (lowercase) is the
4749        // zsh special parameter; bash's `PIPESTATUS` doesn't exist in
4750        // zsh's special-params table. Prior port also populated
4751        // `PIPESTATUS` "for portability" — but that's a real divergence
4752        // from zsh: a script doing `[[ -z $PIPESTATUS ]]` to detect
4753        // zsh-vs-bash would mis-classify. Bug #64 in docs/BUGS.md.
4754        // c:Src/jobs.c:438-441 (storepipestats) — the per-stage statuses go
4755        // into the C globals `pipestats[]` / `numpipestats` whatever the
4756        // emulation. Under sh/ksh `pipestatus` is not a special parameter
4757        // (no PM_SPECIAL, so the paramtab mirror below is skipped), but the
4758        // globals still carry the pipeline — `--mksh`'s `$PIPESTATUS` and
4759        // doshfunc's funcsave snapshot (c:Src/exec.c:5864) read them.
4760        {
4761            use crate::ported::jobs::{NUMPIPESTATS, PIPESTATS};
4762            use std::sync::Mutex;
4763            let count = pipestatus.len().min(crate::ported::zsh_h::MAX_PIPESTATS);
4764            let p = PIPESTATS.get_or_init(|| Mutex::new([0; crate::ported::zsh_h::MAX_PIPESTATS]));
4765            if let Ok(mut pguard) = p.lock() {
4766                pguard[..count].copy_from_slice(&pipestatus[..count]); // c:438 memcpy
4767            }
4768            let n = NUMPIPESTATS.get_or_init(|| Mutex::new(0));
4769            if let Ok(mut nguard) = n.lock() {
4770                *nguard = count; // c:441
4771            }
4772        }
4773        with_executor(|exec| {
4774            // c:Src/jobs.c:83 `int pipestats[MAX_PIPESTATS]` — the values
4775            // live in that C GLOBAL, reached through `pipestatus`'s GSU
4776            // (c:Src/params.c pipestatgetfn). A `typeset -h +g pipestatus`
4777            // local shadow carries no PM_SPECIAL and no GSU, so the C
4778            // writer cannot reach it; skip the paramtab mirror likewise or
4779            // the shadow loses its PM_UNSET (B02typeset.ztst:37,38).
4780            let shadowed = crate::ported::params::paramtab()
4781                .read()
4782                .ok()
4783                .and_then(|t| {
4784                    t.get("pipestatus")
4785                        .map(|pm| (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) == 0)
4786                })
4787                .unwrap_or(false);
4788            if !shadowed {
4789                let strs: Vec<String> = pipestatus.iter().map(|s| s.to_string()).collect();
4790                exec.set_array("pipestatus".to_string(), strs);
4791            }
4792        });
4793
4794        Value::Status(last_status)
4795    });
4796
4797    // Array→String join. Pops one value; if it's an Array (e.g. from Op::Glob),
4798    // joins string-coerced elements with a single space. Pass-through for
4799    // non-arrays so the op is safe to chain after any String-or-Array producer.
4800    // Scalar coercion of an assembled word: pop a Value; if it's an
4801    // Array (produced by a splice segment like `"$@"` / `"${arr[@]}"`),
4802    // IFS[0]-join it to a single scalar; a scalar passes through. This
4803    // is the assignment-context coercion C zsh applies in multsub when
4804    // the expansion is the RHS of a SCALAR assignment (Src/subst.c
4805    // c:3032 sepjoin under ssub) — `v="$@"` joins the positionals with
4806    // ${IFS[1]} rather than leaving an array whose splat would lose all
4807    // but the first element. Joins via sepjoin so a custom / empty IFS
4808    // is honored (not a hardcoded space).
4809    vm.register_builtin(BUILTIN_ARRAY_JOIN, |vm, _argc| {
4810        let val = vm.pop();
4811        match val {
4812            Value::Array(items) => {
4813                let strs: Vec<String> = items.iter().map(|v| v.to_str()).collect();
4814                Value::str(crate::ported::utils::sepjoin(&strs, None))
4815            }
4816            other => other,
4817        }
4818    });
4819
4820    // `cmd &` background execution. Compile_list emits this for any item
4821    // followed by ListOp::Amp: the job text + the cmd's sub-chunk index are
4822    // pushed, then this builtin pops both, looks up the chunk, forks. The
4823    // child detaches via setsid (so SIGINT to the foreground job doesn't kill
4824    // it), runs the bytecode on a fresh VM with builtins re-registered, exits
4825    // with the last status. The parent registers the job in the canonical
4826    // JOBTAB (c:Src/exec.c::execpline Z_ASYNC arm) and returns Status(0).
4827    vm.register_builtin(BUILTIN_RUN_BG, |vm, _argc| {
4828        // `&|` / `&!` set disown → the job is dropped from the table (no
4829        // `[N] pid` announcement, no `[N] done`), matching C exec.c:1752-1758.
4830        let disown = vm.pop().to_int() != 0;
4831        // Stage count. 1 for anything but a multi-stage pipeline; the compiler
4832        // pushes one (text, sub_chunk_index) pair per stage ahead of it.
4833        let nstages = vm.pop().to_int().max(1) as usize;
4834        let mut stages: Vec<(usize, String)> = Vec::with_capacity(nstages);
4835        for _ in 0..nstages {
4836            let idx = vm.pop().to_int() as usize;
4837            let text = vm.pop().to_str();
4838            stages.push((idx, text));
4839        }
4840        stages.reverse(); // popped last-stage-first
4841        let chunks: Vec<fusevm::Chunk> = match stages
4842            .iter()
4843            .map(|(i, _)| vm.chunk.sub_chunks.get(*i).cloned())
4844            .collect::<Option<Vec<_>>>()
4845        {
4846            Some(c) => c,
4847            None => return Value::Status(1),
4848        };
4849
4850        // c:Src/exec.c:1748 — `child_block();`, released by the async arm's
4851        // `child_unblock();` (c:1815) once the job is registered. A child
4852        // that exits at once must not be reaped before `addproc` knows its
4853        // pid. See ChildBlockSpan.
4854        #[cfg(unix)]
4855        let _child_block = ChildBlockSpan::enter();
4856
4857        if nstages > 1 {
4858            // c:Src/exec.c:1795 — the Z_ASYNC arm runs execpline2 in THIS
4859            // shell, so every stage is forked here and becomes its own proc
4860            // entry on the one job. Build the N-1 pipes, fork N children, and
4861            // addproc each; nothing in the parent waits.
4862            let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(nstages - 1);
4863            for _ in 0..nstages - 1 {
4864                let mut fds = [0i32; 2];
4865                if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
4866                    for (r, w) in &pipes {
4867                        unsafe {
4868                            libc::close(*r);
4869                            libc::close(*w);
4870                        }
4871                    }
4872                    return Value::Status(1);
4873                }
4874                pipes.push((fds[0], fds[1]));
4875            }
4876            // c:Src/exec.c:2916 — every async stage enters the subshell with
4877            // ESUB_PGRP, so they all land in ONE new process group led by the
4878            // first stage (c:Src/jobs.c:1582-1583 sets jn->gleader to the first
4879            // proc's pid). Keeping them together is what makes `kill %1` reach
4880            // the whole pipeline. Both parent and child set the pgid so neither
4881            // side can lose the race.
4882            let mut gleader: libc::pid_t = 0;
4883            let mut procs: Vec<(libc::pid_t, String)> = Vec::with_capacity(nstages);
4884            for (i, chunk) in chunks.iter().enumerate() {
4885                match unsafe { libc::fork() } {
4886                    -1 => {
4887                        for (pid, _) in &procs {
4888                            unsafe { libc::kill(*pid, libc::SIGTERM) };
4889                        }
4890                        for (r, w) in &pipes {
4891                            unsafe {
4892                                libc::close(*r);
4893                                libc::close(*w);
4894                            }
4895                        }
4896                        return Value::Status(1);
4897                    }
4898                    0 => {
4899                        forked_child_subsh_levels(); // c:Src/exec.c:1221
4900                        EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
4901                        // c:Src/exec.c:2916 — every async stage runs
4902                        // `entersubsh(ESUB_ASYNC|ESUB_PGRP)`: traps reset, MONITOR
4903                        // off, SIGINT/SIGQUIT ignored without job control, the
4904                        // interactive SIGTERM ignore dropped, job table cleared,
4905                        // subsh set. With job control it joins the job's group,
4906                        // led by the first stage (c:1149-1190); the port reads
4907                        // that leader from jobtab[thisjob], which zshrs fills in
4908                        // only after every fork, so join it here instead.
4909                        let monitor = crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR);
4910                        crate::ported::exec::entersubsh(
4911                            crate::ported::exec::esub::ASYNC | crate::ported::exec::esub::PGRP,
4912                            None,
4913                        );
4914                        if monitor {
4915                            unsafe { libc::setpgid(0, gleader) };
4916                        }
4917                        // Mirrors the RUN_PIPELINE stage child: default SIGPIPE
4918                        // so a broken pipe kills the stage quietly, and drop the
4919                        // parent's EXIT trap (c:Src/exec.c:2917-2918).
4920                        unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL) };
4921                        if let Ok(mut tt) = crate::ported::builtin::traps_table().lock() {
4922                            tt.remove("EXIT");
4923                        }
4924                        if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
4925                            if let Some(slot) =
4926                                st.get_mut(crate::ported::signals_h::SIGEXIT as usize)
4927                            {
4928                                *slot = 0;
4929                            }
4930                        }
4931                        *crate::ported::jobs::THISJOB
4932                            .get_or_init(|| std::sync::Mutex::new(-1))
4933                            .lock()
4934                            .unwrap() = -1;
4935                        let in_fd = if i > 0 { pipes[i - 1].0 } else { -1 };
4936                        // Unlike a foreground pipeline (whose last stage runs
4937                        // inline in the shell), an async pipeline forks its last
4938                        // stage too, so that stage simply has no pipe on stdout.
4939                        let out_fd = if i + 1 < nstages { pipes[i].1 } else { -1 };
4940                        for (r, w) in &pipes {
4941                            unsafe {
4942                                if *r != in_fd && *r != out_fd {
4943                                    libc::close(*r);
4944                                }
4945                                if *w != in_fd && *w != out_fd {
4946                                    libc::close(*w);
4947                                }
4948                            }
4949                        }
4950                        stage_fds_park(in_fd, out_fd);
4951                        crate::fusevm_disasm::maybe_print_stdout(
4952                            &format!("background_job:stage:{i}"),
4953                            chunk,
4954                        );
4955                        let mut stage_vm = fusevm::VM::new(chunk.clone());
4956                        register_builtins(&mut stage_vm);
4957                        let _ = stage_vm.run();
4958                        let _ = std::io::stdout().flush();
4959                        let _ = std::io::stderr().flush();
4960                        std::process::exit(stage_vm.last_status);
4961                    }
4962                    pid => {
4963                        if gleader == 0 {
4964                            gleader = pid;
4965                        }
4966                        if crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) {
4967                            unsafe { libc::setpgid(pid, gleader) };
4968                        }
4969                        procs.push((pid, stages[i].1.clone()));
4970                    }
4971                }
4972            }
4973            for (r, w) in &pipes {
4974                unsafe {
4975                    libc::close(*r);
4976                    libc::close(*w);
4977                }
4978            }
4979            // c:Src/exec.c:2891-2892 — `if (how & Z_ASYNC) lastpid = pid;` runs
4980            // for EVERY stage as it is forked, so `$!` ends up holding the LAST
4981            // stage's pid even though the job's group leader is the first.
4982            if let Some((last, _)) = procs.last() {
4983                crate::ported::modules::clone::lastpid
4984                    .store(*last, std::sync::atomic::Ordering::Relaxed);
4985            }
4986            {
4987                use crate::ported::jobs;
4988                use std::sync::Mutex;
4989                let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
4990                let idx = {
4991                    let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
4992                    let idx = jobs::initjob(&mut tab); // c:exec.c:1756
4993                    for (pid, text) in &procs {
4994                        // c:exec.c:2907 — one addproc per forked stage.
4995                        jobs::addproc(
4996                            &mut tab[idx],
4997                            *pid,
4998                            text,
4999                            false,
5000                            Some(std::time::Instant::now()),
5001                            -1,
5002                            -1,
5003                        );
5004                    }
5005                    tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1803
5006                    idx
5007                };
5008                jobs::clearoldjobtab(); // c:exec.c:1798
5009                if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
5010                    *tj = idx as i32;
5011                }
5012                if disown {
5013                    // c:exec.c:1808-1811
5014                    {
5015                        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
5016                        jobs::pipecleanfilelist(&mut tab[idx], false);
5017                        jobs::deletejob(&mut tab, idx, true);
5018                    }
5019                    if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
5020                        *tj = -1;
5021                    }
5022                } else {
5023                    jobs::spawnjob(); // c:exec.c:1814
5024                }
5025            }
5026            with_executor(|exec| {
5027                for (pid, text) in &procs {
5028                    exec.jobs.add_pid_job(*pid, text.clone(), JobState::Running);
5029                }
5030            });
5031            return Value::Status(0);
5032        }
5033
5034        let chunk = chunks.into_iter().next().unwrap();
5035        let job_text = stages.into_iter().next().unwrap().1;
5036        match unsafe { libc::fork() } {
5037            -1 => Value::Status(1),
5038            0 => {
5039                forked_child_subsh_levels(); // c:Src/exec.c:1221
5040                EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
5041                // c:Src/exec.c:2916 — an async command's child runs
5042                // `entersubsh(ESUB_ASYNC|ESUB_PGRP)`: string traps reset,
5043                // MONITOR off (no ESUB_JOB_CONTROL, c:1245-1246), SIGINT/
5044                // SIGQUIT ignored without job control (c:1137-1140), its own
5045                // process group with it (c:1149-1190), SIGTERM/SIGINT back to
5046                // default in an interactive shell (c:1224-1230), subsh set. The old `setsid()` did none of
5047                // that: the child kept MONITOR and the interactive SIGTERM
5048                // ignore, so it put its own commands in fresh groups and
5049                // `kill %N` reached nothing.
5050                crate::ported::exec::entersubsh(
5051                    crate::ported::exec::esub::ASYNC | crate::ported::exec::esub::PGRP,
5052                    None,
5053                );
5054                // `$ZSH_SUBSHELL` itself is bumped by the chunk's leading
5055                // BUILTIN_EXECCMD_FORKED_LEVEL, which knows whether the job
5056                // is a `( … )` whose own subshell_begin is the one bump.
5057                crate::fusevm_disasm::maybe_print_stdout("background_job", &chunk);
5058                let mut bg_vm = fusevm::VM::new(chunk);
5059                register_builtins(&mut bg_vm);
5060                let _ = bg_vm.run();
5061                let _ = std::io::stdout().flush();
5062                let _ = std::io::stderr().flush();
5063                std::process::exit(bg_vm.last_status);
5064            }
5065            pid => {
5066                // c:Src/exec.c:2887-2890 — C's parent blocks on the synch
5067                // pipe until the child has run entersubsh, so the child's
5068                // own group exists before `kill %N` can target it. Set it
5069                // from this side too (the async-pipeline arm above does the
5070                // same) so neither side can lose the race.
5071                if crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) {
5072                    unsafe { libc::setpgid(pid, pid) };
5073                }
5074                // Parent: record the PID into `$!` (most recent
5075                // backgrounded job's pid). zsh exposes this for any
5076                // script that needs `wait $!`. Also register the
5077                // bare-pid job so a no-args `wait` can synchronize.
5078                // c:Src/jobs.c:73 — `lastpid = pid;` after a
5079                // background fork. zshrs's `$!` getter
5080                // (params.rs::lookup_special_var "!") reads from
5081                // the same atomic, so a single store here is the
5082                // canonical writer.
5083                crate::ported::modules::clone::lastpid
5084                    .store(pid, std::sync::atomic::Ordering::Relaxed);
5085                // c:Src/exec.c:1700 — `thisjob = newjob = initjob()`:
5086                // allocate the canonical jobtab slot. c:Src/exec.c:2950
5087                // zfork path → addproc(pid, text, 0, &bgtime, ...) hangs
5088                // the proc entry (with its display text) off the job.
5089                // c:Src/exec.c:1744-1746 — `clearoldjobtab();
5090                // jobtab[thisjob].stat |= STAT_NOSTTY;` then c:1758
5091                // `spawnjob()` promotes it to curjob (top-level shell
5092                // only), marks STAT_LOCKED and resets thisjob.
5093                {
5094                    use crate::ported::jobs;
5095                    use std::sync::Mutex;
5096                    let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
5097                    let idx = {
5098                        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
5099                        let idx = jobs::initjob(&mut tab); // c:exec.c:1700
5100                        jobs::addproc(
5101                            &mut tab[idx],
5102                            pid,
5103                            &job_text,
5104                            false,
5105                            Some(std::time::Instant::now()),
5106                            -1,
5107                            -1,
5108                        ); // c:exec.c:2950 addproc
5109                        tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
5110                        idx
5111                    };
5112                    jobs::clearoldjobtab(); // c:exec.c:1744
5113                    if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
5114                        *tj = idx as i32;
5115                    }
5116                    if disown {
5117                        // c:exec.c:1752-1755 — `pipecleanfilelist(...);
5118                        // deletejob(jobtab + thisjob, 1); thisjob = -1;` — a
5119                        // disowned job leaves the table entirely, so neither
5120                        // spawnjob's `[N] pid` nor the later `[N] done` prints.
5121                        // This is what keeps zinit-turbo's `… &|` completion
5122                        // jobs silent (they load inside a `zle -F` handler).
5123                        {
5124                            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
5125                            jobs::pipecleanfilelist(&mut tab[idx], false); // c:1753
5126                            jobs::deletejob(&mut tab, idx, true); // c:1754
5127                        }
5128                        if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
5129                            *tj = -1; // c:1755
5130                        }
5131                    } else {
5132                        jobs::spawnjob(); // c:exec.c:1758
5133                    }
5134                }
5135                with_executor(|exec| {
5136                    exec.jobs
5137                        .add_pid_job(pid, job_text.clone(), JobState::Running);
5138                });
5139                Value::Status(0)
5140            }
5141        }
5142    });
5143
5144    // ── Indexed-array storage ─────────────────────────────────────────────
5145    //
5146    // Stack: pushed values then name (LAST). `arr=(a b c)` → 4 args
5147    // (a, b, c, arr). `arr=($(cmd))` → 2 args (FlatArray, arr).
5148    //
5149    // PURE PASSTHRU: pop name + values, dispatch to canonical
5150    // `setaparam` / `sethparam` (C port of `Src/params.c:3595/3602`).
5151    // assignaparam already handles PM_UNIQUE dedupe, type-flag flip,
5152    // PM_NAMEREF rejection, ASSPM_AUGMENT prepend, and createparam
5153    // for fresh names.
5154    vm.register_builtin(BUILTIN_SET_ARRAY, |vm, argc| {
5155        // `${~spec}` carrier: an assignment statement is a word-
5156        // pipeline boundary too — restore the user's GLOB_SUBST
5157        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
5158        // ${options[globsubst]}` must read the user value).
5159        consume_tilde_globsubst_carrier();
5160        let n = argc as usize;
5161        let mut popped: Vec<Value> = Vec::with_capacity(n);
5162        for _ in 0..n {
5163            popped.push(vm.pop());
5164        }
5165        popped.reverse();
5166        if popped.is_empty() {
5167            return Value::Status(1);
5168        }
5169        let name = popped.pop().unwrap().to_str();
5170        let mut values: Vec<String> = Vec::new();
5171        for v in popped {
5172            flatten_array_value(v, &mut values);
5173        }
5174        if array_assignment_expansion_failed() {
5175            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
5176            return Value::Status(1);
5177        }
5178        // Bash sparse: a full `a=(...)` reassign resets the array to dense
5179        // (drops any prior holes from subscript-assign / unset).
5180        if crate::dash_mode::sparse_arrays() {
5181            crate::bash_arrays::clear(&name);
5182        }
5183        let blocked = with_executor(|exec| {
5184            // c:Src/exec.c:4473-4509 save_params — an ARRAY prefix
5185            // assignment (`z=(9 8 7) f`) is a WC_ASSIGN in the same chain
5186            // as a scalar one, so it is snapshotted the same way and put
5187            // back by restore_params when the command returns.
5188            let prefix_restored = exec
5189                .inline_env_stack
5190                .last()
5191                .is_some_and(|frame| frame.recording);
5192            if prefix_restored {
5193                save_inline_prefix_param(exec, &name);
5194            }
5195            // Assoc init `typeset -A m; m=(k v k v ...)` — route to
5196            // canonical sethparam (Src/params.c:3602) which parses the
5197            // flat (k,v) pair list internally.
5198            if exec.assoc(&name).is_some() {
5199                // `[k]=v` / `[k]+=v` elements arrive from the compiler
5200                // as Marker / key / value triples (compile_zsh's port
5201                // of keyvalpairelement, c:Src/subst.c:49-79).
5202                let marker = crate::ported::zsh_h::Marker;
5203                let values = if values.iter().any(|e| e.starts_with(marker)) {
5204                    // c:Src/params.c:3544-3560 — under ASSPM_KEY_VALUE
5205                    // assocs strictly enforce `[key]=value`: every
5206                    // stride-of-3 element must be a Marker. Mixing
5207                    // plain pairs with kv triads is an error.
5208                    let mut i = 0usize;
5209                    while i < values.len() {
5210                        if !values[i].starts_with(marker) {
5211                            crate::ported::utils::zerr(
5212                                "bad [key]=value syntax for associative array",
5213                            );
5214                            crate::ported::utils::errflag.fetch_or(
5215                                crate::ported::zsh_h::ERRFLAG_ERROR,
5216                                std::sync::atomic::Ordering::Relaxed,
5217                            );
5218                            exec.set_last_status(1);
5219                            return true;
5220                        }
5221                        i += 3;
5222                    }
5223                    if values.len() % 3 != 0 {
5224                        // c:Src/params.c:4124-4131 arrhashsetfn — a
5225                        // truncated triad leaves an odd non-Marker
5226                        // count → "bad set of key/value pairs".
5227                        crate::ported::utils::zerr(
5228                            "bad set of key/value pairs for associative array",
5229                        );
5230                        crate::ported::utils::errflag.fetch_or(
5231                            crate::ported::zsh_h::ERRFLAG_ERROR,
5232                            std::sync::atomic::Ordering::Relaxed,
5233                        );
5234                        exec.set_last_status(1);
5235                        return true;
5236                    }
5237                    // c:Src/params.c:4136-4168 arrhashsetfn — whole
5238                    // assignment builds a FRESH table; a `Marker +`
5239                    // triad (`[k]+=v`) appends to the value inserted
5240                    // EARLIER IN THIS SAME LITERAL (assignstrvalue
5241                    // with eltflags=ASSPM_AUGMENT against the new ht),
5242                    // so `h=([k]=a [k]+=b)` yields "ab". Resolve the
5243                    // appends here, then hand flat pairs to sethparam.
5244                    let mut order: Vec<String> = Vec::new();
5245                    let mut map: std::collections::HashMap<String, String> =
5246                        std::collections::HashMap::new();
5247                    for ch in values.chunks(3) {
5248                        let elt_append = ch[0].chars().nth(1) == Some('+');
5249                        let k = ch[1].clone();
5250                        let v = ch[2].clone();
5251                        let nv = if elt_append {
5252                            format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
5253                        } else {
5254                            v
5255                        };
5256                        if !map.contains_key(&k) {
5257                            order.push(k.clone());
5258                        }
5259                        map.insert(k, nv);
5260                    }
5261                    order
5262                        .into_iter()
5263                        .flat_map(|k| {
5264                            let v = map.get(&k).cloned().unwrap_or_default();
5265                            [k, v]
5266                        })
5267                        .collect()
5268                } else {
5269                    values
5270                };
5271                // Odd-count rejection lives in the canonical chain:
5272                // sethparam → setarrvalue (c:3651/c:2920) →
5273                // arrhashsetfn's zerr "bad set of key/value pairs"
5274                // (Src/params.c:4128-4131). zerr sets ERRFLAG_ERROR,
5275                // which aborts the remaining list at the next command
5276                // boundary (BUILTIN_ERREXIT_CHECK trigger 4) —
5277                // matching `zsh -fc 'typeset -A m; m=(odd); print x'`
5278                // printing nothing after the error. Like C's sethparam
5279                // (c:3652-3653 returns v->pm regardless), the Rust
5280                // port returns Some on the odd-count path — the
5281                // failure travels via errflag, so check BOTH.
5282                let pre_err = crate::ported::utils::errflag
5283                    .load(std::sync::atomic::Ordering::Relaxed)
5284                    & crate::ported::zsh_h::ERRFLAG_ERROR;
5285                let res = crate::ported::params::sethparam(&name, values.clone());
5286                let now_err = crate::ported::utils::errflag
5287                    .load(std::sync::atomic::Ordering::Relaxed)
5288                    & crate::ported::zsh_h::ERRFLAG_ERROR;
5289                if res.is_none() || (pre_err == 0 && now_err != 0) {
5290                    // c:Src/exec.c:2632-2633 addvars — `if
5291                    // (!assignaparam(name, arr, myflags)) lastval = 1;`
5292                    // — failed assignment sets lastval so the errflag
5293                    // abort exits 1 (init.c loop() breaks, zsh_main
5294                    // returns lastval).
5295                    exec.set_last_status(1);
5296                    return true;
5297                }
5298                #[cfg(feature = "recorder")]
5299                if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5300                    let ctx = exec.recorder_ctx();
5301                    let attrs = exec.recorder_attrs_for(&name);
5302                    let mut pairs: Vec<(String, String)> = Vec::with_capacity(values.len() / 2);
5303                    let mut iter = values.iter().cloned();
5304                    while let Some(k) = iter.next() {
5305                        if let Some(v) = iter.next() {
5306                            pairs.push((k, v));
5307                        }
5308                    }
5309                    crate::recorder::emit_assoc_assign(&name, pairs, attrs, false, ctx);
5310                }
5311                return false;
5312            }
5313            // Indexed-array: setaparam (Src/params.c:3766) wraps
5314            // assignaparam with ASSPM_WARN — handles PM_UNIQUE dedupe,
5315            // type-flag flip, PM_READONLY rejection.
5316            //
5317            // `[k]=v` elements arrive as Marker / key / value triples
5318            // (compile_zsh's keyvalpairelement port). Mirror
5319            // c:Src/exec.c:2552-2553 — `if (prefork_ret &
5320            // PREFORK_KEY_VALUE) myflags |= ASSPM_KEY_VALUE;` — so
5321            // assignaparam runs its kv-resolution block (sparse fill
5322            // for PM_ARRAY, c:3447-3541; strict-triad enforcement for
5323            // special PM_HASHED targets like `options`, c:3544-3560).
5324            let values = values;
5325            let has_kv = values
5326                .iter()
5327                .any(|e| e.starts_with(crate::ported::zsh_h::Marker));
5328            // The tied-array mirror to a PM_TIED scalar
5329            // (`typeset -T PATH path`) lives canonically in
5330            // setarrvalue's dispatch in C zsh; until that wires
5331            // through assignaparam, mirror here so PATH stays in sync
5332            // after `path=(/x)`.
5333            //
5334            // The mirrored value must be the array AS STORED, which for a
5335            // PM_UNIQUE tie means deduped. c:4066-4076 arrsetfn fixes the
5336            // order:
5337            //     if (pm->node.flags & PM_UNIQUE) uniqarray(x);
5338            //     pm->u.arr = x;
5339            //     if (pm->ename && x) arrfixenv(pm->ename, x);
5340            // — the dedupe happens FIRST, so the scalar publishes the same
5341            // list the array holds and the two halves of a tie always agree.
5342            // Mirroring the raw `values` broke exactly that:
5343            //     typeset -U path; path=(/a /b /a)
5344            //       $path → /a /b        (right)
5345            //       $PATH → /a:/b:/a     (wrong; zsh gives /a:/b)
5346            // i.e. `typeset -U path`, the standard PATH-dedup idiom in
5347            // essentially every .zshrc. assignaparam's own arrfixenv does not
5348            // rescue it: that call is gated on the param having a gsu_a wired,
5349            // and `path` has none.
5350            //
5351            // The dedupe is applied here rather than by reading the array back
5352            // after assignaparam, because this mirror must stay BEFORE it.
5353            // `exec.set_scalar` is heavier than C's arrfixenv — arrfixenv only
5354            // rewrites the environment string, while set_scalar re-derives the
5355            // ARRAY from the scalar. Running it afterwards makes `path=()`
5356            // publish PATH="" and then re-split that back into a one-element
5357            // `path=("")`, where zsh leaves 0 elements.
5358            if let Some((scalar_name, sep)) = exec
5359                .tied_array_to_scalar
5360                .get(&name)
5361                .cloned()
5362            {
5363                let uniq = crate::ported::params::paramtab()
5364                    .read()
5365                    .ok()
5366                    .and_then(|t| t.get(&name).map(|p| p.node.flags))
5367                    .map(|f| (f as u32 & crate::ported::zsh_h::PM_UNIQUE) != 0)
5368                    .unwrap_or(false);
5369                let mirror = if uniq {
5370                    crate::ported::params::simple_arrayuniq(values.clone()) // c:4068
5371                } else {
5372                    values.clone()
5373                };
5374                exec.set_scalar(scalar_name, mirror.join(&sep)); // c:4074-4075
5375            }
5376            // c:Src/exec.c:2632-2633 addvars — `if (!assignaparam(...))
5377            // lastval = 1;` — a failed assignment (bad subscript, bad
5378            // [key]=value syntax, readonly) exits 1 and the errflag
5379            // abort stops the remaining list. Track errflag pre/post
5380            // like the assoc branch above.
5381            let kv_flag = if has_kv {
5382                crate::ported::zsh_h::ASSPM_KEY_VALUE
5383            } else {
5384                0
5385            };
5386            let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
5387                & crate::ported::zsh_h::ERRFLAG_ERROR;
5388            // c:Src/exec.c:2573 — `flags = !(addflags & ADDVAR_RESTORE) ?
5389            // ASSPM_WARN : 0;`: a prefix assignment that is put back after
5390            // the command is implicitly scoped and never warns, the same rule
5391            // the scalar arm applies.
5392            let warn = if prefix_restored { 0 } else { crate::ported::zsh_h::ASSPM_WARN };
5393            let res = crate::ported::params::assignaparam(&name, values.clone(), warn | kv_flag);
5394            let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
5395                & crate::ported::zsh_h::ERRFLAG_ERROR;
5396            if res.is_none() && pre_err == 0 && now_err != 0 {
5397                exec.set_last_status(1);
5398                return true;
5399            }
5400            // Bash sparse: an explicit-index array literal `a=([2]=x [5]=y)`
5401            // leaves the un-indexed slots as HOLES (bash: count 2, indices
5402            // {2,5}), not dense empties. assignaparam has already placed each
5403            // value at its 0-based index; mark every OTHER slot a hole. Gated
5404            // to a PURE indexed literal (every element a `[idx]=val` triple) —
5405            // a mixed positional/indexed literal (`a=(x [3]=y z)`) needs the
5406            // positional-counter replay we don't model, so it stays dense.
5407            if crate::dash_mode::sparse_arrays() && has_kv {
5408                let marker = crate::ported::zsh_h::Marker;
5409                let pure_indexed = !values.is_empty()
5410                    && values.len() % 3 == 0
5411                    && values.chunks(3).all(|ch| ch[0].starts_with(marker));
5412                if pure_indexed {
5413                    let mut explicit: std::collections::BTreeSet<usize> =
5414                        std::collections::BTreeSet::new();
5415                    for ch in values.chunks(3) {
5416                        if let Ok(i) = ch[1].trim().parse::<usize>() {
5417                            explicit.insert(i);
5418                        }
5419                    }
5420                    let len = exec.array(&name).map(|a| a.len()).unwrap_or(0);
5421                    crate::bash_arrays::note_holes_outside(&name, &explicit, len);
5422                }
5423            }
5424            #[cfg(feature = "recorder")]
5425            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5426                let ctx = exec.recorder_ctx();
5427                let attrs = exec.recorder_attrs_for(&name);
5428                emit_path_or_assign(&name, &values, attrs, false, &ctx);
5429            }
5430            false
5431        });
5432        let status = if blocked { 1 } else { 0 };
5433        // c:Src/jobs.c:1748-1757 waitonejob — in C an array-assignment
5434        // simple command goes through execpline → waitjobs; with no
5435        // procs the else-branch stores `pipestats[0] = lastval;
5436        // numpipestats = 1`. Bare SCALAR assignments never create a
5437        // job (no waitjobs), so this clobber is array/assoc-assignment
5438        // specific: `false|true; x=(1 2); echo $pipestatus` → `0` in
5439        // zsh while `x=1` preserves `1 0`. Bug #373.
5440        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
5441        crate::ported::jobs::waitonejob(-1);
5442        Value::Status(status)
5443    });
5444    // `arr+=(d e f)` — array append. Same calling conventions as SET_ARRAY.
5445    //
5446    // PURE PASSTHRU shape: pop name + values, dispatch through the
5447    // canonical assoc / array setter. assignaparam's ASSPM_AUGMENT
5448    // flag handles the C-source-equivalent "preserve prior value"
5449    // semantics; for now we read the current array, extend with
5450    // new values, write through set_array (which routes to
5451    // setaparam → assignaparam where PM_UNIQUE dedupe lands).
5452    vm.register_builtin(BUILTIN_APPEND_ARRAY, |vm, argc| {
5453        let n = argc as usize;
5454        let mut popped: Vec<Value> = Vec::with_capacity(n);
5455        for _ in 0..n {
5456            popped.push(vm.pop());
5457        }
5458        popped.reverse();
5459        if popped.is_empty() {
5460            return Value::Status(1);
5461        }
5462        let name = popped.pop().unwrap().to_str();
5463        let mut values: Vec<String> = Vec::new();
5464        for v in popped {
5465            flatten_array_value(v, &mut values);
5466        }
5467        if array_assignment_expansion_failed() {
5468            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
5469            return Value::Status(1);
5470        }
5471        let blocked = with_executor(|exec| -> bool {
5472            // Assoc append `m+=(k1 v1 ...)`: merge the (k,v) pairs into
5473            // the existing map and write back via canonical sethparam
5474            // (Src/params.c:3602). The canonical C path would go
5475            // assignaparam(ASSPM_AUGMENT) → arrhashsetfn(ASSPM_AUGMENT)
5476            // at Src/params.c:3850, but the zshrs port of
5477            // arrhashsetfn doesn't yet implement value-storage
5478            // (pending Param.u_hash backend wireup) — until that
5479            // lands, do the augment + write here so the storage
5480            // actually mutates.
5481            if exec.assoc(&name).is_some() {
5482                // `[k]=v` / `[k]+=v` elements arrive as Marker / key /
5483                // value triples (compile_zsh's port of
5484                // keyvalpairelement, c:Src/subst.c:49-79).
5485                let marker = crate::ported::zsh_h::Marker;
5486                let mut map = exec.assoc(&name).unwrap_or_default();
5487                if values.iter().any(|e| e.starts_with(marker)) {
5488                    // c:Src/params.c:3544-3560 — strict triad rule.
5489                    let mut i = 0usize;
5490                    while i < values.len() {
5491                        if !values[i].starts_with(marker) {
5492                            crate::ported::utils::zerr(
5493                                "bad [key]=value syntax for associative array",
5494                            );
5495                            crate::ported::utils::errflag.fetch_or(
5496                                crate::ported::zsh_h::ERRFLAG_ERROR,
5497                                std::sync::atomic::Ordering::Relaxed,
5498                            );
5499                            exec.set_last_status(1);
5500                            return true;
5501                        }
5502                        i += 3;
5503                    }
5504                    if values.len() % 3 != 0 {
5505                        // c:Src/params.c:4124-4131 — odd pair count.
5506                        crate::ported::utils::zerr(
5507                            "bad set of key/value pairs for associative array",
5508                        );
5509                        crate::ported::utils::errflag.fetch_or(
5510                            crate::ported::zsh_h::ERRFLAG_ERROR,
5511                            std::sync::atomic::Ordering::Relaxed,
5512                        );
5513                        exec.set_last_status(1);
5514                        return true;
5515                    }
5516                    // c:Src/params.c:4133-4168 arrhashsetfn with
5517                    // ASSPM_AUGMENT — ht = the EXISTING table, so
5518                    // `[k]+=v` appends to the current value
5519                    // (assignstrvalue eltflags=ASSPM_AUGMENT,
5520                    // c:4144-4150) and `[k]=v` overwrites.
5521                    for ch in values.chunks(3) {
5522                        let elt_append = ch[0].chars().nth(1) == Some('+');
5523                        let k = ch[1].clone();
5524                        let v = ch[2].clone();
5525                        let nv = if elt_append {
5526                            format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
5527                        } else {
5528                            v
5529                        };
5530                        map.insert(k, nv);
5531                    }
5532                } else {
5533                    // c:Src/params.c:4076-4085 arrhashsetfn — the SAME odd-count
5534                    // gate the non-augment form uses runs before ASSPM_AUGMENT
5535                    // merges anything:
5536                    //     for (aptr = val; *aptr; ++aptr)
5537                    //         if (**aptr != Marker) ++alen;
5538                    //     if (alen % 2) { freearray(val);
5539                    //         zerr("bad set of key/value pairs for associative
5540                    //              array"); return; }
5541                    // c:4086 `if (flags & ASSPM_AUGMENT)` is reached only AFTER
5542                    // it, so `h+=(k2)` with a lone key is refused and the hash is
5543                    // left alone. The walk below just dropped the unpaired key
5544                    // (`if let Some(v) = it.next()`), so the append silently
5545                    // no-opped at status 0 where zsh errors and exits 1.
5546                    if values.len() % 2 != 0 {
5547                        crate::ported::utils::zerr(
5548                            "bad set of key/value pairs for associative array",
5549                        ); // c:4083
5550                        crate::ported::utils::errflag.fetch_or(
5551                            crate::ported::zsh_h::ERRFLAG_ERROR,
5552                            std::sync::atomic::Ordering::Relaxed,
5553                        );
5554                        // c:Src/exec.c:2632-2633 — a failed assignment sets
5555                        // lastval 1, the same tail the triad form above uses.
5556                        exec.set_last_status(1);
5557                        return true;
5558                    }
5559                    let mut it = values.iter().cloned();
5560                    while let Some(k) = it.next() {
5561                        if let Some(v) = it.next() {
5562                            map.insert(k, v);
5563                        }
5564                    }
5565                }
5566                exec.set_assoc(name, map);
5567                return false;
5568            }
5569            // Indexed-array append `arr+=(d e f)` — route directly
5570            // through canonical assignaparam with ASSPM_AUGMENT
5571            // (`Src/params.c:3570-3585` append-on-array branch).
5572            // assignaparam reads the prior array internally and
5573            // appends the new values, so the bridge no longer needs
5574            // to pre-concat manually. Marker triples from `[k]=v`
5575            // elements add ASSPM_KEY_VALUE (c:Src/exec.c:2552-2553)
5576            // so the kv sparse-fill block (c:Src/params.c:3447-3541)
5577            // resolves them against the existing elements.
5578            let kv_flag = if values
5579                .iter()
5580                .any(|e| e.starts_with(crate::ported::zsh_h::Marker))
5581            {
5582                crate::ported::zsh_h::ASSPM_KEY_VALUE
5583            } else {
5584                0
5585            };
5586            let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
5587                & crate::ported::zsh_h::ERRFLAG_ERROR;
5588            let res = crate::ported::params::assignaparam(
5589                &name,
5590                values.clone(),
5591                crate::ported::zsh_h::ASSPM_AUGMENT | kv_flag,
5592            );
5593            let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
5594                & crate::ported::zsh_h::ERRFLAG_ERROR;
5595            if res.is_none() && pre_err == 0 && now_err != 0 {
5596                // c:Src/exec.c:2632-2633 — failed assignment → lastval 1.
5597                exec.set_last_status(1);
5598                return true;
5599            }
5600            #[cfg(feature = "recorder")]
5601            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5602                let ctx = exec.recorder_ctx();
5603                let attrs = exec.recorder_attrs_for(&name);
5604                emit_path_or_assign(&name, &values, attrs, true, &ctx);
5605            }
5606            // Tied-scalar mirror — TODO faithful: should live in
5607            // setarrvalue's gsu dispatch once boot_ paramtab wiring
5608            // lands (Task #16). Re-read the canonical post-augment
5609            // array so the joined scalar matches.
5610            let tied_scalar = exec.tied_array_to_scalar.get(&name).cloned();
5611            if let Some((scalar_name, sep)) = tied_scalar {
5612                let merged = exec.array(&name).unwrap_or_default();
5613                let joined = merged.join(&sep);
5614                exec.set_scalar(scalar_name.clone(), joined.clone());
5615                let _ = crate::ported::params::zputenv(&format!("{}={}", &scalar_name, &joined));
5616                // c:Src/params.c:5354
5617            }
5618            false
5619        });
5620        // c:Src/jobs.c:1748-1757 waitonejob — `arr+=(...)` is an
5621        // array-assignment simple command and clobbers pipestats to
5622        // `[lastval]` exactly like `arr=(...)` above. Bug #373.
5623        let status = if blocked { 1 } else { 0 };
5624        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
5625        crate::ported::jobs::waitonejob(-1);
5626        Value::Status(status)
5627    });
5628    // `name[@]=(...)` / `name[*]=(...)` — whole-array SET with the assoc
5629    // guard (c:Src/params.c:3324-3327). Stack: [v0..vn, name].
5630    vm.register_builtin(BUILTIN_SET_ARRAY_AT, |vm, argc| {
5631        let (name, values) = pop_array_args_with_name(vm, argc);
5632        let status = with_executor(|exec| {
5633            // c:Src/exec.c:4475-4478 (54941) — `a[@]=(…) f` saves `a`.
5634            if exec.inline_env_stack.last().is_some_and(|frame| frame.recording) {
5635                save_inline_prefix_param(exec, &name);
5636            }
5637            if exec.assoc(&name).is_some() {
5638                // c:Src/params.c:3324-3327 — `[@]` (any slice) on a
5639                // PM_HASHED target is an error.
5640                crate::ported::utils::zerr(&format!(
5641                    "{}: attempt to set slice of associative array",
5642                    name
5643                ));
5644                crate::ported::utils::errflag.fetch_or(
5645                    crate::ported::zsh_h::ERRFLAG_ERROR,
5646                    std::sync::atomic::Ordering::Relaxed,
5647                );
5648                exec.set_last_status(1);
5649                return 1;
5650            }
5651            exec.set_array(name, values); // whole replace (c:3528 setarrvalue)
5652            0
5653        });
5654        Value::Status(status)
5655    });
5656    // `name[@]+=(...)` / `name[*]+=(...)` — whole-array APPEND (push) with
5657    // the same assoc guard.
5658    vm.register_builtin(BUILTIN_APPEND_ARRAY_AT, |vm, argc| {
5659        let (name, values) = pop_array_args_with_name(vm, argc);
5660        let status = with_executor(|exec| {
5661            // c:Src/exec.c:4475-4478 (54941) — `a[@]+=(…) f` saves `a`.
5662            if exec.inline_env_stack.last().is_some_and(|frame| frame.recording) {
5663                save_inline_prefix_param(exec, &name);
5664            }
5665            if exec.assoc(&name).is_some() {
5666                crate::ported::utils::zerr(&format!(
5667                    "{}: attempt to set slice of associative array",
5668                    name
5669                ));
5670                crate::ported::utils::errflag.fetch_or(
5671                    crate::ported::zsh_h::ERRFLAG_ERROR,
5672                    std::sync::atomic::Ordering::Relaxed,
5673                );
5674                exec.set_last_status(1);
5675                return 1;
5676            }
5677            let mut cur = exec.array(&name).unwrap_or_default();
5678            cur.extend(values);
5679            exec.set_array(name, cur); // c:3511-3528 AUGMENT on array → push
5680            0
5681        });
5682        Value::Status(status)
5683    });
5684    vm.register_builtin(BUILTIN_RUN_SELECT, |vm, argc| {
5685        if argc < 2 {
5686            return Value::Status(1);
5687        }
5688        let n = argc as usize;
5689        let mut popped: Vec<Value> = Vec::with_capacity(n);
5690        for _ in 0..n {
5691            popped.push(vm.pop());
5692        }
5693        // popped: [sub_idx, name, word_N, ..., word_1] (popping from top)
5694        let sub_idx_val = popped.remove(0);
5695        let name_val = popped.remove(0);
5696        // c:Src/loop.c — `select` flattens Array values (from `$@`,
5697        // `${arr[@]}`, etc.) into the menu. Without per-element
5698        // splice, `select x do ... done` (bare, iterating $@)
5699        // collapsed all positionals into one joined entry.
5700        let mut words: Vec<String> = Vec::new();
5701        for v in popped.into_iter().rev() {
5702            match v {
5703                Value::Array(items) => {
5704                    for item in items.iter() {
5705                        words.push(item.to_str());
5706                    }
5707                }
5708                other => words.push(other.to_str()),
5709            }
5710        }
5711
5712        let sub_idx = sub_idx_val.to_int() as usize;
5713        let name = name_val.to_str();
5714
5715        // c:Src/loop.c:250-255 — `execsubst(args); if (errflag) { state->pc =
5716        // end; …; return 1; }`: a word list that failed to expand (a NOMATCH
5717        // glob) ends the `select` with status 1, ahead of the empty-list 0.
5718        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
5719            return Value::Status(1);
5720        }
5721
5722        // c:Src/loop.c:248-252 — `if (!args || empty(args)) {
5723        // state->pc = end; ... return 0; }`. An empty option list
5724        // skips the body entirely; without this gate the prompt loop
5725        // runs indefinitely (or twice on the EOF stdin case before
5726        // exiting). Bug #401.
5727        if words.is_empty() {
5728            return Value::Status(0);
5729        }
5730
5731        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
5732            Some(c) => c,
5733            None => return Value::Status(1),
5734        };
5735
5736        let prompt =
5737            with_executor(|exec| exec.scalar("PROMPT3").unwrap_or_else(|| "?# ".to_string()));
5738
5739        let stdin = std::io::stdin();
5740        let mut reader = stdin.lock();
5741        let mut last_status: i32 = 0;
5742
5743        // c:Src/loop.c:264 — `more = selectlist(args, 0);` renders the menu
5744        // ONCE, BEFORE the selection loop. C reprints it ONLY when the user
5745        // enters an EMPTY line (c:290, inside the inner read loop) — never per
5746        // body iteration. This was a single conflated loop that re-rendered at
5747        // the top of every pass, so `printf "1\n2\n" | select x in a b; do
5748        // print $x; done` redrew the list before each prompt where zsh prints
5749        // it once.
5750        //
5751        // (`selectlist` is also ported at src/ported/loop.rs:127, but that copy
5752        // derives its row budget from adjustlines()/adjustcolumns() — an ioctl
5753        // on fd 1 — and so renders nothing when stdout is not a tty, which is
5754        // exactly this path. Keeping the working inline render here.)
5755        let render_menu = || {
5756            // Direct port of zsh's selectlist from
5757            // src/zsh/Src/loop.c:347-409. Layout is column-major
5758            // ("down columns, then across") — NOT row-major. With
5759            // 6 items in 3 cols zsh produces:
5760            //   1  3  5
5761            //   2  4  6
5762            // The previous Rust impl walked row-major which
5763            // produced 1 2 3 / 4 5 6 (visually similar but wrong
5764            // for prompts that mention ordering and breaks scripts
5765            // that rely on column count == ceil(N/rows)).
5766            //
5767            // C variable mapping:
5768            //   ct      -> word count (n)
5769            //   longest -> max item width + 1, then plus digits-of-ct
5770            //   fct     -> column count
5771            //   fw      -> per-column width
5772            //   colsz   -> row count = ceil(ct / fct)
5773            //   t1      -> row index, walks 0..colsz
5774            //   ap      -> item pointer; advances by colsz to step
5775            //              DOWN a column.
5776            let term_width: usize = env::var("COLUMNS")
5777                .ok()
5778                .and_then(|v| v.parse().ok())
5779                .unwrap_or(80);
5780            let ct = words.len();
5781            // loop.c:354-363 — find longest item width.
5782            let mut longest = 1usize;
5783            for w in &words {
5784                let aplen = w.chars().count();
5785                if aplen > longest {
5786                    longest = aplen;
5787                }
5788            }
5789            // loop.c:365-367 — `longest++` then add digits of `ct`.
5790            longest += 1;
5791            let mut t0 = ct;
5792            while t0 > 0 {
5793                t0 /= 10;
5794                longest += 1;
5795            }
5796            // loop.c:369-373 — fct = (cols - 1) / (longest + 3); if
5797            // 0, fct = 1; else fw = (cols - 1) / fct.
5798            let raw_fct = (term_width.saturating_sub(1)) / (longest + 3);
5799            let (fct, fw) = if raw_fct == 0 {
5800                (1, longest + 3)
5801            } else {
5802                (raw_fct, (term_width.saturating_sub(1)) / raw_fct)
5803            };
5804            // loop.c:374 — colsz = (ct + fct - 1) / fct.
5805            let colsz = ct.div_ceil(fct);
5806            // loop.c:375-395 — for each row t1, walk down columns.
5807            for t1 in 0..colsz {
5808                let mut ap_idx = t1;
5809                while ap_idx < ct {
5810                    let w = &words[ap_idx];
5811                    let n = ap_idx + 1;
5812                    let _ = write!(std::io::stderr(), "{}) {}", n, w);
5813                    let mut t2 = w.chars().count() + 2;
5814                    let mut t3 = n;
5815                    while t3 > 0 {
5816                        t2 += 1;
5817                        t3 /= 10;
5818                    }
5819                    // Pad to fw (loop.c:389-390).
5820                    while t2 < fw {
5821                        let _ = write!(std::io::stderr(), " ");
5822                        t2 += 1;
5823                    }
5824                    ap_idx += colsz;
5825                }
5826                let _ = writeln!(std::io::stderr());
5827            }
5828        };
5829        render_menu(); // c:264 — once, before the loop
5830
5831        'select: loop {
5832            // c:266-290 — inner read loop: prompt and read until a NON-EMPTY
5833            // line arrives; each empty line reprints the menu and re-reads.
5834            let trimmed = loop {
5835                let _ = write!(std::io::stderr(), "{}", prompt);
5836                let _ = std::io::stderr().flush();
5837
5838                let mut line = String::new();
5839                match reader.read_line(&mut line) {
5840                    Ok(0) => {
5841                        // c:277-285 — EOF (user pressed Ctrl+D): REPLY="",
5842                        // a newline to stderr, then leave the construct.
5843                        with_executor(|exec| {
5844                            exec.set_scalar("REPLY".to_string(), String::new());
5845                        });
5846                        let _ = writeln!(std::io::stderr());
5847                        let _ = std::io::stderr().flush();
5848                        // !!! KORN-MODE (no C counterpart) !!! ksh93 and mksh end a
5849                        // `select` that hit EOF with status 1, whatever the body
5850                        // last returned (zsh keeps the last body status).
5851                        if crate::extensions::dash_mode::korn_mode() {
5852                            last_status = 1;
5853                        }
5854                        break 'select;
5855                    }
5856                    Ok(_) => {}
5857                    Err(_) => break 'select,
5858                }
5859                let t = line.trim_end_matches(['\n', '\r'][..].as_ref()).to_string();
5860                // c:288-289 — `if (*str) break;`
5861                if !t.is_empty() {
5862                    break t;
5863                }
5864                // c:290 — `more = selectlist(args, more);` on an empty line.
5865                render_menu();
5866            };
5867            // c:291 `setsparam("REPLY", ztrdup(str));` — REPLY is set once the
5868            // inner loop yields a non-empty line. An empty line never reaches
5869            // here: c:290 reprints and re-reads instead.
5870            with_executor(|exec| {
5871                exec.set_scalar("REPLY".to_string(), trimmed.clone());
5872            });
5873
5874            // c:293 `i = atoi(str);` — atoi(3) reads a LEADING integer:
5875            // optional blanks, optional sign, then digits, ignoring whatever
5876            // trails, and yields 0 when there are no digits at all.
5877            // `parse::<usize>()` is strict and rejected `1 2` / `1abc` / `+2`
5878            // / ` 1`, so a reply with anything after the number selected
5879            // NOTHING where zsh selects the leading number's item.
5880            let i: i64 = {
5881                let b = trimmed.as_bytes();
5882                let mut p = 0;
5883                while p < b.len() && (b[p] == b' ' || b[p] == b'\t') {
5884                    p += 1;
5885                }
5886                let neg = p < b.len() && b[p] == b'-';
5887                if p < b.len() && (b[p] == b'-' || b[p] == b'+') {
5888                    p += 1;
5889                }
5890                let mut v: i64 = 0;
5891                while p < b.len() && b[p].is_ascii_digit() {
5892                    v = v.saturating_mul(10).saturating_add((b[p] - b'0') as i64);
5893                    p += 1;
5894                }
5895                if neg {
5896                    -v
5897                } else {
5898                    v
5899                }
5900            };
5901            // c:294-301 — `if (!i) str = "";` else walk i-1 nodes and take
5902            // that word; running off the end leaves "". A NEGATIVE i walks
5903            // until the list is exhausted (`n && i`), which also lands on "".
5904            let chosen = if i <= 0 {
5905                String::new()
5906            } else {
5907                words.get((i - 1) as usize).cloned().unwrap_or_default()
5908            };
5909
5910            with_executor(|exec| {
5911                exec.set_scalar(name.clone(), chosen);
5912            });
5913
5914            // Reset canonical BREAKS/CONTFLAG before running the body
5915            // so a stale value from a sibling construct doesn't leak in.
5916            crate::ported::builtin::BREAKS.store(0, SeqCst);
5917            crate::ported::builtin::CONTFLAG.store(0, SeqCst);
5918
5919            // c:Src/loop.c — `select` increments LOOPS for the body so
5920            // `break` / `continue` inside the body see loops > 0 and
5921            // don't emit `not in while, until, select, or repeat loop`.
5922            // Mirrors execwhile/execrepeat's `LOOPS.fetch_add` pattern.
5923            // The decrement happens after the body call so a body that
5924            // explicitly returns / errors still leaves the counter
5925            // balanced for the next iteration.
5926            crate::ported::builtin::LOOPS.fetch_add(1, SeqCst);
5927
5928            crate::fusevm_disasm::maybe_print_stdout("select:body", &chunk);
5929            let mut body_vm = fusevm::VM::new(chunk.clone());
5930            register_builtins(&mut body_vm);
5931            let _ = body_vm.run();
5932            last_status = body_vm.last_status;
5933
5934            crate::ported::builtin::LOOPS.fetch_sub(1, SeqCst);
5935
5936            // Drain the canonical BREAKS/CONTFLAG counters. Mirrors
5937            // loop.c:529-534's `if (breaks) { breaks--; if (breaks ||
5938            // !contflag) break; contflag = 0; }` drain pattern.
5939            // The legacy `BREAK_SELECT=1` env-var sentinel is still
5940            // honored for backward compat.
5941            let break_legacy = with_executor(|exec| {
5942                let v = exec.scalar("BREAK_SELECT");
5943                exec.unset_scalar("BREAK_SELECT");
5944                v.map(|s| s != "0" && !s.is_empty()).unwrap_or(false)
5945            });
5946            use std::sync::atomic::Ordering::SeqCst;
5947            let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
5948            if breaks > 0 {
5949                let cont = crate::ported::builtin::CONTFLAG.load(SeqCst);
5950                crate::ported::builtin::BREAKS.fetch_sub(1, SeqCst);
5951                if breaks - 1 > 0 || cont == 0 {
5952                    break;
5953                }
5954                crate::ported::builtin::CONTFLAG.store(0, SeqCst);
5955                continue;
5956            }
5957            if break_legacy {
5958                break;
5959            }
5960        }
5961
5962        Value::Status(last_status)
5963    });
5964
5965    // Magic special-parameter assoc lookup. Synthesizes values from
5966    // shell state for zsh's shell-introspection assocs:
5967    //   commands, aliases, galiases, saliases, dis_aliases, dis_galiases,
5968    //   dis_saliases, functions, dis_functions, builtins, dis_builtins,
5969    //   reswords, options, parameters, jobtexts, jobdirs, jobstates,
5970    //   nameddirs, userdirs, modules.
5971    // Returns None if `name` isn't a recognized magic name.
5972
5973    // `${arr[idx]}` — pop name, then idx_str. zsh is 1-based for positive
5974    // indices; we honor that. `@`/`*` return the whole array as Value::Array
5975    // so Op::Exec splice produces N argv slots. For `${foo[key]}` where foo
5976    // is an assoc, the idx is a string key — we check assoc_arrays first
5977    // when the idx isn't `@`/`*` and the name has an assoc binding.
5978    // BUILTIN_ARRAY_INDEX — `${name[idx]}` paramsubst dispatch.
5979    // PURE PASSTHRU: pops the idx + name, hands the canonical
5980    // `${name[idx]}` form to `subst::paramsubst` (C port of
5981    // `Src/subst.c::paramsubst`). All subscript-flag dispatch
5982    // ((I)pat / (R)pat / (i)/(r)/(K)/(k), range slices `[N,M]`,
5983    // negative indices, magic-assoc shape lookup, DQ-join collapse)
5984    // lives inside paramsubst → fetchvalue → getarg in params.rs.
5985    //
5986    // Outer-flag dispatch (`(@)` / `(@k)` / `(v)NAME[(I)pat]` / etc.)
5987    // routes through BUILTIN_BRIDGE_BRACE_ARRAY at the compile path
5988    // (canonical paramsubst flag parser owns dispatch at Src/subst.c:2147+),
5989    // so BUILTIN_ARRAY_INDEX receives clean name+key with no sentinel
5990    // prefixes.
5991    //
5992    // argc contract: `3` means the caller is a `PREFORK_SINGLE` (`ssub`)
5993    // context — a scalar assignment RHS (c:Src/exec.c:2603), a `case` WORD
5994    // (c:Src/loop.c:611-612), a `[[ … ]]` operand (c:Src/cond.c:53) or the
5995    // here-string word (c:Src/exec.c:4711). c:Src/subst.c:4226
5996    //   if (isarr && ssub) { val = sepjoin(aval, NULL, 1); isarr = 0; }
5997    // so a RANGE subscript joins on `IFS[0]` and stops. `2` is every other
5998    // caller, unchanged. The stack shape is the same either way (name, key) —
5999    // the VM hands `argc` to the handler untouched and zshrs never sets
6000    // `Chunk::builtin_argc_is_arity`.
6001    vm.register_builtin(BUILTIN_ARRAY_INDEX, |vm, argc| {
6002        let ssub = argc == 3;
6003        let idx = vm.pop().to_str();
6004        let name = vm.pop().to_str();
6005        let v = array_index_lookup(&name, &idx, ssub);
6006        // c:Src/subst.c:3912 — a RANGE subscript is array-shaped, so
6007        // c:2916-2917 leaves `isarr` non-zero and the join-then-split block
6008        // applies to it exactly as it does to a `[@]`/`[*]` splice or a bare
6009        // array read. The single-element, assoc-key and search-flag shapes all
6010        // come back as scalars (`isarr == 0`) and pass straight through.
6011        //
6012        // The gate: `ssub` joins at c:4226 and stops (c:3913 `force_split =
6013        // !ssub && …`); `qt` clears `spbreak` at c:1707 and joins at c:3033;
6014        // `sep` / `spsep` are the `(j:…:)` / `(s:…:)` separators, which take a
6015        // flagged expansion to `paramsubst` rather than this fast path. So the
6016        // only door in from here is SH_WORD_SPLIT, and the handler had no such
6017        // block at all:
6018        //     setopt shwordsplit; a=('p:q' r); IFS=:; print -rl -- ${a[1,2]}
6019        //       zsh: `p` `q` `r`          zshrs: `p:q` `r`
6020        //     setopt shwordsplit; a=(x '' y); IFS=:; print -rl -- ${a[1,2]}
6021        //       zsh: `x` ``               zshrs: `x`
6022        // The second is `sepsplit_c3932`'s `nulstring` rule (c:Src/subst.c:36),
6023        // which c:186 keeps for an IFS-NON-whitespace separator.
6024        if ssub || !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT) {
6025            return v;
6026        }
6027        let (ifs_opt, in_dq) = with_executor(|exec| (exec.scalar("IFS"), exec.in_dq_context > 0));
6028        if in_dq {
6029            return v; // c:1707 `!qt` — `spbreak` is 0, the block never opens
6030        }
6031        let Value::Array(items) = &v else {
6032            // c:2916 `isarr == 0` — the single-element, assoc-key, scalar-slice
6033            // and search-flag shapes. c:3914's join arm does not apply to them,
6034            // but c:3931's `if (force_split && !isarr)` does, and it is the
6035            // SAME `sepsplit`: `setopt shwordsplit; a=('p:q' r); IFS=:;
6036            // print -rl -- ${a[1]}` is `p` `q` in zsh, and
6037            // `typeset -A h=(k 'p:q'); … ${h[k]}` likewise.
6038            return split_words_c3932(&v.to_str());
6039        };
6040        let elems: Vec<String> = items.iter().map(|x| x.to_str()).collect();
6041        match join_c3914(elems, ifs_opt.as_deref()) {
6042            // c:3914-3927 left `isarr` non-zero, so c:3931's `!isarr` keeps the
6043            // split off too and the ORIGINAL elements are the answer.
6044            JoinC3914::Elements(_) => v,
6045            // c:3932 `sepsplit`, then c:3933-3938's 0/1/N result shape.
6046            JoinC3914::Joined(val) => split_words_c3932(&val),
6047        }
6048    });
6049    // BUILTIN_ARRAY_INDEX_UNBRACED — bare `$name[idx]` (no braces).
6050    // Same subscript dispatch as BUILTIN_ARRAY_INDEX when KSHARRAYS
6051    // is unset, but under KSHARRAYS the UNBRACED form does NOT
6052    // subscript at all:
6053    //   c:Src/subst.c:2800-2802 — fetchvalue's bracket-parse arg is
6054    //     `(unset(KSHARRAYS) || inbrace) ? 1 : -1`; -1 inhibits
6055    //     subscript parsing for the bare form under KSHARRAYS.
6056    //   c:Src/subst.c:2867 — the bracket-consuming loop only runs
6057    //     `while (v || ((inbrace || (unset(KSHARRAYS) && vunset)) &&
6058    //     isbrack(*s)))` — bare + KSHARRAYS leaves `[...]` as literal
6059    //     trailing text.
6060    // The bare `$name` expands (first element for identifier-named
6061    // arrays per c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`),
6062    // the literal `[idx]` (+ any literal suffix) joins the last word,
6063    // and the word undergoes filename generation: `[...]` is a glob
6064    // char class, so unquoted it hits the c:Src/glob.c:1873-1886
6065    // nomatch/nullglob dispatch (reused via exec.expand_glob).
6066    // Operands: [name, idx, suffix, quoted] — `quoted` set when the
6067    // word carries DQ markers (no filename generation in DQ; zsh 5.9:
6068    // `setopt ksharrays; a=(x y z); print "$a[0]"` → `x[0]`).
6069    // Verbatim zsh 5.9 ground truth for the unquoted form:
6070    //   `setopt ksharrays; a=(x y z); print -- $a[0]` →
6071    //   stderr `zsh:1: no matches found: x[0]`, rc=1, empty stdout.
6072    vm.register_builtin(BUILTIN_ARRAY_INDEX_UNBRACED, |vm, _argc| {
6073        let quoted = vm.pop().to_str() == "1";
6074        let suffix = vm.pop().to_str();
6075        let idx = vm.pop().to_str();
6076        let name = vm.pop().to_str();
6077        if !crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
6078            // The bare `$name[idx]` word is not itself a `singsub` caller
6079            // shape the compiler tracks — it reaches this builtin from
6080            // `compile_word_str`, which the `PREFORK_SINGLE` callers do not
6081            // use. Pass `false` until a measured divergence says otherwise.
6082            //
6083            // c:Src/subst.c:1625 — paramsubst runs with `qt` set inside `"…"`;
6084            // array_index_lookup reads it from in_dq_context. Without it the
6085            // lookup ran unquoted and elided EMPTY elements before the join
6086            // below: `a=("" b); "$a[1,2]"` gave `b` for ` b`, and a quoted
6087            // SCANPM_ISVAR_AT selection (see isvar_at) lost its empty words
6088            // (`set -- "" b; "$@[1,2]"` is two words).
6089            let isvar_at = name == "@" || idx == "@";
6090            if quoted {
6091                with_executor(|exec| exec.in_dq_context += 1);
6092            }
6093            let v = array_index_lookup(&name, &idx, false);
6094            if quoted {
6095                with_executor(|exec| exec.in_dq_context -= 1);
6096            }
6097            // c:Src/subst.c:3033-3034 — `if (qt && !getlen && isarr > 0)
6098            // { val = sepjoin(aval, sep, 1); isarr = 0; }`. Inside double
6099            // quotes a subscript that selects an array (a range, or nothing
6100            // at all) is joined into ONE word on IFS[0]; an empty selection
6101            // joins to "" and still counts as a word. `array_index_lookup`
6102            // rebuilds a flat `${name[idx]}` that carries no `qt`, so the
6103            // unbraced form returned the raw array: `"$a[1]"` on an empty
6104            // array vanished and `"$n[1,2]"` split into two words.
6105            //
6106            // `nojoin` (isarr = -1, c:3031-3032) spares a SCANPM_ISVAR_AT
6107            // value, which c:Src/params.c:2251/2272 sets when the name is
6108            // `@` and c:2049-2050 when the subscript is `[@]` (c:Src/subst.c:
6109            // 2917 turns it into isarr = -1). So `"$@[2,3]"` stays one word
6110            // per element, as `"${@[2,3]}"` does; `for 2 3 4 in "$@[7,-1]"`
6111            // in Functions/Misc/regexp-replace depends on it.
6112            let v = match v {
6113                Value::Array(items) if quoted && !isvar_at => {
6114                    let strs: Vec<String> = items.iter().map(|x| x.to_str()).collect();
6115                    Value::str(crate::ported::utils::sepjoin(&strs, None)) // c:3034
6116                }
6117                other => other,
6118            };
6119            if suffix.is_empty() {
6120                return v;
6121            }
6122            // Mirrors the previous compile-shape `ARRAY_INDEX +
6123            // Op::Concat` exactly: Concat stringifies via as_str_cow
6124            // (fusevm value.rs:132-146, arrays join with " ").
6125            if let (true, Value::Array(items)) = (isvar_at, &v) {
6126                let mut items: Vec<Value> = items.iter().cloned().collect();
6127                // An unjoined `"$@[…]"` keeps its elements; the suffix
6128                // glues onto the last one (c:Src/subst.c:4434
6129                // `strcatsub(&y, aptr, aptr, x, xlen, fstr, …)`).
6130                if let Some(last) = items.pop() {
6131                    items.push(Value::str(format!("{}{}", last.to_str(), suffix)));
6132                    return Value::array(items);
6133                }
6134            }
6135            return Value::str(format!("{}{}", v.to_str(), suffix));
6136        }
6137        // KSHARRAYS bare form: no subscript. Bare-`$name` words +
6138        // literal `[idx]suffix` glued onto the last word.
6139        let mut words = ksharrays_bare_words(&name);
6140        let last = format!("{}[{}]{}", words.pop().unwrap_or_default(), idx, suffix);
6141        if quoted {
6142            // DQ context — no filename generation, bracket text stays
6143            // literal.
6144            words.push(last);
6145            return Value::str(words.join(" "));
6146        }
6147        // c:Src/glob.c:1873-1886 — expand_glob handles nullglob /
6148        // NOMATCH (zerr "no matches found" + errflag + the
6149        // current_command_glob_failed cell consumed at the command
6150        // dispatch boundary) / literal passthrough for glob-free text.
6151        let matches = with_executor(|exec| exec.expand_glob(&last));
6152        let mut out: Vec<Value> = words.into_iter().map(Value::str).collect();
6153        out.extend(matches.into_iter().map(Value::str));
6154        if out.len() == 1 {
6155            return out.pop().unwrap();
6156        }
6157        Value::array(out)
6158    });
6159    // BUILTIN_ASSOC_HAS_KEY — `${(k)assoc[name]}` key-existence query.
6160    // Pops [assoc_name, key]; returns key (Str) if present in the
6161    // assoc, empty Str otherwise. Mirrors zsh's `${(k)h[name]}`
6162    // documented semantics in zshparam(1) "Parameter Expansion Flags".
6163    // Distinct from BUILTIN_ARRAY_INDEX (which returns the VALUE) and
6164    // from `${+h[name]}` (which returns "0"/"1"). Bug #145.
6165    vm.register_builtin(BUILTIN_ASSOC_HAS_KEY, |vm, _argc| {
6166        let key = vm.pop().to_str();
6167        let name = vm.pop().to_str();
6168        // c:Src/params.c getindex — the subscript text is substituted
6169        // (singsub) before the lookup; `${(k)H[$k]}` must resolve $k.
6170        // The compiler hands this opcode the RAW subscript text, so a
6171        // dynamic key arrived literally ("$k") and matched nothing.
6172        // singsub is identity for plain keys.
6173        let key = if key.contains('$') || key.contains('`') || key.contains('\u{e18c}') {
6174            crate::ported::subst::singsub(&key)
6175        } else {
6176            key
6177        };
6178        // c:Src/params.c:3131 gethkparam covers ordinary PM_HASHED
6179        // paramtab entries only. Special/magic hashes (`parameters`,
6180        // `options`, … — the zsh/parameter module's partab-backed
6181        // params, Src/Modules/parameter.c) aren't in that storage, so
6182        // a None here doesn't mean "no such assoc". Route those
6183        // through paramsubst, whose assoc materialization handles the
6184        // magic hashes and whose getarg port (c:Src/params.c:1591 +
6185        // Src/subst.c:2922) returns the KEY for `(k)` on a plain
6186        // subscript. zsh 5.9: `${(k)parameters[PATH]}` → "PATH".
6187        // c:Src/params.c:1602-1612 — on a hash subscript C dispatches
6188        // `ht->getnode(ht, s)`; it never enumerates. For the magic hashes that
6189        // distinction is observable: `getfunction_source` (c:Src/Modules/
6190        // parameter.c:549-566) answers for names its scan never lists, `mapfile`
6191        // answers for any readable file, and the job trio calls `getjob(name,
6192        // NULL)` whose `job not found` diagnostic (Src/jobs.c:2150-2151) must be
6193        // emitted by the read. `gethkparam` answers Some(<enumerated keys>) for
6194        // these names, which shortcut the dispatch entirely — `${(k)jobstates[x]}`
6195        // stayed silent and `${(k)functions_source[x]}` returned "" where zsh
6196        // returns the key. Send PARTAB names to paramsubst, which owns the
6197        // getnode path (c:Src/subst.c:2923-2925 — key when set, "" when unset).
6198        // Keys carrying `]`/`}` can't survive the flat rebuild (see
6199        // array_index_lookup), so those keep the enumeration answer.
6200        let magic_getnode = !key.contains(']')
6201            && !key.contains('}')
6202            && crate::ported::modules::parameter::PARTAB
6203                .iter()
6204                .any(|e_| e_.name == name);
6205        match crate::ported::params::gethkparam(&name) {
6206            Some(keys) if !magic_getnode => {
6207                if keys.iter().any(|k| k == &key) {
6208                    Value::str(key)
6209                } else {
6210                    Value::str("")
6211                }
6212            }
6213            // c:2048 — `key` is already expanded, so an `@`/`*` here is a key,
6214            // not the splat; see `expanded_subscript_text`.
6215            _ => paramsubst_to_value(&format!(
6216                "${{(k){}[{}]}}",
6217                name,
6218                expanded_subscript_text(&key)
6219            )),
6220        }
6221    });
6222    vm.register_builtin(BUILTIN_BRIDGE_BRACE_ARRAY, |vm, argc| {
6223        // argc 2 = the compiler flagged this expansion as the VALUE of a
6224        // scalar assignment (`x=…` / `local x=…` / `typeset x=…`), which C
6225        // preforks with PREFORK_SINGLE|PREFORK_ASSIGN (c:Src/exec.c:2546).
6226        // PREFORK_SINGLE is paramsubst's `ssub` (c:Src/subst.c:1759) and gates
6227        // off c:3913's `force_split` — without it `r=${(s.c.)v[2,-1]}` split
6228        // and re-joined on IFS (`b def`) where zsh keeps `bcdef`. Same
6229        // operand contract as BUILTIN_PARAM_FLAG's argc-3 ssub. argc 1 =
6230        // ordinary word, no ssub (kept for callers that predate the operand).
6231        let ssub = if argc >= 2 {
6232            vm.pop().to_int() != 0
6233        } else {
6234            false
6235        };
6236        // Inner body of `${(...)...}` (already stripped of `${`/`}` by
6237        // the caller). The compiler optionally prefixes Qstring
6238        // (\u{e18c}) to signal "expanded in DQ context" — strip it
6239        // here and bump in_dq_context for the paramsubst call so the
6240        // SUB_ZIP and other qt-aware paths fire.
6241        let body = vm.pop().to_str();
6242        let (dq, inner) = if let Some(rest) = body.strip_prefix('\u{e18c}') {
6243            (true, rest.to_string())
6244        } else {
6245            (false, body)
6246        };
6247        if dq {
6248            with_executor(|exec| exec.in_dq_context += 1);
6249        }
6250        let pf_flags = if ssub {
6251            crate::ported::zsh_h::PREFORK_SINGLE // c:1759
6252        } else {
6253            0
6254        };
6255        let v = paramsubst_to_value_status(vm, &format!("${{{}}}", inner), pf_flags);
6256        if dq {
6257            with_executor(|exec| exec.in_dq_context -= 1);
6258        }
6259        v
6260    });
6261
6262    // BUILTIN_PARAM_FLAG — `${(flags)name}` paramsubst dispatch.
6263    // PURE PASSTHRU: pops sentinel-tagged flags + name, hands the
6264    // canonical `${(flags)name}` form to `subst::paramsubst` (C port
6265    // of `Src/subst.c::paramsubst`). The bridge does no flag
6266    // walking, no DQ-context branching, no array/scalar shape
6267    // selection — all of that lives inside paramsubst. Compile-time
6268    // context (DQ / scalar-assign-RHS) flows through executor cells
6269    // (in_dq_context, in_scalar_assign) bumped by BUILTIN_EXPAND_TEXT.
6270    vm.register_builtin(BUILTIN_PARAM_FLAG, |vm, argc| {
6271        // argc 3 = the compiler flagged this expansion as the VALUE of a
6272        // scalar assignment (`x=…` / `local x=…`), which C preforks with
6273        // PREFORK_SINGLE|PREFORK_ASSIGN (c:Src/exec.c:2603 / :4239-4241).
6274        // PREFORK_SINGLE is paramsubst's `ssub` (c:Src/subst.c:1761) and
6275        // gates off c:3913's `force_split`, so `(s::)` / `(f)` / `(0)` do
6276        // not split there. argc 2 = ordinary word, no ssub.
6277        let ssub = if argc >= 3 {
6278            vm.pop().to_int() != 0
6279        } else {
6280            false
6281        };
6282        let flags = vm.pop().to_str();
6283        let name = vm.pop().to_str();
6284        let body = format!("${{({}){}}}", flags, name);
6285        let pf_flags = if ssub {
6286            crate::ported::zsh_h::PREFORK_SINGLE
6287        } else {
6288            0
6289        };
6290        paramsubst_to_value_status(vm, &body, pf_flags)
6291    });
6292
6293    // `foo[key]=val` — single-key set on an assoc array. Stack: [name, key, value].
6294    // PURE PASSTHRU: assignsparam with `name[key]` form (C port of
6295    // `Src/params.c::assignsparam` subscript path at c:3210-3231)
6296    // already does the indexed-array vs assoc decision, PM_HASHED
6297    // auto-vivification, numeric-subscript bounds handling, and
6298    // PM_READONLY rejection.
6299    /// Assign `val` to one element of the PM_HASHED parameter `name`.
6300    ///
6301    /// This is the tail of C's `assignsparam` for a subscripted target:
6302    /// c:Src/params.c:3251 `getvalue(&vbuf, &t, 1)` (→ `fetchvalue` →
6303    /// `getindex`) followed by c:3343 `assignstrvalue(v, val, flags)`.
6304    ///
6305    /// C hands `getindex` the FLAT `"name[subscript]"` text, and that is
6306    /// safe there because its subscript is still the SOURCE spelling: the
6307    /// bracket walk at c:2008 `parse_subscript` runs BEFORE the
6308    /// `parsestr`/`singsub` round at c:1585-1592, so a `]` that arrives by
6309    /// expansion can never terminate it. zshrs expands a subscript before
6310    /// this builtin runs, so re-flattening to `name[key]` and re-splitting
6311    /// corrupts any key containing `]` (`k='x]y'; h[$k]=5` stored `x`). The
6312    /// two halves therefore stay separate the whole way down:
6313    ///
6314    ///   * `sub` is the EXPANDED subscript — the key text.
6315    ///   * `sub_src` is the SOURCE subscript, used for ONE decision, the
6316    ///     one C makes at c:1410: `if (v->pm && (*s == '(' || *s == Inpar))`
6317    ///     — is there a flag block? Flags can only be literal (they are read
6318    ///     at c:1409, before any expansion), so `x='(r)v'; h[$x]=Z` has no
6319    ///     flag block and stores the literal key `(r)v`, while `h[(r)$x]=Z`
6320    ///     does have one and is a search.
6321    ///
6322    /// With no flag block there is nothing for `getindex` to resolve beyond
6323    /// the exact-key rebind at c:1596-1616, so the key goes straight to the
6324    /// element store and never meets a parser. With one, `getindex` runs on
6325    /// the EXPANDED text: its flag block is byte-identical to the source's
6326    /// (flags are literal) and its pattern is already substituted, which is
6327    /// what c:1585-1592 would have produced anyway.
6328    fn assign_hash_element(name: &str, sub: &str, sub_src: &str, val: &str) -> i32 {
6329        use crate::ported::zsh_h::{Inpar, PM_HASHED, PM_READONLY, SCANPM_ARRONLY};
6330        let pm = crate::ported::params::paramtab()
6331            .read()
6332            .ok()
6333            .and_then(|t| t.get(name).cloned());
6334        // c:3216-3221 — `if (v->pm->node.flags & PM_READONLY)`.
6335        if pm
6336            .as_ref()
6337            .is_some_and(|p| (p.node.flags as u32 & PM_READONLY) != 0)
6338        {
6339            crate::ported::utils::zerr(&format!("read-only variable: {}", name)); // c:3217
6340            return 1; // c:3221
6341        }
6342        // c:3171 — `v->pm->node.flags &= ~PM_DEFAULTED;` on the parameter
6343        // being subscripted: an element store makes a TYPESET_TO_UNSET
6344        // declaration (`typeset -A h; h[k]=v`) a set parameter, so
6345        // `typeset -p h` then prints its value.
6346        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
6347            if let Some(p) = tab.get_mut(name) {
6348                p.node.flags &= !(crate::ported::zsh_h::PM_DEFAULTED as i32); // c:3171
6349            }
6350        }
6351        // c:1410 — `if (v->pm && (*s == '(' || *s == Inpar))`, read off the
6352        // SOURCE spelling.
6353        let has_flags = sub_src.starts_with('(') || sub_src.starts_with(Inpar);
6354        if has_flags {
6355            let mut v = crate::ported::zsh_h::value {
6356                pm,
6357                arr: Vec::new(),
6358                // c:2274-2280 — fetchvalue promotes a PM_ARRAY/PM_HASHED
6359                // value with no caller flags to SCANPM_ARRONLY; assignsparam
6360                // arrives through `getvalue`, i.e. flags 0.
6361                scanflags: SCANPM_ARRONLY as i32,
6362                valflags: 0,
6363                start: 0,
6364                end: -1, // c:2279
6365            };
6366            let bracketed = format!("[{}]", sub); // c:2281 `*s == '['`
6367            let mut sp: &str = &bracketed;
6368            if crate::ported::params::getindex(&mut sp, &mut v, 0) != 0 {
6369                // c:2020-2022 — `zerr("invalid subscript")` already reported.
6370                return 1;
6371            }
6372            let elem_is_hash = v
6373                .pm
6374                .as_ref()
6375                .is_some_and(|p| crate::ported::zsh_h::PM_TYPE(p.node.flags as u32) == PM_HASHED);
6376            if elem_is_hash {
6377                // A search subscript: the c:1596 exact-key rebind did not
6378                // happen, so the value still refers to the whole association
6379                // and c:3343 `assignstrvalue` reports it — either "attempt to
6380                // set slice of associative array" (c:2701-2706, the scanflags
6381                // survived the c:2179 clear) or "attempt to set associative
6382                // array to scalar" (c:2831-2839, they did not and no member
6383                // was found).
6384                let pre = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
6385                crate::ported::params::assignstrvalue(
6386                    Some(&mut v),
6387                    Some(val.to_string()),
6388                    crate::ported::zsh_h::ASSPM_WARN,
6389                ); // c:3343
6390                let post = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
6391                return if post != pre { 1 } else { 0 };
6392            }
6393            // c:1596-1616 — a non-search flag group (`(e)`, `(w)`, `(n:N:)`,
6394            // `(p)`, or an unrecognised one, c:1498 `flagerr`): `v->pm` is now
6395            // the ELEMENT and its name is the subscript with the group already
6396            // consumed. That name IS the key.
6397            let key =
6398                v.pm.as_ref()
6399                    .map_or(sub, |p| p.node.nam.as_str())
6400                    .to_string();
6401            return store_hash_element(name, &key, val);
6402        }
6403        // c:1596-1616 with no flag group at all — the subscript is the key
6404        // verbatim. Nothing to parse, so an expanded `]` stays intact.
6405        store_hash_element(name, sub, val)
6406    }
6407
6408    /// c:Src/params.c:2841 — `foundparam->gsu.s->setfn(foundparam, val)`,
6409    /// the write to one member of an association. This port keeps assoc
6410    /// members as plain strings in `paramtab_hashed_storage` rather than as
6411    /// Params carrying their own `strsetfn` (the same substitution
6412    /// `arrhashsetfn` makes at c:4113), so the member write is a map insert.
6413    fn store_hash_element(name: &str, key: &str, val: &str) -> i32 {
6414        if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
6415            store
6416                .entry(name.to_string())
6417                .or_default()
6418                .insert(key.to_string(), val.to_string()); // c:2841
6419        }
6420        0
6421    }
6422
6423    vm.register_builtin(BUILTIN_SET_ASSOC, |vm, _argc| {
6424        // `${~spec}` carrier: an assignment statement is a word-
6425        // pipeline boundary too — restore the user's GLOB_SUBST
6426        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
6427        // ${options[globsubst]}` must read the user value).
6428        consume_tilde_globsubst_carrier();
6429        // argc 4 = compile flagged the subscript as DYNAMIC (`H[$k]`):
6430        // an EXPANDED-empty key is then a legal assoc key (C's
6431        // assignsparam isident gate sees the raw `$k` text and the
6432        // empty key stores at getindex time — zinit's
6433        // ZINIT_SICE[$1…$2] relies on it). argc 3 = source-literal
6434        // key; `H[]` stays the "not an identifier" error.
6435        // Stack shapes (compile_zsh::compile_assign):
6436        //   argc 4 = [name, key, value, key_src]            — literal key
6437        //   argc 5 = [name, key, value, key_src, dynamic]   — expanded key
6438        // `key_src` is the SOURCE spelling of the subscript, kept beside
6439        // the expanded one so nothing downstream has to re-flatten
6440        // `name[key]` and re-split it (c:Src/params.c:2008 parses the
6441        // subscript BEFORE expansion; see `assign_hash_element`).
6442        //   argc 6 = [name, key, value, key_src, dynamic, append] — `+=`;
6443        //            value is the pre-concatenated read-modify-write.
6444        let append = if _argc >= 6 {
6445            vm.pop().to_int() != 0
6446        } else {
6447            false
6448        };
6449        let key_is_dynamic = if _argc >= 5 {
6450            vm.pop().to_int() != 0
6451        } else {
6452            false
6453        };
6454        let key_src = if _argc >= 4 {
6455            Some(vm.pop().to_str())
6456        } else {
6457            None
6458        };
6459        let value = vm.pop().to_str();
6460        let key = vm.pop().to_str();
6461        let name = vm.pop().to_str();
6462        // c:Src/exec.c:4475-4478 (54941) — `A[k]=v cmd`: save_params cuts
6463        // the assignment text at the subscript (`itype_end(s, INAMESPC, 0)`
6464        // stopping at `[`) and snapshots the whole base parameter `A`, so
6465        // the element assignment is undone after a builtin or shell
6466        // function exactly as after a forked external (whose `addvars`
6467        // runs in the child and never touches the parent's `A`).
6468        with_executor(|exec| {
6469            if exec
6470                .inline_env_stack
6471                .last()
6472                .is_some_and(|frame| frame.recording)
6473            {
6474                save_inline_prefix_param(exec, &name);
6475            }
6476        });
6477        // c:Src/params.c:3250-3266 — assignsparam ASSPM_AUGMENT on a
6478        // subscripted integer/float:
6479        //     case PM_INTEGER: case PM_EFLOAT: case PM_FFLOAT:
6480        //         zsfree(val); unqueue_signals();
6481        //         zerr("attempt to add to slice of a numeric variable");
6482        //         return NULL;
6483        if append {
6484            let numeric = crate::ported::params::paramtab()
6485                .read()
6486                .ok()
6487                .and_then(|t| t.get(&name).map(|pm| crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)))
6488                .is_some_and(|t| {
6489                    t == crate::ported::zsh_h::PM_INTEGER
6490                        || t == crate::ported::zsh_h::PM_EFLOAT
6491                        || t == crate::ported::zsh_h::PM_FFLOAT
6492                });
6493            if numeric {
6494                crate::ported::utils::zerr("attempt to add to slice of a numeric variable"); // c:3264
6495                return Value::Status(1);
6496            }
6497        }
6498        let key_src = key_src.unwrap_or_else(|| key.clone());
6499        // c:Src/params.c:3203-3207 — `if (!isident(s)) { zerr("not an
6500        // identifier: %s", s); errflag |= ERRFLAG_ERROR; return NULL; }`.
6501        // Every subscripted assignment passes through that gate, and isident
6502        // rejects an empty subscript at c:1334 `if (!(ss =
6503        // parse_subscript(++ss, 1, ']'))) return 0;` — the LHS text is
6504        // untokenized by then, so the `]` IS parse_subscript's literal endchar
6505        // and c:Src/lex.c:1748 returns NULL. `m[]=z` / `A[]=z` / `s[]=z` are
6506        // therefore all `not an identifier: NAME[]`, never a store.
6507        // Only the SOURCE-LITERAL empty subscript is affected: with a dynamic
6508        // key (`H[$k]=v`) C's isident sees the unexpanded `$k` text, passes,
6509        // and getindex stores the expanded — possibly empty — key.
6510        // The gate lives here because the PM_HASHED fast path and the numeric
6511        // pre-resolve below both reach the store without calling assignsparam
6512        // (the empty key resolved to "" for a hash and to math-0 for an
6513        // indexed array, so `A[]=z` reported "assignment to invalid subscript
6514        // range" instead). Route through assignsparam so the diagnostic and
6515        // the errflag are the canonical ones.
6516        if !key_is_dynamic && key.is_empty() {
6517            crate::ported::params::assignsparam(
6518                &format!("{}[]", name), // c:3203 — the LHS spelling zsh reports
6519                &value,
6520                crate::ported::zsh_h::ASSPM_WARN,
6521            );
6522            return Value::Status(1);
6523        }
6524        // Bash sparse-array tracking for `a[i]=v` (scalar single-index). A set
6525        // that pads the dense Vec past its old end leaves old_len..i as holes;
6526        // on an undefined array, `a[5]=q` leaves only index 5 (count 1). Only
6527        // for INDEXED arrays (assoc keys are strings), bash mode, numeric key.
6528        // Captured before the assign; applied after (on the array path).
6529        let sparse_track: Option<(String, usize, usize)> =
6530            if crate::dash_mode::sparse_arrays() && !key.contains(',') {
6531                crate::dash_mode::sparse_subscript_index(&key).and_then(|i| {
6532                    with_executor(|exec| {
6533                        if !exec.has_assoc(&name) {
6534                            let old_len = exec.array(&name).map(|a| a.len()).unwrap_or(0);
6535                            Some((name.clone(), old_len, i))
6536                        } else {
6537                            None
6538                        }
6539                    })
6540                })
6541            } else {
6542                None
6543            };
6544        if key_is_dynamic && key.is_empty() {
6545            with_executor(|exec| {
6546                let _ = exec;
6547            });
6548            // Mirror assignsparam's PM_HASHED tail directly (the
6549            // textual `name[]` reconstruction can't pass isident).
6550            if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
6551                let entry = store.entry(name.clone()).or_default();
6552                let newval = if let Some(old) = entry.get("") {
6553                    // `+=` arrives pre-concatenated by the compile
6554                    // read-modify-write; plain `=` overwrites.
6555                    let _ = old;
6556                    value.clone()
6557                } else {
6558                    value.clone()
6559                };
6560                entry.insert(String::new(), newval);
6561            }
6562            return Value::Status(0);
6563        }
6564        with_executor(|exec| {
6565            #[cfg(feature = "recorder")]
6566            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
6567                let ctx = exec.recorder_ctx();
6568                let attrs = exec.recorder_attrs_for(&name);
6569                crate::recorder::emit_assoc_assign(
6570                    &name,
6571                    vec![(key.clone(), value.clone())],
6572                    attrs,
6573                    true,
6574                    ctx,
6575                );
6576            }
6577            let _ = exec;
6578        });
6579        // Build `name[key]=value` shape for assignsparam's subscript
6580        // dispatch. Arith-evaluate numeric subscripts on an existing
6581        // indexed array (`a[i+1]=v` form) before handing off — the
6582        // canonical port currently only handles literal int / string
6583        // keys, so pre-resolve here.
6584        let resolved_key = with_executor(|exec| {
6585            // Existence probes only — use the non-cloning `has_*`
6586            // checks. `exec.assoc()` / `exec.array()` return owned
6587            // clones of the whole map/vector, so probing `.is_some()`
6588            // here copied the entire associative array on every
6589            // `h[k]=v` (O(n) per store → O(n²) for a fill loop). The
6590            // profiler flagged `ShellExecutor::assoc → IndexMap::clone`
6591            // as the dominant cost.
6592            let is_indexed = exec.has_array(&name);
6593            let is_assoc = exec.has_assoc(&name);
6594            let is_scalar = !is_indexed && !is_assoc && exec.has_scalar(&name);
6595            // c:Src/params.c::getindex — `(i)pat` / `(I)pat` / `(R)pat`
6596            // / `(r)pat` subscript flags on an indexed array LHS resolve
6597            // to a numeric index (first / last match of pat). On a
6598            // SCALAR LHS the same flags resolve to a CHAR position
6599            // (1-based first/last match of pat in the scalar string)
6600            // for the c:2748+ char-splice assignment. zshrs's
6601            // read-form `${a[(i)pat]}` already implements both shapes;
6602            // the LHS assignment path silently stored the literal
6603            // "(i)pat" as an assoc key (for scalar: auto-vivified to
6604            // PM_HASHED via the assignsparam unknown-subscript
6605            // fallback). Bug #293 (array) / scalar sibling.
6606            //
6607            // Detect the `(flags)pat` shape and resolve to a numeric
6608            // index before assignsparam.
6609            if is_indexed || is_scalar {
6610                if let Some(rest) = key.strip_prefix('(') {
6611                    if let Some(close) = rest.find(')') {
6612                        let flags = &rest[..close];
6613                        let pat = &rest[close + 1..];
6614                        if !flags.is_empty()
6615                            && flags
6616                                .chars()
6617                                .all(|c| matches!(c, 'I' | 'R' | 'i' | 'r' | 'n' | 'e'))
6618                        {
6619                            // Resolve via the array's contents.
6620                            if let Some(arr) = exec.array(&name) {
6621                                let return_index = true; // LHS write — index needed
6622                                let down = flags.contains('I') || flags.contains('R');
6623                                let exact = flags.contains('e');
6624                                let iter: Box<dyn Iterator<Item = (usize, &String)>> = if down {
6625                                    Box::new(arr.iter().enumerate().rev())
6626                                } else {
6627                                    Box::new(arr.iter().enumerate())
6628                                };
6629                                let mut found: Option<usize> = None;
6630                                for (idx, elem) in iter {
6631                                    let matched = if exact {
6632                                        elem == pat
6633                                    } else {
6634                                        crate::ported::pattern::patcompile(
6635                                            &{
6636                                                let mut __pat_tok = (pat).to_string();
6637                                                crate::ported::glob::tokenize(&mut __pat_tok);
6638                                                __pat_tok
6639                                            },
6640                                            crate::ported::zsh_h::PAT_HEAPDUP as i32,
6641                                            None,
6642                                        )
6643                                        .map_or(false, |p| crate::ported::pattern::pattry(&p, elem))
6644                                    };
6645                                    if matched {
6646                                        found = Some(idx);
6647                                        break;
6648                                    }
6649                                }
6650                                let _ = return_index;
6651                                // (i)/(r) return 1-based index of match,
6652                                // arr.len()+1 (or 1 for I/R) on miss
6653                                // per zsh docs. We mirror the read-form
6654                                // semantics from subst.rs.
6655                                let idx_1based = match found {
6656                                    Some(i) => (i + 1) as i64,
6657                                    None => (arr.len() + 1) as i64,
6658                                };
6659                                return idx_1based.to_string();
6660                            }
6661                            // Scalar LHS — resolve to a CHAR position
6662                            // (1-based first/last match of pat in the
6663                            // string). c:Src/params.c:1411-1418 — the
6664                            // scalar path returns the char index from
6665                            // sliding-window pattern match against
6666                            // pm.u_str. Same algorithm as the read-form
6667                            // at subst.rs:5283-5306. Bug (scalar
6668                            // sibling of #293): `a=hello; a[(I)l]=X`
6669                            // previously auto-vivified `a` into
6670                            // PM_HASHED with key "(I)l" instead of
6671                            // splicing X at the last 'l' position
6672                            // (yielding "helXo").
6673                            if is_scalar {
6674                                let s = exec.scalar(&name).unwrap_or_default();
6675                                let s_chars: Vec<char> = s.chars().collect();
6676                                let n = s_chars.len();
6677                                let want_last = flags.contains('I') || flags.contains('R');
6678                                let exact = flags.contains('e');
6679                                let mut found: Option<usize> = None;
6680                                'outer: for start in 0..=n {
6681                                    let lengths: Box<dyn Iterator<Item = usize>> = if want_last {
6682                                        Box::new((1..=(n - start)).rev())
6683                                    } else {
6684                                        Box::new(1..=(n - start))
6685                                    };
6686                                    for len in lengths {
6687                                        let cand: String =
6688                                            s_chars[start..start + len].iter().collect();
6689                                        let matched = if exact {
6690                                            cand == pat
6691                                        } else {
6692                                            crate::ported::pattern::patcompile(
6693                                                &{
6694                                                    let mut __pat_tok = (pat).to_string();
6695                                                    crate::ported::glob::tokenize(&mut __pat_tok);
6696                                                    __pat_tok
6697                                                },
6698                                                crate::ported::zsh_h::PAT_HEAPDUP as i32,
6699                                                None,
6700                                            )
6701                                            .map_or(false, |p| {
6702                                                crate::ported::pattern::pattry(&p, &cand)
6703                                            })
6704                                        };
6705                                        if matched {
6706                                            found = Some(start);
6707                                            if !want_last {
6708                                                break 'outer;
6709                                            }
6710                                            break;
6711                                        }
6712                                    }
6713                                }
6714                                // (I)/(R): scan again to find LAST.
6715                                if want_last {
6716                                    let mut last_found: Option<usize> = found;
6717                                    for start in (0..=n).rev() {
6718                                        for len in 1..=(n - start) {
6719                                            let cand: String =
6720                                                s_chars[start..start + len].iter().collect();
6721                                            let matched = if exact {
6722                                                cand == pat
6723                                            } else {
6724                                                crate::ported::pattern::patcompile(
6725                                                    &{
6726                                                        let mut __pat_tok = (pat).to_string();
6727                                                        crate::ported::glob::tokenize(
6728                                                            &mut __pat_tok,
6729                                                        );
6730                                                        __pat_tok
6731                                                    },
6732                                                    crate::ported::zsh_h::PAT_HEAPDUP as i32,
6733                                                    None,
6734                                                )
6735                                                .map_or(false, |p| {
6736                                                    crate::ported::pattern::pattry(&p, &cand)
6737                                                })
6738                                            };
6739                                            if matched {
6740                                                last_found = Some(start);
6741                                                break;
6742                                            }
6743                                        }
6744                                        if last_found.is_some() && last_found.unwrap() >= start {
6745                                            break;
6746                                        }
6747                                    }
6748                                    found = last_found;
6749                                }
6750                                let idx_1based = match found {
6751                                    Some(i) => (i + 1) as i64,
6752                                    // (i) miss → len+1 (one past end).
6753                                    None => (n + 1) as i64,
6754                                };
6755                                return idx_1based.to_string();
6756                            }
6757                        }
6758                    }
6759                }
6760            }
6761            if is_indexed && key.trim().parse::<i64>().is_err() {
6762                crate::ported::math::mathevali(&crate::ported::subst::singsub(&key))
6763                    .map(|n| n.to_string())
6764                    .unwrap_or(key.clone())
6765            } else {
6766                key.clone()
6767            }
6768        });
6769        // c:Src/params.c getindex — C parses the subscript from the
6770        // TOKENIZED source word, so a `]`/`}` that arrived via `$key`
6771        // expansion is plain data and can never terminate the
6772        // subscript. The textual `name[key]` rebuild below re-parses
6773        // the FLAT string, where an expanded `]` splits the key at the
6774        // first bracket (`c[$k]=5` with k='x]y' stored key "x" and
6775        // spilled junk — zpwr expandstats died on the spill in a later
6776        // math expr). For a PM_HASHED target the compile-time split
6777        // already isolated the exact key: store it directly via the
6778        // canonical hashed storage (same mechanism as the
6779        // dynamic-empty-key arm above / assignsparam's PM_HASHED
6780        // tail), with the readonly guard assignsparam would apply.
6781        let target_flags = with_executor(|exec| exec.param_flags(&name));
6782        // PM_SPECIAL exclusion: the zsh/parameter magic assocs
6783        // (functions / aliases / galiases / saliases / options / …)
6784        // have per-key setfns with SIDE EFFECTS — `functions[x]=body`
6785        // must parse the body into shfunctab (Src/Modules/
6786        // parameter.c:296 setfunction), `aliases[x]=v` must write
6787        // aliastab. The direct hashed-storage store below silently
6788        // swallowed those: zinit's tmp-subst wrappers
6789        // (`functions[autoload]=':zinit-tmp-subst-autoload "$@";'`)
6790        // never became real functions, so every
6791        // `.zinit-tmp-subst-off` spammed `unfunction: no such hash
6792        // table element: autoload/compdef/bindkey/…`. Route specials
6793        // through assignsparam's canonical per-name arms instead.
6794        if (target_flags as u32 & crate::ported::zsh_h::PM_HASHED) != 0
6795            && (target_flags as u32 & crate::ported::zsh_h::PM_SPECIAL) == 0
6796        {
6797            // c:3251 + c:3343 — run the real chain (getindex →
6798            // assignstrvalue) instead of storing the subscript verbatim.
6799            // Storing it verbatim is what made `h[(r)v]=Z` invent a key
6800            // named `(r)v` where zsh reports a slice assignment, and it
6801            // also skipped the c:2701 / c:2831 guards entirely.
6802            let _ = &resolved_key;
6803            return Value::Status(assign_hash_element(&name, &key, &key_src, &value));
6804        }
6805        let subscripted = format!("{}[{}]", name, resolved_key);
6806        crate::ported::params::assignsparam(&subscripted, &value, crate::ported::zsh_h::ASSPM_WARN);
6807        if let Some((nm, old_len, i)) = sparse_track {
6808            crate::bash_arrays::note_subscript_set(&nm, old_len, i);
6809        }
6810        Value::Status(0)
6811    });
6812
6813    // Brace expansion. Routes through executor.xpandbraces (already
6814    // implemented for the pre-fusevm executor). Returns Value::Array.
6815    // BUILTIN_ARRAY_DROP_EMPTY — filter out empty Value::Str entries
6816    // from a Value::Array on the stack. Used by `for x in $@` /
6817    // `for x in $*` unquoted forms which drop empty positionals
6818    // (POSIX-like) but do NOT IFS-split each element internally
6819    // (zsh-specific — scalar word splitting is off by default).
6820    // Distinct from BUILTIN_WORD_SPLIT which routes through
6821    // multsub PREFORK_SPLIT (full IFS-split). Bug #166.
6822    vm.register_builtin(BUILTIN_ARRAY_DROP_EMPTY, |vm, argc| {
6823        let v = vm.pop();
6824        strip_nulstring_markers(drop_word_empties(v, argc))
6825    });
6826    // The c:183-186 decision of BUILTIN_ARRAY_DROP_EMPTY, before c:170's
6827    // remnulargs (strip_nulstring_markers) runs on the survivors.
6828    fn drop_word_empties(v: Value, argc: u8) -> Value {
6829        // End of word: whatever `ARRAY_EMPTIES_ELIDABLE` was carrying is
6830        // spent here even though this builtin drops unconditionally, so a
6831        // following word starts from a clean bit.
6832        let _ = take_array_empties_elidable();
6833        // c:Src/subst.c:4387 / :4404 / :4426 — a QUOTED splat of two or more
6834        // elements stored `nulstring` in its empty nodes, which c:183 finds
6835        // NON-empty, so c:186's `uremnode` skips them and `remnulargs` (c:170)
6836        // restores `""`. `a=(x '' y); PRE"${a[@]}"POST` is three words in zsh.
6837        // See `QUOTED_SPLICE_KEEPS_EMPTIES` for the c:4261 element-count
6838        // cut-off that makes this a run-time decision.
6839        if take_quoted_splice_keeps_empties() {
6840            return v;
6841        }
6842        match v {
6843            Value::Array(items) => {
6844                if argc & (WORD_DROP_KEEPS_FIRST | WORD_DROP_KEEPS_LAST) != 0 && plan9_active() {
6845                    // c:4341 — under plan9 the quoted-empty affix is glued to
6846                    // every element, so no node of the word is empty.
6847                    return Value::Array(items);
6848                }
6849                let last = items.len().saturating_sub(1);
6850                let filtered: Vec<Value> = items
6851                    .iter()
6852                    .enumerate()
6853                    .filter(|(i, x)| word_drop_keeps(argc, *i, last) || !x.to_str().is_empty())
6854                    .map(|(_, x)| x.clone())
6855                    .collect();
6856                Value::array(filtered)
6857            }
6858            Value::Str(s) if s.is_empty() => Value::array(Vec::new()),
6859            other => other,
6860        }
6861    }
6862
6863    // Opens (argc 0) / closes (argc 1) a word whose literals the compiler
6864    // attaches itself — see `BUILTIN_WORD_DEFER_EMPTIES`. Pushes nothing
6865    // meaningful; the emitter pops the status.
6866    vm.register_builtin(BUILTIN_WORD_DEFER_EMPTIES, |_vm, argc| {
6867        if argc == 0 {
6868            open_deferred_affix_word();
6869        } else {
6870            close_deferred_affix_word();
6871        }
6872        Value::Int(0)
6873    });
6874
6875    // c:Src/subst.c:180-187 — prefork's final loop deletes a list node whose
6876    // text is EMPTY: `} else if (!(flags & PREFORK_SINGLE) &&
6877    // !(*ret_flags & PREFORK_KEY_VALUE) && !keep) uremnode(list, node);`.
6878    // The compiler emits it once the word's segments have been folded, which
6879    // is the ORDER c:183-186 has: the node text tested there is the finished
6880    // word, prefix and suffix already attached by the array emit block
6881    // (c:4245 onward — plan9 cross-product at c:4316-4365, the "simply join
6882    // the first and last values" splice at c:4366-4437). So an element that
6883    // is empty at read time is not yet an empty word: under plan9 a prefixed
6884    // word cross-products (`setopt rcexpandparam; a=('' x);
6885    // print -rl -- p$a` → `p`, `px`), and under the splice an affix lands on
6886    // the boundary element (`a=(x y ""); print -rl -- ${a}POST` → `x`, `y`,
6887    // `POST`).
6888    //
6889    // Applies only to the empties of a genuine ARRAY reference. A split field
6890    // — SH_WORD_SPLIT (c:3919 `sepsplit`) or command substitution — is
6891    // `nulstring` in C (c:36), non-empty at c:183, so it SURVIVES:
6892    // `setopt shwordsplit; IFS=:; s='a::b'; print -rl -- P${s}S` is `Pa`,
6893    // ``, `bS`. zshrs renders both as "", so the discriminator is the
6894    // `ARRAY_EMPTIES_ELIDABLE` bit `get_var_impl` sets on the array read.
6895    vm.register_builtin(BUILTIN_WORD_ELIDE_EMPTY, |vm, argc| {
6896        let v = vm.pop();
6897        strip_nulstring_markers(elide_word_empties(v, argc))
6898    });
6899    // The c:183-186 decision of BUILTIN_WORD_ELIDE_EMPTY, before c:170's
6900    // remnulargs (strip_nulstring_markers) runs on the survivors.
6901    fn elide_word_empties(v: Value, argc: u8) -> Value {
6902        // `PARAMSUBST_EMPTIES_DEFERRED` is the same bit reported from the
6903        // other direction: `paramsubst` skipped c:186 for a genuine array
6904        // reference because this word opened with
6905        // `BUILTIN_WORD_DEFER_EMPTIES`, so the removal is owed here. It is
6906        // cleared by that marker's closing half, emitted just below this op.
6907        let elidable = take_array_empties_elidable()
6908            | crate::ported::subst::PARAMSUBST_EMPTIES_DEFERRED.with(|c| c.get());
6909        // A quoted splat never reaches this arm (the compile site sends every
6910        // splice-shaped word to `BUILTIN_ARRAY_DROP_EMPTY` instead), but the
6911        // bit is word-scoped, so spend it here too rather than let it reach
6912        // the next word's drop. See `QUOTED_SPLICE_KEEPS_EMPTIES`.
6913        let _ = take_quoted_splice_keeps_empties();
6914        // argc == 2 is the compiler's "this word carries a quoted-empty
6915        // literal segment" bit (see the emit site). c:4341 — under plan9 that
6916        // affix is glued to EVERY element, so no node of the word can be
6917        // empty and c:186 removes nothing: `setopt rcexpandparam;
6918        // a=("" y); print -rl -- $a''` is `` `y`.
6919        if argc & 2 != 0 && plan9_active() {
6920            return v;
6921        }
6922        // c:Src/subst.c:36 — a quoted-empty literal (`""`, `''`) leaves a
6923        // nulstring in the word, so a SCALAR that expands to nothing around it
6924        // (`$x""`, `""$x`, `$x"$x"`) is still one empty word, not zero.
6925        if argc & 2 != 0 {
6926            let nothing = match &v {
6927                Value::Str(e) => e.is_empty(),
6928                Value::Array(items) => items.is_empty(),
6929                _ => false,
6930            };
6931            if nothing {
6932                return Value::array(vec![Value::str("")]);
6933            }
6934        }
6935        if !elidable {
6936            return v;
6937        }
6938        match v {
6939            Value::Array(items) => {
6940                // `items` is an `Arc<Vec<Value>>`; borrow rather than move.
6941                let last = items.len().saturating_sub(1);
6942                let kept: Vec<Value> = items
6943                    .iter()
6944                    .enumerate()
6945                    .filter(|(i, x)| word_drop_keeps(argc, *i, last) || !x.to_str().is_empty())
6946                    .map(|(_, x)| x.clone())
6947                    .collect();
6948                // c:4245 — an array reference that ends up empty is still an
6949                // ARRAY for the plan9 word-removal rule, not a scalar.
6950                if kept.is_empty() {
6951                    note_empty_is_scalar(false);
6952                }
6953                Value::array(kept)
6954            }
6955            other => other,
6956        }
6957    }
6958
6959    // zsh nofork command substitution (c:Src/subst.c:1904-2100) — and the
6960    // ksh93 funsub / mksh valsub it subsumes. See the BUILTIN_KSH_FUNSUB
6961    // doc comment.
6962    vm.register_builtin(BUILTIN_KSH_FUNSUB, |vm, _argc| {
6963        // 0 = unquoted, 1 = inside `"…"`, 2 = unquoted PREFORK_SINGLE (a
6964        // scalar assignment RHS): trimmed like unquoted, never split.
6965        let qt_code = vm.pop().to_int();
6966        let single = qt_code == 2;
6967        let mut qt = qt_code == 1;
6968        let kind = vm.pop().to_int();
6969        let rplyvar = vm.pop().to_str();
6970        let body = vm.pop().to_str();
6971        // !!! POSIX-FAMILY GATE !!! bash, dash and sh have no nofork
6972        // command substitution — all three answer `bad substitution` and
6973        // fail, measured on this host:
6974        //   bash -c 'printf "%s\\n" "${ printf inner; }"'  -> rc 1
6975        //   dash -c 'printf "%s\\n" "${ printf inner; }"'  -> rc 2
6976        //   sh   -c 'printf "%s\\n" "${ printf inner; }"'  -> rc 1
6977        // The Korn family DOES have it (funsub/valsub) and so does zsh
6978        // 5.10 (`${ … }` / `${| … }` / `${{VAR} … }`), so the gate is the
6979        // bare bash/sh/dash drop-in only: `posix_faithful()` without the
6980        // Korn leg. `--zsh` and native zshrs keep the substitution.
6981        if crate::dash_mode::posix_faithful() && !crate::dash_mode::korn_mode() {
6982            crate::ported::utils::zerr("bad substitution");
6983            crate::ported::utils::errflag.fetch_or(
6984                crate::ported::zsh_h::ERRFLAG_ERROR,
6985                std::sync::atomic::Ordering::Relaxed,
6986            );
6987            with_executor(|exec| exec.set_last_status(1));
6988            return Value::str("");
6989        }
6990        let live_status = vm.last_status;
6991        // c:Src/subst.c:1625 — paramsubst's `qt` is "am I inside double
6992        // quotes", which is a LEXICAL property of the whole word. The
6993        // compiler can see it only when the substitution IS the entire
6994        // word; `"${ print INNER } $?"` reaches here as a segment whose own
6995        // text carries no quotes, so pick the enclosing quoting up from the
6996        // executor's live DQ flag as well (D10nofork.ztst "return statement
6997        // inside, part 1+": trim must NOT eat `print`'s newline there).
6998        if !qt && with_executor(|exec| exec.in_dq_context) != 0 {
6999            qt = true;
7000        }
7001        // c:Src/subst.c — the split decision is read BEFORE the body runs.
7002        // D10nofork.ztst "test word splitting on result" pins that
7003        // explicitly ("setting option inside is too late for that
7004        // substitution"): a `setopt shwordsplit` executed by the body
7005        // must not retroactively split the value it produced.
7006        let split = !qt
7007            && !single
7008            && (crate::dash_mode::korn_mode()
7009                || crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT));
7010        let out = with_executor(|exec| {
7011            use std::sync::atomic::Ordering::Relaxed;
7012            exec.set_last_status(live_status);
7013            // c:2044 — `parse_string(cmdarg, 0)`: the body is parsed from the
7014            // running line, not from 1.
7015            let lineno: u64 = exec.scalar("LINENO").and_then(|s| s.parse().ok()).unwrap_or(0);
7016            // c:1908 — `int trim = (!EMULATION(EMULATE_ZSH)) ? 2 : !qt;`
7017            let trim: i32 = if crate::dash_mode::korn_mode()
7018                || !crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_ZSH)
7019            {
7020                2
7021            } else if qt {
7022                0
7023            } else {
7024                1
7025            };
7026            // c:1993-2007 — `${ cmd }` takes advantage of the parameter
7027            // scope and `$(<file)` semantics: the body runs in the CURRENT
7028            // shell as `>| TMP {\nBODY\n;}` and the file is read back. The
7029            // spelling is C's byte for byte; a body parse error's line
7030            // number depends on it.
7031            let rplytmp: Option<String> = if kind == 0 {
7032                crate::ported::utils::gettempname(None, true) // c:1996
7033            } else {
7034                None
7035            };
7036            let cmdarg: String = match (kind, &rplytmp) {
7037                (0, Some(t)) => format!(
7038                    ">| {} {{\n{}\n;}}", // c:1993 outfmt
7039                    crate::ported::utils::quotestring(t, crate::ported::zsh_h::QT_BACKSLASH),
7040                    body
7041                ),
7042                (0, None) => {
7043                    // c:2004-2007 — TMPPREFIX not writable: `cmdoutval =
7044                    // lastval; cmdarg = NULL;`.
7045                    crate::ported::exec::cmdoutval.store(live_status, Relaxed);
7046                    String::new()
7047                }
7048                _ => body.clone(),
7049            };
7050            // c:2016 — `startparamscope(); /* "local" behaves as if in a
7051            // function */`, paired with c:2093 `endparamscope()`. All three
7052            // forms take it (the C block is under `if (rplyvar)`), which is
7053            // what makes `outer=GLOBAL; ${| local outer=LOCAL; … }` leave
7054            // the outer value alone (D10nofork.ztst "local declaration
7055            // inside").
7056            crate::ported::utils::inc_locallevel(); // c:2016
7057            // c:2018-2024 — `${| cmd }`: `rplypm = createparam("REPLY",
7058            // PM_LOCAL|PM_UNSET|PM_HIDE)` inside the scope, so the body sees
7059            // NO outer REPLY and the outer value is intact afterwards
7060            // (D10nofork.ztst "Basic substitution and REPLY scoping"). mksh's
7061            // valsub is identical, so one save/clear/restore serves both.
7062            let saved_reply = if kind == 1 {
7063                let saved = crate::ported::params::getsparam(&rplyvar);
7064                crate::ported::params::unsetparam(&rplyvar);
7065                saved
7066            } else {
7067                None
7068            };
7069            // c:2025-2031 — `${ cmd }` scopes its result under the reserved
7070            // `.zsh.cmdsubst`: local, unset, hidden and PM_READONLY_SPECIAL,
7071            // so the body cannot assign it (D10nofork.ztst "reserved
7072            // parameter name").
7073            if kind == 0 {
7074                use crate::ported::zsh_h::{PM_HIDE, PM_LOCAL, PM_READONLY_SPECIAL, PM_UNSET};
7075                crate::ported::params::createparam(
7076                    ".zsh.cmdsubst",
7077                    (PM_LOCAL | PM_UNSET | PM_HIDE | PM_READONLY_SPECIAL) as i32,
7078                ); // c:2025-2027
7079                if let Ok(mut tab) = crate::ported::params::paramtab().write() {
7080                    if let Some(pm) = tab.get_mut(".zsh.cmdsubst") {
7081                        pm.level = crate::ported::utils::locallevel(); // c:2029
7082                    }
7083                }
7084            }
7085            if !cmdarg.trim().is_empty() {
7086                // c:2039-2053 — `int obreaks = breaks; … cmdprog =
7087                // parse_string(cmdarg, 0); if (cmdprog) { execode(cmdprog, 1,
7088                // 0, "cmdsubst"); cmdoutval = lastval; if (retflag) { retflag
7089                // = 0; breaks = obreaks; } } else errflag |= ERRFLAG_ERROR;`
7090                // — `return` ends the body only; `exit` stays pending for the
7091                // c:2094 zexit below; `break` keeps unwinding the loop around
7092                // the command.
7093                use crate::ported::builtin::{BREAKS, RETFLAG};
7094                let obreaks = BREAKS.load(Relaxed); // c:2041
7095                // The body's commands are not inside the word's quotes.
7096                let saved_dq = std::mem::replace(&mut exec.in_dq_context, 0);
7097                let saved_subexp =
7098                    crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.replace(0));
7099                // c:Src/exec.c:4410 save_params records only the names in the
7100                // prefix-assignment list (varspc); an assignment the body
7101                // makes while `X=${| … } cmd` is being evaluated is not one
7102                // of them. The inline-env frame records every SET_VAR while
7103                // open (D10nofork.ztst "environment assignment" left REPLY
7104                // unset), so park it for the body.
7105                let frames = std::mem::take(&mut exec.inline_env_stack);
7106                // c:Src/subst.c:2048 — execode pushes the "cmdsubst" context.
7107                let ctx = crate::ported::exec::EvalContextFrame::push("cmdsubst");
7108                let ran = exec.execute_string_at_lineno(&cmdarg, lineno);
7109                drop(ctx);
7110                exec.inline_env_stack = frames;
7111                match ran {
7112                    Ok(st) => {
7113                        exec.set_last_status(st);
7114                        crate::ported::exec::cmdoutval.store(st, Relaxed); // c:2047
7115                    }
7116                    Err(_) => {
7117                        crate::ported::utils::errflag
7118                            .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Relaxed); // c:2053
7119                    }
7120                }
7121                crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.set(saved_subexp));
7122                exec.in_dq_context = saved_dq;
7123                if RETFLAG.load(Relaxed) != 0 {
7124                    RETFLAG.store(0, Relaxed); // c:2050
7125                    BREAKS.store(obreaks, Relaxed); // c:2051
7126                }
7127            }
7128            let val = match kind {
7129                1 => {
7130                    // c:2082-2083 — `val = dupstring(getsparam(rplyvar))`.
7131                    let reply = crate::ported::params::getsparam(&rplyvar).unwrap_or_default();
7132                    match saved_reply {
7133                        Some(v) => {
7134                            crate::ported::params::setsparam(&rplyvar, &v);
7135                        }
7136                        None => {
7137                            crate::ported::params::unsetparam(&rplyvar);
7138                        }
7139                    }
7140                    Value::str(reply)
7141                }
7142                2 => {
7143                    // c:2026-2033 — `${{VAR} cmd }`: VAR is the result
7144                    // parameter and gets NO local scope, so an assignment
7145                    // inside is global. c:2089-2091 then re-enters the
7146                    // ordinary parameter path with `s = dyncat(rplyvar, s)`,
7147                    // which is why an ARRAY result stays an array.
7148                    match exec.array(&rplyvar) {
7149                        Some(items) => {
7150                            Value::array(items.into_iter().map(Value::str).collect::<Vec<_>>())
7151                        }
7152                        None => Value::str(
7153                            crate::ported::params::getsparam(&rplyvar).unwrap_or_default(),
7154                        ),
7155                    }
7156                }
7157                _ => {
7158                    // c:2056-2057 — `rplypm->node.flags &= ~PM_READONLY_SPECIAL;`
7159                    if let Ok(mut tab) = crate::ported::params::paramtab().write() {
7160                        if let Some(pm) = tab.get_mut(".zsh.cmdsubst") {
7161                            pm.node.flags &= !(crate::ported::zsh_h::PM_READONLY_SPECIAL as i32);
7162                        }
7163                    }
7164                    // c:2058-2075 — `if (rplytmp && !errflag) { noerrs = 2;
7165                    // rplylen = zstuff(&cmdarg, rplytmp); … }`: trim==2 strips
7166                    // EVERY trailing newline (ksh/bash), trim==1 exactly one,
7167                    // trim==0 none.
7168                    let mut b = String::new();
7169                    if let Some(t) = &rplytmp {
7170                        if crate::ported::utils::errflag.load(Relaxed) == 0 {
7171                            if let Ok((txt, _)) = crate::ported::input::zstuff(t) {
7172                                b = txt;
7173                            }
7174                        }
7175                        while trim > 0 && b.ends_with('\n') {
7176                            b.pop();
7177                            if trim == 1 {
7178                                break;
7179                            }
7180                        }
7181                        let _ = std::fs::remove_file(t); // c:2079-2080
7182                    }
7183                    Value::str(b)
7184                }
7185            };
7186            crate::ported::params::endparamscope(); // c:2093
7187            // c:2094-2103 — `if (exit_pending) { … stopmsg = 1; zexit(exit_val,
7188            // ZEXIT_NORMAL); }`: an `exit` in the body ran in the CURRENT shell
7189            // and was only deferred by the parameter scope; it ends the shell
7190            // here, before the command around the substitution runs.
7191            {
7192                use crate::ported::builtin::{zexit, EXIT_PENDING, EXIT_VAL, STOPMSG};
7193                use std::sync::atomic::Ordering::Relaxed;
7194                if EXIT_PENDING.load(Relaxed) != 0 {
7195                    STOPMSG.store(1, Relaxed); // c:2100
7196                    zexit(EXIT_VAL.load(Relaxed), crate::ported::zsh_h::ZEXIT_NORMAL); // c:2101
7197                    // c:2103 `_exit(exit_val)` — back here only when zexit deferred
7198                    // for an in-process subshell (SUBSHELL_DEPTH): the forked child
7199                    // C runs would already be gone, so abort the command around
7200                    // the substitution and let the subshell unwind with the value.
7201                    crate::ported::utils::errflag
7202                        .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Relaxed);
7203                }
7204            }
7205            val
7206        });
7207        // A nofork substitution IS a command substitution, so it publishes
7208        // the body's exit the way `$( … )` does: `ksh -c 'v=${ false; };
7209        // print "rc=$?"'` → `rc=1`. The body run above leaves it in the
7210        // executor; the VM's own counter
7211        // is what BUILTIN_SET_VAR hands back as the assignment's status
7212        // (c:Src/exec.c:3396 `lastval = cmdoutval`), so mirror it here.
7213        vm.last_status = with_executor(|exec| exec.last_status());
7214        // An UNQUOTED substitution is word-split only when the shell splits
7215        // ordinary expansions: always under ksh/mksh, and under zsh only
7216        // with SH_WORD_SPLIT. `split` was decided above, before the body
7217        // ran — see the comment there.
7218        match out {
7219            Value::Str(ref s) if split => {
7220                let (_joined, parts, _isarr, _flags) =
7221                    crate::ported::subst::multsub(s, crate::ported::zsh_h::PREFORK_SPLIT);
7222                Value::array(parts.into_iter().map(Value::str).collect::<Vec<_>>())
7223            }
7224            other => other,
7225        }
7226    });
7227
7228    // c:Src/subst.c:3032 `val = sepjoin(aval, sep, 1)` — see the
7229    // BUILTIN_QUOTED_STAR_ONE_WORD doc comment.
7230    vm.register_builtin(BUILTIN_QUOTED_STAR_ONE_WORD, |vm, _argc| match vm.pop() {
7231        Value::Array(items) if items.is_empty() => Value::str(String::new()),
7232        other => other,
7233    });
7234
7235    // BUILTIN_QUOTEDZPUTS — re-wrap top-of-stack scalar via the
7236    // canonical quotedzputs (Src/utils.c:6464). Non-printable bytes
7237    // come back as `$'…'` C-string form so the cond xtrace prefix
7238    // line preserves the source-quoting form for `[[ -n $'\C-[OP' ]]`
7239    // instead of leaking raw ESC + "OP" bytes through the terminal.
7240    vm.register_builtin(BUILTIN_QUOTEDZPUTS, |vm, _argc| {
7241        let s = vm.pop().to_str();
7242        Value::str(crate::ported::utils::quotedzputs(&s))
7243    });
7244
7245    // BUILTIN_QUOTE_TOKENIZED_OUTPUT — char-aware mirror of
7246    // c:Src/exec.c:2114 `quote_tokenized_output`. The canonical
7247    // port at exec::quote_tokenized_output operates on bytes
7248    // (zsh's metafied encoding); zshrs strings are UTF-8 so
7249    // `\u{e187}` Star is `[0xC2, 0x87]`, and a byte walk writes
7250    // 0xC2 raw (invalid UTF-8 lead → U+FFFD on lossy decode).
7251    // Walk by char and dispatch the same switch the byte port
7252    // uses, but with the token chars matching the UTF-8 form.
7253    vm.register_builtin(BUILTIN_QUOTE_TOKENIZED_OUTPUT, |vm, _argc| {
7254        let s = vm.pop().to_str();
7255        let mut out = String::with_capacity(s.len());
7256        let chars: Vec<char> = s.chars().collect();
7257        let mut i = 0;
7258        while i < chars.len() {
7259            let c = chars[i];
7260            // c:2120 — Meta-quoted byte: emit `*++s ^ 32`.
7261            // In UTF-8 strings Meta is `\u{83}`; the next char is
7262            // the metafied payload.
7263            if c == '\u{83}' {
7264                if let Some(&n) = chars.get(i + 1) {
7265                    if (n as u32) < 0x80 {
7266                        out.push(((n as u8) ^ 32) as char);
7267                    } else {
7268                        out.push(n);
7269                    }
7270                    i += 2;
7271                    continue;
7272                }
7273                i += 1;
7274                continue;
7275            }
7276            // c:2124 — Nularg: skip.
7277            if c == '\u{e1a1}' {
7278                i += 1;
7279                continue;
7280            }
7281            // c:2128-2143 — ASCII specials get backslash-prefixed
7282            // then fall through to emit the literal char.
7283            match c {
7284                '\\' | '<' | '>' | '(' | '|' | ')' | '^' | '#' | '~' | '[' | ']' | '*' | '?'
7285                | '$' | ' ' => {
7286                    out.push('\\');
7287                    out.push(c);
7288                    i += 1;
7289                    continue;
7290                }
7291                '\t' => {
7292                    out.push_str("$'\\t'");
7293                    i += 1;
7294                    continue;
7295                }
7296                '\n' => {
7297                    out.push_str("$'\\n'");
7298                    i += 1;
7299                    continue;
7300                }
7301                '\r' => {
7302                    out.push_str("$'\\r'");
7303                    i += 1;
7304                    continue;
7305                }
7306                '=' => {
7307                    if i == 0 {
7308                        out.push('\\');
7309                    }
7310                    out.push(c);
7311                    i += 1;
7312                    continue;
7313                }
7314                _ => {}
7315            }
7316            // c:2163 — `if (itok(*s)) putc(ztokens[*s - Pound]);`
7317            // Map zsh token chars (`\u{e184}`..`\u{e1a1}` range, the
7318            // ones the lexer emits for `#$^*()…`) back to their
7319            // source ASCII via the `ztokens` table.
7320            let cp = c as u32;
7321            if (0xe184..=0xe1a1).contains(&cp) {
7322                let idx = (cp - 0xe184) as usize;
7323                let ztokens = crate::ported::lex::ztokens.as_bytes();
7324                if idx < ztokens.len() {
7325                    out.push(ztokens[idx] as char);
7326                    i += 1;
7327                    continue;
7328                }
7329            }
7330            out.push(c);
7331            i += 1;
7332        }
7333        Value::str(out)
7334    });
7335
7336    // BUILTIN_WORD_SPLIT — `${=var}` IFS-split runtime.
7337    // PURE PASSTHRU: route through canonical `subst::multsub` with
7338    // PREFORK_SPLIT flag (C port of `Src/subst.c::multsub` at c:544
7339    // — the IFS-split walker with whitespace-vs-non-whitespace
7340    // gating, quote-aware parsing, and empty-field handling).
7341    vm.register_builtin(BUILTIN_WORD_SPLIT, |vm, _argc| {
7342        let s = vm.pop().to_str();
7343        let (_joined, parts, _isarr, _flags) =
7344            crate::ported::subst::multsub(&s, crate::ported::zsh_h::PREFORK_SPLIT);
7345        // Empty single-string special case → empty Array (drop empty arg).
7346        // The Array is a carrier for "no argv word", not an array-SHAPED
7347        // result: a single empty field came from an empty SCALAR (c:3922-3923
7348        // `if (!aval || !aval[0]) val = dupstring("");`). Record that, or
7349        // under RC_EXPAND_PARAM `concat_plan9` reads a stale bit and applies
7350        // c:4362's `uremnode` to a word zsh keeps — `x$(true)y` and
7351        // `v=""; x${=v}y` are each the single word `xy`, not zero words.
7352        if parts.len() == 1 && parts[0].is_empty() {
7353            note_empty_is_scalar(true);
7354            return Value::array(Vec::new());
7355        }
7356        // Zero parts is the same story reached by a different route: an empty
7357        // command substitution splits to nothing at all rather than to one
7358        // empty field. `to_str()` above means this builtin's input is always
7359        // a scalar, so an empty result here is never array-shaped.
7360        restore_empty_shape(nodes_to_value(parts), true)
7361    });
7362
7363    // BUILTIN_FORCE_SPLIT — `${=name}` / SH_WORD_SPLIT forced split.
7364    // c:Src/subst.c:3920-3928 —
7365    //     if (force_split && !isarr) {
7366    //         aval = sepsplit(val, spsep, 0, 1);
7367    //         if (!aval || !aval[0])   val = dupstring("");
7368    //         else if (!aval[1])       val = aval[0];
7369    //         else                     isarr = nojoin ? 1 : 2;
7370    //     }
7371    // with spsep == NULL for the `=` flag, so sepsplit falls through to
7372    // Src/utils.c:3711 spacesplit(s, allownull=0). See BUILTIN_FORCE_SPLIT's
7373    // doc comment for the empty-field rule and the argc contract.
7374    vm.register_builtin(BUILTIN_FORCE_SPLIT, |vm, argc| {
7375        // argc is a two-bit field, and there is exactly one emit site
7376        // (compile_zsh.rs, the `${=…}` fast path):
7377        //   bit 0  keep_empties — the word has neighbouring text or `Dnull`
7378        //          markers to attach the empty leading/trailing fields to.
7379        //   bit 1  the operand is a `[@]`/`[*]` SPLICE, so c:3931's `!isarr`
7380        //          gate is live (see below).
7381        let keep_empties = argc & 1 != 0;
7382        let splice_operand = argc & 2 != 0;
7383        let raw_val = vm.pop();
7384        // c:Src/subst.c:3931 — `if (force_split && !isarr)`. The `${=NAME[*]}`
7385        // / `${=NAME[@]}` splice fast paths hand this builtin whatever the
7386        // c:3914-3927 join arm produced, and that arm can leave the expansion
7387        // ARRAY-shaped: with `nojoin` set and IFS the EMPTY string, neither
7388        // c:3916 nor c:3919 fires (c:3919 wants IFS UNSET, `!ifs`, not merely
7389        // empty), so `isarr` is still -1 here and the split does not run at
7390        // all. Only c:184-187's empty-word removal still applies.
7391        //
7392        // Gated on the SPLICE bit, not on the value's Rust type: a scalar
7393        // `${=@}` reads through BUILTIN_GET_VAR_DQ, which also hands over a
7394        // Value::Array, but there `isarr` was already collapsed by c:3033's
7395        // quoted join and the split must still run over the joined text
7396        // (`set -- 'a b' 'c d'; print -l $=@` is FOUR words).
7397        if let (true, Value::Array(items)) = (splice_operand, &raw_val) {
7398            let kept: Vec<Value> = items
7399                .iter()
7400                .filter(|x| keep_empties || !x.to_str().is_empty())
7401                .cloned()
7402                .collect();
7403            return Value::array(kept);
7404        }
7405        let s = raw_val.to_str();
7406        // c:3932 — `sepsplit(val, spsep, 0, 1)`; spsep NULL → spacesplit, plus
7407        // c:184-187's empty-word removal. See `sepsplit_c3932` for the
7408        // `nulstring`-vs-`""` rule that decides which empties survive.
7409        // c:36 `nulstring` — inside a word whose end-of-word drop is still to
7410        // run (BUILTIN_WORD_DEFER_EMPTIES opened it), an IFS-non-whitespace
7411        // empty field keeps its Nularg so that drop can tell it from the
7412        // IFS-whitespace edges it must delete (c:183-186), then removes the
7413        // marker (c:170 remnulargs). `IFS=:; s=a::b; print -rl -- x${=s}y`
7414        // is `xa` `` `by`.
7415        let out = if keep_empties
7416            && crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get()) > 0
7417        {
7418            crate::ported::utils::sepsplit(&s, None, false) // c:3932, Nularg kept
7419        } else {
7420            sepsplit_c3932(&s, keep_empties)
7421        };
7422        if out.is_empty() {
7423            // c:3922-3923 — `if (!aval || !aval[0]) val = dupstring("");`:
7424            // the split produced nothing, so the value is the empty SCALAR.
7425            // Quoted, that is one empty word (c:4465 `if (qt && !*y) y =
7426            // dupstring(nulstring);` → `a=( "${=v}" )` has one element);
7427            // unquoted, prefork deletes it and the word vanishes.
7428            if keep_empties {
7429                return Value::str(String::new());
7430            }
7431            note_empty_is_scalar(true);
7432            return Value::array(Vec::new());
7433        }
7434        if out.len() == 1 {
7435            // c:3924 — `else if (!aval[1]) val = aval[0];` — a one-field
7436            // split stays a SCALAR (this is why `${#${(f)v}}` counts
7437            // characters when the split yields a single line).
7438            return Value::str(out.into_iter().next().unwrap());
7439        }
7440        // c:3927 — `isarr = nojoin ? 1 : 2;`
7441        Value::array(out.into_iter().map(Value::str).collect())
7442    });
7443
7444    // See BUILTIN_BARE_SPBREAK's declaration for the argc contract.
7445    vm.register_builtin(BUILTIN_BARE_SPBREAK, |vm, argc| {
7446        // c:2567 / c:2562 — `${=…}` sets spbreak 2, `${==…}` clears it.
7447        let spbreak = if argc & 1 != 0 { 2 } else { 0 };
7448        let qt = argc & 2 != 0; // c:3033 `qt`
7449        let name = vm.pop().to_str();
7450        let (elems, is_arr, ifs_opt, in_dq) = with_executor(|exec| {
7451            // c:Src/params.c:4745 `ifssetfn` — the C `ifs` global is NULL only
7452            // when IFS is genuinely UNSET, and c:3920's `(!ifs && …)` turns on
7453            // exactly that distinction, so carry the Option, not a defaulted
7454            // string.
7455            let ifs_opt = exec.scalar("IFS");
7456            let in_dq = qt || exec.in_dq_context > 0; // c:3033 `qt`
7457            let (elems, is_arr) = param_value_elems_c2916(exec, &name);
7458            (elems, is_arr, ifs_opt, in_dq)
7459        });
7460        // c:3899-3906 — "treat a one-element array as a scalar for purposes of
7461        // concatenation with surrounding text (some${param}thing) and
7462        // rc_expand_param handling":
7463        //     } else if (isarr && aval && aval[0] && !aval[1]) {
7464        //         val = aval[0];
7465        //         isarr = 0;
7466        // An EMPTY array fails `aval[0]` and keeps `isarr`.
7467        let mut isarr: i32 = i32::from(is_arr && elems.len() != 1);
7468        // c:1819 / c:2564 / c:2569 — `nojoin` for this spelling. `${==…}`
7469        // clears it outright; `${=…}` recomputes it as `!(ifs && *ifs)` (and,
7470        // unlike c:1819's SH_WORD_SPLIT form, with no `!qt` term).
7471        let nojoin = spbreak != 0 && !matches!(ifs_opt.as_deref(), Some(s) if !s.is_empty());
7472        // c:3030-3032 — `if (isarr) { if (nojoin) isarr = -1; }`.
7473        if isarr != 0 && nojoin {
7474            isarr = -1;
7475        }
7476        // c:3033-3036 — `if (qt && !getlen && isarr > 0) { val = sepjoin(aval,
7477        // sep, 1); isarr = 0; }`. This is why `"${==a}"` with a non-empty IFS
7478        // is ONE joined word while the unquoted form keeps its elements.
7479        if in_dq && isarr > 0 {
7480            return Value::str(crate::ported::utils::sepjoin(&elems, None));
7481        }
7482        // c:3912-3930 — the gate is open only for `${=…}` here (`ssub`,
7483        // `spsep`, `sep` and the quoted-with-offset case never reach this
7484        // spelling), and `force_split` is 1, so `join_c3914` is c:3916 and
7485        // c:3919 exactly. c:3931's `sepsplit` is BUILTIN_FORCE_SPLIT at the
7486        // compile site, which is why this stops at the join.
7487        if spbreak != 0 && isarr != 0 {
7488            return match join_c3914(elems, ifs_opt.as_deref()) {
7489                JoinC3914::Joined(val) => Value::str(val), // c:3917 / c:3925
7490                // Neither join arm fired (IFS set but EMPTY), so `isarr` is
7491                // still non-zero at c:3931 and the split does not run either —
7492                // BUILTIN_FORCE_SPLIT's splice bit passes the array through.
7493                JoinC3914::Elements(kept) => {
7494                    Value::array(kept.into_iter().map(Value::str).collect())
7495                }
7496            };
7497        }
7498        if isarr != 0 {
7499            // `${==NAME}` on an array: the block never ran, so the element
7500            // vector is the answer. c:184-187's empty-word removal is the
7501            // BUILTIN_ARRAY_DROP_EMPTY the compile site emits after this.
7502            return Value::array(elems.into_iter().map(Value::str).collect());
7503        }
7504        // c:2927 / c:3904 — a scalar (or the one-element array c:3899
7505        // collapsed): `isarr == 0`, so no join arm applies and c:3931 splits
7506        // this text directly.
7507        Value::str(elems.into_iter().next().unwrap_or_default())
7508    });
7509
7510    vm.register_builtin(BUILTIN_BRACE_EXPAND, |vm, _argc| {
7511        // c:Src/glob.c::xpandbraces — brace expansion runs per word.
7512        // When the upstream produced an array (e.g. `${a:e}` splat),
7513        // expand braces on each element separately so the splat
7514        // survives. `pop().to_str()` would join with space and lose
7515        // the array shape. Parity bug #28 cousin: the BRACE_EXPAND
7516        // emit always fires for any word containing `{` (including
7517        // `${...}` param-expansion braces), so its collapse hit even
7518        // pure-paramsubst args.
7519        let raw = vm.pop();
7520        // Brace expansion runs BETWEEN the expansion builtin that produced
7521        // this word and the concat that consumes it (`x${${P}}y` compiles to
7522        // EXPAND_TEXT, BRACE_EXPAND, CONCAT_DISTRIBUTE). It cannot change
7523        // whether an empty result was scalar- or array-SHAPED — c:Src/glob.c
7524        // xpandbraces only ever rewrites the text of existing words. But both
7525        // exits below funnel through `nodes_to_value`, which records
7526        // `note_empty_is_scalar(false)` for an empty result, so the shape bit
7527        // its producer set was being overwritten with "array" before
7528        // `concat_plan9` could read it. Under RC_EXPAND_PARAM that deleted
7529        // words zsh keeps: `unset P; x${${P}}y` and `x$(true)y` are each the
7530        // single word `xy` (c:4438-4467 scalar arm), not zero words.
7531        // Carry the incoming bit across an empty→empty pass-through.
7532        let incoming_empty_is_scalar = empty_is_scalar();
7533        // c:Src/options.c — `no_brace_expand` (negated braceexpand)
7534        // disables brace expansion entirely. When set, `{a,b}` stays
7535        // literal. Mirror by short-circuiting xpandbraces; pass the
7536        // input through unchanged.
7537        let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
7538        let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
7539        // c:Src/glob.c xpandbraces rewrites word TEXT; it never turns a
7540        // scalar word into an array one. Remember the incoming shape so a
7541        // scalar that brace-expands to exactly one word stays SCALAR:
7542        // nodes_to_value collapses a lone EMPTY node to zero words unless
7543        // in_dq_context > 0, and BUILTIN_EXPAND_TEXT has already decremented
7544        // that by the time this runs — so a quoted word whose expansion came
7545        // out empty, and which carries the Inbrace token so it reaches this
7546        // builtin at all, lost its argument entirely.
7547        let raw_was_scalar = !matches!(raw, Value::Array(_));
7548        let inputs: Vec<String> = match raw {
7549            Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
7550            other => vec![other.to_str()],
7551        };
7552        if !brace_expand {
7553            return restore_empty_shape(nodes_to_value(inputs), incoming_empty_is_scalar);
7554        }
7555        let mut out: Vec<String> = Vec::with_capacity(inputs.len());
7556        for s in inputs {
7557            for w in crate::ported::glob::xpandbraces(&s, brace_ccl) {
7558                // bash: a brace alternative that comes out EMPTY and unquoted
7559                // is a null word and is removed (`echo {,a}` -> `a`); zsh
7560                // keeps it.
7561                if w.is_empty() && crate::dash_mode::bash_mode() {
7562                    continue;
7563                }
7564                out.push(w);
7565            }
7566        }
7567        if raw_was_scalar && out.len() == 1 {
7568            let mut only = out.into_iter().next().unwrap();
7569            crate::ported::glob::remnulargs(&mut only);
7570            return Value::str(only);
7571        }
7572        restore_empty_shape(nodes_to_value(out), incoming_empty_is_scalar)
7573    });
7574
7575    // `*(qual)` glob qualifier filter. Stack: [pattern, qualifier].
7576    // Pattern is glob-expanded normally, then each result is filtered by the
7577    // qualifier predicate. Common qualifiers:
7578    //   .  — regular files only
7579    //   /  — directories only
7580    //   @  — symlinks
7581    //   x  — executable
7582    //   r/w/x — readable/writable/executable
7583    //   N  — nullglob (no error if no match)
7584    //   L+N / L-N — size > N / size < N (in bytes)
7585    //   mh-N / mh+N — modified within N hours / older than N hours
7586    //   md-N / md+N — modified within N days / older than N days
7587    //   on/On — sort by name asc/desc (default)
7588    //   oL/OL — sort by length
7589    //   om/Om — sort by mtime
7590    // Pop a scalar pattern, run expand_glob, push Value::Array. Used
7591    // by the segment-concat compile path for `$D/*`-style words.
7592    vm.register_builtin(BUILTIN_GLOB_EXPAND, |vm, _argc| {
7593        // c:Src/glob.c:1872 — honour `setopt noglob` / `noglob CMD`
7594        // precommand. When the option is on, the word stays literal
7595        // (zsh skips the glob expansion entirely). Without this, the
7596        // segment-fast-path BUILTIN_GLOB_EXPAND fired even after
7597        // `noglob` set the option, so `noglob echo *.xyz` saw the
7598        // NOMATCH error instead of the literal pass-through.
7599        let raw = vm.pop();
7600        let noglob =
7601            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
7602        glob_word_noting_errflag(raw, noglob)
7603    });
7604    // Redirect-target variant of BUILTIN_GLOB_EXPAND. c:Src/glob.c:
7605    // 2161-2167 xpandredir — `prefork(&fake, isset(MULTIOS) ? 0 :
7606    // PREFORK_SINGLE, NULL)` then "Globbing is only done for
7607    // multios.": a redirect target word is only globbed when the
7608    // MULTIOS option is set. With it unset, `echo hi > *.txt`
7609    // creates the literal file `*.txt`, and `wc -c < *.txt` errors
7610    // "no such file or directory: *.txt". Bug #36 follow-up in
7611    // docs/BUGS.md.
7612    vm.register_builtin(BUILTIN_REDIR_GLOB_EXPAND, |vm, _argc| {
7613        let raw = vm.pop();
7614        let noglob =
7615            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
7616        let multios = opt_state_get("multios").unwrap_or(true);
7617        // c:Src/glob.c:2164-2166 — `in_expandredir = 1; globlist(&fake, 0);
7618        // in_expandredir = 0;`. The flag is what lets `zglob`'s no-match
7619        // dispatch (c:1888-1894) tell a redirect target apart from an
7620        // ordinary word: under NULL_GLOB an ordinary word is dropped, but
7621        // a redirect still needs exactly one target, so the empty result
7622        // is `redirection failed (no match)` instead.
7623        crate::ported::glob::IN_EXPANDREDIR.store(1, std::sync::atomic::Ordering::SeqCst); // c:2164
7624        let out = glob_expand_word_value(raw, noglob || !multios); // c:2165
7625        crate::ported::glob::IN_EXPANDREDIR.store(0, std::sync::atomic::Ordering::SeqCst); // c:2166
7626        out
7627    });
7628    // Clear the default-word glob-pending carrier before the word's
7629    // expansion runs, so a flag set by a prior word never leaks in.
7630    vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB_RESET, |_vm, _argc| {
7631        crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| c.set(false));
7632        crate::ported::subst::DEFAULT_WORD_GLOBSUBST_OFF.with(|c| c.set(false));
7633        crate::ported::subst::DEFAULT_WORD_GLOB_BARS.with(|b| b.borrow_mut().clear());
7634        Value::Status(0)
7635    });
7636    // After the word is assembled, run filename generation ONLY if the
7637    // default/alternate paramsubst arm flagged a source-glob default
7638    // (DEFAULT_WORD_GLOB_PENDING). Otherwise pass the word through
7639    // literally — a parameter VALUE must not glob. c:Src/subst.c globlist.
7640    vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB, |vm, _argc| {
7641        let raw = vm.pop();
7642        let pending = crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| {
7643            let v = c.get();
7644            c.set(false); // read + clear
7645            v
7646        });
7647        let bars = crate::ported::subst::DEFAULT_WORD_GLOB_BARS.with(|b| std::mem::take(&mut *b.borrow_mut()));
7648        if !pending {
7649            return raw;
7650        }
7651        let noglob =
7652            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
7653        if bars.is_empty() || noglob {
7654            return glob_expand_word_value(raw, noglob);
7655        }
7656        // c:Src/lex.c:1000-1008 + c:Src/subst.c:3207-3228 — the default word's
7657        // source `|` is a Bar token in the assembled word, so globlist reads a
7658        // top-level alternation (`${x:-b|a}` matches the file `b`;
7659        // `X${x:-b|a}` is the pattern `Xb` | `a`). Re-spell those `|`s from
7660        // the carrier before globbing, and put them back to `|` in a word the
7661        // glob hands back literally.
7662        let rebar = |s: String| -> String {
7663            let mut s = s;
7664            for (plain, barred) in &bars {
7665                if let Some(at) = s.find(plain.as_str()) {
7666                    s.replace_range(at..at + plain.len(), barred);
7667                }
7668            }
7669            s
7670        };
7671        let barred = match raw {
7672            Value::Array(items) => {
7673                Value::array(items.iter().map(|v| Value::str(rebar(v.to_str()))).collect())
7674            }
7675            other => Value::str(rebar(other.to_str())),
7676        };
7677        match glob_expand_word_value(barred, false) {
7678            Value::Array(items) => Value::array(
7679                items
7680                    .iter()
7681                    .map(|v| Value::str(crate::ported::lex::untokenize(&v.to_str())))
7682                    .collect(),
7683            ),
7684            other => Value::str(crate::ported::lex::untokenize(&other.to_str())),
7685        }
7686    });
7687
7688    // `break`/`continue` from a sub-VM body. The compile path emits
7689    // these when the keyword appears at chunk top-level (no enclosing
7690    // for/while in the current chunk's patch lists). Outer-loop
7691    // builtins (BUILTIN_RUN_SELECT and any future loop-via-builtin
7692    // construct) drain canonical BREAKS/CONTFLAG after each iteration.
7693    //
7694    // Writes match `bin_break`'s c:5836+ pattern:
7695    //   continue: contflag = 1; breaks++   (Src/builtin.c::bin_break)
7696    //   break:    breaks++
7697    vm.register_builtin(BUILTIN_SET_BREAK, |_vm, _argc| {
7698        use std::sync::atomic::Ordering::SeqCst;
7699        crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
7700        Value::Status(0)
7701    });
7702    vm.register_builtin(BUILTIN_SET_CONTINUE, |_vm, _argc| {
7703        use std::sync::atomic::Ordering::SeqCst;
7704        crate::ported::builtin::CONTFLAG.store(1, SeqCst);
7705        crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
7706        Value::Status(0)
7707    });
7708
7709    // `break N`/`continue N` with a RUNTIME level count. Pops [count,
7710    // name]; math-evaluates count (c:builtin.c:5811 `mathevali`); on
7711    // count <= 0 emits `argument is not positive: N` via zerrnam (sets
7712    // errflag → abort, c:5813) and pushes Int(0) (matches no jump-table
7713    // entry → control falls through to the errflag abort). Otherwise
7714    // pushes Int(count) for the compiled jump table to dispatch on.
7715    vm.register_builtin(BUILTIN_BREAK_COUNT_VALIDATE, |vm, _argc| {
7716        let name = vm.pop().to_str();
7717        let count_s = vm.pop().to_str();
7718        // c:5816 — mathevali zerr's a bad expression itself; its errflag
7719        // then suppresses the zerrnam below.
7720        let count = match crate::ported::math::mathevali(&count_s) {
7721            Ok(n) => n,
7722            Err(msg) => {
7723                crate::ported::utils::zerr(&msg);
7724                0
7725            }
7726        };
7727        if count <= 0 {
7728            crate::ported::utils::zerrnam(&name, &format!("argument is not positive: {count}"));
7729            return Value::Int(0);
7730        }
7731        Value::Int(count)
7732    });
7733
7734    // `${arr[*]}` — join array elements with the first IFS char into
7735    // a single string. Matches zsh: in DQ context this preserves the
7736    // join; in array context too the result is one Value::Str.
7737    // Set or clear a shell option directly. Used by `noglob CMD ...`
7738    // precommand wrapping — the compiler emits SET_RAW_OPT to flip the
7739    // option ON before compiling the inner words and OFF after, so glob
7740    // expansion of the inner args sees the temporary state.
7741    vm.register_builtin(BUILTIN_SET_RAW_OPT, |vm, _argc| {
7742        let on = vm.pop().to_int() != 0;
7743        let opt = vm.pop().to_str();
7744        // Pure passthru: canonical port lives in
7745        // src/ported/options.rs::opt_state_set_via_alias and
7746        // handles negation-alias resolution per c:Src/options.c.
7747        crate::ported::options::opt_state_set_via_alias(&opt, on);
7748        Value::Status(0)
7749    });
7750
7751    // c:Src/options.c GLOB_SUBST — runtime glob expansion of
7752    // substituted words. Pop a Value (Str or Array); when
7753    // GLOB_SUBST is ON, run expand_glob on each string element;
7754    // when OFF, pass through unchanged. Bug #119 in docs/BUGS.md.
7755    vm.register_builtin(BUILTIN_GLOB_SUBST_EXPAND, |vm, _argc| {
7756        let raw = vm.pop();
7757        // c:Src/subst.c:3231-3233 — after a default / alternate word C runs
7758        // `if (globsubst != 2) globsubst = 0;`, so the finished expansion is
7759        // not shtokenized and only glob tokens an UNQUOTED inner substitution
7760        // produced survive into it. The paramsubst arm reports both halves
7761        // (subst::DEFAULT_WORD_GLOBSUBST_OFF and DEFAULT_WORD_GLOB_PENDING);
7762        // take them for this word: `setopt globsubst; foo='boring*'`
7763        // `${foo+"$foo"}` stays `boring*`, `${foo+$foo}` still globs.
7764        let default_off = crate::ported::subst::DEFAULT_WORD_GLOBSUBST_OFF.with(|c| c.replace(false));
7765        let default_pending = default_off
7766            && crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| c.replace(false));
7767        let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
7768        if !glob_subst || (default_off && !default_pending) {
7769            return raw;
7770        }
7771        // Collect input strings (Str → vec![s]; Array → multiple).
7772        let inputs: Vec<String> = match raw {
7773            Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
7774            other => vec![other.to_str()],
7775        };
7776        // Run expand_glob on each. Empty matches collapse to a
7777        // single literal pass-through to mirror nullglob-off default.
7778        let mut out: Vec<String> = Vec::with_capacity(inputs.len());
7779        for pattern in inputs {
7780            // c:Src/subst.c — GLOB_SUBST subjects the value to the FULL
7781            // filename-generation pipeline: `filesub` (tilde/`=` expansion)
7782            // BEFORE globbing (prefork runs filesub then globlist). zshrs
7783            // globbed but skipped filesub, so `${~x}` / `setopt globsubst`
7784            // left `~/foo` un-expanded. filesubstr matches the Tilde TOKEN,
7785            // so shtokenize the value first (`~`→Tilde, glob metas active),
7786            // run filesub, then untokenize the surviving glob metas back to
7787            // raw for expand_glob (which re-tokenizes internally).
7788            // c:178 — that filesub is prefork's SECOND pass, gated on
7789            // `unset(SHFILEEXPANSION)`: with SH_FILE_EXPANSION the file
7790            // expansion ran before substitution, so a substituted `~`/`=`
7791            // stays literal.
7792            let pattern = if (pattern.contains('~') || pattern.contains('='))
7793                && !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHFILEEXPANSION)
7794            {
7795                let mut tok = pattern.clone();
7796                crate::ported::glob::shtokenize(&mut tok);
7797                let fs = crate::ported::subst::filesub(&tok, 0);
7798                crate::ported::lex::untokenize(&fs)
7799            } else {
7800                pattern
7801            };
7802            // c:Src/glob.c:1872-1886 — `expand_glob` already owns the whole
7803            // no-match dispatch: NULL_GLOB drops the word, NOMATCH errors, and
7804            // only with both off does it hand the literal back. Pushing the
7805            // literal again on an empty result undid NULL_GLOB and kept a
7806            // word whose filesub had failed, so under `_expand`'s options
7807            // `${~exp//…}` of `~[]` "expanded" to itself (Y01completion #10).
7808            out.extend(with_executor(|exec| exec.expand_glob(&pattern)));
7809        }
7810        if out.len() == 1 {
7811            Value::str(out.into_iter().next().unwrap())
7812        } else {
7813            Value::array(out.into_iter().map(Value::str).collect())
7814        }
7815    });
7816
7817    // c:Src/math.c:336-364 — `getmathparam` for ArithCompiler pre-load.
7818    // Pop a variable name, return its math value.
7819    //
7820    // This used to be a second, smaller getmathparam: `getsparam` then
7821    // `parse::<i64>` / `parse::<f64>` / `mathevali`. Two consequences,
7822    // both invisible until you compared the two arithmetic backends on
7823    // the same expression. It had no FORCEFLOAT coercion (c:359-362) and
7824    // no `unset(UNSET)` diagnostic (c:345-346), so which of `setopt
7825    // force_float` and `set -u` applied to `$(( x ))` depended on
7826    // whether `compile_arith` had routed the expression to the
7827    // ArithCompiler or to BUILTIN_ARITH_EVAL. And it re-derived a
7828    // typed param's value from `getsparam`'s printed form, the same
7829    // convbase round-trip c:2641 exists to avoid.
7830    //
7831    // There is one `getmathparam` in C; there is one here now.
7832    vm.register_builtin(BUILTIN_GET_MATH_VAR, |vm, _argc| {
7833        let name = vm.pop().to_str();
7834        let n = crate::ported::math::getmathparam(&name); // c:337
7835        if n.type_ == crate::ported::zsh_h::MN_FLOAT {
7836            Value::Float(n.d)
7837        } else {
7838            Value::Int(n.l)
7839        }
7840    });
7841
7842    // ── Compiled-arithmetic runtime ops ───────────────────────────────
7843    //
7844    // c:Src/math.c:1156 `op(int what)` — the arms `Op::Div` / `Op::Mod` /
7845    // `Op::Pow` cannot express, plus the two write-back / finish helpers
7846    // the compiled `(( … ))` path needs. Each pops its operands in C's
7847    // order: for `a OP b` the stack holds \[a, b\], so `b` pops first.
7848
7849    vm.register_builtin(BUILTIN_ARITH_DIV, |vm, _argc| {
7850        let b = arith_pop_mnumber(vm);
7851        let a = arith_pop_mnumber(vm);
7852        arith_push(arith_div(a, b)) // c:1237-1257
7853    });
7854
7855    vm.register_builtin(BUILTIN_ARITH_MOD, |vm, _argc| {
7856        let b = arith_pop_mnumber(vm);
7857        let a = arith_pop_mnumber(vm);
7858        arith_push(arith_mod(a, b)) // c:1258-1272
7859    });
7860
7861    vm.register_builtin(BUILTIN_ARITH_POW, |vm, _argc| {
7862        let b = arith_pop_mnumber(vm);
7863        let a = arith_pop_mnumber(vm);
7864        arith_push(arith_pow(a, b)) // c:1335-1358
7865    });
7866
7867    // c:Src/math.c:1200 — the `BOOL` operators yield an INTEGER 1/0
7868    // whatever their operands were.
7869    vm.register_builtin(BUILTIN_ARITH_LOGAND, |vm, _argc| {
7870        let b = arith_pop_mnumber(vm);
7871        let a = arith_pop_mnumber(vm);
7872        Value::Int(i64::from(mn_is_true(a) && mn_is_true(b)))
7873    });
7874    vm.register_builtin(BUILTIN_ARITH_LOGOR, |vm, _argc| {
7875        let b = arith_pop_mnumber(vm);
7876        let a = arith_pop_mnumber(vm);
7877        Value::Int(i64::from(mn_is_true(a) || mn_is_true(b)))
7878    });
7879    vm.register_builtin(BUILTIN_ARITH_LOGXOR, |vm, _argc| {
7880        let b = arith_pop_mnumber(vm);
7881        let a = arith_pop_mnumber(vm);
7882        Value::Int(i64::from(mn_is_true(a) != mn_is_true(b)))
7883    });
7884
7885    // c:Src/math.c:972 setmathvar — stack is \[name, value, pval\]; pval
7886    // (argc 3) says the operator read the lvalue first (c:975-993).
7887    vm.register_builtin(BUILTIN_SET_MATH_VAR, |vm, argc| {
7888        let pval = argc >= 3 && vm.pop().to_int() != 0;
7889        let value = arith_pop_mnumber(vm);
7890        let name = vm.pop().to_str();
7891        // c:Src/math.c:1161 — `op()` begins `if (errflag) return;`, so an
7892        // assignment operator reached after a math error (`x %= 0`, the
7893        // `y = 3` of `(( x = 5/0, y = 3 ))`) stores nothing.
7894        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
7895            return arith_push(value);
7896        }
7897        // c:Src/math.c:395 `xstack = stack` / c:455 `stack = xstack` —
7898        // `setmathvar` also memoises the value in the math-local read
7899        // cache (`mptr->pval`, c:340-343), which in C belongs to the
7900        // evaluation FRAME and dies with it. The compiled path has no
7901        // such frame, so without this save/restore the entry outlives
7902        // the expression: a later `sum=0` would update the parameter
7903        // while `getmathparam` kept answering from the cache, and the
7904        // next `(( sum += n ))` would resume from the stale total.
7905        // Every setmathvar write goes through to the parameter table
7906        // (`setnparam`, c:1005), so dropping the memo loses nothing but
7907        // one paramtab read. Saving rather than clearing keeps the
7908        // entries of an enclosing `matheval` frame intact, which is
7909        // what c:455 restores.
7910        let saved = crate::ported::math::m_variables_clone();
7911        // c:Src/math.c:1370-1371 — `c = setmathvar(mvp, c); push(c, …)`:
7912        // the stored value, converted to the parameter's type
7913        // (c:1014-1030), is the value of the assignment.
7914        let stored = crate::ported::math::setmathvar(&name, value, pval);
7915        crate::ported::math::m_variables_set(saved);
7916        arith_push(stored)
7917    });
7918
7919    // c:Src/exec.c:5267 execarith tail + c:Src/math.c:1500 lastmathval.
7920    vm.register_builtin(BUILTIN_ARITH_CMD_FINISH_VAL, |vm, _argc| {
7921        use std::sync::atomic::Ordering;
7922        let val = arith_pop_mnumber(vm);
7923        // c:1500 `lastmathval = z` — recorded even when the value is
7924        // only used for the exit status, because a `functions -M` math
7925        // function returns whatever its last `(( … ))` left here
7926        // (c:1115).
7927        crate::ported::math::M_LASTMATHVAL.with(|c| c.set(val));
7928        let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
7929        if live & crate::ported::zsh_h::ERRFLAG_ERROR != 0 {
7930            // Same split as BUILTIN_ARITH_CMD_FINISH: a HARD error
7931            // (`${v:?msg}`) must keep aborting the script, a soft math
7932            // error is consumed here so the next statement still runs.
7933            if live & crate::ported::zsh_h::ERRFLAG_HARD == 0 {
7934                crate::ported::utils::errflag
7935                    .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
7936            }
7937            vm.last_status = 2;
7938            return Value::Status(2);
7939        }
7940        // c:5267 — `(( 0 ))` is false, anything else is true.
7941        let status = i32::from(!mn_is_true(val));
7942        vm.last_status = status;
7943        Value::Status(status)
7944    });
7945
7946    // c:Src/subst.c:822/830 `if (glbsub) shtokenize(dest)` for the
7947    // `${~spec}` / `$~spec` FLAG, where the compiler emits no
7948    // GLOB_SUBST guard because the metas are meant to be active. Only
7949    // the value's backslashes still need settling — c:Src/glob.c:3651
7950    // leaves the ones before a non-`ztokens` char as data.
7951    // See BUILTIN_PAT_DATA_BACKSLASH docs below for full rationale.
7952    vm.register_builtin(BUILTIN_PAT_DATA_BACKSLASH, |vm, _argc| {
7953        let p = vm.pop().to_str();
7954        // The word arrives in EXPAND_TEXT mode 12's tokenized form (see the
7955        // untokenize there). This leg wants every metacharacter ACTIVE anyway
7956        // — `${~spec}` forced them — so both spellings collapse to the same
7957        // raw character here, exactly as they did when mode 9 untokenized one
7958        // step earlier.
7959        let p = crate::lex::untokenize(&p);
7960        let p = crate::pattern_data_escape::escape_data_backslashes(&p);
7961        // c:Src/glob.c:3575-3580 + 3617-3620 — the `shtokenize` this
7962        // builtin stands in for adds ZSHTOK_SHGLOB under SH_GLOB, and
7963        // that flag makes `(`, `|` and `)` of the VALUE stay ordinary
7964        // characters. Without this the value's parens reach patcompile
7965        // as live metacharacters and `emulate ksh; p='-([AMO]*|[0CRSWnsw])';
7966        // [[ "-s" = $~p ]]` becomes a bad pattern where zsh just fails
7967        // to match.
7968        if crate::pattern_data_escape::shglob_hides_parens() {
7969            return Value::str(crate::pattern_data_escape::escape_shglob_parens(
7970                &p,
7971                crate::pattern_data_escape::dropin_keeps_ksh_groups(),
7972            ));
7973        }
7974        Value::str(p)
7975    });
7976
7977    // c:Src/options.c GLOB_SUBST + Src/cond.c:552 cond_match.
7978    // Pop pattern string; when GLOB_SUBST is OFF, escape every glob
7979    // metachar with `\` so the downstream StrMatch + patcompile
7980    // treat them as literals (matching C's tokenization-based
7981    // gate). When GLOB_SUBST is ON, only the data-backslash respelling
7982    // runs and the metas stay active.
7983    // See BUILTIN_GLOB_SUBST_GUARD docs below for full rationale.
7984    vm.register_builtin(BUILTIN_GLOB_SUBST_GUARD, |vm, argc| {
7985        // argc 2 = a trailing "quoted" flag: the value came from a double-quoted
7986        // substitution, which C never shtokenizes (c:Src/subst.c:822/830).
7987        let force_literal = argc >= 2 && vm.pop().to_int() != 0;
7988        let p = vm.pop().to_str();
7989        let glob_subst =
7990            !force_literal && crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
7991        if glob_subst {
7992            // c:Src/subst.c:822/830 `if (glbsub) shtokenize(dest)` — the
7993            // value's metas go ACTIVE, but c:Src/glob.c:3651 still leaves a
7994            // backslash before a non-`ztokens` char as ordinary data. Respell
7995            // those in the normalizer's literal-backslash form; the ones
7996            // `zshtokenize` WOULD fold into a quote are left alone.
7997            // docs/BUGS.md #1090.
7998            let p = crate::pattern_data_escape::escape_data_backslashes(&p);
7999            // c:Src/glob.c:3575-3580 + 3617-3620 — same `shtokenize` call,
8000            // so the ZSHTOK_SHGLOB suppression of `(` / `|` / `)` applies
8001            // to the GLOB_SUBST leg exactly as it does to the `${~spec}`
8002            // leg above.
8003            if crate::pattern_data_escape::shglob_hides_parens() {
8004                return Value::str(crate::pattern_data_escape::escape_shglob_parens(
8005                    &p,
8006                    crate::pattern_data_escape::dropin_keeps_ksh_groups(),
8007                ));
8008            }
8009            return Value::str(p);
8010        }
8011        // The metacharacters that have to be spelled `\X` to reach
8012        // `ported::pattern`'s normalizer as ordinary characters.
8013        //
8014        // c:Src/lex.c:1390-1404 — `-` / `!` are Dash / Bang TOKENS only when
8015        // the LEXER sees them unquoted; pattern.c's range parser (c:1483) and
8016        // negation test look for the tokens, so a SUBSTITUTED `-` / `!` must
8017        // stay an ordinary character with GLOB_SUBST off. Without those two,
8018        // `cset='^a-z'; [[ - = ["$cset"] ]]` built a live `a-z` range out of
8019        // substituted text.
8020        fn is_pattern_meta(c: char) -> bool {
8021            matches!(
8022                c,
8023                '*' | '?'
8024                    | '['
8025                    | ']'
8026                    | '('
8027                    | ')'
8028                    | '|'
8029                    | '<'
8030                    | '>'
8031                    | '#'
8032                    | '^'
8033                    | '~'
8034                    | '-'
8035                    | '!'
8036                    | '\\'
8037            )
8038        }
8039        // The character an ACTIVE metacharacter token stands for, or None for
8040        // anything that is not one.
8041        //
8042        // c:Src/glob.c:3640-3645 — `shtokenize` arms a metacharacter by
8043        // rewriting it to `(t - ztokens) + Pound`; c:Src/lex.c:38 `ztokens` is
8044        // the table that maps back. c:Src/zsh.h:159-183 is the metacharacter
8045        // range (Pound 0x84 … Bang 0x9c); c:Src/zsh.h:193-206's Snull / Dnull /
8046        // Bnull / Bnullkeep / Nularg are quote and null MARKERS, never
8047        // metacharacters, so they are excluded here and travel with the
8048        // literal run below — where `untokenize` gives them the same reading
8049        // they had when it ran one step earlier (Bnull → `\`, which this pass
8050        // then escapes to the normalizer's literal-backslash `\\`).
8051        fn active_pattern_token(c: char) -> Option<char> {
8052            let cu = c as u32;
8053            if !(0xe184..=0xe19c).contains(&cu) {
8054                return None;
8055            }
8056            let m = crate::ported::lex::ztokens
8057                .chars()
8058                .nth((cu - 0xe184) as usize)?;
8059            is_pattern_meta(m).then_some(m)
8060        }
8061        // !!! RUST-ONLY HELPER — no C counterpart. C needs no such pass: its
8062        // `patcompile` consumes the token encoding itself (c:Src/pattern.c:248
8063        // — `zpc_chars` holds Star / Quest / Inbrack / …, so a RAW `*` never
8064        // dispatches as ZPC_STAR and is literal by construction). zshrs's
8065        // `ported::pattern` normalizer uses a raw-ASCII encoding whose literal
8066        // form is `\X`, so C's one-byte ACTIVE-vs-DATA distinction has to be
8067        // re-SPELLED: a metacharacter `shtokenize` armed becomes the bare
8068        // character, a metacharacter that is a raw byte of a VALUE becomes
8069        // `\X`. A pattern word holds both at once — c:Src/subst.c:1551-1552
8070        // tokenizes the `(~j)` join SEPARATOR while the array ELEMENTS it joins
8071        // keep their raw bytes — which is why escaping the word wholesale (or
8072        // not at all) is wrong in both directions.
8073        //
8074        // Non-token text is untokenized in RUNS rather than per character, so
8075        // `untokenize`'s multi-character decodes still see their whole input:
8076        // the `Meta` pair (lex.rs's metafied high byte) and the
8077        // `Qstring Snull` … `Snull` span of a `$'…'` literal
8078        // (c:Src/subst.c:301-304).
8079        fn flush_literal_run(lit: &mut String, out: &mut String) {
8080            if lit.is_empty() {
8081                return;
8082            }
8083            for c in crate::lex::untokenize(lit).chars() {
8084                if is_pattern_meta(c) {
8085                    out.push('\\');
8086                }
8087                out.push(c);
8088            }
8089            lit.clear();
8090        }
8091        let mut out = String::with_capacity(p.len() * 2);
8092        let mut lit = String::new();
8093        let cs: Vec<char> = p.chars().collect();
8094        let mut i = 0;
8095        while i < cs.len() {
8096            let c = cs[i];
8097            // c:Src/glob.c:3593-3596 `case Meta: s++;` — `Meta` and the
8098            // character after it are ONE unit and neither is a token.
8099            if c as u32 == crate::ported::zsh_h::Meta as u32 && i + 1 < cs.len() {
8100                lit.push(c);
8101                lit.push(cs[i + 1]);
8102                i += 2;
8103                continue;
8104            }
8105            match active_pattern_token(c) {
8106                Some(m) => {
8107                    flush_literal_run(&mut lit, &mut out);
8108                    out.push(m);
8109                }
8110                None => lit.push(c),
8111            }
8112            i += 1;
8113        }
8114        flush_literal_run(&mut lit, &mut out);
8115        Value::str(out)
8116    });
8117
8118    // argc contract — the caller names the `spbreak` (c:Src/subst.c:1707,
8119    // :2563, :2567) its spelling implies, because c:3912's whole join/split
8120    // block is gated on it:
8121    //
8122    //   0  plain `${NAME[*]}`, unquoted and not `ssub`. `spbreak` is
8123    //      SH_WORD_SPLIT, read at RUN time (the option can be set by an
8124    //      earlier statement in the same compiled unit).
8125    //   1  "JOIN and STOP" — a PREFORK_SINGLE (`ssub`) context: a scalar
8126    //      assignment RHS (c:Src/exec.c:2603), a `typeset NAME=…` argument, a
8127    //      `case` word (c:Src/loop.c:611), a `[[ … ]]` operand
8128    //      (c:Src/cond.c:53) or the here-string word (c:Src/exec.c:4711).
8129    //      c:Src/subst.c:4226
8130    //        if (isarr && ssub) { val = sepjoin(aval, NULL, 1); isarr = 0; }
8131    //      "prefork() wants a scalar, so join no matter what else" — the join
8132    //      is the LAST thing that happens to the value, and `singsub`
8133    //      (c:Src/subst.c:514-525) never reaches `globlist`, so nothing splits
8134    //      it afterwards. c:3913's `force_split = !ssub && …` says the same.
8135    //   2  `${==NAME[*]}` — c:2563 clears `spbreak`, so the gate stays SHUT
8136    //      whatever SH_WORD_SPLIT says and the element vector survives.
8137    //   3  `${=NAME[*]}` / `${=NAME[@]}` — c:2567 sets `spbreak = 2`. c:3917
8138    //      joins and c:3932 `sepsplit` splits, but that split is
8139    //      BUILTIN_FORCE_SPLIT at the compile site, so this handler stops at
8140    //      the join rather than splitting twice. When c:3914-3927 leaves the
8141    //      value ARRAY-shaped it returns the array and FORCE_SPLIT's c:3931
8142    //      `!isarr` gate passes it through.
8143    vm.register_builtin(BUILTIN_ARRAY_JOIN_STAR, |vm, argc| {
8144        let join_no_split = argc == 1;
8145        let name = vm.pop().to_str();
8146        let (elems, joined, ifs_opt, in_dq) = with_executor(|exec| {
8147            // c:Src/params.c — `"$*"` joins by IFS[0]. zsh
8148            // distinguishes IFS=unset (→ default `" "`) from
8149            // IFS="" (→ EMPTY separator → fields concatenate).
8150            // chars().next() collapsed both into the default, so
8151            // IFS="" was treated as IFS=" ".
8152            // c:Src/params.c:4745 `ifssetfn` — the C `ifs` global is NULL only
8153            // when IFS is genuinely UNSET, and c:3919's `(!ifs && …)` turns on
8154            // exactly that distinction, so carry the Option, not a defaulted
8155            // string.
8156            let ifs_opt = exec.scalar("IFS");
8157            let ifs_full = ifs_opt
8158                .clone()
8159                .unwrap_or_else(|| " \t\n".to_string());
8160            let sep = ifs_full
8161                .chars()
8162                .next()
8163                .map(|c| c.to_string())
8164                .unwrap_or_default();
8165            let in_dq = exec.in_dq_context > 0;
8166            // c:Src/subst.c:3914 — the join at c:3917 is `sepjoin(aval, …)`,
8167            // i.e. it runs on the ELEMENT vector, and c:3912's gate can leave
8168            // that vector untouched. Keep both shapes: the joined scalar for
8169            // the quoted / join-and-stop arms, and the elements for the plain
8170            // command-argument arm that must not join at all.
8171            let (elems, _) = param_value_elems_c2916(exec, &name);
8172            let joined = elems.join(&sep);
8173            (elems, joined, ifs_opt, in_dq)
8174        });
8175        // In QUOTED (`"${name[*]}"`) context the result IS a single scalar —
8176        // c:Src/subst.c:3033 `if (qt && !getlen && isarr > 0) { val =
8177        // sepjoin(aval, sep, 1); isarr = 0; }`. And argc 1 is the caller
8178        // saying it wants the join without the split (see the contract above).
8179        if in_dq || join_no_split {
8180            return Value::str(joined);
8181        }
8182        // c:Src/subst.c:3912 — the join-then-split block is gated:
8183        //
8184        //     if (ssub || spbreak || spsep || sep || quoted_array_with_offset) {
8185        //         int force_split = !ssub && (spbreak || spsep);
8186        //
8187        // `sep` / `spsep` are the `(j:…:)` / `(s:…:)` separators, which never
8188        // reach this fast path (a flagged expansion goes to paramsubst), and
8189        // the join-and-stop and quoted arms returned above. So the only way in
8190        // left from here is `spbreak`, c:Src/subst.c:1707
8191        //     int spbreak = (pf_flags & PREFORK_SHWORDSPLIT)
8192        //                   && !(pf_flags & PREFORK_SINGLE) && !qt;
8193        // i.e. SH_WORD_SPLIT — or an explicit `${=…}` / `${==…}`, which the
8194        // argc contract above hands down because c:2563/:2567 override the
8195        // option. Without `spbreak` the block does not run at all and the
8196        // expansion keeps its ARRAY shape untouched — `${a[*]}` unquoted is the
8197        // same splat as `${a[@]}`, elements intact:
8198        //     a=("p:q" r); IFS=:; print -rl -- ${a[*]}     -> `p:q`  `r`
8199        //     a=("p q" r);        print -rl -- ${a[*]}     -> `p q`  `r`
8200        //     a=(x y);     IFS=;  print -rl -- ${a[*]}     -> `x`    `y`
8201        // This handler ran the join and the split unconditionally, so the
8202        // first two came back as three words and the third — an EMPTY IFS
8203        // joins with "" and then has no separator left to split on — as the
8204        // single word `xy`.
8205        //
8206        // The empty-word removal below is c:Src/subst.c:184-187 `uremnode`,
8207        // the same prefork pass the `[@]` splat gets from
8208        // BUILTIN_ARRAY_DROP_EMPTY at its compile site; the joined+split path
8209        // used to supply it as a side effect of its `filter`.
8210        let spbreak = match argc {
8211            // c:2563 — `${==NAME[*]}` clears spbreak whatever the option says.
8212            2 => 0,
8213            // c:2567 — `${=NAME[*]}` / `${=NAME[@]}` force it to 2.
8214            3 => 2,
8215            _ => i32::from(crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT)),
8216        };
8217        if spbreak == 0 {
8218            return splice_words_value(prefork_c184_drop_empty(elems));
8219        }
8220        match join_c3914(elems, ifs_opt.as_deref()) {
8221            // c:3914-3927 left `isarr` non-zero, so c:3931's `!isarr` keeps the
8222            // split off as well and the ORIGINAL elements are the answer.
8223            JoinC3914::Elements(kept) => {
8224                if argc == 3 {
8225                    // BUILTIN_FORCE_SPLIT runs next; its c:3931 gate passes an
8226                    // array through and applies the empty-word removal itself.
8227                    return Value::array(kept.into_iter().map(Value::str).collect());
8228                }
8229                splice_words_value(prefork_c184_drop_empty(kept))
8230            }
8231            JoinC3914::Joined(val) => {
8232                if argc == 3 {
8233                    // c:3932's `sepsplit` is BUILTIN_FORCE_SPLIT at the compile
8234                    // site — hand it the joined scalar rather than split twice.
8235                    return Value::str(val);
8236                }
8237                split_words_c3932(&val)
8238            }
8239        }
8240    });
8241
8242    // argc contract — `0` and `1` are the historical callers (the bare
8243    // `$@`/`$*`/`$argv` read, `${^NAME}`, a for-list `$NAME`, a QUOTED
8244    // `"${NAME[@]}"`), which take the splat exactly as read. `2` and `3` are
8245    // the `${NAME[@]}` / `${=NAME[@]}` splice fast paths asking for
8246    // c:Src/subst.c:3912-3939 to run over the result — see
8247    // BUILTIN_ARRAY_JOIN_STAR's contract, whose `0` and `3` these mirror.
8248    // c:3030-3032 forces `isarr = -1` for a `[*]` splice whenever `nojoin` is
8249    // set, so `[@]` and `[*]` need the SAME answer here and share the code.
8250    vm.register_builtin(BUILTIN_ARRAY_ALL, |vm, argc| {
8251        let name = vm.pop().to_str();
8252        // c:Src/params.c:2027-2029 — a `[@]`/`[*]` subscript sets
8253        // SCANPM_ISVAR_AT, i.e. `isarr != 0` (c:2915). An empty result is
8254        // therefore an empty ARRAY, and plan9 deletes the whole word
8255        // (c:4362) rather than keeping the surrounding text.
8256        //
8257        // The note describes the EMPTY value this expansion produces, so it
8258        // must not survive a NON-empty one: the bit is read by concat_plan9
8259        // when the word folds left-associatively, and a later non-empty
8260        // segment overwriting it made `setopt rcexpandparam; e=''; a=(x y);
8261        // print -rl -- ${e}${a}` delete the whole word instead of printing
8262        // `x` and `y` (c:4437 keeps the surrounding text for a scalar
8263        // empty; only c:4362's empty ARRAY deletes it). Restore the
8264        // incoming bit whenever the result is not an empty array.
8265        let saved_empty_is_scalar = empty_is_scalar();
8266        note_empty_is_scalar(false);
8267        let array_all = |vm: &mut fusevm::VM| -> Value {
8268            let _ = &vm;
8269            // bash `"${PIPESTATUS[@]}"` / `"${FUNCNAME[@]}"` / `"${BASH_VERSINFO[@]}"`
8270            // splat — alias the zsh-native special. No-op in --zsh.
8271            if let Some(v) = crate::dash_mode::bash_special_array(&name) {
8272                return Value::array(v.into_iter().map(Value::str).collect());
8273            }
8274            with_executor(|exec| {
8275                // Special positional names — splice the positional list.
8276                if name == "@" || name == "*" || name == "argv" {
8277                    return Value::array(exec.pparams().iter().map(Value::str).collect());
8278                }
8279                // c:Src/Modules/parameter.c — funcstack/funcfiletrace/
8280                // funcsourcetrace/functrace are PM_ARRAY|PM_READONLY
8281                // specials backed by the canonical FUNCSTACK Vec.
8282                // `${funcstack[@]}` inside a function call should splat
8283                // the innermost-first names; without this branch the
8284                // runtime fell to the scalar fallback (get_variable
8285                // returns empty for these specials) and `[@]` came out
8286                // empty. Bug #276 in docs/BUGS.md. Mirrors the parallel
8287                // arrays_get handler at src/ported/subst.rs ~10685.
8288                // c:Src/Modules/datetime.c:256 — `epochtime` PM_ARRAY|
8289                // PM_READONLY backed by getcurrenttime(). Same parallel
8290                // arrangement as the FUNCSTACK-backed specials below.
8291                if name == "epochtime" {
8292                    // c:Src/params.c:589-594 getparamnode → c:563-585 loadparamnode —
8293                    // the `[@]` splat resolves the NAME, clearing PM_AUTOLOAD so
8294                    // paramtypestr (c:Src/Modules/parameter.c:48-50) reports the real
8295                    // type. Mirrors the arrays_get arm in src/ported/subst.rs.
8296                    if !crate::vm_helper::magic_special_shadowed(&name) {
8297                        crate::vm_helper::mark_module_param_used(&name);
8298                    }
8299                    let arr = crate::ported::modules::datetime::getcurrenttime();
8300                    return Value::array(arr.into_iter().map(Value::str).collect());
8301                }
8302                if matches!(
8303                    name.as_str(),
8304                    "funcstack" | "funcfiletrace" | "funcsourcetrace" | "functrace"
8305                ) {
8306                    // c:Src/params.c:589-594 — see the epochtime arm above.
8307                    if !crate::vm_helper::magic_special_shadowed(&name) {
8308                        crate::vm_helper::mark_module_param_used(&name);
8309                    }
8310                    // Route the three trace arrays through the canonical
8311                    // ported getfns (Src/Modules/parameter.c:648/:679/:711)
8312                    // — the previous inline copy emitted wrong shapes
8313                    // (bare filename for funcfiletrace, `name:lineno` for
8314                    // functrace instead of `caller:lineno`); same dedup as
8315                    // the parallel arrays_get handler in subst.rs.
8316                    let vals: Vec<String> = match name.as_str() {
8317                        "funcstack" => crate::ported::modules::parameter::FUNCSTACK
8318                            .lock()
8319                            .map(|f| f.iter().rev().map(|fs| fs.name.clone()).collect())
8320                            .unwrap_or_default(),
8321                        "funcfiletrace" => crate::ported::modules::parameter::funcfiletracegetfn(
8322                            std::ptr::null_mut(),
8323                        ),
8324                        "funcsourcetrace" => {
8325                            crate::ported::modules::parameter::funcsourcetracegetfn(
8326                                std::ptr::null_mut(),
8327                            )
8328                        }
8329                        _ => {
8330                            crate::ported::modules::parameter::functracegetfn(std::ptr::null_mut())
8331                        }
8332                    };
8333                    return Value::array(vals.into_iter().map(Value::str).collect());
8334                }
8335                // c:Src/params.c — `${assoc[@]}` enumerates VALUES (per
8336                // params.c:1696-1750 hashparam splat). Check assoc
8337                // storage BEFORE the scalar fallback so an associative
8338                // array named X resolves `${X[@]}` to the values, not
8339                // empty. Bug #109 in docs/BUGS.md: `${h[@]}` on an
8340                // assoc routed through BUILTIN_ARRAY_ALL, which only
8341                // consulted `exec.array(name)` (the indexed-array map)
8342                // — that lookup missed for assocs, fell through to
8343                // `get_variable("h")` (also empty for an assoc-only
8344                // name), and returned `Array(vec![])`. zsh's expected
8345                // behavior is to enumerate values.
8346                if let Some(assoc_map) = exec.assoc(&name) {
8347                    return Value::array(assoc_map.values().cloned().map(Value::str).collect());
8348                }
8349                match exec.array(&name) {
8350                    Some(v) => {
8351                        // bash sparse arrays: `"${a[@]}"` splats only LIVE
8352                        // elements, dropping hole slots (`a[5]=q` padding,
8353                        // `unset a[i]`). No-op in --zsh (no holes tracked).
8354                        let v = crate::bash_arrays::compact(&name, v);
8355                        Value::array(v.iter().map(Value::str).collect())
8356                    }
8357                    None => {
8358                        // c:Src/Modules/parameter.c:2235-2298 partab[] — the
8359                        // PM_HASHED magic assocs (aliases/functions/parameters/
8360                        // options/commands/builtins/modules/widgets/nameddirs/…)
8361                        // are real hash params in C, so `${aliases[@]}` takes the
8362                        // ordinary getvaluearr path and enumerates their VALUES.
8363                        // zshrs keeps them OUT of the executor's assoc storage
8364                        // (they are synthesized on demand by `subst::assoc_get`),
8365                        // so the `exec.assoc` probe above missed and this arm fell
8366                        // through to the scalar fallback, which returned an EMPTY
8367                        // array: `alias foo=bar; print -r -- "${aliases[@]}"` gave
8368                        // nothing where zsh gives `bar man whence`. Every other
8369                        // form already routed through paramsubst's own magic-assoc
8370                        // arms; only the flagless `[@]`/`[*]` splat compiles to
8371                        // BUILTIN_ARRAY_ALL and reached here.
8372                        //
8373                        // Placed in the `None` arm so a real indexed array or a
8374                        // user-defined assoc of the same name still wins, and so
8375                        // no ordinary array read pays for the PARTAB scan.
8376                        //
8377                        // Same gap on the PM_ARRAY side (c:2239-2291 partab[]
8378                        // rows: reswords/dis_reswords/patchars/dis_patchars/…):
8379                        // `getaparam` reads `pm->u.arr`, which is NULL on the
8380                        // placeholder node zshrs installs for a getfn-backed
8381                        // special, so `${reswords[@]}` splatted nothing. Route
8382                        // through the canonical `arrays_get` getfn dispatch.
8383                        if let Some(arr) = crate::ported::subst::arrays_get(&name) {
8384                            return Value::array(arr.into_iter().map(Value::str).collect());
8385                        }
8386                        if let Some(map) = crate::ported::subst::assoc_get(&name) {
8387                            return Value::array(map.values().cloned().map(Value::str).collect());
8388                        }
8389                        // Fall back to scalar lookup. zsh (unlike bash)
8390                        // does NOT IFS-split a scalar variable in a for
8391                        // list — `for w in $scalar` iterates ONCE with the
8392                        // scalar value. Word-splitting requires either
8393                        // sh_word_split option or explicit `${(s.,.)scalar}`.
8394                        let val = exec.get_variable(&name);
8395                        if val.is_empty() && !exec.has_scalar(&name) && env::var(&name).is_err() {
8396                            // c:Src/subst.c:3480-3485 — `${arr[@]}` on a genuinely
8397                            // UNSET parameter under NO_UNSET is a "parameter not set"
8398                            // error (vunset > 0 && unset(UNSET)), exactly like the
8399                            // scalar `$arr`, the `${arr[*]}` splat, and `${arr[1]}`
8400                            // — all of which already fire it via GET_VAR. The `[@]`
8401                            // splat path returned an empty array silently, so
8402                            // `setopt NO_UNSET; print "${arr[@]}"` exited 0 where zsh
8403                            // exits 1. A DECLARED-but-empty array (`arr=()`) resolves
8404                            // to `Some(vec![])` above and never reaches here, so it
8405                            // still splats to nothing without erroring — matching zsh.
8406                            if opt_state_get("nounset").unwrap_or(false) {
8407                                crate::ported::utils::zerr(&format!("{}: parameter not set", name));
8408                                crate::ported::utils::errflag.fetch_or(
8409                                    crate::ported::zsh_h::ERRFLAG_ERROR,
8410                                    std::sync::atomic::Ordering::Relaxed,
8411                                );
8412                                exec.set_last_status(1);
8413                            }
8414                            // c:Src/subst.c:3480-3485 — an UNSET parameter takes the
8415                            // `vunset` arm: `val = dupstring("")` with isarr left at
8416                            // 0. That is a SCALAR empty, so plan9 keeps the
8417                            // surrounding text (`setopt rcexpandparam;
8418                            // print -r -- "[${unset[@]}]"` → `[]`), unlike a
8419                            // DECLARED-but-empty array (`arr=()`, matched by the
8420                            // `Some(vec![])` arm above), which sets isarr and gets
8421                            // the word deleted at c:4362.
8422                            note_empty_is_scalar(true);
8423                            // c:Src/subst.c:3603-3610 — an UNSET parameter leaves `isarr` at 0
8424                            // and yields `val = ""`, i.e. a SCALAR empty, so a quoted
8425                            // `"${u[@]}"` is ONE empty word (`f "${u[@]}"` → $# == 1) while
8426                            // `"${empty_array[@]}"` is zero. Returning an empty ARRAY here
8427                            // collapsed both to zero words. The unquoted form still drops it:
8428                            // the compiler emits BUILTIN_ARRAY_DROP_EMPTY after this call for
8429                            // non-DQ splices (compile_zsh.rs:6149), and that builtin maps an
8430                            // empty Str to an empty array.
8431                            Value::str(String::new())
8432                        } else if opt_state_get("shwordsplit").unwrap_or(false)
8433                            // c:Src/subst.c:1707-1708 — `spbreak = (pf_flags &
8434                            // PREFORK_SHWORDSPLIT) && … && !qt`: a quoted
8435                            // `"${scalar[@]}"` (argc 4, see below) is never split.
8436                            && argc != 4
8437                        {
8438                            // c:3921 `aval = sepsplit(val, spsep, 0, 1)` — same
8439                            // splitter as `${=name}` (Src/utils.c:3711 spacesplit),
8440                            // not a naive `split().filter(non-empty)`: only the
8441                            // IFS-WHITESPACE-derived empty fields are elided; the
8442                            // `nulstring` ones an IFS-NON-whitespace separator makes
8443                            // survive (c:Src/subst.c:36).
8444                            let nulstring = crate::ported::zsh_h::Nularg.to_string();
8445                            let parts: Vec<Value> =
8446                                crate::ported::utils::sepsplit(&val, None, false)
8447                                    .into_iter()
8448                                    .filter_map(|w| {
8449                                        if w == nulstring {
8450                                            Some(Value::str(String::new()))
8451                                        } else if w.is_empty() {
8452                                            None // c:184-187 prefork uremnode
8453                                        } else {
8454                                            Some(Value::str(w))
8455                                        }
8456                                    })
8457                                    .collect();
8458                            Value::array(parts)
8459                        } else {
8460                            Value::array(vec![Value::str(val)])
8461                        }
8462                    }
8463                }
8464            })
8465        };
8466        let result = array_all(vm);
8467        if !matches!(&result, Value::Array(a) if a.is_empty()) {
8468            note_empty_is_scalar(saved_empty_is_scalar);
8469        }
8470        // argc 4 is the compile site's "this `[@]` splat is QUOTED" bit
8471        // (`compile_zsh.rs`, the `${NAME[@]}` fast path). c:Src/subst.c:4387
8472        // `if (qt && !*y && isarr != 2) y = dupstring(nulstring)` then keeps
8473        // this read's empty elements alive through c:186 — but only above
8474        // c:4261's two-element cut-off, which is why the count is applied here
8475        // and not at the compile site. See `QUOTED_SPLICE_KEEPS_EMPTIES`.
8476        // Every other argc is an UNQUOTED (or `ssub`-joined) read, so it must
8477        // CLEAR the bit rather than leave a previous word's set: a whole-word
8478        // `"${a[@]}"` emits no end-of-word drop, so nothing else spends it.
8479        let quoted_splat_elems = match &result {
8480            Value::Array(items) if argc == 4 => items.len(),
8481            _ => 0,
8482        };
8483        note_quoted_splice_elems(quoted_splat_elems);
8484        // c:Src/subst.c:3912-3939 for the `${NAME[@]}` splice fast paths. Only
8485        // an ARRAY-shaped result reaches the block (c:3914 `if (isarr || …)`);
8486        // the scalar arms above — an unset parameter (c:3480-3485), the
8487        // scalar-fallback read — leave `isarr` at 0 and pass straight through.
8488        if !matches!(argc, 2 | 3) {
8489            return result;
8490        }
8491        let Value::Array(items) = &result else {
8492            return result;
8493        };
8494        let elems: Vec<String> = items.iter().map(|v| v.to_str()).collect();
8495        // c:1707 / :2567 — `spbreak` is SH_WORD_SPLIT for a plain `${NAME[@]}`
8496        // (argc 2), forced to 2 by the `=` flag (argc 3).
8497        let spbreak = if argc == 3 {
8498            2
8499        } else {
8500            i32::from(crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT))
8501        };
8502        // The gate never opens / neither join arm fires: no join, no split,
8503        // only c:184-187's empty-word removal — which the compile site used to
8504        // append as a separate BUILTIN_ARRAY_DROP_EMPTY, and that builtin
8505        // always returned an ARRAY. Keep that exact shape so nothing
8506        // downstream (concat, plan9, argv splice) sees a one-element splat turn
8507        // into a scalar.
8508        // c:Src/subst.c:4366-4437 then c:183-186 — the removal tests the
8509        // FINISHED node, after the word's text is glued onto the first and
8510        // last elements. When this read is a segment of such a word
8511        // (BUILTIN_WORD_DEFER_EMPTIES opened it) the word's own end-of-word
8512        // drop runs that test; removing the empty elements here, before the
8513        // concat, dropped the ones the affixes land on:
8514        // `a=(x y ""); print -rl -- p${a[@]}q` is `px` `y` `q` in zsh.
8515        let defer_to_word = crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get()) > 0;
8516        let untouched = |e: Vec<String>| {
8517            let e = if defer_to_word { e } else { prefork_c184_drop_empty(e) };
8518            Value::array(e.into_iter().map(Value::str).collect())
8519        };
8520        if spbreak == 0 {
8521            return untouched(elems);
8522        }
8523        let ifs_opt = with_executor(|exec| exec.scalar("IFS"));
8524        match join_c3914(elems, ifs_opt.as_deref()) {
8525            JoinC3914::Elements(kept) => {
8526                if argc == 3 {
8527                    Value::array(kept.into_iter().map(Value::str).collect())
8528                } else {
8529                    untouched(kept)
8530                }
8531            }
8532            JoinC3914::Joined(val) => {
8533                if argc == 3 {
8534                    // BUILTIN_FORCE_SPLIT is c:3932 at the compile site.
8535                    Value::str(val)
8536                } else if defer_to_word {
8537                    // c:3932 sepsplit, then c:184-187 on the FINISHED word: the
8538                    // enclosing word's end-of-word drop removes the truly empty
8539                    // edge fields after its text is glued on and strips the c:36
8540                    // `nulstring` markers. `setopt shwordsplit; IFS=:; a=(a ""
8541                    // b); print -rl -- x${a[@]}y` is `xa` `` `by`.
8542                    Value::array(
8543                        crate::ported::utils::sepsplit(&val, None, false)
8544                            .into_iter()
8545                            .map(Value::str)
8546                            .collect(),
8547                    )
8548                } else {
8549                    splice_words_value(sepsplit_c3932(&val, false))
8550                }
8551            }
8552        }
8553    });
8554
8555    // BUILTIN_ARRAY_FLATTEN(N): pops N values, flattens one level of Array
8556    // nesting, pushes the resulting Array AND its length as a separate Int.
8557    // The two-value return shape lets the caller (for-loop compile path)
8558    // SetSlot the length before SetSlot'ing the array, without re-deriving
8559    // the length from the array via a second builtin call.
8560    // `coproc [name] { body }` — bidirectional pipe to backgrounded body.
8561    // Stack discipline (top first): [name (str, "" for default), sub_idx (int)].
8562    // On success: parent's `executor.arrays[name]` becomes [write_fd, read_fd]
8563    // and Status(0) is returned. The caller writes to the child's stdin via
8564    // write_fd, reads its stdout via read_fd, and closes both when done.
8565    //
8566    // Bash's coproc convention is `${NAME[0]}` = read_fd, `${NAME[1]}` =
8567    // write_fd. We follow that: arrays[name] = [read_fd_str, write_fd_str].
8568    vm.register_builtin(BUILTIN_RUN_COPROC, |vm, _argc| {
8569        let sub_idx = vm.pop().to_int() as usize;
8570        let job_text = vm.pop().to_str();
8571        let raw_name = vm.pop().to_str();
8572        let name = if raw_name.is_empty() {
8573            "COPROC".to_string()
8574        } else {
8575            raw_name
8576        };
8577        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
8578            Some(c) => c,
8579            None => return Value::Status(1),
8580        };
8581
8582        // c:Src/exec.c:1760 — `thisjob = newjob = initjob()` comes BEFORE the
8583        // old coproc's fds are closed (c:1768-1772), so that coproc, which
8584        // only starts exiting once they close, still holds its job slot and
8585        // the new one gets the next number: `coproc cat; …; coproc cat; jobs`
8586        // lists `[2]`. Taking the slot after the fork raced the reaper.
8587        let newjob = {
8588            let table = crate::ported::jobs::JOBTAB
8589                .get_or_init(|| std::sync::Mutex::new(Vec::new()));
8590            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
8591            crate::ported::jobs::initjob(&mut tab)
8592        };
8593        // A coproc that never starts gives the slot back.
8594        let release_newjob = || {
8595            if let Some(table) = crate::ported::jobs::JOBTAB.get() {
8596                let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
8597                tab[newjob].stat = 0;
8598            }
8599        };
8600        // c:Src/exec.c:1710-1712 — starting a new coproc closes the
8601        // previous one's fds FIRST:
8602        //     if (coprocin >= 0) { zclose(coprocin); zclose(coprocout); }
8603        // The old coproc child then sees EOF on its stdin and exits on
8604        // its own schedule (its job-table entry stays until it's
8605        // reaped) — zsh does NOT deletejob it here. This is also what
8606        // makes the `exec 4<&p; coproc exit; read -u4` EOF idiom work:
8607        // the replacement coproc closes the shell's write end to the
8608        // old one.
8609        {
8610            use std::sync::atomic::Ordering;
8611            let old_in = crate::ported::modules::clone::coprocin.load(Ordering::Relaxed);
8612            if old_in >= 0 {
8613                let old_out = crate::ported::modules::clone::coprocout.load(Ordering::Relaxed);
8614                unsafe {
8615                    libc::close(old_in);
8616                    if old_out >= 0 {
8617                        libc::close(old_out);
8618                    }
8619                }
8620                crate::ported::modules::clone::coprocin.store(-1, Ordering::Relaxed);
8621                crate::ported::modules::clone::coprocout.store(-1, Ordering::Relaxed);
8622            }
8623        }
8624
8625        // (parent_read ← child_stdout)
8626        let mut p2c = [0i32; 2]; // parent writes, child reads
8627        let mut c2p = [0i32; 2]; // child writes, parent reads
8628        if unsafe { libc::pipe(p2c.as_mut_ptr()) } < 0 {
8629            release_newjob();
8630            return Value::Status(1);
8631        }
8632        if unsafe { libc::pipe(c2p.as_mut_ptr()) } < 0 {
8633            unsafe {
8634                libc::close(p2c[0]);
8635                libc::close(p2c[1]);
8636            }
8637            release_newjob();
8638            return Value::Status(1);
8639        }
8640        // c:Src/exec.c:5160 mpipe — both pipes' fds are moved above
8641        // the user-visible range (movefd → F_DUPFD ≥ 10) so the
8642        // coproc fds never collide with explicit user fds like
8643        // `exec 3>&p`.
8644        for fd in p2c.iter_mut().chain(c2p.iter_mut()) {
8645            *fd = crate::ported::utils::movefd(*fd);
8646        }
8647
8648        // c:Src/exec.c:1748/1815 — a coproc is spawned by execpline's
8649        // Z_ASYNC arm, inside the same `child_block()` span as `cmd &`.
8650        #[cfg(unix)]
8651        let _child_block = ChildBlockSpan::enter();
8652
8653        match unsafe { libc::fork() } {
8654            -1 => {
8655                unsafe {
8656                    libc::close(p2c[0]);
8657                    libc::close(p2c[1]);
8658                    libc::close(c2p[0]);
8659                    libc::close(c2p[1]);
8660                }
8661                release_newjob();
8662                Value::Status(1)
8663            }
8664            0 => {
8665                forked_child_subsh_levels(); // c:Src/exec.c:1221
8666                EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
8667                // Child: stdin from p2c[0], stdout to c2p[1]. Close all
8668                // unused fds. setsid so SIGINT to fg doesn't hit us.
8669                unsafe {
8670                    libc::dup2(p2c[0], libc::STDIN_FILENO);
8671                    libc::dup2(c2p[1], libc::STDOUT_FILENO);
8672                    libc::close(p2c[0]);
8673                    libc::close(p2c[1]);
8674                    libc::close(c2p[0]);
8675                    libc::close(c2p[1]);
8676                    libc::setsid();
8677                }
8678                crate::fusevm_disasm::maybe_print_stdout("coproc:child", &chunk);
8679                let mut co_vm = fusevm::VM::new(chunk);
8680                register_builtins(&mut co_vm);
8681                let _ = co_vm.run();
8682                let _ = std::io::stdout().flush();
8683                let _ = std::io::stderr().flush();
8684                std::process::exit(co_vm.last_status);
8685            }
8686            pid => {
8687                // Parent: close child ends, store [read_fd, write_fd] in NAME.
8688                unsafe {
8689                    libc::close(p2c[0]);
8690                    libc::close(c2p[1]);
8691                }
8692                let read_fd = c2p[0];
8693                let write_fd = p2c[1];
8694                // bash(1) `coproc`: the child's pid is published as NAME_PID.
8695                if crate::dash_mode::bash_mode() {
8696                    crate::ported::params::setsparam(&format!("{name}_PID"), &pid.to_string());
8697                }
8698                with_executor(|exec| {
8699                    exec.unset_scalar(&name);
8700                    exec.set_array(name, vec![read_fd.to_string(), write_fd.to_string()]);
8701                });
8702                // c:Src/exec.c — `coprocin`/`coprocout` are the
8703                // canonical globals that bin_read's `-p` arm
8704                // (Src/builtin.c:6510) and bin_print's `-p` arm
8705                // (Src/builtin.c:4827) read to find the
8706                // coprocess fds. The Rust port has the atomic
8707                // declarations at src/ported/modules/clone.rs:262
8708                // but the coproc-launch path never updated them,
8709                // so `read -p` / `print -p` always errored with
8710                // "-p: no coprocess" even when a coproc was
8711                // running. Bug #388 in docs/BUGS.md. Update them
8712                // here so the canonical builtins find the live
8713                // pipe.
8714                crate::ported::modules::clone::coprocin
8715                    .store(read_fd, std::sync::atomic::Ordering::Relaxed);
8716                crate::ported::modules::clone::coprocout
8717                    .store(write_fd, std::sync::atomic::Ordering::Relaxed);
8718                // c:Src/exec.c:1725 — `fdtable[coprocin] =
8719                // fdtable[coprocout] = FDT_UNUSED;`: the two kept ends
8720                // are user-reachable (via `>&p` / `<&p`), so they drop
8721                // the FDT_INTERNAL mark movefd gave them.
8722                crate::ported::utils::fdtable_set(read_fd, crate::ported::zsh_h::FDT_UNUSED);
8723                crate::ported::utils::fdtable_set(write_fd, crate::ported::zsh_h::FDT_UNUSED);
8724                // c:Src/exec.c:2837 — `lastpid = (zlong) pid;`. zsh
8725                // sets the `$!` global to the coproc child's PID so
8726                // subsequent `$!` reads return it. The Rust port at
8727                // exec.rs:6773 mirrors this for regular background
8728                // jobs but the coproc launch path was missing the
8729                // assignment, leaving `$!` at 0 after `coproc cmd`.
8730                crate::ported::modules::clone::lastpid
8731                    .store(pid, std::sync::atomic::Ordering::Relaxed);
8732                // c:Src/exec.c:1700-1758 — the coproc rides the SAME
8733                // Z_ASYNC job-table path as `cmd &`: `thisjob = newjob
8734                // = initjob()` (c:1700), addproc hangs the pid+text
8735                // proc entry off the job, `jobtab[thisjob].stat |=
8736                // STAT_NOSTTY` (c:1746), `clearoldjobtab()` (c:1744)
8737                // and `spawnjob()` (c:1758) promote it to curjob. This
8738                // is what makes `jobs` list the coproc as
8739                // `[1]  + running    cat` and `kill %1` resolve it.
8740                // Mirrors the BUILTIN_RUN_BG parent arm exactly.
8741                {
8742                    use crate::ported::jobs;
8743                    use std::sync::Mutex;
8744                    let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
8745                    let idx = {
8746                        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
8747                        let idx = newjob; // c:exec.c:1760, reserved above
8748                        jobs::addproc(
8749                            &mut tab[idx],
8750                            pid,
8751                            &job_text,
8752                            false,
8753                            Some(std::time::Instant::now()),
8754                            -1,
8755                            -1,
8756                        );
8757                        tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
8758                        idx
8759                    };
8760                    jobs::clearoldjobtab(); // c:exec.c:1744
8761                    if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
8762                        *tj = idx as i32;
8763                    }
8764                    jobs::spawnjob(); // c:exec.c:1758
8765                }
8766                with_executor(|exec| {
8767                    exec.jobs
8768                        .add_pid_job(pid, job_text.clone(), JobState::Running);
8769                });
8770                Value::Status(0)
8771            }
8772        }
8773    });
8774
8775    vm.register_builtin(BUILTIN_ARRAY_FLATTEN, |vm, argc| {
8776        // `${~spec}` carrier: a `for`/`select` WORD LIST is a word-
8777        // pipeline boundary too. In C the `globsubst` flag is a
8778        // paramsubst-LOCAL int (Src/subst.c:1671 `int globsubst =
8779        // isset(GLOBSUBST);`, forced to 2 by `${~…}` at
8780        // Src/subst.c:2603) whose only lasting effect is the
8781        // `shtokenize` of that substitution's own result — it never
8782        // reaches the option table, so `execfor`'s list prefork
8783        // (Src/loop.c:196-235) cannot leak it into the loop BODY.
8784        // zshrs carries the flag through the global option table so
8785        // the compile-emitted glob ops of the SAME word can see it
8786        // (documented deviation at subst.rs:3190), and restores it at
8787        // command-dispatch boundaries — but a `for` list has no
8788        // trailing dispatch of its own, so `for i in "${a:#${~p}*}"`
8789        // left GLOB_SUBST ON and filename-generated the FIRST body
8790        // command's words (`_parameters:34` → `ary+=($i:"$v")` glob-
8791        // erroring "bad pattern: HISTCHARS:!^#", which aborted the
8792        // whole `pr<TAB>` completion). This builtin ends EVERY for/
8793        // select list expansion and runs AFTER each word's
8794        // GLOB_SUBST_EXPAND op, so the carrier has been read by then.
8795        consume_tilde_globsubst_carrier();
8796        let n = argc as usize;
8797        let start = vm.stack.len().saturating_sub(n);
8798        let raw: Vec<Value> = vm.stack.drain(start..).collect();
8799        let mut flat: Vec<Value> = Vec::with_capacity(raw.len());
8800        for v in raw {
8801            match v {
8802                Value::Array(items) => flat.extend(items.iter().cloned()),
8803                other => flat.push(other),
8804            }
8805        }
8806        let len = flat.len() as i64;
8807        // Push the array first; the Int(len) becomes the builtin's return
8808        // value (which CallBuiltin already pushes). Caller consumes in
8809        // reverse: SetSlot(len_slot) pops Int, SetSlot(arr_slot) pops Array.
8810        vm.push(Value::array(flat));
8811        Value::Int(len)
8812    });
8813
8814    // Shell variable get/set — routes through executor.variables so nested
8815    // VMs (function calls) and tree-walker callers see the same storage.
8816    // GET_VAR / GET_VAR_DQ share one body via `get_var_impl`; the only
8817    // difference is `force_dq`, which the compiler sets for QUOTED simple
8818    // reads (`"$name"`) so an array's empty elements are preserved (the
8819    // `in_dq_context` runtime flag is 0 for these compiler-direct reads).
8820    fn get_var_impl(vm: &mut fusevm::VM, argc: u8, force_dq: bool) -> Value {
8821        let args = pop_args(vm, argc);
8822        let name = args.into_iter().next().unwrap_or_default();
8823        let live_status = vm.last_status;
8824        // `$@` and `$*` need splice semantics — return Value::Array of
8825        // positional params so for-loop's BUILTIN_ARRAY_FLATTEN spreads them
8826        // and pop_args splits them into argv slots. zsh's `"$@"` bslashquote-each-
8827        // word semantics matches: each pos-param becomes its own arg.
8828        // Same for arrays accessed by name (e.g. `$arr` in some contexts).
8829        //
8830        // vm.last_status is authoritative: `subshell_end` now returns
8831        // Some(status) and fusevm's `Op::SubshellEnd` writes it into
8832        // vm.last_status, so a deferred subshell `exit N` is visible
8833        // here. Suppressing this sync (as an older revision did, back
8834        // when the host hook returned nothing) made LASTVAL win over
8835        // any status the VM set AFTER SubshellEnd — which dropped the
8836        // `!` negation of `Src/exec.c:1979-1980`
8837        //   if ((slflags & WC_SUBLIST_NOT) && !errflag && !retflag)
8838        //       lastval = !lastval;
8839        // for `! (exit 7)` (emit_negate_status' SetStatus updated
8840        // vm.last_status, then `$?` read the stale LASTVAL=7).
8841        let sync_status = |exec: &mut ShellExecutor| {
8842            exec.set_last_status(live_status);
8843        };
8844        if name == "@" || name == "*" {
8845            // Quoting decides empty-word retention (c:Src/subst.c:
8846            // 184-187): the COMPILE site knows it and emits
8847            // BUILTIN_ARRAY_DROP_EMPTY after this read for the
8848            // unquoted form only — in_dq_context is NOT a valid
8849            // discriminator here (the quoted "$@" fast path emits
8850            // GET_VAR directly without an EXPAND_TEXT wrapper).
8851            let pp = with_executor(|exec| {
8852                sync_status(exec);
8853                exec.pparams()
8854            });
8855            // c:Src/subst.c:1817 — `int nojoin = (pf_flags &
8856            // PREFORK_SHWORDSPLIT) ? !(ifs && *ifs) && !qt : 0;`
8857            // c:Src/subst.c:3908-3911 — `if (nojoin == 0 || sep) { val =
8858            //     sepjoin(aval, sep, 1); isarr = 0; }`
8859            // c:Src/subst.c:3919-3921 — `if (force_split && !isarr) { aval =
8860            //     sepsplit(val, spsep, 0, 1); … }`
8861            //
8862            // So under SH_WORD_SPLIT an UNQUOTED `$@`/`$*` with a NON-EMPTY
8863            // `$IFS` is first JOINED on `$IFS[1]` and then re-split on `$IFS`
8864            // — which is why `setopt shwordsplit; set -- one:two b:c; IFS=:;
8865            // print -l $@` is four words in zsh (and in bash, and in ksh).
8866            // The port returned the raw positional list, so an element
8867            // carrying an IFS byte was never broken up
8868            // (D04parameter.ztst "Splitting of $@ on IFS: single element";
8869            // `zshrs --bash -c 'set -- "a b" c; printf "[%s]\n" $@'` printed
8870            // `[a b]` where bash prints `[a]` `[b]`).
8871            //
8872            // The gates are C's, verbatim: `!force_dq` is `!qt`, an UNSET or
8873            // EMPTY `$IFS` leaves `nojoin` at 1 (no join, no split), and the
8874            // whole rule is inert without SH_WORD_SPLIT (`nojoin = 0` there,
8875            // but `force_split` at c:3913 is `!ssub && (spbreak || spsep)`,
8876            // all clear, so neither branch runs).
8877            if !force_dq && crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT) {
8878                // c:1817 — carry the Option: an UNSET `$IFS` (`!ifs`) joins on
8879                // " " and splits at c:3919-3925 (`(!ifs && isarr < 0)`), unlike an
8880                // EMPTY one. `join_c3914` encodes both arms.
8881                let ifs_opt = with_executor(|exec| exec.scalar("IFS"));
8882                if let JoinC3914::Joined(joined) = join_c3914(pp.clone(), ifs_opt.as_deref()) {
8883                    // c:3919 `sepsplit(val, spsep, 0, 1)` with spsep NULL →
8884                    // spacesplit (c:Src/utils.c:3711), then c:184-187. The
8885                    // IFS-whitespace edge fields are truly empty and c:186
8886                    // deletes them only from the FINISHED word, after the
8887                    // word's text is glued on (c:4366-4437): `setopt
8888                    // shwordsplit; set -- "" x ""; print -rl -- p$@q` is `p`
8889                    // `x` `q`. So inside a word that defers that removal
8890                    // (BUILTIN_WORD_DEFER_EMPTIES) the fields go out raw, the
8891                    // c:36 `nulstring` markers included, exactly as
8892                    // BUILTIN_FORCE_SPLIT hands them over; a whole word gets
8893                    // the removal from the compile site's BUILTIN_ARRAY_DROP_EMPTY,
8894                    // which also strips the markers (c:170). multsub's
8895                    // PREFORK_SPLIT walker, used before, dropped the edge fields
8896                    // outright and folded `nulstring` to "" (`IFS=:; set -- a::b;
8897                    // print -rl -- $@` lost its middle field).
8898                    let parts = crate::ported::utils::sepsplit(&joined, None, false); // c:3919, Nularg kept
8899                    return Value::array(parts.into_iter().map(Value::str).collect());
8900                }
8901            }
8902            // c:Src/subst.c:4387 — `"$@"` is the quoted splat of the positional
8903            // list, so its empty elements become `nulstring` and survive
8904            // c:186: `set -- x '' y; PRE"$@"POST` is three words in zsh. `*` is
8905            // excluded because c:3032's quoted `sepjoin` has already collapsed
8906            // it to one scalar (the compile site routes `"$*"` through
8907            // BUILTIN_EXPAND_TEXT and discards this value). c:4261's
8908            // element-count cut-off is applied by `note_quoted_splice_elems`.
8909            // An UNQUOTED `$@` passes 0, which CLEARS the bit: a preceding
8910            // whole-word `"${a[@]}"` emits no end-of-word drop, so its bit is
8911            // still standing and this word's drop must not honour it.
8912            let quoted_at_elems = if force_dq && name == "@" { pp.len() } else { 0 };
8913            note_quoted_splice_elems(quoted_at_elems);
8914            return Value::array(pp.iter().map(Value::str).collect());
8915        }
8916        // RC_EXPAND_PARAM: when the option is set and `name` refers to
8917        // an array, return Value::Array so the enclosing word's
8918        // BUILTIN_CONCAT_DISTRIBUTE distributes element-wise. Without
8919        // the option, arrays still join to a space-separated scalar
8920        // (zsh's default unquoted-array-as-scalar semantics).
8921        // `isset` is the single atomic load C's `isset(RCEXPANDPARAM)` is
8922        // (c:Src/zsh.h:2557); `opt_state_get` re-resolved the name through the
8923        // option table under a lock on every parameter read.
8924        let rc_expand = isset(crate::ported::zsh_h::RCEXPANDPARAM);
8925        // c:Src/subst.c — under KSHARRAYS a bare `$name` (no [@]/[*] subscript;
8926        // this GET_VAR path only handles the bare form) is element 1 ONLY — a
8927        // scalar. RC_EXPAND_PARAM then has a single value to distribute, so
8928        // `$acc` → "p1", NOT the whole array. Skip the whole-array rc_expand
8929        // shortcut when KSHARRAYS is set and fall through to the normal path,
8930        // which applies the element-1 collapse. Without this gate,
8931        // `setopt KSH_ARRAYS rc_expand_param; print -r -- $acc` splatted every
8932        // element while zsh prints just "p1".
8933        let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
8934        // c:Src/subst.c:4245 `if (isarr)` gates the whole plan9 block
8935        // (c:4316), and TWO earlier arms have already zeroed `isarr` for a
8936        // bare array read that is quoted or scalar-substituted:
8937        //   c:3032 `if (qt && !getlen && isarr > 0) { val = sepjoin(aval,
8938        //           sep, 1); isarr = 0; }`                       — DQ context
8939        //   c:3905 `if (nojoin == 0 || sep) { val = sepjoin(aval, sep, 1);
8940        //           isarr = 0; }` under `if (ssub || …)` at c:3901
8941        //                                          — PREFORK_SINGLE (scalar
8942        //                                            assignment RHS)
8943        // So RC_EXPAND_PARAM never cross-products a plain `"$a"` / `b=$a`;
8944        // the array collapses to one IFS-joined scalar first. `force_dq` is
8945        // exactly the compiler's flag for those two contexts (it is set for
8946        // `in_dq || scalar_assign_depth > 0 || assign_builtin_arg_depth > 0`),
8947        // Without this gate `setopt rcexpandparam; a=(x y); print -rl -- "$a"Z`
8948        // emitted `xZ` / `yZ` instead of zsh's single `x yZ` — which is how
8949        // `_sqlite`'s `"($exclusive)"$^dashes'-header[…]'` reached
8950        // `comparguments` as five words starting `(-noheader`.
8951        //
8952        // Only the compile-time flag is consulted. The runtime
8953        // `in_dq_context` counter stays set while a `$(…)` INSIDE double
8954        // quotes runs its body, so reading it here would join an unquoted
8955        // `$a` in `"$(print -l -- $a)"`.
8956        if rc_expand && !ksh_arrays && !force_dq {
8957            let arr_val = with_executor(|exec| {
8958                sync_status(exec);
8959                exec.array(&name)
8960            });
8961            if let Some(arr) = arr_val {
8962                // c:4245 — a real array reference (`isarr != 0`). An empty one
8963                // takes plan9's word-removal path (c:4362), so clear the
8964                // scalar bit; a preceding empty-SCALAR expansion in the same
8965                // word would otherwise leave it set and keep the word alive
8966                // (`empty=''; e=(); a=("$empty"); print -rl -- x$e y`).
8967                // Only an EMPTY array may clear it: a non-empty one carries
8968                // no emptiness of its own, and clearing on it wiped the bit a
8969                // preceding empty SCALAR had set — `setopt rcexpandparam;
8970                // e=''; a=(x y); print -rl -- $e$a` lost the whole word.
8971                if arr.is_empty() {
8972                    note_empty_is_scalar(false);
8973                }
8974                // A genuine array reference, so an empty element that
8975                // survives the plan9 cross-product IS an empty word:
8976                // `setopt rcexpandparam; a=('' x); print -rl -- ${a}`
8977                // is one word (`x`) in zsh. See `ARRAY_EMPTIES_ELIDABLE`.
8978                note_array_empties_elidable();
8979                return Value::array(arr.into_iter().map(Value::str).collect());
8980            }
8981        }
8982        // Magic-assoc fallback FIRST — `${aliases}` / `${functions}`
8983        // / `${commands}` / etc. should return the value list per
8984        // zsh's bare-assoc semantics. Without this, those names fell
8985        // through to `get_variable` which is empty (they live in
8986        // separate executor tables, not `assoc_arrays`). Return as
8987        // a Value::Array so `arr=(${aliases})` distributes into
8988        // multiple elements, matching zsh's array-context word
8989        // splitting for assoc-bare references.
8990        // Only a name that is a special hash or array row, or a module
8991        // parameter that autoloads, can answer here. For every other name
8992        // `partab_array_get` / `partab_scan_keys` return None after their
8993        // shadow check, module gate and bookkeeping insert — work that cost
8994        // about a third of each parameter read in compinit. The AUTOLOAD
8995        // check keeps `mark_module_param_used`'s first-touch load for those.
8996        let partab_candidate = crate::ported::modules::parameter::PARTAB
8997            .iter()
8998            .any(|e| e.name == name.as_str())
8999            || crate::ported::modules::parameter::PARTAB_ARRAY
9000                .iter()
9001                .any(|e| e.name == name.as_str())
9002            || crate::vm_helper::AUTOLOAD_PARAMS.iter().any(|(p, _)| *p == name.as_str());
9003        let magic_vals = if !partab_candidate {
9004            None
9005        } else {
9006            with_executor(|exec| {
9007            sync_status(exec);
9008            // Canonical PARTAB dispatch (Src/Modules/parameter.c:2235-
9009            // 2298 + SPECIALPMDEFs in mapfile/terminfo/termcap/system/
9010            // zleparameter): PARTAB_ARRAY entries → whole-array getfn;
9011            // PARTAB entries → scan keys + per-key getpm/scanpm fn
9012            // pointers.
9013            let _ = exec;
9014            if let Some(values) = partab_array_get(&name) {
9015                Some(values)
9016            } else if let Some(keys) = partab_scan_keys(&name) {
9017                Some(
9018                    keys.iter()
9019                        .map(|k| partab_get(&name, k).unwrap_or_default())
9020                        .collect::<Vec<_>>(),
9021                )
9022            } else {
9023                None
9024            }
9025            })
9026        };
9027        if let Some(vals) = magic_vals {
9028            // Distinguish "name IS a magic-assoc with no entries"
9029            // (return Array(empty)) from "name is unknown — fall
9030            // through to get_variable".
9031            // c:Src/params.c:2293-2296 — KSHARRAYS bare reference
9032            // collapses to the FIRST element in scan order
9033            // (`v->end = 1, v->isarr = 0`). For `options` the scan
9034            // order is optiontab bucket order (OPTIONTAB), so zsh 5.9
9035            // prints `off` (posixargzero) for
9036            // `setopt ksharrays; print $options`.
9037            if isset(crate::ported::zsh_h::KSHARRAYS) {
9038                // c:Src/params.c:2342-2350 — a PM_HASHED special takes the
9039                // same `!v->scanflags && EMULATION(EMULATE_KSH)` arm as a
9040                // plain association: `s = "[0]"; getindex(...)`, i.e. the
9041                // value under key "0" through the special's getfn, empty
9042                // when absent. PARTAB_ARRAY rows are PM_ARRAY and keep the
9043                // first element.
9044                if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH)
9045                    && partab_array_get(&name).is_none()
9046                {
9047                    return Value::str(partab_get(&name, "0").unwrap_or_default());
9048                }
9049                return Value::str(vals.into_iter().next().unwrap_or_default());
9050            }
9051            // Same array reference shape as the indexed-array arm below.
9052            if !force_dq {
9053                note_array_empties_elidable();
9054            }
9055            return Value::array(vals.into_iter().map(Value::str).collect());
9056        }
9057        // Indexed-array path: return Value::Array so pop_args splats
9058        // each element into its own argv slot. Direct port of zsh's
9059        // unquoted `$arr` semantics — each element becomes a separate
9060        // word in command-arg position.
9061        //
9062        // DQ context exception: inside `"...$arr..."`, zsh joins with
9063        // the first char of $IFS (default space) so the DQ word stays
9064        // a single argv slot. Detect via in_dq_context (bumped by
9065        // BUILTIN_EXPAND_TEXT mode 1) and return the joined scalar.
9066        // Direct port of Src/subst.c:1759-1813 nojoin/sepjoin: in DQ
9067        // (qt=1) without explicit `(@)`, sepjoin runs and the result
9068        // is one word.
9069        let arr_assoc_data = with_executor(|exec| {
9070            sync_status(exec);
9071            let in_dq = force_dq || exec.in_dq_context > 0;
9072            // KSH_ARRAYS: bare `$arr` returns ONLY arr[0] (zero-
9073            // based first-element-only semantics). Direct port of
9074            // Src/params.c getstrvalue's KSH_ARRAYS gate which
9075            // returns aval[0] instead of the whole array.
9076            let ksh_arrays = isset(crate::ported::zsh_h::KSHARRAYS);
9077            if let Some(arr) = exec.array(&name) {
9078                if ksh_arrays {
9079                    return Some((vec![arr.first().cloned().unwrap_or_default()], in_dq));
9080                }
9081                return Some((arr, in_dq));
9082            }
9083            // Existence only: `exec.assoc()` cloned the whole map to answer it.
9084            if exec.has_assoc(&name) {
9085                // c:Src/params.c:2351-2358 — under KSH EMULATION a bare
9086                // `$assoc` is `${assoc[0]}` (a KEY-"0" lookup), so it is
9087                // EMPTY unless the hash actually has a key "0". This is
9088                // EMULATION-gated, not KSHARRAYS-option-gated: `emulate -L
9089                // ksh; typeset -A h=(a 1 b 2); print $h` is empty, whereas
9090                // `setopt ksharrays; …; print $h` collapses to the bucket-
9091                // first value below.
9092                // bash(1): "Referencing an array variable without a subscript is
9093                // equivalent to referencing the array with a subscript of 0".
9094                if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH)
9095                    || crate::dash_mode::bash_mode()
9096                {
9097                    let v = crate::ported::subst::assoc_get(&name)
9098                        .and_then(|m| m.get("0").cloned())
9099                        .unwrap_or_default();
9100                    return Some((vec![v], in_dq));
9101                }
9102                // c:Src/hashtable.c scanhashtable — a bare `$assoc` joins its
9103                // VALUES in zsh hash-BUCKET order (the same order `(k)`/`(v)`
9104                // enumerate), NOT sorted or insertion order. `assoc_get`
9105                // rebuilds zsh's bucket layout; use it so `$as` matches
9106                // `${(v)as}` (`as=(zebra 9 apple 1)` → `9 1`, not the
9107                // alphabetical `1 9`). Under KSHARRAYS the bare form collapses
9108                // to the bucket-FIRST value (`9`), matching zsh.
9109                let values: Vec<String> = crate::ported::subst::assoc_get(&name)
9110                    .map(|m| m.values().cloned().collect())
9111                    .unwrap_or_default();
9112                if ksh_arrays {
9113                    return Some((vec![values.into_iter().next().unwrap_or_default()], in_dq));
9114                }
9115                return Some((values, in_dq));
9116            }
9117            None
9118        });
9119        if let Some((items, in_dq)) = arr_assoc_data {
9120            if in_dq {
9121                // c:Src/utils.c:3936-3945 sepjoin default-sep rule:
9122                // set-but-empty IFS joins with "" (`IFS=""; echo
9123                // "$arr"` concatenates); only unset / space-leading
9124                // IFS yields " ". The previous get_variable read
9125                // couldn't distinguish unset from set-empty.
9126                return Value::str(crate::ported::utils::sepjoin(&items, None));
9127            }
9128            // c:Src/subst.c:3912 — the join-then-split block:
9129            //
9130            //     if (ssub || spbreak || spsep || sep || quoted_array_with_offset) {
9131            //         int force_split = !ssub && (spbreak || spsep);
9132            //         if (isarr || quoted_array_with_offset) {
9133            //
9134            // A BARE `$NAME` / `${NAME}` read of an ARRAY is `isarr != 0`, so it
9135            // takes the same block the `${NAME[@]}` / `${NAME[*]}` splice does
9136            // (BUILTIN_ARRAY_ALL / BUILTIN_ARRAY_JOIN_STAR above) — c:3030-3032
9137            // makes the three spellings indistinguishable once inside it. The
9138            // only door in from HERE is `spbreak`, c:Src/subst.c:1707
9139            //     int spbreak = (pf_flags & PREFORK_SHWORDSPLIT)
9140            //                   && !(pf_flags & PREFORK_SINGLE) && !qt;
9141            // — SH_WORD_SPLIT. `qt` and PREFORK_SINGLE both reach this handler
9142            // as `force_dq` (the compile site emits GET_VAR_DQ for either), and
9143            // that arm returned above, so getting here already means `!qt` and
9144            // `!ssub`; the explicit `${=…}` / `${==…}` spellings have their own
9145            // fast path. `sep` / `spsep` are the `(j:…:)` / `(s:…:)` separators,
9146            // which never reach a compiler fast path.
9147            //
9148            // This handler had no such block at all: it returned the element
9149            // vector whatever the option said, so an element carrying an IFS
9150            // byte was never broken up —
9151            //     setopt shwordsplit; a=('p:q' r); IFS=:;    print -rl -- $a
9152            //       zsh: `p` `q` `r`            zshrs: `p:q` `r`
9153            //     setopt shwordsplit; a=('p q' r); unset IFS; print -rl -- ${a}
9154            //       zsh: `p` `q` `r`            zshrs: `p q` `r`
9155            //     setopt shwordsplit; a=(x '' y); IFS=:;     print -rl -- $a
9156            //       zsh: `x` `` `y`             zshrs: `x` `y`
9157            // The last is `sepsplit_c3932`'s `nulstring` rule: the join makes
9158            // `x::y`, and an empty field delimited by IFS-NON-whitespace comes
9159            // back as c:Src/subst.c:36's one-byte `nulstring`, which c:186 KEEPS.
9160            //
9161            // Only the JOINED outcome changes shape. When c:3914-3927 leaves the
9162            // vector alone (an IFS that is set but EMPTY: c:1819 `nojoin` is 1
9163            // and c:3919's second arm needs `!ifs`, which a set-empty IFS fails)
9164            // c:3931's `!isarr` closes the split too, and the answer is the
9165            // untouched array the code below already returns.
9166            if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT) {
9167                let ifs_opt = with_executor(|exec| exec.scalar("IFS"));
9168                if let JoinC3914::Joined(val) = join_c3914(items.clone(), ifs_opt.as_deref()) {
9169                    // c:3932 `aval = sepsplit(val, spsep, 0, 1)`, then
9170                    // c:3933-3938's 0/1/N result shape.
9171                    return split_words_c3932(&val);
9172                }
9173            }
9174            // c:Src/subst.c:184-187 — prefork's `else if (!keep)
9175            // uremnode(list, node)`: UNQUOTED expansion drops empty
9176            // list nodes before they reach argv, so `a=(y '' x);
9177            // print -- $a` passes TWO args in zsh (`y x`), while the
9178            // quoted "${a[@]}" splat keeps the empty slot.
9179            //
9180            // The removal must NOT happen HERE. c:186 deletes an
9181            // assembled list NODE — it runs after stringsubst has
9182            // concatenated the word's surrounding text onto the
9183            // elements (the array emit block is c:4245 onward, the
9184            // non-plan9 prefix/suffix attach c:4366-4437), so an
9185            // element that is empty at READ time can still finish as a
9186            // non-empty WORD. Filtering on the way out of the read cost
9187            // exactly that: `a=(x y ""); print -rl -- ${a}POST` dropped
9188            // the slot the suffix was going to land on and printed
9189            // `x` `yPOST` where zsh prints `x` `y` `POST`. The elision
9190            // now lives at the end of word assembly
9191            // (`BUILTIN_WORD_ELIDE_EMPTY`, emitted by compile_zsh once
9192            // the word's segments are folded); flag the value so that
9193            // builtin can tell a genuine array's empty element from a
9194            // split field's `nulstring` (see `ARRAY_EMPTIES_ELIDABLE`).
9195            note_array_empties_elidable();
9196            // c:4245 — a real array reference: `isarr != 0`, so an empty
9197            // one takes plan9's word-removal path, not the scalar path.
9198            // Only note it when the array IS empty — see the rc_expand arm
9199            // above for why a non-empty read must not touch the bit.
9200            if items.is_empty() {
9201                note_empty_is_scalar(false);
9202            }
9203            return Value::array(items.into_iter().map(Value::str).collect());
9204        }
9205        let (val, in_dq, is_known) = with_executor(|exec| {
9206            sync_status(exec);
9207            let v = exec.get_variable(&name);
9208            // For nounset detection: a name is "known" when it has a
9209            // paramtab/array/assoc/env entry. Special chars ($?, $#,
9210            // $@, $*, $-, $$, $!, $_, $0) always count as known
9211            // regardless of value. Pure-digit positional params
9212            // count as known iff index <= $# (set -- has populated
9213            // that slot). c:Src/subst.c:1689 — NOUNSET fires on
9214            // unset positional param too: `set --; echo "$1"` with
9215            // nounset must diagnose.
9216            let is_special_single = name.len() == 1
9217                && matches!(
9218                    name.chars().next().unwrap(),
9219                    '?' | '#' | '@' | '*' | '-' | '$' | '!' | '_' | '0'
9220                );
9221            let is_pure_digit = !name.is_empty() && name.chars().all(|c| c.is_ascii_digit());
9222            let positional_known = if is_pure_digit {
9223                let idx: usize = name.parse().unwrap_or(0);
9224                if idx == 0 {
9225                    true // $0 always set
9226                } else {
9227                    idx <= exec.pparams().len()
9228                }
9229            } else {
9230                false
9231            };
9232            let known = !v.is_empty()
9233                || name.is_empty()
9234                || is_special_single
9235                || positional_known
9236                || crate::ported::params::paramtab()
9237                    .read()
9238                    .ok()
9239                    .map(|t| t.contains_key(&name))
9240                    .unwrap_or(false)
9241                || env::var(&name).is_ok();
9242            (v, force_dq || exec.in_dq_context > 0, known)
9243        });
9244        // c:Src/subst.c:1689 — NO_UNSET / nounset: reading an unset
9245        // parameter fires "parameter not set" diagnostic and aborts
9246        // the substitution. Direct port of the noerrs gate at c:1689
9247        // (zerr + errflag). Matches `set -u` POSIX semantics.
9248        // `nounset` is the negated alias of UNSET (c:Src/options.c optns), so
9249        // this is C's `unset(UNSET)`: one atomic load, not a name lookup.
9250        if !is_known && !isset(crate::ported::zsh_h::UNSET) {
9251            crate::ported::utils::zerr(&format!("{}: parameter not set", name));
9252            crate::ported::utils::errflag.fetch_or(
9253                crate::ported::zsh_h::ERRFLAG_ERROR,
9254                std::sync::atomic::Ordering::Relaxed,
9255            );
9256            with_executor(|exec| exec.set_last_status(1));
9257            return Value::str("");
9258        }
9259        // Empty unquoted scalar → drop the arg (zsh "remove empty
9260        // unquoted words" rule). Returning empty Value::Array makes
9261        // pop_args contribute zero items. DQ context keeps the empty
9262        // string so "$a" stays a single empty arg. Direct port of
9263        // subst.c's elide-empty pass.
9264        if val.is_empty() && !in_dq {
9265            // c:1650-1656 / c:4437 — a SCALAR parameter has `isarr == 0`,
9266            // so it never reaches plan9's word-removal at c:4362. Flag the
9267            // empty Array below as a scalar so `setopt rcexpandparam;
9268            // v=; print -rl -- x$v y` still emits `x` (only an empty
9269            // ARRAY deletes the word).
9270            note_empty_is_scalar(true);
9271            return Value::array(Vec::new());
9272        }
9273        // c:Src/subst.c:1759 SH_WORD_SPLIT — when shwordsplit is set and
9274        // we're in unquoted command-arg position (not DQ), split scalar
9275        // value on IFS into multiple words. Matches BUILTIN_ARRAY_ALL's
9276        // shwordsplit arm (fusevm_bridge.rs:2200). Without this, bare
9277        // `$s` in `print $s` stayed a single arg even with the option
9278        // set, breaking POSIX-style scalar word-splitting.
9279        if !in_dq && isset(crate::ported::zsh_h::SHWORDSPLIT) {
9280            // c:Src/subst.c:318-324 → c:1707 `spbreak`, c:3921 `sepsplit`, then
9281            // c:4366-4437 attach the word's affixes BEFORE c:184-187 deletes the
9282            // empty nodes. `spacesplit` (c:Src/utils.c:3730-3760, allownull 0)
9283            // gives a leading/trailing run of IFS-whitespace a real `""` node,
9284            // which survives only once text is glued onto it:
9285            //     setopt shwordsplit; s=' foo bar '; print -rl -- x${s}y
9286            //     zsh: x / foo / bar / y      (the edges became `x` and `y`)
9287            // while an IFS-non-whitespace empty field is `nulstring` and always
9288            // survives. This read cannot see the affixes, and dropping every
9289            // truly-empty field here made that `xfoo` / `bary`. paramsubst
9290            // already carries both rules (PARAMSUBST_AFFIXES_DEFERRED leaves the
9291            // removal to the word's end-of-word drop), and it is the same
9292            // expansion C runs, so hand it the `${name}` spelling.
9293            return paramsubst_to_value(&format!("${{{}}}", name));
9294        }
9295        Value::str(val)
9296    }
9297    // Provenance: BUILTIN_GET_VAR / _DQ are the bytecode-level parameter
9298    // READ ops, so this is the tap that hands a tracked parameter's
9299    // lineage to the value the read produced. The name is peeked off the
9300    // stack before `get_var_impl` consumes it.
9301    fn get_var_prov(vm: &mut fusevm::VM, argc: u8, force_dq: bool) -> Value {
9302        if !crate::provenance::active() {
9303            return get_var_impl(vm, argc, force_dq);
9304        }
9305        let name = vm.peek().to_str();
9306        let value = get_var_impl(vm, argc, force_dq);
9307        crate::provenance::on_param_read(&name, &value);
9308        value
9309    }
9310    vm.register_builtin(BUILTIN_GET_VAR, |vm, argc| get_var_prov(vm, argc, false));
9311    vm.register_builtin(BUILTIN_GET_VAR_DQ, |vm, argc| get_var_prov(vm, argc, true));
9312
9313    // `name+=val` (no parens) — runtime dispatch:
9314    //   - if `name` is in `arrays` → push `val` as new element
9315    //   - if `name` is in `assoc_arrays` → refuse (zsh errors here)
9316    //   - else → scalar concat (existing behavior)
9317    // Stack: [name, value].
9318    vm.register_builtin(BUILTIN_APPEND_SCALAR_OR_PUSH, |vm, argc| {
9319        let args = pop_args(vm, argc);
9320        let mut iter = args.into_iter();
9321        let name = iter.next().unwrap_or_default();
9322        let value = iter.next().unwrap_or_default();
9323        with_executor(|exec| {
9324            // Array form: `arr+=elem` pushes a single element.
9325            // Routes through canonical assignaparam(name, [value],
9326            // ASSPM_AUGMENT) — Src/params.c:3357 c:3402-3412 augment
9327            // path prepends prior scalar / appends to existing array.
9328            // Existence probe uses the non-cloning `has_array` — the
9329            // owning `exec.array()` clone here made `arr+=x` in a loop
9330            // O(n²) (see the assoc-store fix above).
9331            if exec.has_array(&name) {
9332                // c:Src/params.c — under KSHARRAYS a bare array name
9333                // addresses element 0 (ksh), so `a+=X` (scalar augment)
9334                // CONCATENATES onto the first element ("firstlast second"),
9335                // it does NOT push a new element. C routes scalar `+=`
9336                // through assignsparam (which targets the elem-0 value);
9337                // zshrs's APPEND_SCALAR_OR_PUSH would otherwise push.
9338                if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
9339                    let mut arr = exec.array(&name).unwrap_or_default();
9340                    if arr.is_empty() {
9341                        arr.push(value.clone());
9342                    } else {
9343                        arr[0] = format!("{}{}", arr[0], value);
9344                    }
9345                    exec.set_array(name.clone(), arr);
9346                    #[cfg(feature = "recorder")]
9347                    if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
9348                        let ctx = exec.recorder_ctx();
9349                        let attrs = exec.recorder_attrs_for(&name);
9350                        emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
9351                    }
9352                    return;
9353                }
9354                let _ = crate::ported::params::assignaparam(
9355                    &name,
9356                    vec![value.clone()],
9357                    crate::ported::zsh_h::ASSPM_AUGMENT
9358                        | crate::ported::zsh_h::ASSPM_WARN, // c:Src/exec.c:2573
9359                );
9360                #[cfg(feature = "recorder")]
9361                if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
9362                    let ctx = exec.recorder_ctx();
9363                    let attrs = exec.recorder_attrs_for(&name);
9364                    emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
9365                }
9366                return;
9367            }
9368            // An association falls through too: c:Src/params.c:3178-3192
9369            // `resetparam(v->pm, PM_SCALAR)`s a non-special PM_HASHED
9370            // target (unset KSHARRAYS) and the augment then appends to the
9371            // fresh empty scalar, so `typeset -A h; h+=x` leaves h="x".
9372            // Scalar / integer / float form: route through canonical
9373            // assignsparam(name, value, ASSPM_AUGMENT) which
9374            // dispatches PM_TYPE — PM_SCALAR concats, PM_INTEGER
9375            // arith-adds (c:2775-2778), PM_FLOAT float-adds.
9376            let _ = crate::ported::params::assignsparam(
9377                &name,
9378                &value,
9379                crate::ported::zsh_h::ASSPM_AUGMENT | crate::ported::zsh_h::ASSPM_WARN, // c:Src/exec.c:2573 addvars
9380            );
9381            #[cfg(feature = "recorder")]
9382            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
9383                let ctx = exec.recorder_ctx();
9384                let attrs = exec.recorder_attrs_for(&name);
9385                // Re-read the canonical value via get_variable for the
9386                // recorder bundle (assignsparam may have transformed it
9387                // through integer/float arithmetic).
9388                let final_val = exec.get_variable(&name);
9389                let lower = name.to_ascii_lowercase();
9390                if matches!(
9391                    lower.as_str(),
9392                    "path" | "fpath" | "manpath" | "module_path" | "cdpath"
9393                ) {
9394                    emit_path_or_assign(&name, std::slice::from_ref(&final_val), attrs, true, &ctx);
9395                } else {
9396                    crate::recorder::emit_assign_typed(&name, &final_val, attrs, ctx);
9397                }
9398            }
9399        });
9400        Value::Status(0)
9401    });
9402
9403    // BUILTIN_SET_VAR — `name=value` runtime scalar assignment.
9404    // PURE PASSTHRU: hand to canonical `setsparam` (C port of
9405    // `Src/params.c::setsparam`). That walks assignsparam →
9406    // assignstrvalue which already does:
9407    //   - readonly rejection (zerr + errflag at c:2701)
9408    //   - PM_INTEGER math evaluation (mathevali at c:3590)
9409    //   - PM_EFLOAT / PM_FFLOAT float coercion (c:3608)
9410    //   - PM_LOWER / PM_UPPER case fold (via setstrvalue)
9411    //   - GSU special-param dispatch (homesetfn / ifssetfn / etc.)
9412    //   - allexport env mirror via the PM_EXPORTED setfn
9413    //
9414    // Bridge-only concerns kept here:
9415    //   - inline_env_stack (zsh `X=foo cmd` scoped env)
9416    //   - recorder emission (PFA-SMR)
9417    //   - vm.last_status propagation for `a=$(cmd)` exit-code chaining
9418    // Sets the GLOB_ASSIGN-eligibility flag consumed by the NEXT BUILTIN_SET_VAR.
9419    // Emitted only when a scalar-assign RHS had an unquoted glob token. Takes no
9420    // args; its pushed return is discarded by a following Op::Pop.
9421    vm.register_builtin(BUILTIN_MARK_GLOB_ELIGIBLE, |_vm, _argc| {
9422        SET_VAR_GLOB_ELIGIBLE.with(|c| c.set(true));
9423        fusevm::Value::Int(0)
9424    });
9425    vm.register_builtin(BUILTIN_SET_VAR, |vm, argc| {
9426        // `${~spec}` carrier: an assignment statement is a word-
9427        // pipeline boundary too — restore the user's GLOB_SUBST
9428        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
9429        // ${options[globsubst]}` must read the user value).
9430        consume_tilde_globsubst_carrier();
9431        // Snapshot the raw Values BEFORE pop_args's to_str
9432        // flattening — needed to distinguish Int (arith assignment,
9433        // integer-typed param) from Str (scalar assignment).
9434        let mut raw_values: Vec<fusevm::Value> = Vec::with_capacity(argc as usize);
9435        for _ in 0..argc {
9436            raw_values.push(vm.pop());
9437        }
9438        raw_values.reverse();
9439        let name = raw_values.first().map(|v| v.to_str()).unwrap_or_default();
9440        let value_raw = raw_values.get(1).cloned();
9441        let value = value_raw.as_ref().map(|v| v.to_str()).unwrap_or_default();
9442        // c:Src/params.c — when the bytecode hands us an Int value
9443        // (only the arith assignment paths emit this — `(( X = N ))`
9444        // is the canonical site), route through setiparam so the
9445        // param ends up PM_INTEGER + inherits the math layer's
9446        // `lastbase` for display formatting (`(( X = 16#ff ));
9447        // echo \$X` → `16#FF`). Scalar `X=val` and `$((expr))`
9448        // assignments still take the setsparam path below.
9449        let int_assign = matches!(value_raw, Some(fusevm::Value::Int(_)));
9450        let float_assign = matches!(value_raw, Some(fusevm::Value::Float(_)));
9451        let mut assign_failed = false;
9452        with_executor(|exec| {
9453            // c:Src/params.c assignsparam — PM_READONLY rejection
9454            // BEFORE any env mutation. The inline-env-prefix path
9455            // (`X=2 env`) called env::set_var unconditionally before
9456            // the readonly check fired in setsparam, so the OS env
9457            // got X=2 even though the assignment errored. env then
9458            // inherited the polluted env from fork, leaking the
9459            // attempted override past the readonly guard. Mirror
9460            // C's order: readonly check → zerr → bail; only mutate
9461            // env when the assignment is admissible. Bug #551
9462            // (security-relevant).
9463            if exec.is_readonly_param(&name) {
9464                crate::ported::utils::zerr(&format!("read-only variable: {}", name));
9465                return;
9466            }
9467            // Inline-assignment frame tracking (`X=foo cmd` reverts on
9468            // command return). Only the PREFIX assignments belong in
9469            // the frame: c:Src/exec.c:4410 save_params snapshots the
9470            // parsed WC_ASSIGN chain and nothing else. The frame stays
9471            // on the stack while the command runs, so gate on
9472            // `recording` (cleared by SEAL_INLINE_ENV once the prefix
9473            // assignments have committed) — otherwise every assignment
9474            // the command itself makes gets recorded and then reverted
9475            // (`X=y . file` wiped every global the file defined).
9476            let prefix_restored = exec
9477                .inline_env_stack
9478                .last()
9479                .is_some_and(|frame| frame.recording);
9480            if prefix_restored {
9481                save_inline_prefix_param(exec, &name);
9482                let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value));
9483                // c:Src/params.c:5354
9484            }
9485            // c:Src/exec.c:2641 —
9486            //     if ((addflags & ADDVAR_EXPORT) && !strchr(name, '[')) {
9487            //         ...
9488            //         allexp = opts[ALLEXPORT];
9489            //         opts[ALLEXPORT] = 1;
9490            //         if (isset(KSHARRAYS))
9491            //             unsetparam(name);
9492            //         pm = assignsparam(name, val, myflags);
9493            //         opts[ALLEXPORT] = allexp;
9494            //     } else
9495            //         pm = assignsparam(name, val, myflags);
9496            // The assignment itself is what has to run under ALLEXPORT —
9497            // `createparam` ORs in PM_EXPORTED for a name that did not
9498            // exist (c:Src/params.c:1170-1171) and `assignstrvalue`'s tail
9499            // calls `export_param` for one that did (c:2836-2841) — so the
9500            // flip wraps the setsparam/setiparam/setnparam dispatch and the
9501            // env mirror below, and nothing else. Doing it any other way
9502            // would be special-casing the `${(t)}` readout instead of
9503            // marking the parameter.
9504            //
9505            // !!! NOT PORTED — the STTY leg (c:2642-2645 `STTYval =
9506            // ztrdup(val)`). `execute` consumes STTYval on the next
9507            // external command (exec.rs:4081) and C drops it again at
9508            // c:4310 / c:4400-4401 when the prefixed command finishes;
9509            // the bridge has no counterpart for those clears, so capturing
9510            // it here would apply `STTY=… cmd`'s settings to whatever
9511            // command ran next instead.
9512            let addvar_export = exec
9513                .inline_env_stack
9514                .last()
9515                .is_some_and(|frame| frame.recording && frame.export)
9516                && !name.contains('['); // c:2641
9517            let saved_allexport = if addvar_export {
9518                let allexp = isset(crate::ported::zsh_h::ALLEXPORT); // c:2646
9519                crate::ported::options::opt_state_set("allexport", true); // c:2647
9520                if isset(crate::ported::zsh_h::KSHARRAYS) {
9521                    // c:2648 — force the name to be recreated by the scalar
9522                    // assignment rather than written into the array that is
9523                    // already there: `setopt ksharrays; A=(1 2); A=x f`
9524                    // reports `scalar-export` inside the call in zsh.
9525                    crate::ported::params::unsetparam(&name); // c:2649
9526                }
9527                Some(allexp)
9528            } else {
9529                None
9530            };
9531            // c:Src/exec.c:2573 — `flags = !(addflags & ADDVAR_RESTORE) ?
9532            // ASSPM_WARN : 0;`, with the comment "Don't do this if there is a
9533            // list of variables marked to be restored after the command, since
9534            // then the assignment is implicitly scoped." A prefix assignment
9535            // recorded into the open inline-env frame is exactly that: it is
9536            // put back by restore_params when the command returns (c:4144-4145
9537            // `if (restorelist) flags |= ADDVAR_RESTORE;`, and c:4346-4347 for
9538            // an external's forked child). The non-recording arms (POSIX
9539            // special builtins, the do_save = 0 case) and plain assignments
9540            // keep the warning.
9541            let asg_flags = if prefix_restored {
9542                0
9543            } else {
9544                crate::ported::zsh_h::ASSPM_WARN as i32
9545            };
9546            // Canonical setsparam handles readonly, integer math, case
9547            // fold, GSU dispatch. For Int values (arith assigns) route
9548            // through setiparam so the param is PM_INTEGER + inherits
9549            // the math layer's lastbase for display formatting. For
9550            // Float (arith assigns producing MN_FLOAT) route through
9551            // setnparam so the param is PM_FFLOAT — `(( b = a * 2 ))`
9552            // with scalar `a="3.14"` should create b as typeset -F,
9553            // not a scalar holding "6.28".
9554            if int_assign {
9555                if let Some(fusevm::Value::Int(i)) = value_raw {
9556                    crate::ported::params::assigniparam(&name, i, asg_flags);
9557                } else {
9558                    assign_failed = crate::ported::params::assignsparam(&name, &value, asg_flags).is_none();
9559                }
9560            } else if float_assign {
9561                if let Some(fusevm::Value::Float(f)) = value_raw {
9562                    // ArithCompiler returns Value::Float whenever any
9563                    // operand came through Str (BUILTIN_GET_VAR yields
9564                    // Value::Str even for integer-shaped scalars). To
9565                    // avoid forcing every `(( b = a + 3 ))` to PM_FFLOAT
9566                    // when `a="5"` (integer-shaped), detect integer-
9567                    // valued floats and route through setiparam instead.
9568                    // True floats (non-integral) reach setnparam →
9569                    // PM_FFLOAT so `typeset -p b` shows `typeset -F …`.
9570                    if f.fract() == 0.0 && f.is_finite() && f.abs() <= i64::MAX as f64 {
9571                        crate::ported::params::assigniparam(&name, f as i64, asg_flags);
9572                    } else {
9573                        let mnval = crate::ported::math::mnumber {
9574                            l: 0,
9575                            d: f,
9576                            type_: crate::ported::math::MN_FLOAT,
9577                        };
9578                        crate::ported::params::assignnparam(&name, mnval, asg_flags);
9579                    }
9580                } else {
9581                    assign_failed = crate::ported::params::assignsparam(&name, &value, asg_flags).is_none();
9582                }
9583            } else {
9584                // c:Src/exec.c:2554-2567 — GLOB_ASSIGN. When the
9585                // `globassign` option is on and the scalar RHS is a glob
9586                // pattern, glob it and recreate the parameter as a scalar
9587                // (≤1 match) or array (>1) — csh-style assignment. The
9588                // bridge hands `value` UNTOKENIZED, so re-tokenize
9589                // (shtokenize) before haswilds/globlist; zsh's wordcode
9590                // value arrives pre-tokenized via `htok`. The
9591                // `isset(GLOBASSIGN)` gate is first and cheap (option off
9592                // by default), so the common path is unchanged.
9593                let mut globbed = false;
9594                // Only glob the RHS when the compiler flagged an UNQUOTED glob
9595                // token in the literal wordcode (SET_VAR_GLOB_ELIGIBLE). zsh's
9596                // GLOB_ASSIGN (Src/exec.c:2554) globs literal patterns only —
9597                // `x="/tmp/*"`, `x='/tmp/*'`, `x=$param`, `x=$(cmd)` all assign
9598                // verbatim. The value arrives here untokenized (DQ-wrapped by
9599                // the compiler), so this compile-time flag is the only surviving
9600                // signal of whether the pattern was quote-protected.
9601                let glob_eligible = SET_VAR_GLOB_ELIGIBLE.with(|c| c.replace(false));
9602                if glob_eligible && crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBASSIGN) {
9603                    let mut tv = value.clone();
9604                    crate::ported::glob::shtokenize(&mut tv);
9605                    if crate::ported::pattern::haswilds(&tv) {
9606                        // Committed to the glob path: never fall back to
9607                        // assigning the literal pattern (zsh errors on
9608                        // no-match instead).
9609                        globbed = true;
9610                        // globlist tokenizes its input internally (for
9611                        // haswilds + glob_path) and prints the ORIGINAL
9612                        // string verbatim in its "no matches found" error,
9613                        // so feed it the UNtokenized value — passing the
9614                        // tokenized form would leak the Star/Quest token
9615                        // bytes into the error message.
9616                        let mut ll: crate::ported::linklist::LinkList<String> = Default::default();
9617                        ll.push_back(value.clone());
9618                        crate::ported::subst::globlist(&mut ll, 0); // c:2556
9619                        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
9620                            == 0
9621                        {
9622                            let matches: Vec<String> = ll
9623                                .nodes
9624                                .iter()
9625                                .map(|s| crate::ported::lex::untokenize(s).to_string())
9626                                .collect();
9627                            crate::ported::params::unsetparam(&name); // c:2562
9628                            if matches.len() <= 1 {
9629                                let v = matches.into_iter().next().unwrap_or_default();
9630                                assign_failed =
9631                                    crate::ported::params::assignsparam(&name, &v, asg_flags).is_none();
9632                            } else {
9633                                crate::ported::params::assignaparam(&name, matches, asg_flags);
9634                            }
9635                        }
9636                        // errflag set → globlist already reported
9637                        // "no matches found"; leave the param unassigned
9638                        // to match zsh's abort.
9639                    }
9640                }
9641                // c:Src/exec.c addvars — a NULL return from
9642                // assignsparam (e.g. nameref resolving out of scope,
9643                // createparam refusal at c:1108-1118) fails the
9644                // assignment with status 1.
9645                if !globbed {
9646                    assign_failed = crate::ported::params::assignsparam(&name, &value, asg_flags).is_none();
9647                }
9648            }
9649            // PM_EXPORTED / allexport env mirror — read AFTER setsparam
9650            // so the flag bit reflects any GSU setfn side-effects.
9651            let allexport = opt_state_get("allexport").unwrap_or(false);
9652            let already_exported =
9653                (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
9654            if allexport || already_exported {
9655                // c:Src/params.c:3024 — the env mirror is `addenv(pm, value)`,
9656                // and addenv builds its string with `mkenvstr(nam, value,
9657                // pm->flags)` (c:5463) so `copyenvstr` (c:5434) can apply the
9658                // PM_LOWER / PM_UPPER fold. Formatting `name=value` by hand
9659                // skipped that: `typeset -lx v; v=HeLLo` exported `HeLLo`
9660                // where zsh exports `hello`. The fold has to happen HERE
9661                // because the paramtab now stores the value verbatim.
9662                let envstr = crate::ported::params::mkenvstr(
9663                    &name,
9664                    &value,
9665                    exec.param_flags(&name), // c:5463 pm->flags
9666                );
9667                let _ = crate::ported::params::zputenv(&envstr); // c:Src/params.c:5354
9668            }
9669            // c:2651 — `opts[ALLEXPORT] = allexp;`
9670            if let Some(allexp) = saved_allexport {
9671                crate::ported::options::opt_state_set("allexport", allexp);
9672            }
9673            #[cfg(feature = "recorder")]
9674            if crate::recorder::is_enabled()
9675                && exec.local_scope_depth == 0
9676                && !matches!(
9677                    name.as_str(),
9678                    "PPID" | "LINENO" | "ZSH_ARGZERO" | "argv0" | "ARGC" | "?" | "_" | "RANDOM"
9679                )
9680            {
9681                let ctx = exec.recorder_ctx();
9682                let attrs = exec.recorder_attrs_for(&name);
9683                crate::recorder::emit_assign_typed(&name, &value, attrs, ctx);
9684            }
9685            // c:Src/exec.c:1367-1370 — `if (code == WC_ASSIGN) { cmdoutval = 0;
9686            // addvars(state, state->pc - 1, 0); setunderscore(""); … }`. A
9687            // simple command consisting ONLY of scalar assignments clears `$_`;
9688            // it never goes through execcmd's c:3545-3547
9689            // `setunderscore(lastnode(args))`. src/ported/exec.rs:6946-6950
9690            // already ports that arm, but the WC_ASSIGN wordcode never
9691            // executes under fusevm — a bare `x=1` arrives here as
9692            // BUILTIN_SET_VAR, so `$_` kept the PREVIOUS command's last
9693            // argument. Symptom: in the `unset <TAB>` listing (the user's
9694            // `_parameters` override runs `maxLen=50` right before the
9695            // `$parameters` walk) zsh shows `_` empty while zshrs showed the
9696            // completion-internal `^a*` — `_parameters -g '^a*'`'s last arg.
9697            //
9698            // Two exclusions, both verified against zsh 5.9.2
9699            // (`true aa; <form>; print -r -- "[$_]"`):
9700            //   * PREFIX assignments (`x=1 true dd` → `dd`) are part of a
9701            //     command, so c:3545-3547 owns `$_`. They are exactly the
9702            //     assignments recorded into an open inline-env frame above.
9703            //   * `(( q = 1 ))` (→ `aa`, unchanged) is WC_ARITH, not
9704            //     WC_ASSIGN; the arith paths are the only ones that hand this
9705            //     builtin an Int/Float `Value` (see the `int_assign` note).
9706            if !int_assign
9707                && !float_assign
9708                && !exec
9709                    .inline_env_stack
9710                    .last()
9711                    .is_some_and(|frame| frame.recording)
9712            {
9713                // c:1369 — assignment-only command clears `$_`. The
9714                // former DUAL-STATE note here is obsolete: `set_zunderscore`
9715                // and the ported `setunderscore` now write the SAME
9716                // `init::zunderscore` global (params.rs `zunderscore_lock`
9717                // points at it), matching C's single store, so this one call
9718                // is the whole effect.
9719                crate::ported::exec::setunderscore(""); // c:1369
9720            }
9721        });
9722        // c:Src/exec.c:2654-2663 — `if (!pm) { lastval = 1; ... if
9723        // (!cmdoutval) cmdoutval = 1; }`; BUILTIN_ASSIGN_ONLY_STATUS
9724        // reads the flag back as the command's status.
9725        if assign_failed {
9726            ASSIGN_FAILED_FLAG.store(true, std::sync::atomic::Ordering::Relaxed);
9727        }
9728        Value::Status(vm.last_status)
9729    });
9730
9731    // c:Src/exec.c execfor → Src/params.c:6362 setloopvar — the
9732    // for-loop variable bind. Distinct from BUILTIN_SET_VAR because a
9733    // PM_NAMEREF loop variable REBINDS (new refname) instead of
9734    // assigning through the resolved chain.
9735    vm.register_builtin(BUILTIN_SET_LOOP_VAR, |vm, argc| {
9736        let args = pop_args(vm, argc);
9737        let name = args.first().cloned().unwrap_or_default();
9738        let value = args.get(1).cloned().unwrap_or_default();
9739        if crate::vm_helper::is_nameref(&name) {
9740            let ef_before =
9741                crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
9742            crate::ported::params::setloopvar(&name, &value); // c:6362
9743            let ef_after = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
9744            if (ef_after & crate::ported::utils::ERRFLAG_ERROR) != 0 && ef_after != ef_before {
9745                // zerr fired (read-only reference / invalid self
9746                // reference) — abort the loop, status 1 (C errflag).
9747                vm.last_status = 1;
9748                return Value::Bool(false);
9749            }
9750            return Value::Bool(true);
9751        }
9752        // Plain loop var — canonical scalar path (same shape as
9753        // BUILTIN_SET_VAR's setsparam arm).
9754        with_executor(|exec| {
9755            if exec.is_readonly_param(&name) {
9756                crate::ported::utils::zerr(&format!("read-only variable: {}", name));
9757                return;
9758            }
9759            crate::ported::params::setsparam(&name, &value);
9760            let allexport = opt_state_get("allexport").unwrap_or(false);
9761            let already_exported =
9762                (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
9763            if allexport || already_exported {
9764                // c:Src/params.c:3024 — the env mirror is `addenv(pm, value)`,
9765                // and addenv builds its string with `mkenvstr(nam, value,
9766                // pm->flags)` (c:5463) so `copyenvstr` (c:5434) can apply the
9767                // PM_LOWER / PM_UPPER fold. Formatting `name=value` by hand
9768                // skipped that: `typeset -lx v; v=HeLLo` exported `HeLLo`
9769                // where zsh exports `hello`. The fold has to happen HERE
9770                // because the paramtab now stores the value verbatim.
9771                let envstr = crate::ported::params::mkenvstr(
9772                    &name,
9773                    &value,
9774                    exec.param_flags(&name), // c:5463 pm->flags
9775                );
9776                let _ = crate::ported::params::zputenv(&envstr); // c:Src/params.c:5354
9777            }
9778        });
9779        Value::Bool(true)
9780    });
9781
9782    // Pre-compiled function registration — used by compile_zsh.rs's
9783    // FuncDef path. Stack: [name, base64-bincode-of-Chunk]. We decode
9784    // the base64, deserialize the Chunk, and store directly in
9785    // executor.functions_compiled. Bypasses the ShellCommand JSON layer.
9786    // BUILTIN_VAR_EXISTS — `[[ -v name ]]` set-test.
9787    // PURE PASSTHRU: build `${+name}` and route through canonical
9788    // `subst::paramsubst` which returns "1" for set / "0" for unset
9789    // (C port of `Src/subst.c::paramsubst` plus-prefix arm).
9790    // paramsubst handles all the shapes the 48-line hand-roll did:
9791    //   - bare scalar / array / assoc
9792    //   - subscripted `a[N]` / `h[key]`
9793    //   - positional params (any digit-only name)
9794    //   - env-var fallback (`HOME` set via getsparam → lookup_special_var)
9795    vm.register_builtin(BUILTIN_VAR_EXISTS, |vm, _argc| {
9796        let name = vm.pop().to_str();
9797        // c:Src/cond.c:361 `case 'v': return !issetvar(left)`. `-v` is
9798        // NOT `${+name}` — issetvar (params.c:751) additionally rejects
9799        // trailing chars after the parsed name/subscript (`arr[3]extra`,
9800        // nested `arr[2][1]`) and validates array-slice bounds (an
9801        // out-of-range `(i)`-not-found index is "unset"). `${+}` is
9802        // lenient and reported those as set.
9803        Value::Bool(crate::ported::params::issetvar(&name) != 0)
9804    });
9805
9806    // `time { compound; ... }` — runs the sub-chunk and prints elapsed
9807    // wall-clock time. zsh's full `time` also tracks user/system CPU via
9808    // getrusage on the *child*; we approximate via wall-time only since
9809    // the sub-chunk runs in-process (no fork). Output format matches
9810    // `time simple-cmd` (already implemented elsewhere via exectime).
9811    vm.register_builtin(BUILTIN_TIME_SUBLIST, |vm, argc| {
9812        // A negative sub-chunk index is the compiler's marker for the BARE
9813        // `time` keyword, which has no body to run.
9814        // c:Src/exec.c:5331-5334 exectime:
9815        //   if (WC_TIMED_TYPE(state->pc[-1]) == WC_TIMED_EMPTY) {
9816        //       shelltime(NULL,NULL,NULL,0);
9817        //       return 0;
9818        //   }
9819        // `shelltime(NULL, NULL, NULL, 0)` is the ONE call that prints the
9820        // shell/children pair: with delta==0 and both pointers NULL, both
9821        // `!delta == !shell` (c:Src/jobs.c:1964) and `!delta == !kids`
9822        // (c:1985) hold. Verified: `zsh -fc 'time'` prints
9823        //   shell  0.00s user 0.00s system … / children  0.00s user …
9824        // while every `time <body>` form prints nothing (see the is_cursh
9825        // note below). zshrs previously compiled bare `time` to a plain
9826        // `status = 0` and printed nothing at all.
9827        let sub_idx_raw = vm.pop().to_int();
9828        let sub_idx = sub_idx_raw as usize;
9829        // c:Src/jobs.c:1028-1029 — `pn->text` arg to printtime. argc==2
9830        // means the compiler also pushed a desc string (bug #66 fix);
9831        // older callers with argc==1 push only sub_idx and we synthesize
9832        // an empty desc for backward compat with cached bytecode that
9833        // predates the desc-threading patch.
9834        let desc = if argc >= 2 {
9835            vm.pop().to_str().to_string()
9836        } else {
9837            String::new()
9838        };
9839        // c:Src/exec.c:3536-3538 — `if (!text && (!sfcontext && (jobbing ||
9840        // (how & Z_TIMED)))) text = getjobtext(…)`: inside a shell function
9841        // (sfcontext = SFC_DIRECT, c:5632-5633) no job text is taken, so the
9842        // report's %J is empty: `f(){ time /bin/sleep 0 }; f` prints
9843        // `  0.00s user …` with no command.
9844        let desc = if crate::ported::exec::sfcontext.load(std::sync::atomic::Ordering::Relaxed)
9845            != crate::ported::zsh_h::SFC_NONE
9846        {
9847            String::new()
9848        } else {
9849            desc
9850        };
9851        // c:Src/exec.c:3690 — the compiler's `is_cursh` verdict for the
9852        // timed body (see compile_zsh.rs `time_cursh_hint`): 1 = current
9853        // shell, 0 = forked job, 2 = decide from the command name below.
9854        // argc < 4 means bytecode cached before this operand pair existed;
9855        // fall back to the old fork-counter heuristic in that case.
9856        let (cursh_hint, cursh_name) = if argc >= 4 {
9857            let hint = vm.pop().to_int();
9858            let name = vm.pop().to_str().to_string();
9859            (hint, name)
9860        } else {
9861            (-1, String::new())
9862        };
9863        // c:Src/jobs.c:1029-1037 dumptime — a timed multi-stage pipeline
9864        // arrives with its per-stage texts and the last stage's is_cursh
9865        // verdict (compile_zsh.rs Time arm): [texts…, last_hint, last_name, n].
9866        let timed_pipeline = if argc > 4 {
9867            let n = vm.pop().to_int().max(0) as usize;
9868            let last_name = vm.pop().to_str().to_string();
9869            let last_hint = vm.pop().to_int();
9870            let mut texts: Vec<String> = (0..n).map(|_| vm.pop().to_str().to_string()).collect();
9871            texts.reverse();
9872            Some(TimedPipeline {
9873                texts,
9874                last_forks: !time_body_is_cursh(last_hint, &last_name),
9875                procs: Vec::new(),
9876            })
9877        } else {
9878            None
9879        };
9880        if sub_idx_raw < 0 {
9881            crate::ported::jobs::shelltime(None, None, None, 0); // c:5333
9882            return Value::Status(0); // c:5334
9883        }
9884        let chunk_opt = vm.chunk.sub_chunks.get(sub_idx).cloned();
9885        let Some(chunk) = chunk_opt else {
9886            return Value::Status(0);
9887        };
9888        // c:Src/jobs.c:1968 — `getrusage(RUSAGE_CHILDREN, &ti)` before
9889        // and after the timed sublist gives accurate per-stage user/sys
9890        // CPU. Wall-time-only approximation (0.7×/0.1× fudge factors)
9891        // produced bogus user/sys columns and ignored TIMEFMT. Bug #66
9892        // in docs/BUGS.md.
9893        let ru_before: libc::rusage = unsafe {
9894            let mut r: libc::rusage = std::mem::zeroed();
9895            libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
9896            r
9897        };
9898        // c:Src/jobs.c — zsh's `time` reports only for JOBS (forked
9899        // work). Builtins/brace-groups/functions run in the shell
9900        // process with no job, so `zsh -fc 'time true'` emits NOTHING.
9901        // Snapshot the fork-event counter; report only if the timed
9902        // body forked (external command or subshell).
9903        let forks_before = crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed);
9904        // c:Src/jobs.c:1943 — `getrusage(RUSAGE_SELF, &ti)` — shelltime's
9905        // "shell" line reports the SHELL PROCESS's own CPU delta, which is
9906        // where a current-shell (`is_cursh`) body's work lands.
9907        let ru_self_before: libc::rusage = unsafe {
9908            let mut r: libc::rusage = std::mem::zeroed();
9909            libc::getrusage(libc::RUSAGE_SELF, &mut r);
9910            r
9911        };
9912        let start = Instant::now();
9913        crate::fusevm_disasm::maybe_print_stdout("time_sublist", &chunk);
9914        let mut sub_vm = fusevm::VM::new(chunk);
9915        register_builtins(&mut sub_vm);
9916        let outer_timed = TIMED_PIPELINE.with(|t| t.replace(timed_pipeline));
9917        let _ = sub_vm.run();
9918        let timed_pipeline = TIMED_PIPELINE.with(|t| t.replace(outer_timed));
9919        let status = sub_vm.last_status;
9920        let elapsed = start.elapsed();
9921        let ru_self_after: libc::rusage = unsafe {
9922            let mut r: libc::rusage = std::mem::zeroed();
9923            libc::getrusage(libc::RUSAGE_SELF, &mut r);
9924            r
9925        };
9926        let ru_after: libc::rusage = unsafe {
9927            let mut r: libc::rusage = std::mem::zeroed();
9928            libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
9929            r
9930        };
9931        // Delta children rusage = timed work's CPU.
9932        let mut delta = ru_after;
9933        let sub = timeval_sub;
9934        delta.ru_utime = sub(ru_after.ru_utime, ru_before.ru_utime);
9935        delta.ru_stime = sub(ru_after.ru_stime, ru_before.ru_stime);
9936        let ti = crate::ported::zsh_h::timeinfo::from_rusage(&delta);
9937        // c:Src/jobs.c:808-809 — `s = getsparam("TIMEFMT"); s ||
9938        // DEFAULT_TIMEFMT`. Honor user-set TIMEFMT, fall back to the
9939        // canonical default.
9940        let fmt = crate::ported::params::getsparam("TIMEFMT")
9941            .unwrap_or_else(|| crate::ported::zsh_system_h::DEFAULT_TIMEFMT.to_string());
9942        // c:Src/jobs.c:768 `desc` arg — for the `time { sublist }` /
9943        // `time simple-cmd` keyword path, zsh passes the sublist's
9944        // source text (used by %J via printtime). The compiler now
9945        // threads the rendered source text through as the desc operand
9946        // (compile_zsh.rs Time arm, argc==2 form). Bug #66.
9947        // c:Src/exec.c:3690 — resolve the compiler's verdict. Hint 2 means
9948        // "a simple command whose head word decides it": `is_builtin ||
9949        // is_shfunc`. A reserved-word / builtin / function head runs in the
9950        // current shell; anything else forks and becomes a job.
9951        let is_cursh = match cursh_hint {
9952            1 => true,
9953            0 => false,
9954            2 => time_body_is_cursh(2, &cursh_name),
9955            // Bytecode cached before the hint operands existed: keep the
9956            // historical fork-counter heuristic (report only if something
9957            // forked) so a stale cache doesn't start emitting shell/children
9958            // lines for every builtin.
9959            _ => {
9960                let forked = crate::vm_helper::FORK_EVENTS
9961                    .load(std::sync::atomic::Ordering::Relaxed)
9962                    != forks_before;
9963                if forked {
9964                    let line =
9965                        crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
9966                    eprintln!("{}", line);
9967                }
9968                return Value::Status(status);
9969            }
9970        };
9971        let _ = forks_before;
9972        // c:Src/exec.c:1699 — execpline hands `how` (Z_TIMED included) to
9973        // the pipeline's last stage, and a last stage that runs in the
9974        // current shell reaches the c:4443 shelltime call below just as a
9975        // timed builtin does: `time /bin/echo a | read x` prints the shell
9976        // and children lines before dumptime's line for the forked stage.
9977        let last_stage_cursh = timed_pipeline.as_ref().is_some_and(|t| !t.last_forks);
9978        if is_cursh || last_stage_cursh {
9979            // c:Src/exec.c:4443-4444 — `if ((is_cursh || do_exec) && (how &
9980            // Z_TIMED)) shelltime(&shti, &chti, &then, 1);`
9981            //
9982            // c:Src/jobs.c:1933-1993 shelltime(shell, kids, then, delta=1):
9983            //   getrusage(RUSAGE_SELF, &ti);  dtime_tv(… shell delta …);
9984            //   dtime_ts(&dtimespec, then, &now);
9985            //   if (!delta == !shell)  printtime(&dtimespec, &ti, "shell");
9986            //   getrusage(RUSAGE_CHILDREN, &ti); dtime_tv(… kids delta …);
9987            //   if (!delta == !kids)   printtime(&dtimespec, &ti, "children");
9988            // With delta=1 and both pointers non-NULL, BOTH lines print.
9989            //
9990            // !!! DO NOT "FIX" THIS TO PRINT NOTHING !!!
9991            // The locally-installed `zsh` may print nothing here and look
9992            // like the oracle. It is not: this behaviour was ADDED by
9993            // upstream 53088 (ChangeLog 2024-09-14, Bart Schaefer) —
9994            // "Src/exec.c, Src/jobs.c, Test/A01grammar.ztst,
9995            //  Test/A08time.ztst: enable `time' on builtins, assignments,
9996            //  and other current-shell actions, including failed commands."
9997            // — which also ADDED Test/A08time.ztst chunks 8-15, the ones
9998            // that assert `shell*` / `children*` for `time x=1`,
9999            // `time echo $(…)`, `time for ((…))`, `time builtin nonesuch`
10000            // and `time false`. zsh 5.9 (2022-05) predates 53088, so a 5.9.x
10001            // binary is silent for every one of those shapes while both
10002            // vendored C trees (src/zsh 5.9.0.3-test and 5.9.999.3-test)
10003            // carry the c:4443 call. Corpus + C source are the spec here.
10004            let mut d_self = ru_self_after;
10005            d_self.ru_utime = sub(ru_self_after.ru_utime, ru_self_before.ru_utime); // c:1954
10006            d_self.ru_stime = sub(ru_self_after.ru_stime, ru_self_before.ru_stime); // c:1955
10007            let ti_self = crate::ported::zsh_h::timeinfo::from_rusage(&d_self);
10008            // c:1972 — `printtime(&dtimespec, &ti, "shell");`
10009            eprintln!(
10010                "{}",
10011                crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti_self, &fmt, "shell")
10012            );
10013            // c:1993 — `printtime(&dtimespec, &ti, "children");`
10014            eprintln!(
10015                "{}",
10016                crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, "children")
10017            );
10018        }
10019        if is_cursh {
10020            // A current-shell body is no job: nothing more to report.
10021        } else if let Some(report) = timed_pipeline.filter(|t| !t.procs.is_empty()).and_then(|t| {
10022            // c:Src/jobs.c:1029-1037 — `for (pn = jn->procs; pn; pn = pn->next)
10023            // printtime(dtime_ts(…, &pn->bgtime, &pn->endtime), &pn->ti,
10024            // pn->text);` — one line per process the pipeline forked.
10025            let jn = crate::ported::zsh_h::job {
10026                procs: t.procs,
10027                ..Default::default()
10028            };
10029            crate::ported::jobs::dumptime(&jn)
10030        }) {
10031            eprintln!("{}", report);
10032        } else if !last_stage_cursh {
10033            // c:Src/jobs.c:1037 — the forked job's own printtime line, with
10034            // `pn->text` (the command source) as %J.
10035            let line = crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
10036            eprintln!("{}", line);
10037        }
10038        Value::Status(status)
10039    });
10040
10041    // `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocator.
10042    // Stack: [path, varid, op_byte]. Opens path with the appropriate mode
10043    // and stores the resulting fd number in $varid as a string. We use
10044    // a high starting fd (10+) by allocating then dup'ing — matches zsh's
10045    // "fresh fd >= 10" promise so subsequent commands don't collide on
10046    // stdin/out/err.
10047    vm.register_builtin(BUILTIN_OPEN_NAMED_FD, |vm, _argc| {
10048        use std::sync::atomic::Ordering;
10049        let op_byte = vm.pop().to_int() as u8;
10050        let varid = vm.pop().to_str();
10051        let path = vm.pop().to_str();
10052        // Param introspection used by both the open and close forms.
10053        // c:Src/exec.c:2188 `getvalue(&vbuf, &s, 0)` reaches the node through
10054        // realparamtab->getnode, which is getprivatenode once zsh/param/private
10055        // is set up (Src/Modules/param_private.c:678): a private from an
10056        // enclosing scope is not this parameter.
10057        let param_flags = crate::ported::params::paramtab().read().ok().and_then(|t| {
10058            t.get(&varid).and_then(|p| {
10059                let vis = crate::ported::modules::param_private::getprivatenode(&**p as *const _);
10060                (!vis.is_null()).then(|| unsafe { (*vis).node.flags })
10061            })
10062        });
10063        let param_readonly = param_flags
10064            .map(|f| (f & crate::ported::zsh_h::PM_READONLY as i32) != 0)
10065            .unwrap_or(false);
10066        // `{varid}>&-` / `{varid}<&-` — REDIR_CLOSE with varid.
10067        // Direct port of Src/exec.c:3805-3850.
10068        if matches!(
10069            op_byte,
10070            b if b == fusevm::op::redirect_op::DUP_WRITE
10071                || b == fusevm::op::redirect_op::DUP_READ
10072        ) {
10073            let n = path.trim_start_matches('&');
10074            if n == "-" {
10075                let val = with_executor(|exec| exec.scalar(&varid)).unwrap_or_default();
10076                let fd1 = val.parse::<i32>();
10077                // c:3811-3816 — bad=1: parameter doesn't contain an fd.
10078                let Ok(fd1) = fd1 else {
10079                    crate::ported::utils::zwarn(&format!(
10080                        "parameter {} does not contain a file descriptor",
10081                        varid
10082                    ));
10083                    with_executor(|exec| exec.redirect_failed = true);
10084                    return Value::Status(1);
10085                };
10086                // c:3813-3814 — bad=2: readonly parameter.
10087                if param_readonly {
10088                    crate::ported::utils::zwarn(&format!(
10089                        "can't close file descriptor from readonly parameter {}",
10090                        varid
10091                    ));
10092                    with_executor(|exec| exec.redirect_failed = true);
10093                    return Value::Status(1);
10094                }
10095                // c:3830-3835 — bad=3: fd >= 10 marked FDT_INTERNAL.
10096                if fd1 >= 10
10097                    && fd1 <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
10098                    && crate::ported::utils::fdtable_get(fd1) == crate::ported::zsh_h::FDT_INTERNAL
10099                {
10100                    crate::ported::utils::zwarn(&format!(
10101                        "file descriptor {} used by shell, not closed",
10102                        fd1
10103                    ));
10104                    with_executor(|exec| exec.redirect_failed = true);
10105                    return Value::Status(1);
10106                }
10107                // c:3870-3873 — close; report failure (varid form
10108                // always reports, unlike bare `N>&-`).
10109                // In an in-process subshell only the body's copy closes.
10110                crate::ported::exec::SubshFdFrame::touch(fd1);
10111                if crate::ported::utils::zclose(fd1) < 0 {
10112                    crate::ported::utils::zwarn(&format!(
10113                        "failed to close file descriptor {}: {}",
10114                        fd1,
10115                        crate::ported::utils::zsh_errno_msg(
10116                            std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
10117                        ) // c:Src/exec.c:3929 `%e`
10118                    ));
10119                    return Value::Status(1);
10120                }
10121                return Value::Status(0);
10122            }
10123            // `{varid}>&N` — dup N to a fresh fd >= 10, store in varid.
10124            if let Ok(src) = n.parse::<i32>() {
10125                if param_readonly {
10126                    crate::ported::utils::zwarn(&format!(
10127                        "can't allocate file descriptor to readonly parameter {}",
10128                        varid
10129                    ));
10130                    with_executor(|exec| exec.redirect_failed = true);
10131                    return Value::Status(1);
10132                }
10133                let dup = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
10134                if dup < 0 {
10135                    crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src));
10136                    with_executor(|exec| exec.redirect_failed = true);
10137                    return Value::Status(1);
10138                }
10139                // c:2404-2412 addfd varid arm — movefd + FDT_EXTERNAL.
10140                let final_fd = crate::ported::utils::movefd(dup);
10141                crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
10142                // In an in-process subshell the descriptor dies with the body.
10143                crate::ported::exec::SubshFdFrame::opened(final_fd);
10144                with_executor(|exec| {
10145                    #[cfg(feature = "recorder")]
10146                    crate::recorder::note_descriptor_param(&varid);
10147                    exec.set_scalar(varid, final_fd.to_string());
10148                });
10149                return Value::Status(0);
10150            }
10151            return Value::Status(1);
10152        }
10153        // `{varid}<<HERE` (op byte 255) / `{varid}<<<str` (op byte
10154        // 254) — zshrs-side contract with compile_redir; fusevm's
10155        // redirect_op stops at 8. C path: gethere/getherestr write the
10156        // body to a temp file (Src/exec.c:4660-4682), then addfd's
10157        // varid arm moves the read fd >= 10, marks FDT_EXTERNAL and
10158        // sets the param (c:2402-2412). `path` carries the BODY text
10159        // here.
10160        //
10161        // The two markers ARE the `REDIRF_FROM_HEREDOC` distinction of
10162        // c:Src/exec.c:4671-4672 (flag set at c:Src/parse.c:2970-2971):
10163        // 255 is the here-DOCUMENT spelling, whose body reaches the
10164        // consumer byte-for-byte, and 254 the genuine here-STRING,
10165        // which gains one trailing newline "as if the string given was
10166        // a complete command line" (c:4665-4666).
10167        if op_byte == 255 || op_byte == 254 {
10168            let from_heredoc = op_byte == 255;
10169            if param_readonly {
10170                crate::ported::utils::zwarn(&format!(
10171                    "can't allocate file descriptor to readonly parameter {}",
10172                    varid
10173                ));
10174                with_executor(|exec| exec.redirect_failed = true);
10175                return Value::Status(1);
10176            }
10177            // c:4671-4672 — `trim_end_matches('\n')` + unconditional
10178            // append was lossy in both directions: `exec {f}<<EOF`
10179            // with two trailing blank lines produced "hello\n" where
10180            // zsh produces "hello\n\n\n".
10181            let body = if from_heredoc {
10182                path
10183            } else {
10184                format!("{}\n", path)
10185            };
10186            let mut tmpl: Vec<u8> = b"/tmp/zshrs_hd_XXXXXX\0".to_vec();
10187            let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
10188            if write_fd < 0 {
10189                crate::ported::utils::zwarn(&format!(
10190                    "can't create temp file for here document: {}",
10191                    crate::ported::utils::zsh_errno_msg(
10192                        std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
10193                    ) // c:Src/exec.c:3828 `%e`
10194                ));
10195                return Value::Status(1);
10196            }
10197            let bytes = body.as_bytes();
10198            let mut off = 0;
10199            while off < bytes.len() {
10200                let n = unsafe {
10201                    libc::write(
10202                        write_fd,
10203                        bytes[off..].as_ptr() as *const libc::c_void,
10204                        bytes.len() - off,
10205                    )
10206                };
10207                if n <= 0 {
10208                    unsafe { libc::close(write_fd) };
10209                    return Value::Status(1);
10210                }
10211                off += n as usize;
10212            }
10213            unsafe { libc::close(write_fd) };
10214            let read_fd =
10215                unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
10216            unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
10217            if read_fd < 0 {
10218                return Value::Status(1);
10219            }
10220            let final_fd = crate::ported::utils::movefd(read_fd);
10221            if final_fd < 0 {
10222                return Value::Status(1);
10223            }
10224            crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
10225            // In an in-process subshell the descriptor dies with the body.
10226            crate::ported::exec::SubshFdFrame::opened(final_fd);
10227            with_executor(|exec| {
10228                #[cfg(feature = "recorder")]
10229                crate::recorder::note_descriptor_param(&varid);
10230                exec.set_scalar(varid, final_fd.to_string());
10231            });
10232            return Value::Status(0);
10233        }
10234        // Open form: `{varid}>file` etc.
10235        // c:Src/exec.c:2177-2215 checkclobberparam — gate BEFORE open.
10236        if param_readonly {
10237            // c:2191-2197
10238            crate::ported::utils::zwarn(&format!(
10239                "can't allocate file descriptor to readonly parameter {}",
10240                varid
10241            ));
10242            with_executor(|exec| exec.redirect_failed = true);
10243            return Value::Status(1);
10244        }
10245        // c:2199-2213 — NO_CLOBBER refuses to overwrite a parameter
10246        // already holding an OPEN fd (decimal value, fdtable says
10247        // FDT_EXTERNAL).
10248        if !isset(crate::ported::zsh_h::CLOBBER) && op_byte != fusevm::op::redirect_op::CLOBBER {
10249            if let Some(val) = with_executor(|exec| exec.scalar(&varid)) {
10250                if let Ok(fd) = val.parse::<i32>() {
10251                    if fd >= 0
10252                        && fd <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
10253                        && crate::ported::utils::fdtable_get(fd)
10254                            == crate::ported::zsh_h::FDT_EXTERNAL
10255                    {
10256                        crate::ported::utils::zwarn(&format!(
10257                            "can't clobber parameter {} containing file descriptor {}",
10258                            varid, fd
10259                        ));
10260                        with_executor(|exec| exec.redirect_failed = true);
10261                        return Value::Status(1);
10262                    }
10263                }
10264            }
10265        }
10266        let path_c = match CString::new(path.clone()) {
10267            Ok(c) => c,
10268            Err(_) => return Value::Status(1),
10269        };
10270        let flags = match op_byte {
10271            b if b == fusevm::op::redirect_op::READ => libc::O_RDONLY,
10272            b if b == fusevm::op::redirect_op::WRITE || b == fusevm::op::redirect_op::CLOBBER => {
10273                libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC
10274            }
10275            b if b == fusevm::op::redirect_op::APPEND => {
10276                libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND
10277            }
10278            b if b == fusevm::op::redirect_op::READ_WRITE => libc::O_RDWR | libc::O_CREAT,
10279            _ => return Value::Status(1),
10280        };
10281        let fd = unsafe { libc::open(path_c.as_ptr(), flags, 0o666) };
10282        if fd < 0 {
10283            // c:Src/exec.c:3790-3795 — report the open failure and mark the
10284            // redirect failed so the command is SKIPPED, matching the numeric-fd
10285            // (`3< file`) and non-varid (`< file`) paths. Previously this
10286            // silently returned Status(1) with no diagnostic and no
10287            // redirect_failed flag, so `{fd}< /nonexistent` ran the command
10288            // anyway with exit 0 (zsh errors "no such file or directory" +
10289            // skips the command). `%e: %s` = strerror(errno) : filename.
10290            let e = std::io::Error::last_os_error();
10291            let msg = redir_errno_msg(&e);
10292            crate::ported::utils::zwarn(&format!("{}: {}", msg, path));
10293            with_executor(|exec| exec.redirect_failed = true);
10294            return Value::Status(1);
10295        }
10296        // c:2404-2412 addfd varid arm — `fd1 = movefd(fd2);
10297        // fdtable[fd1] = FDT_EXTERNAL; setiparam(varid, fd1);`.
10298        // FDT_EXTERNAL (not INTERNAL): the user owns this fd — the
10299        // NO_CLOBBER gate above and `{fd}>&-` close both key off it.
10300        let final_fd = crate::ported::utils::movefd(fd);
10301        if final_fd < 0 {
10302            crate::ported::utils::zerr(&format!(
10303                "cannot move fd {}: {}",
10304                fd,
10305                crate::ported::utils::zsh_errno_msg(
10306                    std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
10307                ) // c:Src/exec.c:2463 `%e`
10308            ));
10309            return Value::Status(1);
10310        }
10311        crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
10312        // In an in-process subshell the descriptor dies with the body.
10313        crate::ported::exec::SubshFdFrame::opened(final_fd);
10314        let _ = Ordering::Relaxed;
10315        with_executor(|exec| {
10316            #[cfg(feature = "recorder")]
10317            crate::recorder::note_descriptor_param(&varid);
10318            exec.set_scalar(varid, final_fd.to_string());
10319        });
10320        Value::Status(0)
10321    });
10322
10323    // BUILTIN_SET_TRY_BLOCK_ERROR — capture the try-block's exit
10324    // status into `__zshrs_try_block_saved_status` (a scratch
10325    // scalar) so the always-arm can later restore it. Also set
10326    // `TRY_BLOCK_ERROR` per zsh semantics: it stays at -1 unless
10327    // the try-block fired an explicit error (errflag), per
10328    // c:Src/exec.c execlist's WC_TRYBLOCK arm.
10329    vm.register_builtin(BUILTIN_SET_TRY_BLOCK_ERROR, |vm, _argc| {
10330        use std::sync::atomic::Ordering;
10331        let vm_status = vm.last_status;
10332        // c:Src/exec.c WC_TRYBLOCK — the always-arm runs with a
10333        // clean escape state. Snapshot RETFLAG / BREAKS / CONTFLAG /
10334        // EXIT_PENDING here and clear them; RESTORE_TRY_BLOCK_STATUS
10335        // re-applies them at always-arm exit so the propagation jump
10336        // emitted by compile_zsh fires correctly.
10337        let ret_save = crate::ported::builtin::RETFLAG.swap(0, Ordering::Relaxed); // c:769-770
10338        let brk_save = crate::ported::builtin::BREAKS.swap(0, Ordering::Relaxed); // c:771-772
10339        let cont_save = crate::ported::builtin::CONTFLAG.swap(0, Ordering::Relaxed); // c:773-774
10340        let exit_save = crate::ported::builtin::EXIT_PENDING.swap(0, Ordering::Relaxed);
10341        // c:Src/loop.c:762-763 — `save_try_errflag = try_errflag;
10342        // save_try_interrupt = try_interrupt;`. Restored at c:778-779
10343        // by RESTORE_TRY_BLOCK_STATUS so a nested try block doesn't
10344        // clobber the enclosing one's `$TRY_BLOCK_ERROR`.
10345        let try_err_save = crate::ported::r#loop::try_errflag.load(Ordering::Relaxed); // c:762
10346        let try_int_save = crate::ported::r#loop::try_interrupt.load(Ordering::Relaxed); // c:763
10347        TRY_ESCAPE_SAVE.with(|s| {
10348            s.borrow_mut().push((
10349                ret_save,
10350                brk_save,
10351                cont_save,
10352                exit_save,
10353                try_err_save,
10354                try_int_save,
10355            ));
10356        });
10357        // c:Src/loop.c:764-766 — `try_errflag = (zlong)(errflag &
10358        // ERRFLAG_ERROR); try_interrupt = (zlong)((errflag &
10359        // ERRFLAG_INT) ? 1 : 0);`. Both are the RAW FLAG BITS, not the
10360        // try-list's exit status: ERRFLAG_ERROR is 1 (zsh.h:2972), so
10361        // `$TRY_BLOCK_ERROR` is 1-or-0 in zsh regardless of what the
10362        // failing command's `$?` was. The try-list's status is carried
10363        // separately in `__zshrs_try_block_saved_status`.
10364        let live_errflag = crate::ported::utils::errflag.load(Ordering::Relaxed);
10365        let try_err = (live_errflag & crate::ported::zsh_h::ERRFLAG_ERROR) as i64; // c:765
10366        let try_int = if (live_errflag & crate::ported::zsh_h::ERRFLAG_INT) != 0 {
10367            1i64
10368        } else {
10369            0i64
10370        }; // c:766
10371        crate::ported::r#loop::try_errflag.store(try_err, Ordering::Relaxed); // c:765
10372        crate::ported::r#loop::try_interrupt.store(try_int, Ordering::Relaxed); // c:766
10373                                                                                // c:Src/loop.c:755 — `endval = lastval ? lastval : errflag;`.
10374                                                                                // The status of the WHOLE `{…} always {…}` construct, captured
10375                                                                                // BEFORE the always-list runs (exectry returns it at c:801) and
10376                                                                                // deliberately including the errflag fallback: a try-list that
10377                                                                                // failed with `lastval == 0` but raised errflag still reports 1.
10378        let endval = if vm_status != 0 {
10379            vm_status
10380        } else {
10381            live_errflag
10382        }; // c:755
10383        with_executor(|exec| {
10384            // flags=0 (not setsparam's ASSPM_WARN): VM-internal scratch —
10385            // must never surface as a WARN_CREATE_GLOBAL diagnostic inside
10386            // a user function running `{...} always {...}` (f-sy-h's
10387            // `_zsh_highlight` does exactly that under warncreateglobal).
10388            crate::ported::params::assignsparam(
10389                "__zshrs_try_block_saved_status",
10390                &endval.to_string(),
10391                0,
10392            );
10393            let _ = exec;
10394            // Mirror into paramtab so `${parameters[TRY_BLOCK_ERROR]}`
10395            // and the PM_INTEGER `u_val` shadow agree with the atomic
10396            // the special-var getter reads. (setsparam → intsetfn's
10397            // TRY_BLOCK_ERROR arm re-stores the same value.)
10398            exec.set_scalar("TRY_BLOCK_ERROR".to_string(), try_err.to_string());
10399            exec.set_scalar("TRY_BLOCK_INTERRUPT".to_string(), try_int.to_string());
10400        });
10401        // c:Src/loop.c:768 — `errflag = 0;` ("We need to reset all
10402        // errors to allow the block to execute"). C clears the WHOLE
10403        // word, not just ERRFLAG_ERROR.
10404        crate::ported::utils::errflag.store(0, Ordering::Relaxed); // c:768
10405        Value::Status(0)
10406    });
10407
10408    // BUILTIN_BEGIN_INLINE_ENV / END_INLINE_ENV — wrap an
10409    // inline-assignment-prefixed command (`X=foo Y=bar cmd`):
10410    // BEGIN pushes a save frame; SET_VAR fires for each assign and
10411    // ALSO env::set_var's the value (visible to cmd's child); the
10412    // command runs; END pops the frame and restores both shell-var
10413    // and process-env state. Direct port of zsh's addvars() →
10414    // execute_simple → restore-after-exec contract.
10415    vm.register_builtin(BUILTIN_BEGIN_INLINE_ENV, |vm, argc| {
10416        // c:Src/exec.c:4114-4126 — whether the frame RECORDS anything is
10417        // `do_save`:
10418        //     if (isset(POSIXBUILTINS)) {
10419        //         if (is_shfunc || (hn->flags & (BINF_PSPECIAL|BINF_ASSIGN)))
10420        //             do_save = (orig_cflags & BINF_COMMAND);
10421        //         else
10422        //             do_save = 1;
10423        //     } else { ... }
10424        //     if (do_save && varspc) save_params(...);
10425        // A frame is pushed either way so BEGIN/END stay balanced; an
10426        // empty save list simply restores nothing, which IS the
10427        // assignment persisting. POSIX.1-2017 XCU 2.9.1: "If the command
10428        // name is a special built-in utility, variable assignments shall
10429        // affect the current execution environment." Verified:
10430        // `dash|ksh|mksh -c 'v=0; v=1 :; printf "[%s]\n" "$v"'` → `[1]`,
10431        // and `v=2 true` → `[1]` because `true` is NOT special.
10432        //
10433        // The name arrives as a compile-time constant (empty when the
10434        // command word is an expansion, which takes the save arm).
10435        let name = if argc >= 1 {
10436            vm.pop().to_str()
10437        } else {
10438            String::new()
10439        };
10440        let mut frame = crate::vm_helper::InlineEnvFrame::new();
10441        // !!! bash EXCEPTION — bash(1), "POSIX Mode": "Assignment
10442        // statements preceding POSIX special builtins persist in the shell
10443        // environment after the builtin completes." bash does this ONLY in
10444        // posix mode, so default `--bash` keeps zsh's save/restore
10445        // (`bash -c 'v=0; v=1 :; printf "[%s]\n" "$v"'` → `[0]`, while
10446        // dash / ksh93 / mksh / bash-as-sh all print `[1]`). zshrs tracks
10447        // bash's `set -o posix` in dash_mode::BASH_ONLY_OPTS, so honor it.
10448        let bash_suppresses =
10449            crate::dash_mode::bash_mode() && !crate::dash_mode::bash_set_o_get("posix");
10450        if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
10451            && !name.is_empty()
10452            && !bash_suppresses
10453        {
10454            // c:4123 `do_save = (orig_cflags & BINF_COMMAND)` — the
10455            // `command` prefix is BINF_COMMAND (c:Src/builtin.c:44
10456            // `BIN_PREFIX("command", BINF_COMMAND)`) and resets the
10457            // behavior, so it keeps the save.
10458            let is_command_prefix = name == "command";
10459            // !!! dash / pdksh EXCEPTION — C's `is_shfunc` leg is not
10460            // universal. `f(){ :; }; v=0; v=4 f` leaves `v` at 0 in dash,
10461            // ash AND mksh, while ksh93 and bash-as-sh (the `--sh`
10462            // reference) leave it at 4. Only the builtin legs are shared.
10463            let is_shfunc = !crate::dash_mode::dash_strict()
10464                && !crate::dash_mode::pdksh_family()
10465                && crate::ported::hashtable::shfunctab_lock()
10466                    .read()
10467                    .map(|t| t.get(&name).is_some())
10468                    .unwrap_or(false);
10469            if !is_command_prefix
10470                && (is_shfunc || builtin_is_pspecial(&name) || builtin_is_assign_family(&name))
10471            {
10472                frame.recording = false; // c:4122-4123 do_save = 0
10473            }
10474        }
10475        // c:4137-4147 — the OTHER decision the command word drives, and the
10476        // one that has nothing to do with POSIX_BUILTINS:
10477        //     /* Export this if the command is a shell function,
10478        //      * but not if it's a builtin.
10479        //      */
10480        //     int flags = 0;
10481        //     if (is_shfunc)
10482        //         flags |= ADDVAR_EXPORT;
10483        //     ...
10484        //     addvars(state, varspc, flags);
10485        // `addvars` turns that bit into `opts[ALLEXPORT] = 1` around the
10486        // assignment (c:2641-2651), so `X=y shellfn` leaves `X` EXPORTED —
10487        // `${(t)X}` reads `scalar-export` inside the call and `export -p`
10488        // lists it — while `X=y eval …` leaves a plain `scalar`. Verified
10489        // against /opt/homebrew/bin/zsh: `f(){ print ${(t)Q} }; Q=y f` →
10490        // `scalar-export`, `Q=y eval 'print ${(t)Q}'` → `scalar`.
10491        //
10492        // The external-command leg (c:4343-4348 sets ADDVAR_EXPORT
10493        // unconditionally) runs in the FORKED child, after which nothing
10494        // reads the child's parameter table; the value reaches the child's
10495        // environment through the `zputenv` the set-var builtin already
10496        // does either way, so only the shell-function leg is observable and
10497        // only it is mirrored here.
10498        //
10499        // !!! KNOWN GAP — an EXPANDED command word (`c=f; X=y $c`) has no
10500        // compile-time literal, so `name` is the unexpanded text and the
10501        // lookup misses: the prefix assignment stays unexported where zsh
10502        // exports it. Same limitation, same cause as the POSIX_BUILTINS
10503        // `do_save` decision above (compile_zsh.rs:2133-2137) — C resolves
10504        // `hn` at runtime from the expanded word and the bridge is handed a
10505        // constant.
10506        frame.export = crate::ported::hashtable::shfunctab_lock()
10507            .read()
10508            .map(|t| t.get(&name).is_some())
10509            .unwrap_or(false); // c:4142-4143
10510        with_executor(|exec| {
10511            exec.inline_env_stack.push(frame);
10512        });
10513        Value::Status(0)
10514    });
10515    // Closes the frame's save list — see BUILTIN_SEAL_INLINE_ENV.
10516    vm.register_builtin(BUILTIN_SEAL_INLINE_ENV, |_vm, _argc| {
10517        with_executor(|exec| {
10518            if let Some(frame) = exec.inline_env_stack.last_mut() {
10519                frame.recording = false;
10520            }
10521        });
10522        // c:Src/exec.c:4147-4148 — `addvars(state, varspc, flags); if
10523        // (errflag) {` — see PREFIX_ASSIGN_FAILED.
10524        let failed = (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
10525            & crate::ported::zsh_h::ERRFLAG_ERROR)
10526            != 0;
10527        PREFIX_ASSIGN_FAILED.with(|c| c.set(failed));
10528        // The command word is known now unless it is an expansion, which
10529        // BUILTIN_EXEC_DYNAMIC settles.
10530        if let Some(name) = PREFIX_ASSIGN_CMD.with(|c| c.borrow_mut().take()) {
10531            if !name.is_empty() {
10532                settle_prefix_assign_exit(&name);
10533            }
10534        }
10535        Value::Status(0)
10536    });
10537    // See BUILTIN_PREFIX_ASSIGNS_START.
10538    vm.register_builtin(BUILTIN_PREFIX_ASSIGNS_START, |vm, argc| {
10539        let name = if argc >= 1 { vm.pop().to_str() } else { String::new() };
10540        PREFIX_ASSIGN_CMD.with(|c| *c.borrow_mut() = Some(name));
10541        PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.set(false));
10542        Value::Status(0)
10543    });
10544    vm.register_builtin(BUILTIN_END_INLINE_ENV, |_vm, _argc| {
10545        PREFIX_ASSIGN_FAILED.with(|c| c.set(false));
10546        // No dispatch resolved the command: the assignments ran in the shell.
10547        if PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.get()) {
10548            settle_prefix_assign_exit("");
10549        }
10550        with_executor(|exec| {
10551            if let Some(frame) = exec.inline_env_stack.pop() {
10552                frame.restore(); // c:Src/exec.c:4519 restore_params
10553            }
10554        });
10555        Value::Status(0)
10556    });
10557    // c:Src/exec.c:3969-3976 — bare-exec assignment epilogue: see the
10558    // const's doc block. POSIX_BUILTINS → assignments persist (pop the
10559    // frame, discard the saved state); otherwise → restore_params
10560    // (same walk as END_INLINE_ENV).
10561    vm.register_builtin(BUILTIN_EXEC_INLINE_ENV_DONE, |_vm, _argc| {
10562        let persist = isset(crate::ported::zsh_h::POSIXBUILTINS);
10563        with_executor(|exec| {
10564            if let Some(frame) = exec.inline_env_stack.pop() {
10565                if persist {
10566                    return; // c:3971 — no save/restore under POSIX_BUILTINS
10567                }
10568                frame.restore(); // c:Src/exec.c:4519 restore_params
10569            }
10570        });
10571        Value::Status(0)
10572    });
10573
10574    // BUILTIN_RESTORE_TRY_BLOCK_STATUS — emitted at the end of an
10575    // `always` arm. Per zshmisc, the exit status of the entire
10576    // `{ try } always { finally }` construct is the try-list's
10577    // status, regardless of what happens in the always-list (the
10578    // exception is `return`/`exit` inside always, which short-
10579    // circuits and the cleanup is the only thing that runs). So
10580    // restore TRY_BLOCK_ERROR unconditionally — the always-list's
10581    // exit status is discarded for the construct.
10582    vm.register_builtin(BUILTIN_RESTORE_TRY_BLOCK_STATUS, |_vm, _argc| {
10583        use std::sync::atomic::Ordering;
10584        // c:Src/loop.c:801 — `return endval;`. The construct's exit
10585        // status is the try-list's (captured at c:755 by
10586        // SET_TRY_BLOCK_ERROR), never the always-list's.
10587        let saved = with_executor(|exec| {
10588            exec.scalar("__zshrs_try_block_saved_status")
10589                .and_then(|s| s.parse::<i32>().ok())
10590                .unwrap_or(0)
10591        });
10592        // c:Src/exec.c:1375 — `lastval = lv;` on the exectry return.
10593        // The always-list's own commands left their status in LASTVAL
10594        // (`always { : }` → 0); without this store the errflag re-raise
10595        // below aborts the shell with the always-list's 0 instead of
10596        // the try-list's failure status.
10597        crate::ported::builtin::LASTVAL.store(saved, Ordering::Relaxed); // c:1375
10598                                                                         // c:Src/loop.c:774-777 — the error RE-RAISE. This is the
10599                                                                         // whole point of TRY_BLOCK_ERROR being writable:
10600                                                                         //
10601                                                                         //     if (try_errflag)  errflag |= ERRFLAG_ERROR;
10602                                                                         //     else              errflag &= ~ERRFLAG_ERROR;
10603                                                                         //     if (try_interrupt) errflag |= ERRFLAG_INT;
10604                                                                         //     else               errflag &= ~ERRFLAG_INT;
10605                                                                         //
10606                                                                         // SET_TRY_BLOCK_ERROR cleared errflag (c:768) so the always-arm
10607                                                                         // could run; the try-block's error is PARKED in `try_errflag`
10608                                                                         // and re-raised HERE unless the always-arm zeroed it
10609                                                                         // (`TRY_BLOCK_ERROR=0`, the documented swallow idiom — routed
10610                                                                         // to the atomic by intsetfn's IPDEF6 arm, params.rs).
10611                                                                         //
10612                                                                         // zshrs used to just drop the parked error, so
10613                                                                         // `f() { { typeset -r ro=1; ro=2 } always { … }; print reached }`
10614                                                                         // kept running and exited 0, where zsh aborts f with status 1.
10615        let te = crate::ported::r#loop::try_errflag.load(Ordering::Relaxed); // c:774
10616        let ti = crate::ported::r#loop::try_interrupt.load(Ordering::Relaxed); // c:776
10617        if te != 0 {
10618            crate::ported::utils::errflag
10619                .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
10620        // c:775
10621        } else {
10622            crate::ported::utils::errflag
10623                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
10624            // c:777
10625        }
10626        if ti != 0 {
10627            crate::ported::utils::errflag
10628                .fetch_or(crate::ported::zsh_h::ERRFLAG_INT, Ordering::Relaxed);
10629        // c:779
10630        } else {
10631            crate::ported::utils::errflag
10632                .fetch_and(!crate::ported::zsh_h::ERRFLAG_INT, Ordering::Relaxed);
10633            // c:781
10634        }
10635        // Re-apply the escape flags captured by SET_TRY_BLOCK_ERROR.
10636        // If the always-arm itself fired return/break/continue/exit,
10637        // its handler already overwrote the canonical atomics; let
10638        // those win — the always-arm's own escape always takes
10639        // priority over the try-block's deferred one.
10640        if let Some((ret, brk, cont, exit_p, try_err_save, try_int_save)) =
10641            TRY_ESCAPE_SAVE.with(|s| s.borrow_mut().pop())
10642        {
10643            // c:Src/loop.c:782-783 — `try_errflag = save_try_errflag;
10644            // try_interrupt = save_try_interrupt;`
10645            crate::ported::r#loop::try_errflag.store(try_err_save, Ordering::Relaxed); // c:782
10646            crate::ported::r#loop::try_interrupt.store(try_int_save, Ordering::Relaxed);
10647            // c:783
10648            if crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) == 0 {
10649                crate::ported::builtin::RETFLAG.store(ret, Ordering::Relaxed);
10650            }
10651            if crate::ported::builtin::BREAKS.load(Ordering::Relaxed) == 0 {
10652                crate::ported::builtin::BREAKS.store(brk, Ordering::Relaxed);
10653            }
10654            if crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed) == 0 {
10655                crate::ported::builtin::CONTFLAG.store(cont, Ordering::Relaxed);
10656            }
10657            if crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) == 0 {
10658                crate::ported::builtin::EXIT_PENDING.store(exit_p, Ordering::Relaxed);
10659            }
10660        }
10661        Value::Status(saved)
10662    });
10663
10664    // `[[ -r/-w/-x file ]]` — the cond path must use access(2) (the
10665    // C-faithful doaccess), NOT fusevm's generic Op::TestFile which only
10666    // checks existence for -r/-w (so a `chmod 000` file read as readable;
10667    // C02cond.ztst:13). Stack: [path, mode]; mode is the access(2) bit
10668    // (R_OK=4, W_OK=2, X_OK=1). Mirrors cond.rs:232/238/267 `doaccess`.
10669    vm.register_builtin(BUILTIN_COND_ACCESS, |vm, _argc| {
10670        let mode = vm.pop().to_int() as i32;
10671        let path = vm.pop().to_str();
10672        Value::Bool(crate::ported::cond::doaccess(&path, mode) != 0)
10673    });
10674
10675    // `[[ -prefix PAT ]]` / `-suffix` / `-after` / `-between` module condition.
10676    // Stack (pushed by the ModCond compile arm): arg0 … argN-1, then the
10677    // operator word last. argc = N+1.
10678    // c:Src/subst.c:4419-4420 `if (globsubst) shtokenize(y)` — see the
10679    // BUILTIN_COND_SHTOKENIZE doc for why a module condition needs it.
10680    vm.register_builtin(BUILTIN_COND_SHTOKENIZE, |vm, _argc| {
10681        let mut s = vm.pop().to_str();
10682        crate::ported::glob::shtokenize(&mut s);
10683        Value::str(s)
10684    });
10685
10686    vm.register_builtin(BUILTIN_COND_MOD, |vm, argc| {
10687        use crate::ported::zle::complete::{cond_psfix, cond_range, CVT_PREPAT, CVT_SUFPAT};
10688        let op = vm.pop().to_str(); // operator word (pushed last → popped first)
10689        let n = (argc as usize).saturating_sub(1);
10690        let mut args: Vec<String> = Vec::with_capacity(n);
10691        for _ in 0..n {
10692            args.push(vm.pop().to_str());
10693        }
10694        args.reverse(); // restore arg0 … argN-1 order
10695                        // Dispatch the module/completion condition (C evalcond COND_MOD path:
10696                        // condtab lookup + arity check, cond.c:149-185, over the four cotab[]
10697                        // entries at complete.c:1697-1702). Handlers return 1=match/true.
10698        let name: String = op
10699            .trim_start_matches(|c: char| c == '-' || c == '\u{e19b}')
10700            .to_string();
10701        // c:Src/cond.c:149-150 — `cd = getconddef((ctype == COND_MODI),
10702        // name + 1, 1)`. The `autol = 1` argument is what makes an
10703        // autoloadable condition LOAD its module: `getconddef`
10704        // (Src/module.c:647) sees the `c:`-stub's `p->module` and calls
10705        // `ensurefeature(p->module, "c:", name)`, then re-looks-up the
10706        // now-real definition that `zsh/complete`'s cotab installed.
10707        // Without this call zshrs answered `[[ -prefix … ]]` straight
10708        // from the compiled-in handler table and left `zsh/complete`
10709        // unloaded, where `zsh -f` reports it loaded after the first use.
10710        // `try_lock`: every other MODULESTAB caller holds the same mutex
10711        // for a moment and the load chain re-enters it; falling through
10712        // to the compiled-in table is the safe outcome, not a deadlock.
10713        let cd = match crate::ported::module::MODULESTAB.try_lock() {
10714            Ok(mut tab) => crate::ported::module::getconddef(0, &name, 1, &mut tab), // c:150
10715            Err(_) => None,
10716        };
10717        // c:151-155 — arity check against the conddef's own min/max
10718        // (`if (l < cd->min || (cd->max >= 0 && l > cd->max))`). The
10719        // fallback pins the same numbers the cotab rows carry
10720        // (complete.c:1698-1701) for the window before zsh/complete is
10721        // loaded, when `getconddef` has only the module-less stub.
10722        let (min, max): (usize, usize) = match cd.as_ref() {
10723            Some(c) if c.max >= 0 => (c.min.max(0) as usize, c.max as usize), // c:152
10724            // !!! KORN-MODE EXTENSION (no C counterpart) !!! ksh93 `[[ -R NAME ]]`
10725            // is true when NAME is a nameref.
10726            _ if name == "R" && args.len() == 1 && crate::extensions::dash_mode::ksh93_mode() => {
10727                return Value::Bool(crate::ported::params::is_nameref(&args[0]));
10728            }
10729            _ => match name.as_str() {
10730                "prefix" | "suffix" => (1, 2), // c:1700-1701
10731                "after" => (1, 1),             // c:1698
10732                "between" => (2, 2),           // c:1699
10733                _ => {
10734                    // c:Src/cond.c:186-193 — no conddef matched, so C falls out
10735                    // of both `getconddef` arms to `zwarnnam(fromtest, "unknown
10736                    // condition: %s", errname)` and then `return 2;` (the
10737                    // "module not found, error" exit). Status 2 — not 1 — is
10738                    // what `evalcond` hands back, and c:Src/exec.c:5216-5221
10739                    // turns a 2 into a shell error. Arm the same carrier
10740                    // BUILTIN_COND_UNKNOWN uses so the shared
10741                    // BUILTIN_COND_STATUS_FROM_BOOL tail emits 2 and aborts;
10742                    // returning a bare Bool(false) collapsed it to 1, so
10743                    // `[[ -zz a ]]` exited 1 where zsh exits 2.
10744                    COND_BAD_PATTERN.with(|c| c.set(true)); // c:193
10745                    crate::ported::utils::zerr(&format!(
10746                        "unknown condition: {}",
10747                        op.replace('\u{e19b}', "-")
10748                    ));
10749                    return Value::Bool(false);
10750                }
10751            },
10752        };
10753        if args.len() < min || args.len() > max {
10754            // c:Src/cond.c:177-181 — `if (l < cd->min || (cd->max >= 0 &&
10755            // l > cd->max)) { zwarnnam(fromtest, "unknown condition: %s",
10756            // errname); return 2; }`. Status 2, same as the module-not-found
10757            // arm above.
10758            //
10759            // This arm previously refused to arm the carrier because the
10760            // PARSER turned a zero-operand `-word` into a ModCond, so
10761            // `[[ -prefix ]]` would have exited 2 where zsh exits 0. That
10762            // parser bug is fixed (src/ported/parse.rs par_cond_2: a
10763            // multi-char `-word` with no operand now takes c:2590's
10764            // `par_cond_double("-n", s1)` string-test arm, and a two-char
10765            // one takes c:2592's `par_cond_multi(s1, newlinklist())`), so
10766            // the only way to reach here is a genuine arity violation —
10767            // `[[ -between a ]]`, which zsh answers 2.
10768            COND_BAD_PATTERN.with(|c| c.set(true)); // c:180
10769            crate::ported::utils::zerr(&format!(
10770                "unknown condition: {}",
10771                op.replace('\u{e19b}', "-")
10772            ));
10773            return Value::Bool(false);
10774        }
10775        // c:158 — `return !cd->handler(strs, cd->condid);`
10776        let r = match cd.as_ref().and_then(|c| c.handler.map(|h| (h, c.condid))) {
10777            Some((handler, condid)) => handler(&args, condid),
10778            // Pre-load window: zsh/complete's cotab is not installed yet,
10779            // so dispatch through the compiled-in handlers directly.
10780            None => match name.as_str() {
10781                "prefix" => cond_psfix(&args, CVT_PREPAT),
10782                "suffix" => cond_psfix(&args, CVT_SUFPAT),
10783                "after" => cond_range(&args, 0),
10784                "between" => cond_range(&args, 1),
10785                _ => 0,
10786            },
10787        };
10788        Value::Bool(r == 1)
10789    });
10790
10791    vm.register_builtin(BUILTIN_IS_TTY, |vm, _argc| {
10792        let fd_str = vm.pop().to_str();
10793        let fd: i32 = fd_str.trim().parse().unwrap_or(-1);
10794        let is_tty = if fd < 0 {
10795            false
10796        } else {
10797            unsafe { libc::isatty(fd) != 0 }
10798        };
10799        Value::Bool(is_tty)
10800    });
10801
10802    // c:Src/exec.c:4918/5040/5069 — a process substitution used inside a
10803    // `[[ … ]]` cond operand errors "process substitution %s cannot be
10804    // used here" (getoutputfile/getproc run with thisjob == -1). Emitted
10805    // by the compiler in place of ProcessSubIn/Out when in_cond_operand.
10806    vm.register_builtin(BUILTIN_PROCSUB_COND_ERROR, |_vm, _argc| {
10807        let cmd = _vm.pop().to_str();
10808        crate::ported::utils::zerr(&format!("process substitution {} cannot be used here", cmd));
10809        // c:getoutputfile returns NULL with errflag set → the enclosing
10810        // statement aborts (empty stdout, exit 1), rather than the cond
10811        // merely evaluating false.
10812        crate::ported::utils::errflag.fetch_or(
10813            crate::ported::zsh_h::ERRFLAG_ERROR,
10814            std::sync::atomic::Ordering::Relaxed,
10815        );
10816        with_executor(|exec| exec.set_last_status(1));
10817        _vm.last_status = 1;
10818        Value::str("")
10819    });
10820
10821    // Set $LINENO before executing the next statement. Direct
10822    // port of zsh's `lineno` global tracking from Src/input.c
10823    // (`if ((inbufflags & INP_LINENO) || !strin) && c == '\n')
10824    // lineno++;`). The compiler emits one of these before each
10825    // top-level pipe in `compile_sublist`, carrying the line
10826    // number captured by the parser at `ZshPipe.lineno`. Pops
10827    // [n], updates `$LINENO` in the variable table.
10828    vm.register_builtin(BUILTIN_SET_LINENO, |vm, _argc| {
10829        let n = vm.pop().to_int();
10830        set_lineno_impl(n)
10831    });
10832
10833    // Direct port of Src/prompt.c:1623 cmdpush. Token is a `CS_*`
10834    // value (zsh.h:2775-2806) emitted by compile_zsh around each
10835    // compound command (if/while/[[…]]/((…))/$(…)) and consumed by
10836    // `%_` in PS4 / prompt expansion.
10837    vm.register_builtin(BUILTIN_CMD_PUSH, |vm, _argc| {
10838        let token = vm.pop().to_int() as u8;
10839        // Route through canonical cmdpush (Src/prompt.c:1623). The
10840        // prompt expander reads from the file-static `CMDSTACK` at
10841        // `prompt.rs:2006`, not `exec.cmd_stack` — without this,
10842        // `%_` in PS4 saw an empty stack during xtrace.
10843        if (token as i32) < crate::ported::zsh_h::CS_COUNT {
10844            crate::ported::prompt::cmdpush(token);
10845        }
10846        Value::Status(0)
10847    });
10848
10849    // Direct port of Src/prompt.c:1631 cmdpop.
10850    vm.register_builtin(BUILTIN_CMD_POP, |_vm, _argc| {
10851        crate::ported::prompt::cmdpop();
10852        Value::Status(0)
10853    });
10854
10855    vm.register_builtin(BUILTIN_OPTION_SET, |vm, _argc| {
10856        let name = vm.pop().to_str();
10857        // Direct port of `optison(char *name, char *s)` at Src/cond.c:502 — `[[ -o NAME ]]`
10858        // reads through the same `opts[]` array that `setopt NAME`
10859        // writes via `dosetopt`. Earlier code read a duplicate Executor
10860        // HashMap which never saw `bin_setopt`'s writes (those land in
10861        // `OPTS_LIVE` via `opt_state_set`). Routing through the canonical
10862        // C port restores the single-store invariant: one `opts[]`,
10863        // shared between setopt/unsetopt and `[[ -o ]]`.
10864        let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
10865        match r {
10866            0 => Value::Bool(true),  // c:cond.c:520 set
10867            1 => Value::Bool(false), // c:cond.c:518/520 unset
10868            _ => {
10869                // c:cond.c:514 — unknown option. optison already emitted the
10870                // diagnostic (via zwarn, now that fromtest=NULL for
10871                // `[[ -o ]]`); re-printing here double-emitted for
10872                // `[[ ! -o bad ]]` / `[[ -o a || -o b ]]`.
10873                Value::Bool(false)
10874            }
10875        }
10876    });
10877    // Tri-state `-o` for compile_cond's direct status path. Returns
10878    // 0 / 1 / 3 as a Value::Int that compile_cond consumes via
10879    // Op::SetStatus. Mirrors zsh's `[[ -o invalid ]]` returning $?=3.
10880    vm.register_builtin(BUILTIN_OPTION_CHECK_TRISTATE, |vm, _argc| {
10881        let name = vm.pop().to_str();
10882        let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
10883                                                           // optison itself prints the diagnostic via zwarnnam when r=3
10884                                                           // and POSIXBUILTINS is unset (the canonical path). Don't
10885                                                           // double-emit here. r is already 0/1/3.
10886        Value::Int(r as i64)
10887    });
10888
10889    // BUILTIN_PARAM_FILTER — `${var:#pat}` / `${var:|name}` etc.
10890    // PURE PASSTHRU: rebuild `${name:#pat}` and route to paramsubst.
10891    vm.register_builtin(BUILTIN_PARAM_FILTER, |vm, argc| {
10892        // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
10893        // compile_zsh::emit_param_modifier (see the note there).
10894        let pf_flags = if argc as usize > 2 && vm.pop().to_int() != 0 {
10895            crate::ported::zsh_h::PREFORK_SINGLE
10896        } else {
10897            0
10898        };
10899        let pattern = vm.pop().to_str();
10900        let name = vm.pop().to_str();
10901        let body = format!("${{{}:#{}}}", name, pattern);
10902        paramsubst_to_value_status(vm, &body, pf_flags)
10903    });
10904
10905    // `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()`
10906    // — subscripted-array assign with array RHS. Stack pushed by
10907    // compile_assign as: [elem0, elem1, …, elemN-1, name, key].
10908    vm.register_builtin(BUILTIN_SET_SUBSCRIPT_RANGE, |vm, argc| {
10909        let n = argc as usize;
10910        let mut popped: Vec<Value> = Vec::with_capacity(n);
10911        for _ in 0..n {
10912            popped.push(vm.pop());
10913        }
10914        popped.reverse();
10915        if popped.len() < 3 {
10916            return Value::Status(1);
10917        }
10918        // c:Src/params.c:3511-3526 — trailing append flag. The ARRAY
10919        // subscript path (`a[N]+=(v)` / `a[lo,hi]+=(v)`) sets it so the
10920        // AUGMENT transform below collapses the range to an empty range
10921        // after the slice end and inserts ONLY the new value. The scalar
10922        // path pre-concats the old slice (ARRAY_INDEX+Concat) and passes
10923        // 0, so it keeps plain-replace semantics.
10924        // The trailing marker is 0/1 for the ARRAY-RHS emitter and 2 for
10925        // the SCALAR-RHS comma emitter (compile_zsh::compile_assign),
10926        // which also pushes the SOURCE subscript just below it. The
10927        // scalar form is the one C does NOT necessarily treat as a
10928        // range: c:Src/params.c:1515 `(c != Outbrack && (ishash || c !=
10929        // ','))` stops the comma from separating subscripts when the
10930        // parameter is a hash, so `h[1,2]=Z` is the ordinary key `1,2`.
10931        // The compile-time split cannot know the type, so it defers here.
10932        let marker = popped.pop().map_or(String::new(), |v| v.to_str());
10933        // Marker 3 is the scalar-RHS form of `+=` (old slice already
10934        // concatenated in, like 2); it differs only for a numeric target.
10935        let scalar_append = marker == "3";
10936        let scalar_rhs = marker == "2" || scalar_append; // c:1515 deferral
10937        let key_src = if scalar_rhs {
10938            popped.pop().map(|v| v.to_str())
10939        } else {
10940            None
10941        };
10942        let append = marker == "1";
10943        let key = popped.pop().unwrap().to_str();
10944        let name = popped.pop().unwrap().to_str();
10945        // c:Src/exec.c:4475-4478 (54941) — a subscripted prefix assignment
10946        // (`H[2,4]=oo f`) saves its base parameter for restore_params.
10947        with_executor(|exec| {
10948            if exec
10949                .inline_env_stack
10950                .last()
10951                .is_some_and(|frame| frame.recording)
10952            {
10953                save_inline_prefix_param(exec, &name);
10954            }
10955        });
10956        // c:Src/params.c:1585-1592 — `if (needtok) { parsestr(&s);
10957        // singsub(&s); }`: the subscript body is parameter-substituted
10958        // BEFORE it is read, whether it goes on to `mathevalarg`
10959        // (c:1601, the array/scalar range bounds) or straight into the
10960        // hash as a key (c:1596-1616). The ARRAY-RHS emitter pre-expands
10961        // its subscript at word-compile time, but the SCALAR-RHS comma
10962        // emitter hands over the raw source, so this round has to happen
10963        // here: `mathevali("$n")` is 0, which silently turned
10964        // `a=abcdef; n=3; a[$n,-1]=X` into a whole-string overwrite
10965        // (`X` instead of `abX`), and it is why the fzf-tab
10966        // `t[$#MATCH/2+1,-1]=""` form still lost its bound.
10967        //
10968        // Only for a source-level LIVE expansion: an escaped `\$`
10969        // reached `parsestr` as the Bnull marker and `singsub` leaves it
10970        // alone, so `h[\$x,y]` keys on the literal `$x,y`.
10971        let key = if scalar_rhs
10972            && key_src.as_deref().is_some_and(|src| {
10973                let b = src.as_bytes();
10974                (0..b.len())
10975                    .any(|i| (b[i] == b'$' || b[i] == b'`') && (i == 0 || b[i - 1] != b'\\'))
10976            }) {
10977            crate::ported::subst::singsub(&key) // c:1592
10978        } else {
10979            key
10980        };
10981        let mut values: Vec<String> = Vec::new();
10982        for v in popped {
10983            match v {
10984                Value::Array(items) => {
10985                    for it in items.iter() {
10986                        values.push(it.to_str());
10987                    }
10988                }
10989                other => values.push(other.to_str()),
10990            }
10991        }
10992        // c:Src/params.c:3316/3365 — assignaparam's subscript arm finds the
10993        // parameter through getvalue/fetchvalue, whose getparamnode
10994        // (c:570-575) follows a PM_NAMEREF chain to its endpoint, so
10995        // `ptr[2,4]=(x y)` splices the array `ptr` names. Everything below
10996        // looks `name` up directly and spliced the reference itself ("ptr:
10997        // attempt to assign array value to non-array"). A chain ending on a
10998        // visible parameter is that parameter's name; any other outcome
10999        // (subscripted refname, hidden binding, placeholder, loop) goes to
11000        // the ported assignaparam, which resolves the chain itself.
11001        let name = if crate::ported::params::is_nameref(&name) {
11002            use crate::ported::params::nameref_resolution as NR;
11003            let visible = |t: &str, level: i32, has_pm: bool| {
11004                !has_pm
11005                    || crate::ported::params::paramtab()
11006                        .read()
11007                        .ok()
11008                        .and_then(|tb| tb.get(t).map(|p| p.level))
11009                        == Some(level)
11010            };
11011            match crate::ported::params::resolve_nameref_name(&name, None) {
11012                NR::Target { name: t, subscript: None, pm, level } if visible(&t, level, pm.is_some()) => t,
11013                _ if !scalar_rhs => {
11014                    let asspm = if append { crate::ported::zsh_h::ASSPM_AUGMENT } else { 0 };
11015                    let done =
11016                        crate::ported::params::assignaparam(&format!("{name}[{key}]"), values, asspm);
11017                    return Value::Status(if done.is_some() { 0 } else { 1 });
11018                }
11019                _ => name,
11020            }
11021        } else {
11022            name
11023        };
11024        // Bash sparse-array tracking: a single-index `a[i]=v` that pads the
11025        // dense Vec past its old end leaves indices old_len..i as HOLES (not
11026        // real elements), so `${#a[@]}`/`${!a[@]}` skip them like bash. Only
11027        // in bash mode, only for a plain non-append single index (0-based
11028        // under ksharrays). Captured before the assign; applied after.
11029        let sparse_track: Option<(String, usize, usize)> =
11030            if crate::dash_mode::sparse_arrays() && !append && !key.contains(',') {
11031                crate::dash_mode::sparse_subscript_index(&key)
11032                    .filter(|_| !with_executor(|exec| exec.has_assoc(&name)))
11033                    .map(|i| {
11034                    let old_len =
11035                        with_executor(|exec| exec.array(&name).map(|a| a.len()).unwrap_or(0));
11036                    (name.clone(), old_len, i)
11037                })
11038            } else {
11039                None
11040            };
11041        // c:Src/params.c:3383-3389 — a subscripted ARRAY assignment to an
11042        // associative array is an error, whatever the subscript looks like:
11043        //     if (v && PM_TYPE(v->pm->node.flags) == PM_HASHED) {
11044        //         unqueue_signals();
11045        //         zerr("%s: attempt to set slice of associative array",
11046        //              v->pm->node.nam);
11047        //         freearray(val);
11048        //         errflag |= ERRFLAG_ERROR;
11049        //         return NULL;
11050        //     }
11051        // assignaparam (params.rs) ports this, but a single-key `h[k]=(1 2)`
11052        // never reaches it: the VM lowers subscripted assignment to this
11053        // builtin instead. The comma form `h[a,b]=(1 2)` DID error — it takes a
11054        // different route — so the gap looked like a subscript-parsing quirk
11055        // when it was really "the check lives on a path this form doesn't
11056        // take". Untreated, the assignment was SILENTLY DISCARDED: rc=0 and
11057        // `${h[k]}` still read its old value.
11058        //
11059        // Only array-valued assignment is rejected; `h[k]=x` is a scalar
11060        // element store and stays legal.
11061        {
11062            let is_hashed = crate::ported::params::paramtab()
11063                .read()
11064                .ok()
11065                .and_then(|t| {
11066                    t.get(&name).map(|pm| {
11067                        crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
11068                            == crate::ported::zsh_h::PM_HASHED
11069                    })
11070                })
11071                .unwrap_or(false);
11072            if is_hashed {
11073                if scalar_rhs {
11074                    // c:1515 — for a hash the comma is not a subscript
11075                    // separator, so this was never a range: hand the
11076                    // WHOLE subscript to the element path. `h[1,2]=Z`
11077                    // keys on `1,2` in zsh; rejecting it here was the
11078                    // compile-time range split leaking through.
11079                    let src = key_src.unwrap_or_else(|| key.clone());
11080                    let val = values.first().cloned().unwrap_or_default();
11081                    return Value::Status(assign_hash_element(&name, &key, &src, &val));
11082                }
11083                crate::ported::utils::zerr(&format!(
11084                    "{name}: attempt to set slice of associative array" // c:3385
11085                ));
11086                crate::ported::utils::errflag.fetch_or(
11087                    crate::ported::zsh_h::ERRFLAG_ERROR,
11088                    std::sync::atomic::Ordering::Relaxed,
11089                ); // c:3387
11090                return Value::Status(1); // c:3388
11091            }
11092        }
11093
11094        with_executor(|exec| {
11095            // Parse subscript: slice `lo,hi` or single index `i`.
11096            // setarrvalue (Src/params.c:2895) expects 1-based start/
11097            // end inclusive where start==end means replace one
11098            // element. Negative bounds translate to len+n+1 (1-based).
11099            //
11100            // c:Src/params.c — the END side accepts 0 as a valid value
11101            // that signals "insert BEFORE start position" (the canonical
11102            // `a[N,N-1]=val` prepend / mid-insert idiom). Bug #275 in
11103            // docs/BUGS.md: the previous Rust port clamped end up to 1,
11104            // collapsing `a[1,0]=(X Y)` into `a[1,1]=(X Y)` which
11105            // OVERWRITES position 1 instead of prepending. Provide two
11106            // translators — start_translate clamps to 1 (1-based);
11107            // end_translate keeps 0 intact so the splice in
11108            // setarrvalue (start_idx=0..end_idx=0) inserts at the front.
11109            // Bug #589: for scalars (no array), use the scalar's char
11110            // count as `len` so negative-index translation (`a[2,-1]`)
11111            // computes against the actual string length, not 0.
11112            let len = exec
11113                .array(&name)
11114                .map(|a| a.len() as i64)
11115                .or_else(|| {
11116                    crate::ported::params::paramtab().read().ok().and_then(|t| {
11117                        t.get(&name).and_then(|pm| {
11118                            if crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
11119                                == crate::ported::zsh_h::PM_SCALAR
11120                            {
11121                                pm.u_str.as_ref().map(|s| s.chars().count() as i64)
11122                            } else {
11123                                None
11124                            }
11125                        })
11126                    })
11127                })
11128                .unwrap_or(0);
11129            let start_translate = |raw: i64| -> i32 {
11130                if raw < 0 {
11131                    (len + raw + 1).max(1) as i32
11132                } else {
11133                    raw.max(1) as i32
11134                }
11135            };
11136            let end_translate = |raw: i64| -> i32 {
11137                if raw < 0 {
11138                    (len + raw + 1).max(0) as i32
11139                } else {
11140                    raw.max(0) as i32
11141                }
11142            };
11143            // c:Src/params.c — KSH_ARRAYS option flips array subscripts
11144            // from 1-based to 0-based. setarrvalue expects 1-based
11145            // inclusive bounds, so under KSH_ARRAYS we shift positive
11146            // inputs by +1 before translation. Negative bounds left
11147            // alone (count from end). Sibling of #610/#611/#612.
11148            // Bug #613.
11149            let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
11150            let ksh_shift = |raw: i64| -> i64 {
11151                if ksh_arrays && raw >= 0 {
11152                    raw + 1
11153                } else {
11154                    raw
11155                }
11156            };
11157            // c:Src/params.c getindex — subscript bounds are MATH
11158            // expressions, not bare integers: `a[(( ${#a}+1 ))]=(x)`,
11159            // `a[n+1]=(x)`. Plain `parse::<i64>()` returned 0 on any
11160            // arithmetic subscript, which the `i == 0` guard turned into
11161            // a silent no-op (computed-index append never landed). Parse
11162            // the literal fast-path first, then fall back to mathevali
11163            // (which handles `(( ))` grouping, var refs, and operators).
11164            // c:Src/params.c:2036 + c:2118 — getindex resolves EACH range
11165            // bound with `getarg`, not with mathevalarg directly, so a bound
11166            // may be a `(r)`/`(R)`/`(i)`/`(I)`/`(k)`/`(K)` SEARCH rather than
11167            // an arithmetic expression. On an array getarg's search arm
11168            // (c:1672-1719) returns the 1-BASED INDEX `r` of the match — the
11169            // value-vs-index distinction only sets `*inv`, which getindex
11170            // rejects for a range START (c:2121) and otherwise ignores. So map
11171            // the VALUE-returning direction letters onto their INDEX twins and
11172            // read the position back. Without this both bounds evaluated to 0
11173            // through mathevali and `array[(R)nomatch,(r)nomatch]=(…)` became
11174            // a front INSERT instead of the whole-array replace zsh performs.
11175            let cur_arr: Vec<String> = exec.array(&name).unwrap_or_default();
11176            let eval_bound = |s: &str| -> i64 {
11177                let t = s.trim();
11178                if let Some(rest) = t.strip_prefix('(') {
11179                    if let Some(close) = rest.find(')') {
11180                        let grp = &rest[..close];
11181                        if !grp.is_empty()
11182                            && grp
11183                                .chars()
11184                                .all(|c| matches!(c, 'r' | 'R' | 'i' | 'I' | 'k' | 'K' | 'e'))
11185                        {
11186                            // c:1394-1410 — r/k select the FIRST match, R/K the
11187                            // LAST; i/I are the same searches returning the index.
11188                            let mapped: String = grp
11189                                .chars()
11190                                .map(|c| match c {
11191                                    'r' | 'k' => 'i',
11192                                    'R' | 'K' => 'I',
11193                                    other => other,
11194                                })
11195                                .collect();
11196                            let expr = format!("({}){}", mapped, &rest[close + 1..]);
11197                            if let Some(crate::ported::params::getarg_out::Value(v)) =
11198                                crate::ported::params::getarg(&expr, Some(&cur_arr), None, None)
11199                            {
11200                                return v.to_str().trim().parse::<i64>().unwrap_or(0);
11201                            }
11202                        }
11203                    }
11204                }
11205                t.parse::<i64>()
11206                    .ok()
11207                    .or_else(|| crate::ported::math::mathevali(t).ok())
11208                    .unwrap_or(0)
11209            };
11210            let (raw_start, raw_end) = if let Some((s_str, e_str)) = key.split_once(',') {
11211                (ksh_shift(eval_bound(s_str)), ksh_shift(eval_bound(e_str)))
11212            } else {
11213                let i = ksh_shift(eval_bound(&key));
11214                (i, i)
11215            };
11216            // c:Src/params.c:2124-2151 (getindex) — `start == 0 && end == 0`
11217            // is the range entirely off the START of the index range. With
11218            // KSH_ZERO_SUBSCRIPT it degrades to "the first element"
11219            // (`end = startnextlen;` c:2140, i.e. the 0-based range [0,1)).
11220            // Without it the range is flagged VALFLAG_EMPTY (c:2148) and
11221            // setarrvalue (c:2910) rejects the assignment with
11222            // "assignment to invalid subscript range". zshrs silently
11223            // returned for `a[0]=(x)` and front-INSERTED for `a[0,0]=(x)`.
11224            let mut valflags = 0i32;
11225            let (raw_start, raw_end) = if raw_start == 0 && raw_end == 0 {
11226                if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT) {
11227                    (1, 1) // c:2140 — first element, in the 1-based convention below
11228                } else {
11229                    valflags |= crate::ported::zsh_h::VALFLAG_EMPTY; // c:2148
11230                    (-1, 0) // c:2149 — bounds unused; setarrvalue errors first
11231                }
11232            } else {
11233                (raw_start, raw_end)
11234            };
11235            // c:Src/params.c:2114 (getindex) — a SINGLE subscript is the
11236            // range `[i, i]`: `end = we ? we : start;` sets BOTH bounds to
11237            // the same RAW user index, and only the START is then shifted
11238            // down by one (`if (start > 0) start -= startprevlen;` c:2120).
11239            // setarrvalue (c:2944-2953) afterwards resolves each bound
11240            // INDEPENDENTLY — `start += len` clamped at 0, `end += len + 1`
11241            // clamped at 0 — so an out-of-range negative index such as
11242            // `a[-10]` on a 3-element array yields start==end==0, an EMPTY
11243            // range at the front that INSERTS. Running the end bound of a
11244            // single subscript through `start_translate` (which floors at 1)
11245            // instead collapsed that to [0,1) and OVERWROTE element 1:
11246            // `a=(some sunny day); a[-10]=(we'll meet again)` dropped "some"
11247            // (Test/D04parameter.ztst:1420 "Out of range negative array
11248            // subscripts"). Both forms therefore share one translator pair.
11249            let (start, end) = (start_translate(raw_start), end_translate(raw_end));
11250            // c:Src/params.c:3518-3520 (assignaparam ASSPM_AUGMENT) — a
11251            // subscripted `+=` to an array does NOT prepend the old slice;
11252            // it collapses the range to an EMPTY range positioned right
11253            // AFTER the slice end (`v->start = v->end--`) and splices in
11254            // ONLY the new value: `a[2]+=(d)` on (a b c) → (a b d c);
11255            // `a[2,3]+=(x)` on (1 2 3 4) → (1 2 3 x 4). In setarrvalue's
11256            // 1-based convention here that means start = end+1 (so
11257            // start_idx == end_idx == end → splice arr[end..end]).
11258            let (start, end) = if append && end > 0 {
11259                (end + 1, end)
11260            } else {
11261                (start, end)
11262            };
11263            // c:Src/params.c:392-430 IPDEF9("argv"/"@"/"*", &pparams) —
11264            // the positional parameters live in the `pparams` vector, NOT
11265            // paramtab, so a subscript splice (`argv[2]=(X Y Z)`,
11266            // `2=(X Y Z)`) must read/write pparams. Splice a synthetic
11267            // array param holding the current positionals via the
11268            // canonical setarrvalue, then store the result back to
11269            // pparams — mirroring assignaparam's argv/@/* special-case
11270            // (params.rs:6937) for the whole-array form.
11271            if name == "argv" || name == "@" || name == "*" {
11272                let mut pm = {
11273                    crate::ported::params::createparam(
11274                        &name,
11275                        crate::ported::zsh_h::PM_ARRAY as i32,
11276                    );
11277                    crate::ported::params::paramtab()
11278                        .write()
11279                        .ok()
11280                        .and_then(|mut t| t.remove(&name))
11281                };
11282                if let Some(ref mut p) = pm {
11283                    p.u_arr = Some(exec.pparams());
11284                }
11285                let mut v = crate::ported::zsh_h::value {
11286                    pm,
11287                    arr: Vec::new(),
11288                    scanflags: 0,
11289                    valflags,
11290                    start,
11291                    end,
11292                };
11293                crate::ported::params::setarrvalue(&mut v, values);
11294                let result = v.pm.and_then(|p| p.u_arr).unwrap_or_default();
11295                exec.set_pparams(result);
11296                return;
11297            }
11298            // Route through canonical setarrvalue (Src/params.c:2895).
11299            // It handles PM_READONLY rejection, PM_HASHED slice-error,
11300            // PM_ARRAY splice + bounds clamp + padding (c:2980+).
11301            let taken = match crate::ported::params::paramtab().write() {
11302                Ok(mut tab) => tab.remove(&name),
11303                Err(_) => None,
11304            };
11305            // c:Src/exec.c:2640 / getvalue(…, 1) — a subscript assignment to
11306            // a NONEXISTENT parameter auto-creates it. getindex/fetchvalue
11307            // with the create flag calls createparam(name, PM_ARRAY) so the
11308            // splice has an array to write into; `unset u; u[1,2]=(a z)`
11309            // then yields the array (a z). Without this, setarrvalue saw
11310            // v.pm == None and silently stored nothing. The single-index
11311            // scalar-value path (SET_ASSOC/SET_ARRAY_AT) already vivifies;
11312            // this brings the range/array-value path to parity.
11313            let taken = taken.or_else(|| {
11314                crate::ported::params::createparam(&name, crate::ported::zsh_h::PM_ARRAY as i32);
11315                crate::ported::params::paramtab()
11316                    .write()
11317                    .ok()
11318                    .and_then(|mut t| t.remove(&name))
11319            });
11320            // c:Src/params.c:2748+ — PM_SCALAR with subscript range
11321            // SPLICES the value into the scalar's char string. Bug
11322            // #589: zshrs's slice handler always called setarrvalue,
11323            // erroring "attempt to assign array value to non-array"
11324            // for `a=hello; a[2,3]=XYZ`. Detect PM_SCALAR and route
11325            // through assignstrvalue (which does scalar splice via
11326            // the PM_SCALAR arm at params.rs:3709-3789).
11327            //
11328            // Only a SCALAR RHS takes that route: it is assignsparam
11329            // (c:Src/params.c:3183), whose assignstrvalue also covers an
11330            // integer/float target (c:2777 — the whole value is replaced,
11331            // `integer i=5; i[1,2]=3` gives 3). An ARRAY RHS is assignaparam
11332            // → setarrvalue, which rejects every non-array target
11333            // (c:2896-2900 "attempt to assign array value to non-array":
11334            // `s=abc; s[2]=(x y)`).
11335            let target_type = taken
11336                .as_ref()
11337                .map(|pm| crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32));
11338            let is_scalar = scalar_rhs
11339                && target_type.is_some_and(|t| {
11340                    t & (crate::ported::zsh_h::PM_ARRAY | crate::ported::zsh_h::PM_HASHED) == 0
11341                });
11342            // c:Src/params.c:3250-3266 — `+=` on a subscripted integer/float:
11343            //     case PM_INTEGER: case PM_EFLOAT: case PM_FFLOAT:
11344            //         zsfree(val); unqueue_signals();
11345            //         zerr("attempt to add to slice of a numeric variable");
11346            //         return NULL;
11347            if scalar_append
11348                && target_type.is_some_and(|t| {
11349                    t == crate::ported::zsh_h::PM_INTEGER
11350                        || t == crate::ported::zsh_h::PM_EFLOAT
11351                        || t == crate::ported::zsh_h::PM_FFLOAT
11352                })
11353            {
11354                if let (Some(pm), Ok(mut tab)) = (taken, crate::ported::params::paramtab().write()) {
11355                    tab.insert(name, pm);
11356                }
11357                crate::ported::utils::zerr("attempt to add to slice of a numeric variable"); // c:3264
11358                return;
11359            }
11360            let mut v = crate::ported::zsh_h::value {
11361                pm: taken,
11362                arr: Vec::new(),
11363                scanflags: 0,
11364                valflags,
11365                start,
11366                end,
11367            };
11368            if is_scalar {
11369                // Scalar splice — concat values, route through
11370                // assignstrvalue which dispatches by PM_TYPE.
11371                // start_translate returns 1-based positions; assignstrvalue's
11372                // PM_SCALAR arm at params.rs:3735+ expects 0-based start
11373                // (chars before start are kept) and 0-based end-exclusive
11374                // (chars from end are kept). Convert: start-=1.
11375                if v.start > 0 {
11376                    v.start -= 1;
11377                }
11378                let val: String = values.join("");
11379                crate::ported::params::assignstrvalue(Some(&mut v), Some(val), 0);
11380            } else {
11381                crate::ported::params::setarrvalue(&mut v, values);
11382            }
11383            // Write the mutated Param back to paramtab — setarrvalue
11384            // mutated v.pm in-place; the prior `tab.remove(&name)` at
11385            // the top of this handler took ownership, so we re-insert
11386            // here. setarrvalue + this re-insert IS the canonical
11387            // store (Src/params.c:2895). No further mirror needed.
11388            if let Some(pm) = v.pm {
11389                if let Ok(mut tab) = crate::ported::params::paramtab().write() {
11390                    tab.insert(name, pm);
11391                }
11392            }
11393        });
11394        if let Some((nm, old_len, i)) = sparse_track {
11395            crate::bash_arrays::note_subscript_set(&nm, old_len, i);
11396        }
11397        Value::Status(0)
11398    });
11399
11400    // BUILTIN_CONCAT_SPLICE — word-segment concat for an expansion whose
11401    // ARRAY shape survives into the word (`${arr[@]}`, `$@`, `${(@)a}`,
11402    // `${=v}`, slices). c:Src/subst.c:4245 `if (isarr)` gates the two
11403    // emit shapes and c:1663 `int plan9 = isset(RCEXPANDPARAM);` picks
11404    // between them at RUNTIME — so the option, not the compile-time
11405    // segment shape, decides splice-vs-cross-product here.
11406    vm.register_builtin(BUILTIN_CONCAT_SPLICE, |vm, _argc| {
11407        let rhs = vm.pop();
11408        let lhs = vm.pop();
11409        if plan9_active() {
11410            return concat_plan9_prov(lhs, rhs);
11411        }
11412        concat_splice_prov(lhs, rhs)
11413    });
11414
11415    // BUILTIN_CONCAT_DISTRIBUTE — word-segment concat. With
11416    // rcexpandparam (zsh option), distributes element-wise (cartesian
11417    // product). Default mode: joins arrays with IFS first char to a
11418    // single scalar before concat, matching zsh's default unquoted
11419    // and DQ semantics. Direct port of Src/subst.c sepjoin path
11420    // (line ~1813) which gates element-vs-join on the rc_expand_param
11421    // option, defaulting to join.
11422    // BUILTIN_CONCAT_DISTRIBUTE_FORCED — same shape as
11423    // CONCAT_DISTRIBUTE, but always cartesian-distributes when one
11424    // side is Array. Used for compile-time-detected explicit
11425    // distribution forms (`${^arr}` etc.) where the source flag
11426    // overrides the rcexpandparam option default.
11427    // `${^arr}` — RC_EXPAND_PARAM forced on by the flag. concat_plan9 carries
11428    // both halves of C's plan9 block: the c:4316-4350 cartesian emit AND the
11429    // c:4362 `uremnode` word deletion for an empty array. The DISTRIBUTE_FORCED
11430    // handler below cannot be reused: it is shared with `${(@)a}` / `${(f)v}` /
11431    // `${a[@]}`, which KEEP the word on empty (`x${(@)a}y` → `xy`).
11432    vm.register_builtin(BUILTIN_CONCAT_PLAN9, |vm, _argc| {
11433        let rhs = vm.pop();
11434        let lhs = vm.pop();
11435        concat_plan9_prov(lhs, rhs)
11436    });
11437
11438    // `${^^arr}` — RC_EXPAND_PARAM forced OFF (c:2553-2555 `plan9 = 0`). Every
11439    // other concat builtin re-checks plan9_active(), which is the OPTION, so
11440    // under `setopt rcexpandparam` they cross-product regardless of the flag.
11441    // Go straight to concat_splice — C's non-plan9 path (c:4366-4437).
11442    vm.register_builtin(BUILTIN_CONCAT_SPLICE_NOPLAN9, |vm, _argc| {
11443        let rhs = vm.pop();
11444        let lhs = vm.pop();
11445        concat_splice_prov(lhs, rhs)
11446    });
11447
11448    vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE_FORCED, |vm, _argc| {
11449        let rhs = vm.pop();
11450        let lhs = vm.pop();
11451        match (lhs, rhs) {
11452            (Value::Array(la), Value::Array(ra)) => {
11453                if ra.is_empty() {
11454                    return Value::Array(la);
11455                }
11456                if la.is_empty() {
11457                    return Value::Array(ra);
11458                }
11459                let mut out = Vec::with_capacity(la.len() * ra.len());
11460                for a in la.iter() {
11461                    let a_s = a.as_str_cow();
11462                    for b in ra.iter() {
11463                        let b_s = b.as_str_cow();
11464                        let mut s = String::with_capacity(a_s.len() + b_s.len());
11465                        s.push_str(&a_s);
11466                        s.push_str(&b_s);
11467                        out.push(Value::str(s));
11468                    }
11469                }
11470                Value::array(out)
11471            }
11472            (Value::Array(la), rhs_scalar) => {
11473                // An EMPTY array contributes nothing to a concatenated
11474                // word — the surrounding scalar text survives. zsh:
11475                // `x${^a}y` (a=()) / `x${(P)scalar-empty}y` → "xy", NOT
11476                // a dropped word. Without this, a `(P)` indirect to an
11477                // unset/empty scalar (which nodes_to_value collapses to
11478                // Value::Array([]) for standalone-removal semantics)
11479                // cartesian-dropped the whole word — p10k's
11480                // `typeset -g _$2=${(P)2}` then arrived as a bare
11481                // `typeset` and dumped every parameter (~217× → 19 MB
11482                // terminal flood → startup hang).
11483                if la.is_empty() {
11484                    return rhs_scalar;
11485                }
11486                let r = rhs_scalar.as_str_cow();
11487                let out: Vec<Value> = la
11488                    .iter()
11489                    .map(|a| {
11490                        let a_s = a.as_str_cow();
11491                        let mut s = String::with_capacity(a_s.len() + r.len());
11492                        s.push_str(&a_s);
11493                        s.push_str(&r);
11494                        Value::str(s)
11495                    })
11496                    .collect();
11497                Value::array(out)
11498            }
11499            (lhs_scalar, Value::Array(ra)) => {
11500                // Symmetric empty-array-contributes-nothing rule; see
11501                // the (Array, scalar) arm above.
11502                if ra.is_empty() {
11503                    return lhs_scalar;
11504                }
11505                let l = lhs_scalar.as_str_cow();
11506                let out: Vec<Value> = ra
11507                    .iter()
11508                    .map(|b| {
11509                        let b_s = b.as_str_cow();
11510                        let mut s = String::with_capacity(l.len() + b_s.len());
11511                        s.push_str(&l);
11512                        s.push_str(&b_s);
11513                        Value::str(s)
11514                    })
11515                    .collect();
11516                Value::array(out)
11517            }
11518            (lhs_s, rhs_s) => {
11519                let l = lhs_s.as_str_cow();
11520                let r = rhs_s.as_str_cow();
11521                let mut s = String::with_capacity(l.len() + r.len());
11522                s.push_str(&l);
11523                s.push_str(&r);
11524                Value::str(s)
11525            }
11526        }
11527    });
11528
11529    vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE, |vm, argc| {
11530        let rhs = vm.pop();
11531        let lhs = vm.pop();
11532        // c:Src/subst.c:4245 `if (isarr)` — an unquoted array embedded
11533        // in a word ALWAYS emits one word per element, never a scalar
11534        // join. The shape (splice vs plan9 cross-product) is chosen at
11535        // RUNTIME by c:1663 `int plan9 = isset(RCEXPANDPARAM);`, exactly
11536        // as BUILTIN_CONCAT_SPLICE does. The only extra case DISTRIBUTE
11537        // handles is the DQ context: the compiler emits
11538        // CallBuiltin(BUILTIN_CONCAT_DISTRIBUTE, 1) when the parent word
11539        // is DQ-wrapped (compile_zsh.rs parent_is_dq), and inside DQ
11540        // `"pre${arr}post"` joins via $IFS[0] to a single scalar
11541        // regardless of the option (c:Src/subst.c:1650-1656 isarr
11542        // comment). The default UNQUOTED path emits argc=2 (lhs + rhs).
11543        // Bug #246 in docs/BUGS.md.
11544        if argc == 1 {
11545            // DQ context: join any Array side to scalar via sepjoin's
11546            // IFS default. c:Src/utils.c:3936-3945 — set-but-empty IFS
11547            // joins with "" (`IFS=""; echo "x$*y"` → `xabcy`); only
11548            // unset / space-leading IFS yields " ".
11549            let join_arr = |arr: &[Value]| -> String {
11550                let strs: Vec<String> = arr.iter().map(|v| v.as_str_cow().into_owned()).collect();
11551                crate::ported::utils::sepjoin(&strs, None)
11552            };
11553            // Provenance: the DQ-join arm consumes both operands, so
11554            // capture them first (Arc clones, only while armed).
11555            let prov_operands = crate::provenance::active().then(|| (lhs.clone(), rhs.clone()));
11556            let l = match lhs {
11557                Value::Array(a) => join_arr(&a),
11558                other => other.as_str_cow().into_owned(),
11559            };
11560            let r = match rhs {
11561                Value::Array(a) => join_arr(&a),
11562                other => other.as_str_cow().into_owned(),
11563            };
11564            let mut s = String::with_capacity(l.len() + r.len());
11565            s.push_str(&l);
11566            s.push_str(&r);
11567            let out = Value::str(s);
11568            if let Some((pl, pr)) = prov_operands {
11569                crate::provenance::on_concat(&pl, &pr, &out);
11570            }
11571            return out;
11572        }
11573        // Unquoted plain `${arr}`: same runtime dispatch as
11574        // BUILTIN_CONCAT_SPLICE — c:4245 `if (isarr)` always distributes
11575        // one word per element; c:1663 picks splice (default, first/last
11576        // sticking, c:4366-4437) vs plan9 cross-product (c:4316-4365).
11577        // concat_splice / concat_plan9 both honor EMPTY_EXPANSION_IS_SCALAR
11578        // so the p10k `${(P)2}` empty-array word-removal semantics survive.
11579        if plan9_active() {
11580            return concat_plan9_prov(lhs, rhs);
11581        }
11582        concat_splice_prov(lhs, rhs)
11583    });
11584
11585    // See BUILTIN_WORD_ASSEMBLE_PLAN9's doc comment for the stack contract.
11586    vm.register_builtin(BUILTIN_WORD_ASSEMBLE_PLAN9, |vm, argc| {
11587        // Pop argc values: descriptor was pushed FIRST (bottom), segments
11588        // after it, so popping top-first then reversing yields
11589        // [descriptor, seg0, …, seg(n-1)].
11590        let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
11591        for _ in 0..argc {
11592            popped.push(vm.pop());
11593        }
11594        popped.reverse();
11595        let mut it = popped.into_iter();
11596        let descriptor = it.next().map(|v| v.to_str()).unwrap_or_default();
11597        let plan9_flags: Vec<bool> = descriptor.chars().map(|c| c == '1').collect();
11598        let segments: Vec<Value> = it.collect();
11599        word_assemble_plan9(&segments, &plan9_flags)
11600    });
11601
11602    // `[[ a -ef b ]]` — same-inode test. Resolves both paths via fs::metadata
11603    // (follows symlinks the way zsh's -ef does) and compares (dev, inode).
11604    // Returns false on any I/O error (path missing, permission denied, etc.).
11605    vm.register_builtin(BUILTIN_SAME_FILE, |vm, _argc| {
11606        let b = vm.pop().to_str();
11607        let a = vm.pop().to_str();
11608        let same = match (fs::metadata(&a), fs::metadata(&b)) {
11609            (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
11610            _ => false,
11611        };
11612        Value::Bool(same)
11613    });
11614
11615    // `[[ -c path ]]` — character device.
11616    vm.register_builtin(BUILTIN_IS_CHARDEV, |vm, _argc| {
11617        let path = vm.pop().to_str();
11618        let result = fs::metadata(&path)
11619            .map(|m| m.file_type().is_char_device())
11620            .unwrap_or(false);
11621        Value::Bool(result)
11622    });
11623    // `[[ -b path ]]` — block device.
11624    vm.register_builtin(BUILTIN_IS_BLOCKDEV, |vm, _argc| {
11625        let path = vm.pop().to_str();
11626        let result = fs::metadata(&path)
11627            .map(|m| m.file_type().is_block_device())
11628            .unwrap_or(false);
11629        Value::Bool(result)
11630    });
11631    // `[[ -p path ]]` — FIFO (named pipe).
11632    vm.register_builtin(BUILTIN_IS_FIFO, |vm, _argc| {
11633        let path = vm.pop().to_str();
11634        let result = fs::metadata(&path)
11635            .map(|m| m.file_type().is_fifo())
11636            .unwrap_or(false);
11637        Value::Bool(result)
11638    });
11639    // `[[ -S path ]]` — socket.
11640    vm.register_builtin(BUILTIN_IS_SOCKET, |vm, _argc| {
11641        let path = vm.pop().to_str();
11642        let result = fs::symlink_metadata(&path)
11643            .map(|m| m.file_type().is_socket())
11644            .unwrap_or(false);
11645        Value::Bool(result)
11646    });
11647
11648    // `[[ -k path ]]` / `-u` / `-g` — sticky / setuid / setgid bit.
11649    vm.register_builtin(BUILTIN_HAS_STICKY, |vm, _argc| {
11650        let path = vm.pop().to_str();
11651        let result = fs::metadata(&path)
11652            .map(|m| m.permissions().mode() & libc::S_ISVTX as u32 != 0)
11653            .unwrap_or(false);
11654        Value::Bool(result)
11655    });
11656    vm.register_builtin(BUILTIN_HAS_SETUID, |vm, _argc| {
11657        let path = vm.pop().to_str();
11658        let result = fs::metadata(&path)
11659            .map(|m| m.permissions().mode() & libc::S_ISUID as u32 != 0)
11660            .unwrap_or(false);
11661        Value::Bool(result)
11662    });
11663    vm.register_builtin(BUILTIN_HAS_SETGID, |vm, _argc| {
11664        let path = vm.pop().to_str();
11665        let result = fs::metadata(&path)
11666            .map(|m| m.permissions().mode() & libc::S_ISGID as u32 != 0)
11667            .unwrap_or(false);
11668        Value::Bool(result)
11669    });
11670    vm.register_builtin(BUILTIN_OWNED_BY_USER, |vm, _argc| {
11671        let path = vm.pop().to_str();
11672        let euid = unsafe { libc::geteuid() };
11673        let result = fs::metadata(&path)
11674            .map(|m| m.uid() == euid)
11675            .unwrap_or(false);
11676        Value::Bool(result)
11677    });
11678    vm.register_builtin(BUILTIN_OWNED_BY_GROUP, |vm, _argc| {
11679        let path = vm.pop().to_str();
11680        let egid = unsafe { libc::getegid() };
11681        let result = fs::metadata(&path)
11682            .map(|m| m.gid() == egid)
11683            .unwrap_or(false);
11684        Value::Bool(result)
11685    });
11686
11687    // `[[ -N path ]]` — file's access time is NOT newer than its
11688    // modification time (zsh man: "true if file exists and its
11689    // access time is not newer than its modification time"). Used
11690    // by zsh's mailbox-watching code. The semantic is `atime <=
11691    // mtime` (equivalent to `mtime >= atime`) — equal counts as
11692    // true, which a strict `mtime > atime` check missed for newly
11693    // created files where both stamps are identical.
11694    vm.register_builtin(BUILTIN_FILE_MODIFIED_SINCE_ACCESS, |vm, _argc| {
11695        let path = vm.pop().to_str();
11696        let result = fs::metadata(&path)
11697            .map(|m| m.atime() <= m.mtime())
11698            .unwrap_or(false);
11699        Value::Bool(result)
11700    });
11701
11702    // `[[ a -nt b ]]` — true if `a`'s mtime is strictly later than `b`'s.
11703    // BOTH files must exist; if either is missing the result is false.
11704    // (Earlier behavior was bash's "missing == infinitely-old"; zsh
11705    // strictly requires both files to exist.)
11706    vm.register_builtin(BUILTIN_FILE_NEWER, |vm, _argc| {
11707        let b = vm.pop().to_str();
11708        let a = vm.pop().to_str();
11709        // Use SystemTime modified() for nanosecond precision —
11710        // MetadataExt::mtime() returns seconds only, so two files
11711        // touched within the same second compared equal even when
11712        // 500ms apart. zsh tracks ns and uses `>=` for ties (touching
11713        // a then b in quick succession should still report b newer).
11714        let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
11715        let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
11716        let result = match (ta, tb) {
11717            (Some(ta), Some(tb)) => ta > tb,
11718            // bash, ksh93 and mksh: a missing right operand is infinitely old.
11719            (Some(_), None) => crate::dash_mode::bash_mode() || crate::dash_mode::korn_mode(),
11720            _ => false,
11721        };
11722        Value::Bool(result)
11723    });
11724
11725    // `[[ a -ot b ]]` — mirror of -nt. Same both-must-exist contract.
11726    vm.register_builtin(BUILTIN_FILE_OLDER, |vm, _argc| {
11727        let b = vm.pop().to_str();
11728        let a = vm.pop().to_str();
11729        let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
11730        let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
11731        let result = match (ta, tb) {
11732            (Some(ta), Some(tb)) => ta < tb,
11733            // bash, ksh93 and mksh: a missing left operand is infinitely old.
11734            (None, Some(_)) => crate::dash_mode::bash_mode() || crate::dash_mode::korn_mode(),
11735            _ => false,
11736        };
11737        Value::Bool(result)
11738    });
11739
11740    // `set -e` / `setopt errexit` post-command check. Compiler emits
11741    // this after each top-level command's SetStatus (skipped inside
11742    // conditionals/pipelines/&&||/`!`). If errexit is on AND the last
11743    // command exited non-zero AND it's not a `return` from a function,
11744    // exit the shell with that status.
11745    // `set -x` / `setopt xtrace` — print each command before it runs.
11746    // The compiler emits this BEFORE the actual builtin/external call
11747    // with the command's literal text as a single string arg. We
11748    // print to stderr if xtrace is on. Honors `$PS4` (default `+ `).
11749    //
11750    // ── XTRACE flow control ────────────────────────────────────────
11751    // Mirror of C zsh's `doneps4` flag in execcmd_exec (Src/exec.c).
11752    // When an assignment trace fires (XTRACE_ASSIGN), it emits PS4
11753    // and sets this flag so the subsequent XTRACE_ARGS skips its own
11754    // PS4 emission — the assignment + command end up on the SAME
11755    // line: `<PS4>a=1 echo hello\n`. XTRACE_ARGS / XTRACE_NEWLINE
11756    // reset the flag after emitting the trailing `\n`.
11757    vm.register_builtin(BUILTIN_XTRACE_IS_ON, |_vm, _argc| {
11758        // Push live xtrace state. Caller pairs this with JumpIfFalse
11759        // to skip the trace-string-building block when xtrace is off,
11760        // avoiding side-effectful operand re-evaluation. Bug #159 in
11761        // docs/BUGS.md.
11762        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11763        Value::Int(if on { 1 } else { 0 })
11764    });
11765
11766    vm.register_builtin(BUILTIN_XTRACE_LINE, |vm, _argc| {
11767        // Keep the Value; `to_str()` allocates a String, and this handler
11768        // runs on EVERY `(( … ))` / `[[ … ]]` / loop-head evaluation, where
11769        // xtrace is off essentially always. Defer the allocation to the
11770        // `on` branch below.
11771        let cmd_val = vm.pop();
11772        // Sync exec.last_status with the live vm.last_status BEFORE
11773        // the next command runs. Direct port of the zsh exec.c
11774        // contract — `$?` reads the exit status of the *most recent*
11775        // command. XTRACE_LINE is emitted by the compiler BEFORE
11776        // every simple command, so it's the natural sync point.
11777        let live = vm.last_status;
11778        with_executor(|exec| {
11779            exec.set_last_status(live);
11780        });
11781        // C zsh emits xtrace for `(( … ))` / `[[ … ]]` / `case` /
11782        // `if/while/until/for/repeat` head expressions via
11783        // `printprompt4(); fprintf(xtrerr, "%s\n", expr)` at
11784        // Src/exec.c:5240 (math), c:5286 (cond), c:4117 (for), etc.
11785        // The compiler emits BUILTIN_XTRACE_LINE only at those
11786        // construct boundaries (compile_arith / compile_cond /
11787        // compile_if / compile_while / compile_for / compile_case);
11788        // simple commands route to BUILTIN_XTRACE_ARGS instead. So
11789        // this handler always emits when xtrace is on — no prefix-
11790        // string heuristic.
11791        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11792        if on {
11793            let already = XTRACE_DONE_PS4.with(|f| f.get());
11794            if !already {
11795                printprompt4();
11796            }
11797            // c:exec.c:5240/5286 — `fprintf(xtrerr, "%s\n", expr)`. Buffer
11798            // the line + newline, flush once (single write).
11799            xtrerr_fputs(&cmd_val.to_str());
11800            xtrerr_fputs("\n");
11801            xtrerr_flush();
11802            XTRACE_DONE_PS4.with(|f| f.set(false));
11803        }
11804        Value::Status(0)
11805    });
11806
11807    // BUILTIN_XTRACE_ARRAY_LINE — xtrace line for an `arr=(...)` / `arr+=(...)`
11808    // assignment. Stack on entry: [array, prefix] (argc = 2); pops prefix
11809    // ("name=( " / "name+=( "), then the whole assembled Value::Array. Direct
11810    // port of c:Src/exec.c::addvars:2624-2632, guarded on the live xtrace
11811    // state like C's `if (xtr)`: prints `prefix qz(e0) qz(e1) … ) ` with each
11812    // element shell-quoted (quotedzputs). Replaces the former one-VM-slot-per-
11813    // element trace, which overflowed next_slot (u16) on large literals.
11814    vm.register_builtin(BUILTIN_XTRACE_ARRAY_LINE, |vm, _argc| {
11815        let prefix = vm.pop().to_str();
11816        let arr = vm.pop();
11817        let live = vm.last_status;
11818        with_executor(|exec| {
11819            exec.set_last_status(live);
11820        });
11821        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11822        if on {
11823            let already = XTRACE_DONE_PS4.with(|f| f.get());
11824            if !already {
11825                printprompt4();
11826            }
11827            let mut line = String::with_capacity(prefix.len() + 16);
11828            line.push_str(&prefix);
11829            if let Value::Array(items) = arr {
11830                for it in items.iter() {
11831                    line.push_str(&crate::ported::utils::quotedzputs(&it.to_str()));
11832                    line.push(' ');
11833                }
11834            }
11835            line.push_str(") ");
11836            line.push('\n');
11837            xtrerr_fputs(&line);
11838            xtrerr_flush();
11839            XTRACE_DONE_PS4.with(|f| f.set(false));
11840        }
11841        Value::Status(0)
11842    });
11843
11844    // BUILTIN_MAKE_ARRAY_COUNTED — pop a count Int (top), then pop that many
11845    // values below it, and push them as one Value::Array (bottom-of-group
11846    // first). Same result as Op::MakeArray(N) but N comes from the stack as an
11847    // i64, dodging MakeArray's u16 operand cap. The compiler emits this only
11848    // when a literal `arr=(...)` has more than u16::MAX elements.
11849    vm.register_builtin(BUILTIN_MAKE_ARRAY_COUNTED, |vm, _argc| {
11850        let count = vm.pop().to_int().max(0) as usize;
11851        let mut items: Vec<Value> = Vec::with_capacity(count);
11852        for _ in 0..count {
11853            items.push(vm.pop());
11854        }
11855        items.reverse();
11856        Value::array(items)
11857    });
11858
11859    // BUILTIN_ARGV_RFLATTEN — recursively flatten a MakeArray-packed argv
11860    // bundle so a >255-arg Call/CallFunction/CallBuiltin (dispatched with
11861    // argc=1 over the single packed Array) recovers every positional arg. The
11862    // call ops flatten only one level; a brace/glob/`$arr` word contributes a
11863    // nested Array that would otherwise stringify. See the const doc.
11864    vm.register_builtin(BUILTIN_ARGV_RFLATTEN, |vm, _argc| {
11865        let v = vm.pop();
11866        let mut out: Vec<String> = Vec::new();
11867        flatten_array_value(v, &mut out);
11868        Value::array(out.into_iter().map(Value::str).collect())
11869    });
11870
11871    // Like XTRACE_LINE but reads the top `argc - 1` values from the
11872    // VM stack WITHOUT consuming them (peek), then pops a prefix
11873    // string at the top. Joins prefix + peeked args with spaces using
11874    // zsh's quotedzputs-equivalent quoting. Direct port of
11875    // Src/exec.c:2055-2066 — emit AFTER expansion, with each arg
11876    // shell-quoted, so `for i in a b; echo for $i` traces as
11877    // `echo for a` / `echo for b`, not `echo for $i`.
11878    //
11879    // Stack contract on entry: [arg1, arg2, ..., argN, prefix].
11880    // Pops prefix; peeks argN..arg1 below. argc = N + 1.
11881    vm.register_builtin(BUILTIN_XTRACE_ARGS, |vm, argc| {
11882        let prefix = vm.pop().to_str();
11883        let live = vm.last_status;
11884        with_executor(|exec| {
11885            exec.set_last_status(live);
11886        });
11887        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11888        if on {
11889            let n_args = argc.saturating_sub(1) as usize;
11890            let len = vm.stack.len();
11891            // c:Src/exec.c:2055 — argv is the POST-expansion word
11892            // list, so an arg that expanded to multiple words splats
11893            // into multiple trace tokens AND an arg that expanded to
11894            // zero words (empty unquoted `${UNSET}`) emits nothing.
11895            // pop_args (line 6243) already does this splat for the
11896            // real handler; mirror the same Array → splat / empty →
11897            // drop logic here so xtrace renders `echo ${UNSET}` as
11898            // `echo` (zsh) instead of `echo ''` (the previous
11899            // single-arg stringify path returned "" and then
11900            // quotedzputs wrapped it in `''`).
11901            let arg_strs: Vec<String> = if n_args > 0 && len >= n_args {
11902                let mut out = Vec::new();
11903                for v in &vm.stack[len - n_args..] {
11904                    match v {
11905                        Value::Array(items) => {
11906                            for item in items.iter() {
11907                                out.push(quotedzputs(&item.to_str()));
11908                            }
11909                        }
11910                        other => out.push(quotedzputs(&other.to_str())),
11911                    }
11912                }
11913                out
11914            } else {
11915                Vec::new()
11916            };
11917            // Builtins dispatch through `execbuiltin` (Src/builtin.c:442)
11918            // which emits its own PS4 + name + args xtrace. To avoid
11919            // double-emission, skip our emission here when the first
11920            // arg is a known builtin with a registered HandlerFunc —
11921            // those go through execbuiltin and will trace themselves.
11922            // Externals + builtins-not-yet-routed-through-execbuiltin
11923            // keep our emission as a stand-in.
11924            let goes_through_execbuiltin = crate::ported::builtin::BUILTINS
11925                .iter()
11926                .any(|b| b.node.nam == prefix && b.handlerfunc.is_some());
11927            if !goes_through_execbuiltin {
11928                let line = if arg_strs.is_empty() {
11929                    prefix
11930                } else {
11931                    format!("{} {}", prefix, arg_strs.join(" "))
11932                };
11933                // Mirrors Src/exec.c:2055 xtrace emission. C does:
11934                //   if (!doneps4) printprompt4();
11935                //   ... emit args + spaces ...
11936                //   fputc('\n', xtrerr); fflush(xtrerr);
11937                // printprompt4 + the args + `\n` all land in the xtrerr
11938                // buffer; the single fflush below writes the whole line in
11939                // one syscall so concurrent pipeline stages never
11940                // interleave (c:makecline:2122-2123).
11941                let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
11942                if !already_ps4 {
11943                    printprompt4();
11944                }
11945                xtrerr_fputs(&line);
11946                xtrerr_fputs("\n"); // c:2122 fputc('\n', xtrerr)
11947                xtrerr_flush(); // c:2123 fflush(xtrerr)
11948            }
11949            XTRACE_DONE_PS4.with(|f| f.set(false));
11950        }
11951        Value::Status(0)
11952    });
11953
11954    // BUILTIN_XTRACE_ASSIGN — direct port of the per-assignment
11955    // trace block at Src/exec.c:2517-2582. C body excerpt:
11956    //   xtr = isset(XTRACE);
11957    //   if (xtr) { printprompt4(); doneps4 = 1; }
11958    //   while (assign) {
11959    //       if (xtr) fprintf(xtrerr, "%s+=" or "%s=", name);
11960    //       ... eval value into `val` ...
11961    //       if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
11962    //       ...
11963    //   }
11964    //
11965    // Stack on entry: [..., name, value]. PEEKS both (they're left
11966    // on stack for SET_VAR to pop). Emits `name=<quoted-val> ` with
11967    // no newline; trailing `\n` comes from XTRACE_ARGS (cmd path)
11968    // or XTRACE_NEWLINE (assignment-only path).
11969    vm.register_builtin(BUILTIN_XTRACE_ASSIGN, |vm, _argc| {
11970        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11971        if on {
11972            // PEEK [..., name, value] — argc==2 by contract.
11973            let len = vm.stack.len();
11974            if len >= 2 {
11975                let name = vm.stack[len - 2].to_str();
11976                let value = vm.stack[len - 1].to_str();
11977                let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
11978                if !already_ps4 {
11979                    printprompt4();
11980                    XTRACE_DONE_PS4.with(|f| f.set(true));
11981                }
11982                // C: `fprintf(xtrerr, "%s=", name)` then `quotedzputs
11983                // (val); fputc(' ', xtrerr);`. Append to the xtrerr buffer
11984                // (no newline / no flush — the line continues with the
11985                // command via XTRACE_ARGS, or ends at XTRACE_NEWLINE).
11986                xtrerr_fputs(&format!("{}={} ", name, quotedzputs(&value)));
11987            }
11988        }
11989        Value::Status(0)
11990    });
11991
11992    // BUILTIN_XTRACE_NEWLINE — emit trailing `\n` + flush iff a
11993    // prior XTRACE_ASSIGN this line already emitted PS4. Mirrors
11994    // C's `fputc('\n', xtrerr); fflush(xtrerr);` at exec.c:3398
11995    // (the assignment-only path through execcmd_exec).
11996    vm.register_builtin(BUILTIN_XTRACE_NEWLINE, |_vm, _argc| {
11997        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11998        if on {
11999            let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
12000            if already_ps4 {
12001                xtrerr_fputs("\n"); // c:3398 fputc('\n', xtrerr)
12002                xtrerr_flush(); // c:3398 fflush(xtrerr)
12003                XTRACE_DONE_PS4.with(|f| f.set(false));
12004            }
12005        }
12006        Value::Status(0)
12007    });
12008
12009    // c:Src/exec.c WC_TRYBLOCK — post-always re-jump probes. Each
12010    // returns 1 + consumes the atomic when the corresponding
12011    // escape flag is set; the try-block compile pairs each with
12012    // a JumpIfFalse + Jump → outer scope's return / break /
12013    // continue patches.
12014    vm.register_builtin(BUILTIN_RETFLAG_CHECK, |_vm, _argc| {
12015        use std::sync::atomic::Ordering;
12016        let r = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
12017        if r != 0 {
12018            // Don't clear here — doshfunc owns the clear at c:6047
12019            // when the function unwinds. Leaving it set propagates
12020            // through nested `eval`/`source` callers correctly.
12021            Value::Int(1)
12022        } else {
12023            Value::Int(0)
12024        }
12025    });
12026    vm.register_builtin(BUILTIN_BREAKS_CHECK, |_vm, _argc| {
12027        use std::sync::atomic::Ordering;
12028        let b = crate::ported::builtin::BREAKS.load(Ordering::Relaxed);
12029        let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
12030        // `break` sets BREAKS but NOT CONTFLAG; `continue` sets both.
12031        // Filter out the continue path here so the two checks are
12032        // mutually exclusive.
12033        if b != 0 && c == 0 {
12034            // Consume BREAKS so the outer loop's break_patches
12035            // landing doesn't double-decrement.
12036            crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
12037            Value::Int(1)
12038        } else {
12039            Value::Int(0)
12040        }
12041    });
12042    vm.register_builtin(BUILTIN_CONTFLAG_CHECK, |_vm, _argc| {
12043        use std::sync::atomic::Ordering;
12044        let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
12045        if c != 0 {
12046            crate::ported::builtin::CONTFLAG.store(0, Ordering::Relaxed);
12047            crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
12048            Value::Int(1)
12049        } else {
12050            Value::Int(0)
12051        }
12052    });
12053    // c:Src/loop.c — `loops++` / `loops--` bracket every iterative
12054    // construct: execfor c:114/188, execwhile c:427/491, execrepeat
12055    // c:523/546. `loops` is a GLOBAL, not a per-frame counter, and
12056    // `bin_break` reads it (`if (!loops)`) to decide whether `break` /
12057    // `continue` is legal. Because doshfunc does NOT reset it (only
12058    // restores it under LOCAL_LOOPS, c:6104-6112), a function called
12059    // from inside a loop sees the CALLER's count and its `break` ends
12060    // the caller's loop. zshrs's compiled for/while/until/repeat lower
12061    // to raw jumps, so without these two ops the counter stayed 0 and
12062    // every such `break` errored out instead.
12063    vm.register_builtin(BUILTIN_LOOP_ENTER, |_vm, _argc| {
12064        crate::ported::builtin::LOOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); // c:114
12065        Value::Int(0)
12066    });
12067    // c:Src/loop.c:141-145 + :199-203 (execfor), :478-481 (execwhile),
12068    // :534-537 (execrepeat) — every loop that abandons its body because
12069    // `errflag` is set FORCES the escaping status first:
12070    //     if (errflag) { if (breaks) breaks--; lastval = 1; break; }
12071    // so a fatal error inside a loop leaves 1, not whatever the failing
12072    // command set. `setopt extendedglob; for i in 1 2; do [[ abc == [ ]]; done`
12073    // exits 1 in zsh while the bare `[[ abc == [ ]]` exits 2; zshrs's compiled
12074    // loops jumped straight to the chunk-end landing and carried the cond's 2
12075    // out. `execselect` has no such assignment (c:217+), which is why
12076    // `compile_select` does not bump `open_loop_depth` and never emits this.
12077    // c:Src/loop.c:479-482 — `if (errflag) { lastval = 1; break; }`, the
12078    // guard every C loop runs AFTER its body. Without it a completer whose
12079    // loop body raises an error spins forever in an interactive shell: the
12080    // errflag makes the prologue skip every statement, but the loop's exit
12081    // test reads a residual `$? == 0` and loops again.  Reproduced at 98% CPU
12082    // with `_files -g "*(-.):t:source files" -g "(a:t:directories"` behind a
12083    // `#compdef` — `while _next_label …; do` (Completion/Unix/Type/_files
12084    // sh:121) never terminated, so `<cmd> <TAB>` produced nothing, ever.
12085    // lldb pinned three samples at the same loop head in the `_files` chunk.
12086    //
12087    // C tests the WHOLE errflag word here (ERRFLAG_INT included), exactly as
12088    // the list loop does at c:Src/exec.c:1443.
12089    vm.register_builtin(BUILTIN_LOOP_ERRFLAG_BREAK, |vm, _argc| {
12090        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) == 0 {
12091            return Value::Int(0);
12092        }
12093        vm.last_status = 1; // c:481/201/536
12094        with_executor(|exec| exec.set_last_status(1)); // c:481/201/536
12095        Value::Int(1) // c:482/202/537 — break
12096    });
12097
12098    vm.register_builtin(BUILTIN_LOOP_ERRFLAG_STATUS, |vm, _argc| {
12099        // The `if (errflag)` half of the C guard is re-tested HERE, not at
12100        // compile time: the same abort edge also carries an ERREXIT
12101        // (`set -e`) exit, which in C leaves execlist via `zexit(lastval)`
12102        // and never reaches the loop's `if (errflag)` arm — so that status
12103        // must survive untouched.
12104        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
12105            & crate::ported::zsh_h::ERRFLAG_ERROR)
12106            != 0
12107        {
12108            vm.last_status = 1; // c:144/201/480/536
12109            with_executor(|exec| exec.set_last_status(1)); // c:144/201/480/536
12110        }
12111        Value::Int(0)
12112    });
12113    vm.register_builtin(BUILTIN_LOOP_EXIT, |_vm, _argc| {
12114        use std::sync::atomic::Ordering::SeqCst;
12115        // Saturating: a chunk aborted mid-loop (errflag, `return`)
12116        // unwinds through `run_chunk`'s restore rather than this op, so
12117        // never let a stray decrement drive the count negative.
12118        let _ = crate::ported::builtin::LOOPS
12119            .fetch_update(SeqCst, SeqCst, |n| Some(if n > 0 { n - 1 } else { 0 })); // c:188
12120        Value::Int(0)
12121    });
12122
12123    // c:Src/loop.c:529-534 (execwhile), :180-185 (execfor), :540-545
12124    // (execrepeat) — the identical post-body drain every loop runs:
12125    //     if (breaks) {
12126    //         breaks--;
12127    //         if (breaks || !contflag) break;
12128    //         contflag = 0;
12129    //     }
12130    // Returns Int(1) when this loop must terminate, Int(0) when it
12131    // should proceed to the next iteration. Only a `break`/`continue`
12132    // executed in a DIFFERENT chunk (a called function, `eval`, a
12133    // sourced file) reaches here — an in-chunk `break` compiles to a
12134    // direct jump and never touches the counter.
12135    vm.register_builtin(BUILTIN_LOOP_BREAK_DRAIN, |_vm, _argc| {
12136        use std::sync::atomic::Ordering::SeqCst;
12137        let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
12138        if breaks == 0 {
12139            return Value::Int(0);
12140        }
12141        let remaining = breaks - 1;
12142        crate::ported::builtin::BREAKS.store(remaining, SeqCst); // c:530
12143        let contflag = crate::ported::builtin::CONTFLAG.load(SeqCst);
12144        if remaining != 0 || contflag == 0 {
12145            return Value::Int(1); // c:532 — `break`
12146        }
12147        crate::ported::builtin::CONTFLAG.store(0, SeqCst); // c:533
12148        Value::Int(0)
12149    });
12150
12151    // c:Src/exec.c:1370 execlist — `while (wc_code(code) == WC_LIST &&
12152    // !breaks && !retflag && !errflag)`. A pending `breaks` stops the
12153    // CURRENT list at the next statement boundary WITHOUT consuming it,
12154    // so the flag keeps travelling outward until a loop's drain eats it.
12155    // Non-consuming by design: the drain above is the only consumer.
12156    vm.register_builtin(BUILTIN_BREAKS_PENDING, |_vm, _argc| {
12157        let b = crate::ported::builtin::BREAKS.load(std::sync::atomic::Ordering::SeqCst);
12158        Value::Int(if b != 0 { 1 } else { 0 })
12159    });
12160
12161    vm.register_builtin(BUILTIN_NOEXEC_CHECK, |vm, _argc| {
12162        // c:Src/exec.c:1390 — `set -n` / `noexec` option: parse but
12163        // don't execute. Returns Int(1) when noexec is set so the
12164        // emit-side JumpIfTrue skips the statement body.
12165        //
12166        // Arg: 1 when every command of the sublist is a simple command
12167        // with a command word. c:Src/parse.c:1928 marks those cmplx, so C
12168        // never takes execsimple's `if (!isset(EXECOPT)) return lastval =
12169        // 0;` (c:Src/exec.c:1351) for them: execcmd_exec expands the words
12170        // first and skips only the execution (c:4051). Those commands
12171        // carry their own BUILTIN_NOEXEC_CMD_GATE, so noexec must not
12172        // skip them here.
12173        let gated_in_command = vm.pop().to_int() != 0;
12174        if !gated_in_command && opt_state_get("noexec").unwrap_or(false) {
12175            return Value::Int(1);
12176        }
12177        // c:Src/exec.c:1390 — execlist's list-loop gate:
12178        //   `while (wc_code(code) == WC_LIST && !breaks && !retflag
12179        //          && !errflag)`
12180        // — once errflag is set, the NEXT sublist never starts, so
12181        // lastval survives untouched to the shell exit. Without this
12182        // prologue gate the follow-up statement RAN, its dispatch
12183        // saw errflag, returned 1, and SetStatus clobbered lastval —
12184        // `[[ x == [a- ]]; print rc=$?` exited 1 instead of zsh's 2
12185        // (the cond syntax error set lastval=2 per exec.c:5216-5221).
12186        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
12187            & crate::ported::zsh_h::ERRFLAG_ERROR)
12188            != 0
12189        {
12190            return Value::Int(1);
12191        }
12192        Value::Int(0)
12193    });
12194    // See BUILTIN_NOEXEC_CMD_GATE.
12195    vm.register_builtin(BUILTIN_NOEXEC_CMD_GATE, |vm, _argc| {
12196        if !opt_state_get("noexec").unwrap_or(false) {
12197            return Value::Int(0);
12198        }
12199        // c:Src/exec.c:3760-3763 — `if (errflag) { lastval = 1; goto err; }`
12200        // runs after globlist and before the EXECOPT test, so an expansion
12201        // error still sets the status under NO_EXEC.
12202        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
12203            & crate::ported::zsh_h::ERRFLAG_ERROR)
12204            != 0
12205        {
12206            vm.last_status = 1; // c:3761 `lastval = 1;`
12207            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
12208        }
12209        Value::Int(1) // c:4051 — the execution branch is not taken
12210    });
12211    // c:Src/exec.c:1536-1538 —
12212    //     /* suppress errexit for commands before && and || and after ! */
12213    //     if (isandor || isnot)
12214    //         noerrexit |= NOERREXIT_EXIT | NOERREXIT_RETURN;
12215    // The bits live on the PROCESS-GLOBAL `noerrexit`, so they are still
12216    // in force inside a shell function called from that position (doshfunc
12217    // clears only NOERREXIT_RETURN, c:5930). zshrs suppressed the check
12218    // purely at COMPILE time (`errexit_suppress_depth`), which cannot
12219    // reach a separately-compiled function body — so
12220    //   TRAPZERR(){ print E }; f(){ print f; false; }; f && t
12221    // fired the ZERR trap from inside `f` where zsh stays silent
12222    // (C03traps:14, E01options:18,19,21).
12223    vm.register_builtin(BUILTIN_NOERREXIT_SUPPRESS, |_vm, _argc| {
12224        use std::sync::atomic::Ordering;
12225        let old = crate::ported::exec::noerrexit.load(Ordering::Relaxed); // c:1417
12226        NOERREXIT_SAVES.with(|st| st.borrow_mut().push(old));
12227        crate::ported::exec::noerrexit.store(
12228            old | crate::ported::zsh_h::NOERREXIT_EXIT | crate::ported::zsh_h::NOERREXIT_RETURN,
12229            Ordering::Relaxed,
12230        ); // c:1538
12231        Value::Int(0)
12232    });
12233    // c:Src/exec.c:1621 / c:1626 — `noerrexit = oldnoerrexit;`
12234    vm.register_builtin(BUILTIN_NOERREXIT_RESTORE, |_vm, _argc| {
12235        use std::sync::atomic::Ordering;
12236        if let Some(old) = NOERREXIT_SAVES.with(|st| st.borrow_mut().pop()) {
12237            crate::ported::exec::noerrexit.store(old, Ordering::Relaxed); // c:1621
12238        }
12239        Value::Int(0)
12240    });
12241    vm.register_builtin(BUILTIN_DONETRAP_RESET, |_vm, _argc| {
12242        donetrap_reset_impl()
12243    });
12244    // See BUILTIN_FORKED_REDIRS.
12245    vm.register_builtin(BUILTIN_FORKED_REDIRS, |vm, _argc| {
12246        if FORKED_REDIR_VM.with(|f| f.replace(0)) == vm as *const fusevm::VM as usize {
12247            // The next op is the command's WithRedirectsBegin.
12248            let depth = with_executor(|exec| exec.redirect_scope_stack.len()) + 1;
12249            FORKED_REDIR_SCOPE.with(|c| c.set(depth));
12250        }
12251        Value::Int(0)
12252    });
12253    vm.register_builtin(BUILTIN_EXECCMD_FORKED_LEVEL, |vm, _argc| {
12254        let is_subsh = vm.pop().to_int() != 0;
12255        execcmd_forked_level(is_subsh, vm as *const fusevm::VM as usize);
12256        Value::Int(0)
12257    });
12258    vm.register_builtin(BUILTIN_EXITING_EXIT_TRAP, |_vm, _argc| {
12259        use crate::ported::signals_h::SIGEXIT;
12260        use std::sync::atomic::Ordering;
12261        // `trap '…' EXIT` bodies live in traps_table, TRAPEXIT in sigtrapped.
12262        let trapped = crate::ported::signals::sigtrapped.lock().ok().is_some_and(|st| st.get(SIGEXIT as usize).is_some_and(|&s| s != 0))
12263            || crate::ported::builtin::traps_table().lock().ok().is_some_and(|t| t.contains_key("EXIT"));
12264        if trapped {
12265            let eflag = crate::ported::utils::errflag.load(Ordering::Relaxed); // c:1701
12266            crate::ported::utils::errflag.store(0, Ordering::Relaxed); // c:1702
12267            crate::ported::signals::dotrap(SIGEXIT); // c:1703
12268            // c:1705 — `sigtrapped[SIGEXIT] = 0;`
12269            if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
12270                if let Some(slot) = st.get_mut(SIGEXIT as usize) {
12271                    *slot = 0;
12272                }
12273            }
12274            if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
12275                t.remove("EXIT");
12276            }
12277            crate::ported::utils::errflag.store(eflag, Ordering::Relaxed); // c:1706
12278        }
12279        Value::Int(0)
12280    });
12281
12282    // c:Src/exec.c:1451/1455/1476/1390 — see BUILTIN_STMT_PROLOGUE_FAST.
12283    vm.register_builtin(BUILTIN_STMT_PROLOGUE_FAST, |vm, _argc| {
12284        use std::sync::atomic::Ordering;
12285        let line = vm.pop().to_int();
12286        SUBLIST_SERIAL.with(|c| c.set(c.get().wrapping_add(1)));
12287        GLOB_WORD_ERRFLAG.with(|c| c.set(false));
12288        PREFORK_CUT.with(|c| c.set(false));
12289        if line >= 0 {
12290            set_lineno_impl(line); // c:1451
12291        }
12292        donetrap_reset_impl(); // c:1455
12293        // c:1390 — `set -n`, and execlist's own `!errflag` list gate.
12294        if opt_state_get("noexec").unwrap_or(false)
12295            || (crate::ported::utils::errflag.load(Ordering::Relaxed)
12296                & crate::ported::zsh_h::ERRFLAG_ERROR)
12297                != 0
12298        {
12299            return Value::Bool(false);
12300        }
12301        // c:1423 `sigtrapped[SIGDEBUG]` — checked through both
12302        // registries because `settrap` and `bin_trap` populate
12303        // different ones (signals.rs:1481-1511 dotrap).
12304        let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
12305        let debug_trapped = crate::ported::signals::sigtrapped
12306            .lock()
12307            .map(|v| v.get(sig_debug).copied().unwrap_or(0))
12308            .unwrap_or(0);
12309        if debug_trapped != 0 {
12310            return Value::Bool(false);
12311        }
12312        let debug_in_table = crate::ported::builtin::traps_table()
12313            .lock()
12314            .map(|t| t.contains_key("DEBUG"))
12315            .unwrap_or(false);
12316        Value::Bool(!debug_in_table)
12317    });
12318
12319    vm.register_builtin(BUILTIN_SUBLIST_FINISH, |vm, _argc| {
12320        // c:Src/jobs.c:1754 — `pipestats[0] = lastval;`. C has ONE
12321        // `lastval` global (c:Src/exec.c:120), so `waitonejob` reads
12322        // exactly the status the finished sublist just produced.
12323        //
12324        // zshrs splits that global in two: the fusevm status cell that
12325        // `Op::SetStatus`/`Op::GetStatus` and therefore `$?` use, and
12326        // the `builtin::LASTVAL` mirror that the ported `waitonejob`
12327        // reads. The compound-command compilers (compile_if,
12328        // compile_while, compile_for, compile_case, …) settle their
12329        // result with `Op::SetStatus` alone, so LASTVAL still holds
12330        // whatever the last dispatched BUILTIN returned — the loop
12331        // condition, typically. `if [[ -z x ]]; then :; fi` and
12332        // `while false; do :; done` both end with `$? == 0` and a
12333        // stale LASTVAL of 1.
12334        //
12335        // compile_sublist pushes `Op::GetStatus` ahead of this call so
12336        // the authoritative status arrives as an argument; republish it
12337        // through LASTVAL to reunify the two before the ported
12338        // waitonejob reads it, exactly as the single-command dispatch
12339        // sites at c:Src/exec.c:4367 do.
12340        let status = vm.pop().to_int() as i32;
12341        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
12342        // c:Src/jobs.c:1750-1756 — `compile_sublist` only emits this
12343        // marker for a cmplx sublist element that is not a multi-stage
12344        // pipeline, i.e. exactly the case where C's job carries no
12345        // procs, so drive the canonical port with a procs-less job the
12346        // same way the single-command sites do.
12347        crate::ported::jobs::waitonejob(-1);
12348        Value::Status(0)
12349    });
12350
12351    // `[[ -z X ]]` / `[[ -n X ]]` — pop one Value and apply the empty /
12352    // non-empty test evalcond's `-z` / `-n` arms perform (cond.rs).
12353    //
12354    // The Array→words conversion lives at the bridge. For
12355    // `"${arr[@]}"` in DQ context the splice yields `Value::Array`
12356    // — an empty array still expands to one implicit empty word
12357    // (per zsh's "${arr[@]}" splat preserving at least one slot
12358    // in cond context), so:
12359    //   - Array(0)   → ["-z", ""]            → 0 (true)
12360    //   - Array(1)   → ["-z", word]          → 0/1
12361    //   - Array(2+)  → ["-z", w1, w2, ...]   → 2 (parse
12362    //                                          error: too many ops)
12363    //                                          → coerced to false
12364    //   - Str(s)     → ["-z", s]             → 0/1
12365    //
12366    // Bug #185 in docs/BUGS.md.
12367    fn run_cond_str_empty(v: Value, op: &str) -> Value {
12368        let words: Vec<String> = match v {
12369            Value::Array(arr) => arr.iter().map(|x| x.to_str()).collect(),
12370            Value::Str(s) => vec![s.to_string()],
12371            other => vec![other.to_str()],
12372        };
12373        // c:Src/cond.c:62-66 — `evalcond` returns 0=true, 1=false,
12374        // 2=syntax-error. Coerce error to false (observable behavior
12375        // in zsh: `[[ -z a b ]]` errors and the test as a whole
12376        // returns non-zero).
12377        // c:Src/cond.c — evalcond's `-n` arm is `!strlen(left)` negated and its
12378        // `-z` arm `strlen(left)`; a single operand needs no wordcode. Two or
12379        // more words are the syntax error (2) the wordcode parser reports
12380        // for `-z a b`. No word at all is the implicit empty operand.
12381        let ret = if words.len() > 1 {
12382            2
12383        } else {
12384            let empty = words.first().map_or(true, |w| w.is_empty());
12385            ((op == "-z") != empty) as i32
12386        };
12387        Value::Int(if ret == 0 { 1 } else { 0 })
12388    }
12389    vm.register_builtin(BUILTIN_COND_STR_EMPTY, |vm, _argc| {
12390        let v = vm.pop();
12391        run_cond_str_empty(v, "-z")
12392    });
12393    vm.register_builtin(BUILTIN_COND_STR_NONEMPTY, |vm, _argc| {
12394        let v = vm.pop();
12395        run_cond_str_empty(v, "-n")
12396    });
12397
12398    // `exec N<<<"str"` — herestring redirect to explicit fd, applied
12399    // permanently. Direct port of `Src/exec.c:4655 getherestr` +
12400    // `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)` at c:3766-
12401    // 3780 for the nullexec=1 bare-exec-redir path. Bug #205 in
12402    // docs/BUGS.md.
12403    // c:Src/exec.c:4671-4672 — a here-string DERIVED FROM a here-doc
12404    // gets no appended newline. See BUILTIN_HEREDOC_BODY_SINK.
12405    vm.register_builtin(BUILTIN_HEREDOC_BODY_SINK, |vm, _argc| {
12406        let body = vm.pop().to_str();
12407        if crate::provenance::active() {
12408            crate::provenance::on_heredoc("heredoc", &body);
12409        }
12410        with_executor(|exec| exec.host_set_pending_stdin(body));
12411        Value::Int(0)
12412    });
12413
12414    vm.register_builtin(BUILTIN_EXEC_HERESTR_FD, |vm, _argc| {
12415        // Stack (pushed by compile_redir): [content, fd, from_heredoc].
12416        let from_heredoc = vm.pop().to_int() != 0;
12417        let fd = vm.pop().to_int() as i32;
12418        let content = vm.pop().to_str();
12419        // c:Src/exec.c:4671-4672 — `getherestr` appends the newline
12420        // only for a REAL here-string:
12421        //     if (!(fn->flags & REDIRF_FROM_HEREDOC))
12422        //         t[len++] = '\n';
12423        // The `REDIRF_FROM_HEREDOC` flag is set by
12424        // c:Src/parse.c:2970-2971 when the redirection was written as
12425        // `<<WORD` and the parser turned the collected body into a
12426        // here-string. This helper serves BOTH spellings — `exec
12427        // 3<<<str` (append) and `exec 3<<EOF` (verbatim) — so the flag
12428        // has to be threaded in rather than assumed. It used to
12429        // unconditionally append, and the here-doc call site
12430        // compensated with `trim_end_matches('\n')`, which collapsed
12431        // N trailing blank lines to one and added a newline to a body
12432        // that ended without one.
12433        let body = if from_heredoc {
12434            content
12435        } else {
12436            format!("{}\n", content)
12437        };
12438        // c:Src/exec.c:3779 `addfd(forked, save, mfds, fn->fd1, fil, 0,
12439        // NULL)` → c:2421-2443: park the OLD contents of fd1 so
12440        // `fixfds` (c:4530) can put them back at the end of the
12441        // command; a bare `exec` (nullexec==1, c:3978-3986) skips it
12442        // and the redirection persists. This runs BEFORE the temp
12443        // file is opened on purpose: C reads fd1's pre-redirect state
12444        // too, and taking it afterwards would dup the here-document
12445        // itself and "restore" fd N to the body at scope end.
12446        with_executor(|exec| exec.save_fd_for_scope(fd));
12447        // c:4673-4679 — gettempfile → write_loop → close → reopen
12448        // read-only → unlink. Rust equivalent via tempfile crate or
12449        // explicit O_TMPFILE; use mkstemp + unlink-immediately to
12450        // mirror C exactly.
12451        use std::ffi::CString;
12452        let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
12453        let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
12454        if write_fd < 0 {
12455            crate::ported::utils::zwarn(&format!(
12456                "can't create temp file for here document: {}",
12457                crate::ported::utils::zsh_errno_msg(
12458                    std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
12459                ) // c:Src/exec.c:3828 `%e`
12460            ));
12461            return Value::Status(1);
12462        }
12463        // c:4675 — write_loop(fd, t, len)
12464        let bytes = body.as_bytes();
12465        let mut off = 0;
12466        while off < bytes.len() {
12467            let n = unsafe {
12468                libc::write(
12469                    write_fd,
12470                    bytes[off..].as_ptr() as *const libc::c_void,
12471                    bytes.len() - off,
12472                )
12473            };
12474            if n <= 0 {
12475                unsafe { libc::close(write_fd) };
12476                return Value::Status(1);
12477            }
12478            off += n as usize;
12479        }
12480        unsafe { libc::close(write_fd) }; // c:4676
12481                                          // Path null-terminated by mkstemp; reopen for reading.
12482        let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
12483        // c:4678 — unlink immediately so the file disappears on
12484        // close, leaving only the fd reference.
12485        unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
12486        if read_fd < 0 {
12487            return Value::Status(1);
12488        }
12489        // c:Src/utils.c:2047-2065 `redup(x, y)` — the `zclose(x)` lives
12490        // INSIDE the `else if (x != y)` arm:
12491        //     if(x < 0)        zclose(y);
12492        //     else if (x != y) { dup2(x, y); …; zclose(x); }
12493        // When x == y, redup is a NO-OP. This helper closed `read_fd`
12494        // unconditionally, and `read_fd == fd` is the COMMON case for
12495        // `exec 3<<…`: mkstemp takes the lowest free fd (3), closes it
12496        // at c:4676, then `open` reclaims the same 3, so `dup2(3, 3)`
12497        // returned 3 and the very next `close(3)` threw the redirect
12498        // away. Every `exec N<<…` / `exec N<<<…` left N closed, and
12499        // `cat <&N` reported "N: bad file descriptor".
12500        if read_fd != fd {
12501            let r = unsafe { libc::dup2(read_fd, fd) };
12502            unsafe { libc::close(read_fd) };
12503            if r < 0 {
12504                return Value::Status(1);
12505            }
12506        }
12507        Value::Status(0)
12508    });
12509    // c:Src/exec.c:2418 + addfd splice — MULTIOS fan-out. Stack
12510    // layout pushed by compile_zsh's coalescing pass:
12511    //   [target_1, op_byte_1, target_2, op_byte_2, …, target_N,
12512    //    op_byte_N, fd]
12513    // argc = 2N + 1. Pops, opens every target, sets up a pipe +
12514    // splitter thread that reads pipe → writes every chunk to
12515    // every opened target, dup2's pipe-write-end onto fd. The
12516    // splitter is closed + joined by host_redirect_scope_end.
12517    // Bug #36 in docs/BUGS.md.
12518    vm.register_builtin(BUILTIN_MULTIOS_REDIRECT, |vm, argc| {
12519        if argc < 3 || argc % 2 == 0 {
12520            // Bad shape — bail.
12521            return Value::Status(1);
12522        }
12523        // Pop fd first (top of stack).
12524        let fd = vm.pop().to_int() as i32;
12525        // Then pop (op, target) pairs in reverse compile order. Keep
12526        // targets as Values — a glob-bearing target arrives as a
12527        // Value::Array of matches.
12528        let n_targets = ((argc - 1) / 2) as usize;
12529        let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_targets);
12530        for _ in 0..n_targets {
12531            let op_byte = vm.pop().to_int() as u8;
12532            let target = vm.pop();
12533            pairs.push((op_byte, target));
12534        }
12535        // Restore compile order (target_1 first).
12536        pairs.reverse();
12537        // c:Src/exec.c:3785-3796 — an errflag from expanding a target fails
12538        // the redirection (see redir_target_expansion_failed).
12539        if with_executor(redir_target_expansion_failed) {
12540            return Value::Status(1);
12541        }
12542
12543        // c:Src/glob.c:2195-2203 xpandredir — "Loop over matches,
12544        // duplicating the redirection for each file found": a glob
12545        // target with N matches becomes N members of the same multio
12546        // (`echo hi > *.txt` with two matches writes both files).
12547        let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
12548        for (op_byte, target) in pairs {
12549            match target {
12550                // c:Src/glob.c:2161 — without MULTIOS the word is preforked
12551                // PREFORK_SINGLE: one word, an array joined by sepjoin.
12552                Value::Array(items) if !opt_state_get("multios").unwrap_or(true) => {
12553                    let words: Vec<String> = items.iter().map(|v| v.to_str()).collect();
12554                    entries.push((op_byte, crate::ported::utils::sepjoin(&words, None)));
12555                }
12556                Value::Array(items) => {
12557                    for item in items.iter() {
12558                        entries.push((op_byte, item.to_str()));
12559                    }
12560                }
12561                other => entries.push((op_byte, other.to_str())),
12562            }
12563        }
12564        if entries.is_empty() {
12565            return Value::Status(1);
12566        }
12567
12568        // c:Src/exec.c:2418 — `else if (!mfds[fd1] || unset(MULTIOS))`:
12569        // with MULTIOS unset every redirect takes the REPLACE path in
12570        // script order — each target is still opened (created /
12571        // truncated) and dup2'd over the fd, so the LAST one wins and
12572        // earlier files end up empty (`unsetopt multios; print x > a
12573        // > b` leaves `a` empty, `x` in `b`). host_apply_redirect is
12574        // exactly one replace step, noclobber gate included.
12575        let multios_on = opt_state_get("multios").unwrap_or(true);
12576        if !multios_on {
12577            with_executor(|exec| {
12578                for (op_byte, target) in &entries {
12579                    exec.host_apply_redirect(fd as u8, *op_byte, target);
12580                    if exec.redirect_failed {
12581                        // c:Src/exec.c execerr — abort the remaining
12582                        // redirect list on failure.
12583                        break;
12584                    }
12585                }
12586            });
12587            return Value::Status(0);
12588        }
12589
12590        if entries.len() == 1 {
12591            // Single member after splicing — a plain replace
12592            // (c:2418 new-multio arm). Route through
12593            // host_apply_redirect so the noclobber gate, the
12594            // pipeline-output split partial, and error handling all
12595            // apply exactly as for an un-bagged redirect.
12596            let (op_byte, target) = &entries[0];
12597            with_executor(|exec| {
12598                exec.host_apply_redirect(fd as u8, *op_byte, target);
12599            });
12600            return Value::Status(0);
12601        }
12602
12603        // c:Src/exec.c:3722-3724 — when this command's stdout IS the
12604        // pipeline output, C seeds mfds[1] with the pipe BEFORE
12605        // walking the redirect list, so the pipe is the multio's
12606        // first member (`print x >&1 > f | cat` sends `x` down the
12607        // pipe TWICE: once for the seed, once for the `>&1` dup).
12608        let pipe_seed = fd == 1
12609            && with_executor(|exec| {
12610                exec.pipe_output_scope
12611                    .is_some_and(|d| d + 1 == exec.redirect_scope_stack.len())
12612            });
12613
12614        // Save current fd state for scope-end restoration — BEFORE
12615        // the first member's replace dup2 below.
12616        // c:Src/exec.c:2425 — `int fdN = movefd(fd1); save[fd1] = fdN;`. A SAVED
12617        // descriptor is shell state and must live above the script's fd range:
12618        // plain dup() returns the LOWEST free fd, which parked the saved stdout
12619        // on fd 3, so `print -u 3 -r -- X 2>/dev/null` wrote into the shell's own
12620        // saved descriptor and reported success where zsh says `bad file number`.
12621        // F_DUPFD with a floor of 10 is exactly what movefd does.
12622        // c:Src/exec.c:3978-3986 — a bare `exec`'s redirections (nullexec==1)
12623        // are permanent: the saved copy is closed, not restored, and the
12624        // tee process outlives this command (see the closemn arm below).
12625        let permanent = with_executor(|exec| exec.exec_redirs_permanent);
12626        if permanent {
12627            // Inside an in-process `( … )` / `$( … )` the change is permanent
12628            // only for the subshell (see SubshFdFrame), as for a single target.
12629            crate::ported::exec::SubshFdFrame::touch(fd);
12630        }
12631        // c:2478 — `if (!forked && save[fd1] == -2)`.
12632        let forked = with_executor(|exec| redir_scope_forked(exec.redirect_scope_stack.len()));
12633        let saved = if forked {
12634            -1
12635        } else {
12636            unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) }
12637        };
12638        // c:2426-2437 — "fd1 may already be closed here, so ignore bad
12639        // file descriptor error": `save[fd1] = fdN` stores -1 for a closed
12640        // fd, and the scope-end restore (c:Src/utils.c:2047 `if (x < 0)
12641        // zclose(y)`) then CLOSES it. Without that slot the splitter's pipe
12642        // write end stayed dup'd on the fd, the splitter never saw EOF and
12643        // the scope-end join hung: `print -u3 x 3>a 3>b` never returned.
12644        // A forked child parks no copy, but the splitter's pipe write end
12645        // on `fd` must still go at scope end or its join never sees EOF;
12646        // the -1 slot closes it there, as the child's exit does in C.
12647        let saved_slot = if forked {
12648            Some(-1)
12649        } else if saved >= 0 {
12650            Some(saved)
12651        } else if std::io::Error::last_os_error().raw_os_error() == Some(libc::EBADF) {
12652            Some(-1)
12653        } else {
12654            None
12655        };
12656        if permanent {
12657            // c:4038-4040 — `for (i = 0; i < 10; i++) if (save[i] != -2)
12658            // zclose(save[i]);`
12659            if saved >= 0 {
12660                unsafe { libc::close(saved) };
12661            }
12662        } else if let Some(saved) = saved_slot {
12663            with_executor(|exec| {
12664                if let Some(top) = exec.redirect_scope_stack.last_mut() {
12665                    top.push((fd, saved));
12666                    mark_saved_fd(saved);
12667                } else if saved >= 0 {
12668                    unsafe { libc::close(saved) };
12669                }
12670            });
12671        }
12672
12673        // Accumulate member fds in redirect order. c:Src/exec.c:
12674        // 2447-2480 addfd — the FIRST member REPLACES the fd
12675        // (c:2448-2450 `mfds[fd1]->ct=1; mfds[fd1]->fds[0]=fd1;`), so
12676        // a later numeric `>&N` self-dup resolves against the fd's
12677        // value at that point in the sequence: `print x > f >&1`
12678        // writes f TWICE; `print x >&1 > f` writes the ORIGINAL
12679        // stdout + f.
12680        let mut target_fds: Vec<i32> = Vec::with_capacity(entries.len() + 1);
12681        if pipe_seed {
12682            let p = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
12683            if p >= 0 {
12684                target_fds.push(p);
12685            }
12686        }
12687        let noclobber = opt_state_get("noclobber").unwrap_or(false)
12688            || !opt_state_get("clobber").unwrap_or(true);
12689        for (i, (op_byte, target)) in entries.iter().enumerate() {
12690            let open_result: std::io::Result<i32> = match *op_byte {
12691                r::DUP_WRITE | r::DUP_READ => {
12692                    // Numeric `>&N` — dup the LIVE fd N (after any
12693                    // earlier member's replace).
12694                    match target.trim_start_matches('&').parse::<i32>() {
12695                        Ok(src) => {
12696                            let d = unsafe { libc::fcntl(src, libc::F_DUPFD_CLOEXEC, 10) };
12697                            if d >= 0 {
12698                                Ok(d)
12699                            } else {
12700                                Err(std::io::Error::last_os_error())
12701                            }
12702                        }
12703                        Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
12704                    }
12705                }
12706                r::WRITE => {
12707                    // c:Src/exec.c clobber_open — noclobber applies
12708                    // to multio file targets too; failure aborts the
12709                    // remaining redirect list (execerr), so `setopt
12710                    // noclobber; touch a; print x > a > b` errors on
12711                    // `a` and never creates `b`.
12712                    let target_meta = std::fs::metadata(target).ok();
12713                    let target_is_regular_file = target_meta
12714                        .as_ref()
12715                        .map(|m| m.file_type().is_file())
12716                        .unwrap_or(false);
12717                    // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY re-uses
12718                    // an empty regular file under noclobber (same allowance
12719                    // as the single-redirect path).
12720                    let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
12721                        && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
12722                    if noclobber && target_is_regular_file && !clobber_empty_ok {
12723                        eprintln!(
12724                            "{}:{}: file exists: {}",
12725                            shname(),
12726                            crate::ported::lex::lineno(),
12727                            target
12728                        );
12729                        for prev in &target_fds {
12730                            unsafe {
12731                                libc::close(*prev);
12732                            }
12733                        }
12734                        with_executor(|exec| {
12735                            exec.redirect_failed = true;
12736                        });
12737                        // Sink the upcoming command's output (mirrors
12738                        // the single-redirect noclobber arm in
12739                        // host_apply_redirect).
12740                        if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
12741                            let new_fd = file.into_raw_fd();
12742                            unsafe {
12743                                libc::dup2(new_fd, fd);
12744                                libc::close(new_fd);
12745                            }
12746                        }
12747                        return Value::Status(1);
12748                    }
12749                    fs::OpenOptions::new()
12750                        .write(true)
12751                        .create(true)
12752                        .truncate(true)
12753                        .open(target)
12754                        .map(|f| f.into_raw_fd())
12755                }
12756                r::APPEND => fs::OpenOptions::new()
12757                    .write(true)
12758                    .create(true)
12759                    .append(true)
12760                    .open(target)
12761                    .map(|f| f.into_raw_fd()),
12762                _ => fs::OpenOptions::new()
12763                    .write(true)
12764                    .create(true)
12765                    .truncate(true)
12766                    .open(target)
12767                    .map(|f| f.into_raw_fd()),
12768            };
12769            match open_result {
12770                Ok(tfd) => {
12771                    // c:2463-2470 / c:2481 — every member of a split stream
12772                    // is `movefd`'d above the script's fd range. A file opened
12773                    // while the target fd is closed comes back AS that fd
12774                    // (`print -u3 x 3>a 3>b`: `a` opens on 3), and the pipe
12775                    // dup2 below then replaced the member with the splitter's
12776                    // own write end — `a` stayed empty and the splitter fed
12777                    // its output back into itself.
12778                    let tfd = crate::ported::utils::movefd(tfd);
12779                    if i == 0 && !pipe_seed {
12780                        // c:2448-2450 — first member replaces the fd.
12781                        unsafe {
12782                            libc::dup2(tfd, fd);
12783                        }
12784                    }
12785                    target_fds.push(tfd);
12786                }
12787                Err(e) => {
12788                    // c:Src/exec.c:3741 — `zwarn("%e: %s", errno, fname)`:
12789                    // zwarning supplies the `name:LINE:` prefix with the
12790                    // REAL current lineno; redir_errno_msg builds the `%e`
12791                    // errno message (was a hardcoded ErrorKind match that
12792                    // showed generic "redirect failed" for EROFS/etc.).
12793                    let msg = redir_errno_msg(&e);
12794                    crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
12795                    // Close already-opened fds to avoid leaks.
12796                    for prev in &target_fds {
12797                        unsafe {
12798                            libc::close(*prev);
12799                        }
12800                    }
12801                    with_executor(|exec| {
12802                        exec.redirect_failed = true;
12803                    });
12804                    return Value::Status(1);
12805                }
12806            }
12807        }
12808
12809        // Create the splitter pipe.
12810        let (read_end, write_end) = match os_pipe::pipe() {
12811            Ok(p) => p,
12812            Err(_) => {
12813                for f in &target_fds {
12814                    unsafe {
12815                        libc::close(*f);
12816                    }
12817                }
12818                return Value::Status(1);
12819            }
12820        };
12821        // c:Src/exec.c:5222 — `pp[0] = movefd(pp[0]);` in `mpipe()`.
12822        // c:Src/utils.c:1990-2012 movefd — "if(fd != -1 && fd < 10)"
12823        // dup into the >=10 range and zclose the low copy, then mark
12824        // the result FDT_INTERNAL. Every shell-internal fd goes
12825        // through this so it can never share a number with the
12826        // user-visible `>&N` range that the redirect bookkeeping
12827        // opens/dups/closes. This splitter kept the raw (low) pipe
12828        // read end, so an unrelated close of that number shut it
12829        // under the splitter thread and dropping the owned
12830        // PipeReader aborted the process with std's "IO Safety
12831        // violation: owned file descriptor already closed"
12832        // (E01options.ztst:46 `( echo hello ) >a >b`, ~2 runs in 3).
12833        let read_end = unsafe {
12834            <os_pipe::PipeReader as std::os::unix::io::FromRawFd>::from_raw_fd(
12835                crate::extensions::fds::movefd(read_end.into_raw_fd()),
12836            )
12837        };
12838        let pipe_write_raw = AsRawFd::as_raw_fd(&write_end);
12839        if permanent && (0..10).contains(&fd) {
12840            // c:Src/exec.c:4014-4016 — "We are done with redirection. close
12841            // the mnodes, spawning tee/cat processes as necessary." A bare
12842            // `exec 3>&1 3>&2` keeps the multio for the rest of the shell's
12843            // life, so the scope-bound splitter thread below (drained and
12844            // joined at scope end) cannot serve it: nothing ever ends the
12845            // scope, and the thread dies with the process before it copies
12846            // what the last command wrote. closemn forks the tee process C
12847            // uses for every multio; it reads until the shell's last copy
12848            // of the pipe write end closes, i.e. after the shell exits.
12849            // c:4045 — "We're done with this job, no need to wait for it."
12850            let write_dup = unsafe { libc::fcntl(pipe_write_raw, libc::F_DUPFD_CLOEXEC, 10) };
12851            drop(write_end);
12852            if write_dup < 0 {
12853                for f in &target_fds {
12854                    unsafe { libc::close(*f) };
12855                }
12856                return Value::Status(1);
12857            }
12858            unsafe {
12859                libc::dup2(write_dup, fd);
12860                libc::close(write_dup);
12861            }
12862            let mn = crate::ported::zsh_h::multio {
12863                ct: target_fds.len() as i32,
12864                rflag: 1, // c:2462 — output multio: a tee, not a cat
12865                pipe: std::os::unix::io::IntoRawFd::into_raw_fd(read_end),
12866                fds: target_fds,
12867            };
12868            let mut mfds: [Option<Box<crate::ported::zsh_h::multio>>; 10] = Default::default();
12869            mfds[fd as usize] = Some(Box::new(mn));
12870            crate::ported::exec::closemn(&mut mfds, fd, crate::ported::zsh_h::REDIR_CLOSE);
12871            return Value::Status(0);
12872        }
12873        // Spawn the splitter thread: read pipe → write every chunk
12874        // to every target fd. Each write inside the thread uses
12875        // libc::write directly on the raw fd (no Rust File ownership
12876        // so the splitter can close after EOF without racing main).
12877        let target_fds_for_thread = target_fds.clone();
12878        let handle = crate::signal_thread::spawn(move || {
12879            let mut r = read_end;
12880            let mut buf = [0u8; 8192];
12881            loop {
12882                match std::io::Read::read(&mut r, &mut buf) {
12883                    Ok(0) => break,
12884                    Ok(n) => {
12885                        for &tfd in &target_fds_for_thread {
12886                            let mut off = 0;
12887                            while off < n {
12888                                let w = unsafe {
12889                                    libc::write(
12890                                        tfd,
12891                                        buf[off..n].as_ptr() as *const libc::c_void,
12892                                        n - off,
12893                                    )
12894                                };
12895                                if w <= 0 {
12896                                    break;
12897                                }
12898                                off += w as usize;
12899                            }
12900                        }
12901                    }
12902                    Err(_) => break,
12903                }
12904            }
12905            // Close every target so file contents flush.
12906            for tfd in target_fds_for_thread {
12907                unsafe {
12908                    libc::close(tfd);
12909                }
12910            }
12911        });
12912
12913        // Dup the pipe write-end onto the target fd; close the
12914        // original write_end so EOF arrives when host_redirect_scope_end
12915        // closes our tracked pipe_write_fd.
12916        let write_dup = unsafe { libc::fcntl(pipe_write_raw, libc::F_DUPFD_CLOEXEC, 10) };
12917        drop(write_end);
12918        if write_dup < 0 {
12919            return Value::Status(1);
12920        }
12921        unsafe {
12922            libc::dup2(write_dup, fd);
12923            libc::close(write_dup);
12924        }
12925        // Track the running splitter so scope-end can drain + join.
12926        // The "write_fd" we store is the user-visible fd (e.g. 1).
12927        // Closing that fd at scope-end isn't quite right; we need a
12928        // way to send EOF. Solution: track the write_dup we just
12929        // closed; instead keep a second dup for the close-on-end.
12930        // Shell-internal bookkeeping fd — above the script's range (movefd).
12931        let close_on_end = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
12932        with_executor(|exec| {
12933            if let Some(top) = exec.multios_scope_stack.last_mut() {
12934                top.push((close_on_end, handle));
12935            } else {
12936                // No scope — leak the dup; thread will keep running
12937                // until process exit. Should not happen because
12938                // host_redirect_scope_begin pushed a frame.
12939                unsafe { libc::close(close_on_end) };
12940            }
12941        });
12942        Value::Status(0)
12943    });
12944    // c:Src/exec.c:2418 input-arm — MULTIOS read fan-in. Stack
12945    // layout pushed by compile_zsh (mirrors the write side):
12946    //   [source_1, op_1, source_2, op_2, …, source_N, op_N, fd]
12947    // argc = 2N + 1; op distinguishes file opens (READ) from numeric
12948    // `<&N` dups (DUP_READ); a glob source arrives as Value::Array
12949    // and splices into one member per match (c:Src/glob.c:2195-2203).
12950    // Opens every source, sets up a pipe + producer thread that
12951    // reads each source in order and writes to the pipe write-end,
12952    // then closes its write-end so the consumer gets EOF. dup2 the
12953    // pipe read-end onto fd. Bug #36 input side in docs/BUGS.md.
12954    vm.register_builtin(BUILTIN_MULTIOS_READ, |vm, argc| {
12955        if argc < 3 || argc % 2 == 0 {
12956            return Value::Status(1);
12957        }
12958        let fd = vm.pop().to_int() as i32;
12959        let n_sources = ((argc - 1) / 2) as usize;
12960        let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_sources);
12961        for _ in 0..n_sources {
12962            let op_byte = vm.pop().to_int() as u8;
12963            let source = vm.pop();
12964            pairs.push((op_byte, source));
12965        }
12966        pairs.reverse();
12967        // c:Src/exec.c:3785-3796 — an errflag from expanding a target fails
12968        // the redirection (see redir_target_expansion_failed).
12969        if with_executor(redir_target_expansion_failed) {
12970            return Value::Status(1);
12971        }
12972
12973        // Splice glob match arrays (c:Src/glob.c:2195-2203).
12974        let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
12975        for (op_byte, source) in pairs {
12976            match source {
12977                // c:Src/glob.c:2161 — without MULTIOS the word is preforked
12978                // PREFORK_SINGLE: one word, an array joined by sepjoin.
12979                Value::Array(items) if !opt_state_get("multios").unwrap_or(true) => {
12980                    let words: Vec<String> = items.iter().map(|v| v.to_str()).collect();
12981                    entries.push((op_byte, crate::ported::utils::sepjoin(&words, None)));
12982                }
12983                Value::Array(items) => {
12984                    for item in items.iter() {
12985                        entries.push((op_byte, item.to_str()));
12986                    }
12987                }
12988                other => entries.push((op_byte, other.to_str())),
12989            }
12990        }
12991        if entries.is_empty() {
12992            return Value::Status(1);
12993        }
12994
12995        // c:Src/exec.c:2418 — `unset(MULTIOS)`: sequential replace,
12996        // last source wins (`unsetopt multios; cat < a < b` reads
12997        // only b; a is still opened — and errors still surface).
12998        let multios_on = opt_state_get("multios").unwrap_or(true);
12999        if !multios_on {
13000            with_executor(|exec| {
13001                for (op_byte, source) in &entries {
13002                    multios_apply_member(exec, fd, *op_byte, source);
13003                    if exec.redirect_failed {
13004                        break;
13005                    }
13006                }
13007            });
13008            return Value::Status(0);
13009        }
13010
13011        // c:Src/exec.c:3775-3777 — a pipeline stage's input pipe is already
13012        // mfds[0]'s first member, so even one source concatenates.
13013        let pipe_seed = with_executor(|exec| take_pipe_input_seed(exec, fd));
13014        if entries.len() == 1 && pipe_seed.is_none() {
13015            // Single member after splicing — plain replace.
13016            let (op_byte, source) = &entries[0];
13017            with_executor(|exec| {
13018                multios_apply_member(exec, fd, *op_byte, source);
13019            });
13020            return Value::Status(0);
13021        }
13022
13023        // Save current fd state for scope-end restoration — BEFORE
13024        // the first member's replace dup2 below.
13025        // c:Src/exec.c:2425 — `int fdN = movefd(fd1); save[fd1] = fdN;`. A SAVED
13026        // descriptor is shell state and must live above the script's fd range:
13027        // plain dup() returns the LOWEST free fd, which parked the saved stdout
13028        // on fd 3, so `print -u 3 -r -- X 2>/dev/null` wrote into the shell's own
13029        // saved descriptor and reported success where zsh says `bad file number`.
13030        // F_DUPFD with a floor of 10 is exactly what movefd does.
13031        // c:2478 — `if (!forked && save[fd1] == -2)`.
13032        let forked = with_executor(|exec| redir_scope_forked(exec.redirect_scope_stack.len()));
13033        let saved = if forked {
13034            -1
13035        } else {
13036            unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) }
13037        };
13038        // c:2426-2437 — a closed fd saves -1, restored by closing it (see
13039        // the write-side multio above).
13040        // A forked child parks no copy, but the pipe read end on `fd` must
13041        // still go at scope end or a splitter blocked on a full pipe never
13042        // finishes its join; the -1 slot closes it there, as the child's
13043        // exit does in C.
13044        let saved_slot = if forked {
13045            Some(-1)
13046        } else if saved >= 0 {
13047            Some(saved)
13048        } else if std::io::Error::last_os_error().raw_os_error() == Some(libc::EBADF) {
13049            Some(-1)
13050        } else {
13051            None
13052        };
13053        if let Some(saved) = saved_slot {
13054            with_executor(|exec| {
13055                if let Some(top) = exec.redirect_scope_stack.last_mut() {
13056                    top.push((fd, saved));
13057                    mark_saved_fd(saved);
13058                } else if saved >= 0 {
13059                    unsafe { libc::close(saved) };
13060                }
13061            });
13062        }
13063
13064        // Open every source in redirect order; numeric `<&N` dups
13065        // resolve against the LIVE fd table. First member replaces
13066        // the fd (c:2448-2450) so later self-dups see it.
13067        let mut source_fds: Vec<i32> = Vec::with_capacity(entries.len() + 1);
13068        source_fds.extend(pipe_seed);
13069        for (i, (op_byte, source)) in entries.iter().enumerate() {
13070            let open_result: std::io::Result<i32> = match *op_byte {
13071                r::DUP_READ | r::DUP_WRITE => match source.trim_start_matches('&').parse::<i32>() {
13072                    Ok(src) => {
13073                        let d = unsafe { libc::fcntl(src, libc::F_DUPFD_CLOEXEC, 10) };
13074                        if d >= 0 {
13075                            Ok(d)
13076                        } else {
13077                            Err(std::io::Error::last_os_error())
13078                        }
13079                    }
13080                    Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
13081                },
13082                // c:Src/exec.c:3821-3835 — `fil = getherestr(fn);`
13083                MULTIOS_OP_HERESTR_BODY | MULTIOS_OP_HEREDOC_BODY => {
13084                    multios_body_fd(source, *op_byte == MULTIOS_OP_HERESTR_BODY)
13085                }
13086                _ => fs::File::open(source).map(|f| f.into_raw_fd()),
13087            };
13088            match open_result {
13089                Ok(opened) => {
13090                    // c:Src/exec.c:2458-2465 — addfd moves every multio member
13091                    // out of the script's fd range (`fdN = movefd(fd1)`,
13092                    // `fdN = movefd(fd2)`). A member opened by `open` gets the
13093                    // LOWEST free descriptor, which for `cat 3<o1 3<o2 <&3` is
13094                    // 3 itself: the `dup2` of the concatenator pipe onto fd 3
13095                    // below then closed o1, the producer read its own empty
13096                    // pipe instead, and the command hung.
13097                    let tfd = crate::ported::utils::movefd(opened);
13098                    if i == 0 {
13099                        unsafe {
13100                            libc::dup2(tfd, fd);
13101                        }
13102                    }
13103                    source_fds.push(tfd);
13104                }
13105                Err(e) => {
13106                    let msg = match e.kind() {
13107                        std::io::ErrorKind::PermissionDenied => "permission denied",
13108                        std::io::ErrorKind::NotFound => "no such file or directory",
13109                        _ => "open failed",
13110                    };
13111                    // c:Src/exec.c:3741 — zwarn with real lineno prefix.
13112                    crate::ported::utils::zwarn(&format!("{}: {}", msg, source));
13113                    for prev in &source_fds {
13114                        unsafe {
13115                            libc::close(*prev);
13116                        }
13117                    }
13118                    with_executor(|exec| {
13119                        exec.redirect_failed = true;
13120                    });
13121                    return Value::Status(1);
13122                }
13123            }
13124        }
13125
13126        // Create the concatenator pipe.
13127        if !with_executor(|exec| multios_read_concat(exec, fd, source_fds)) {
13128            return Value::Status(1);
13129        }
13130        Value::Status(0)
13131    });
13132    // c:Src/exec.c:3978-3986 — nullexec==1 marker. See the const's
13133    // doc block. Arg: 1 = entering a bare-exec redirect, 0 = leaving.
13134    vm.register_builtin(BUILTIN_EXEC_PERM_REDIRS, |vm, _argc| {
13135        let on = vm.pop().to_int() != 0;
13136        with_executor(|exec| exec.exec_redirs_permanent = on);
13137        Value::Status(0)
13138    });
13139    // Bare-exec redirect epilogue — see the const's doc block.
13140    // c:Src/exec.c:252-259 (execerr) + c:4367-4386 (done: POSIX gate).
13141    vm.register_builtin(BUILTIN_EXEC_REDIR_DONE, |vm, _argc| {
13142        use std::sync::atomic::Ordering;
13143        let failed = with_executor(|exec| {
13144            let f = exec.redirect_failed;
13145            exec.redirect_failed = false;
13146            f
13147        });
13148        if !failed {
13149            // c:Src/exec.c:4032 — `lastval = errflag ? errflag : cmdoutval;`
13150            // cmdoutval is NOT reset by a command without assignments, so
13151            // a `$(...)` from an earlier command (`x=$(false); exec 3>f`)
13152            // still decides this status.
13153            let ef = crate::ported::utils::errflag.load(Ordering::Relaxed);
13154            return Value::Status(if ef != 0 { ef } else { crate::ported::exec::cmdoutval.load(Ordering::Relaxed) });
13155        }
13156        // c:255 — `redir_err = lastval = 1` (dash: 2, see fatal_error_status).
13157        let redir_st = crate::extensions::dash_mode::fatal_error_status().unwrap_or(1);
13158        vm.last_status = redir_st;
13159        if isset(crate::ported::zsh_h::POSIXBUILTINS) && !isset(crate::ported::zsh_h::INTERACTIVE) {
13160            // c:4379-4383 — non-interactive POSIX fatal: exit(1).
13161            // In-process equivalent: arm EXIT_PENDING/EXIT_VAL so the
13162            // next BUILTIN_ERREXIT_CHECK (trigger 2) unwinds the
13163            // script with status 1 — same deferred-exit shape the
13164            // `exit` builtin uses inside subshell contexts.
13165            crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
13166            crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
13167        }
13168        Value::Status(redir_st)
13169    });
13170    // c:Src/exec.c:3722-3724 — see the const's doc block. No args.
13171    // BUILTIN_XTRERR_COPY — c:Src/exec.c:3765-3773, emitted right before a
13172    // simple command's WithRedirectsBegin. No args.
13173    vm.register_builtin(BUILTIN_XTRERR_COPY, |_vm, _argc| {
13174        xtrerr_flush(); // c:3766 fflush(xtrerr)
13175        if isset(crate::ported::zsh_h::XTRACE)
13176            && crate::ported::utils::xtrerr.load(std::sync::atomic::Ordering::Relaxed) == libc::STDERR_FILENO
13177        {
13178            // c:3767-3769
13179            let fd = crate::ported::utils::movefd(unsafe { libc::dup(libc::STDERR_FILENO) });
13180            if fd >= 0 {
13181                // c:Src/exec.c:779 `closem(FDT_XTRACE, 0)` closes this fd in a
13182                // child before it execs; zshrs's spawns run no closem, so
13183                // close-on-exec stands in for it.
13184                unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
13185                crate::ported::utils::xtrerr.store(fd, std::sync::atomic::Ordering::Relaxed); // c:3770
13186                crate::ported::utils::fdtable_set(fd, crate::ported::zsh_h::FDT_XTRACE); // c:3771
13187                let depth = with_executor(|exec| exec.redirect_scope_stack.len()) + 1;
13188                XTRERR_COPIES.with(|v| v.borrow_mut().push((depth, fd)));
13189            }
13190        }
13191        Value::Status(0)
13192    });
13193    vm.register_builtin(BUILTIN_PIPE_OUTPUT_MARK, |_vm, _argc| {
13194        with_executor(|exec| exec.pipe_output_pending = true);
13195        Value::Status(0)
13196    });
13197    // c:Src/exec.c:3710-3724 — install this pipeline stage's fds.
13198    //     /* Make a copy of stderr for xtrace output before redirecting */
13199    //     fflush(xtrerr);
13200    //     ...
13201    //     /* Add pipeline input/output to mnodes */
13202    //     if (input)  addfd(forked, save, mfds, 0, input, 0, NULL);
13203    //     if (output) addfd(forked, save, mfds, 1, output, 1, NULL);
13204    // Emitted into the stage chunk by compile_zsh.rs (after the arg
13205    // words' expansion ops, before the redirect scope), and fed by
13206    // BUILTIN_RUN_PIPELINE via `stage_fds_park`. Doing the dup2 HERE
13207    // rather than before the chunk runs is what makes a `$(...)` in a
13208    // stage's arguments see the shell's fd 0 instead of the pipe.
13209    vm.register_builtin(BUILTIN_PIPE_FDS_INSTALL, |vm, argc| {
13210        // Arg: `|&` merge-stderr flag (compile_zsh always passes it).
13211        let merge_stderr = pop_args(vm, argc)
13212            .first()
13213            .map(|s| s != "0" && !s.is_empty())
13214            .unwrap_or(false);
13215        let (in_fd, out_fd) = stage_fds_take();
13216        if in_fd < 0 && out_fd < 0 {
13217            return Value::Status(0);
13218        }
13219        // c:3711 `fflush(xtrerr)` — flush before the fds move, so
13220        // anything buffered from the expansion phase lands on the
13221        // ORIGINAL fd, not on the pipe.
13222        let _ = std::io::stdout().flush();
13223        let _ = std::io::stderr().flush();
13224        unsafe {
13225            if in_fd >= 0 {
13226                libc::dup2(in_fd, libc::STDIN_FILENO);
13227                if in_fd != libc::STDIN_FILENO {
13228                    libc::close(in_fd);
13229                }
13230                // c:3775-3776 — the pipe is mfds[0]'s first member.
13231                PIPE_INPUT_INSTALLED.with(|c| c.set(Some(SUBLIST_SERIAL.with(|s| s.get()))));
13232            }
13233            if out_fd >= 0 {
13234                libc::dup2(out_fd, libc::STDOUT_FILENO);
13235                if out_fd != libc::STDOUT_FILENO {
13236                    libc::close(out_fd);
13237                }
13238                // `cmd |& next`: the `2>&1` C appends to cmd's redirect
13239                // list (walked at c:3730+, i.e. after this addfd), so
13240                // stderr follows the pipe, not the shell's stdout.
13241                if merge_stderr {
13242                    libc::dup2(libc::STDOUT_FILENO, libc::STDERR_FILENO);
13243                }
13244            }
13245        }
13246        Value::Status(0)
13247    });
13248    // c:Src/exec.c — block-level redirect-failure gate. When a
13249    // compound command (`{ … } < file`, `( … ) > file`, etc.) has a
13250    // failing redirect (e.g. `< /nonexistent`), zsh skips the entire
13251    // body AND sets lastval to 1. The simple-command path's
13252    // redirect_failed check (line 215-221 above) only catches the
13253    // failure when a builtin dispatches and is consumed by that
13254    // single builtin call — so a multi-statement block kept running
13255    // its remaining statements after the redir error. Emit-side at
13256    // compile_zsh.rs::compile_command's Redirected arm pairs this
13257    // with a JumpIfTrue → WithRedirectsEnd to abandon the body.
13258    vm.register_builtin(BUILTIN_REDIRECT_FAILED_CHECK, |vm, _argc| {
13259        let failed = with_executor(|exec| {
13260            let f = exec.redirect_failed;
13261            exec.redirect_failed = false;
13262            f
13263        });
13264        if failed {
13265            vm.last_status = crate::extensions::dash_mode::fatal_error_status().unwrap_or(1);
13266            Value::Int(1)
13267        } else {
13268            Value::Int(0)
13269        }
13270    });
13271    // c:Src/exec.c — drop-in replacement for fusevm's Op::Exec used by
13272    // the dynamic-first-word path (`$cmd`, `$(cmd)`, glob-named cmds).
13273    // fusevm's Op::Exec returns Value::Status(0) when post-expansion
13274    // argv is empty (vm.rs:1722) — that clobbers \$? for the
13275    // `\$(exit 1); echo \$?` case where the cmd-subst left
13276    // last_status = 1 but the empty expansion gets exec'd to 0.
13277    // Mirror C zsh: when the word list is empty after expansion,
13278    // \$? becomes whatever the inner cmd-subst's last_status is
13279    // (preserved here by returning Value::Status(last_status)).
13280    // c:Src/cond.c:308-316 — `if (!(pprog = patcompile(right, ...)))
13281    //   { zwarnnam(fromtest, "bad pattern: %s", right); return 2; }`.
13282    // The cond path must NOT use str_match/glob_match_static: the
13283    // case-statement consumer of those follows Src/loop.c:667 zerr
13284    // semantics (errflag abort), while cond is a zwarn + status-2
13285    // soft failure. COND_BAD_PATTERN carries the 2 across the
13286    // Bool-shaped stack contract (so `!=`'s LogNot can't lose it).
13287    thread_local! {
13288        static COND_BAD_PATTERN: std::cell::Cell<bool> =
13289            const { std::cell::Cell::new(false) };
13290    }
13291    vm.register_builtin(BUILTIN_COND_STRMATCH, |vm, _argc| {
13292        let pat = pattern_filesub(&vm.pop().to_str());
13293        let s = vm.pop().to_str();
13294        // bash `shopt -s nocasematch` → case-insensitive `[[ == ]]` / `[[ != ]]`.
13295        // Lowercase BOTH sides for the match decision (glob metacharacters are
13296        // not letters, so the pattern's `*`/`?`/`[…]` structure is preserved).
13297        // No-op unless the bash shopt is active. --zsh unaffected.
13298        let (s, pat) = if crate::dash_mode::nocasematch() {
13299            (s.to_lowercase(), pat.to_lowercase())
13300        } else {
13301            (s, pat)
13302        };
13303        // !!! BASH-MODE (no C counterpart) !!! bash enables extglob while parsing
13304        // `[[ … ]]` (bash(1) `shopt` extglob: "the extended pattern matching
13305        // ksh93 `~(options)pat` prefixes (`~(i)`, `~(E)`, `~(F)`, `~(G)`).
13306        if let Some(m) = crate::pattern_data_escape::ksh93_flagged_match(&s, &pat) {
13307            return Value::Bool(m);
13308        }
13309        // operators are enabled within [[ ]]"), so `[[ abc == @(a|x)bc ]]` matches
13310        // with the shopt off. Hold KSHGLOB on for this one match.
13311        let _extglob_in_dbracket = BashDbracketExtglob::enter(&pat);
13312        // A bare POSIX-family drop-in reads a source-level `(` by its own
13313        // rules, not by zsh's parse-time tokenization — see
13314        // `dropin_source_pattern_parens_literal`.
13315        let pat_eff = if crate::pattern_data_escape::dropin_source_pattern_parens_literal() {
13316            crate::pattern_data_escape::escape_shglob_parens(
13317                &pat,
13318                crate::pattern_data_escape::dropin_keeps_ksh_groups(),
13319            )
13320        } else {
13321            pat.clone()
13322        };
13323        let mut pat_tok = pat_eff.clone();
13324        crate::ported::glob::tokenize(&mut pat_tok);
13325        if crate::ported::pattern::patcompile(
13326            &pat_tok,
13327            crate::ported::zsh_h::PAT_STATIC as i32,
13328            None,
13329        )
13330        .is_none()
13331        {
13332            // c:314 — zwarnnam(fromtest, "bad pattern: %s", right).
13333            crate::ported::utils::zwarn(&format!("bad pattern: {}", pat));
13334            COND_BAD_PATTERN.with(|c| c.set(true));
13335            return Value::Bool(false);
13336        }
13337        // Match via the shared engine so `(#b)`/`(#m)` backref and
13338        // MATCH-variable population stays in one place.
13339        Value::Bool(crate::vm_helper::glob_match_static(&s, &pat_eff))
13340    });
13341    // See BUILTIN_RM_STAR_CHECK.
13342    vm.register_builtin(BUILTIN_RM_STAR_CHECK, |vm, argc| {
13343        use crate::ported::zsh_h::{interact, isset, ERRFLAG_ERROR, RMSTARSILENT, SHINSTDIN, Star};
13344        use std::sync::atomic::Ordering;
13345        let mask = vm.pop().to_int() as u64;
13346        let n = (argc as usize).saturating_sub(1);
13347        let mut words: Vec<Value> = (0..n).map(|_| vm.pop()).collect();
13348        words.reverse();
13349        // c:3549-3551 — `type == WC_SIMPLE && interact && unset(RMSTARSILENT)
13350        // && isset(SHINSTDIN)` and at least one argument after `rm`.
13351        if interact() && !isset(RMSTARSILENT) && isset(SHINSTDIN) && n > 0 {
13352            let is_star = |c: char| c == Star || c == '*';
13353            for (i, word) in words.iter().enumerate() {
13354                // c:3554 — `node && !errflag`
13355                if crate::ported::utils::errflag.load(Ordering::Relaxed) != 0 {
13356                    break;
13357                }
13358                if i >= 64 || mask & (1u64 << i) == 0 {
13359                    continue;
13360                }
13361                let s: Vec<char> = word.to_str().chars().collect();
13362                let l = s.len();
13363                let rmall = if l == 1 && is_star(s[0]) {
13364                    // c:3559-3560 — `checkrmall(pwd)`
13365                    let pwd = crate::ported::params::getsparam("PWD").unwrap_or_default();
13366                    crate::ported::utils::checkrmall(&pwd)
13367                } else if l >= 2 && s[l - 2] == '/' && is_star(s[l - 1]) {
13368                    // c:3564-3570 — the word up to the `/`, or `/` itself.
13369                    let dir: String = if l == 2 { "/".to_string() } else { s[..l - 2].iter().collect() };
13370                    crate::ported::utils::checkrmall(&dir)
13371                } else {
13372                    true
13373                };
13374                if !rmall {
13375                    crate::ported::utils::errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed); // c:3561 / c:3573
13376                    break;
13377                }
13378            }
13379        }
13380        for w in words {
13381            vm.push(w);
13382        }
13383        Value::Int(0)
13384    });
13385    // c:Src/cond.c:122-193, the COND_MODI arm: `[[ a -op b ]]` with an `-op`
13386    // that is not one of get_cond_num's (Src/parse.c:2697-2701). See
13387    // BUILTIN_COND_MODI.
13388    vm.register_builtin(BUILTIN_COND_MODI, |vm, _argc| {
13389        let is_dash = |s: &str| s.starts_with('-') || s.starts_with(crate::ported::zsh_h::Dash);
13390        let mut name = vm.pop().to_str(); // c:130 — the operator word
13391        let right = vm.pop().to_str();
13392        let left = vm.pop().to_str();
13393        let mut strs = vec![left, right]; // c:136-141
13394        let l: i32 = 2; // c:141
13395        // c:143-148
13396        let errname = if is_dash(&name) {
13397            crate::lex::untokenize(&name)
13398        } else if is_dash(&strs[0]) {
13399            crate::lex::untokenize(&strs[0])
13400        } else {
13401            "<null>".to_string()
13402        };
13403        let lookup = |inf: i32, word: &str| {
13404            let cond = word.chars().skip(1).collect::<String>(); // `name + 1`
13405            match crate::ported::module::MODULESTAB.try_lock() {
13406                Ok(mut tab) => crate::ported::module::getconddef(inf, &cond, 1, &mut tab),
13407                Err(_) => None,
13408            }
13409        };
13410        // c:149-150
13411        let mut cd = if is_dash(&name) { lookup(1, &name) } else { None };
13412        if cd.is_none() {
13413            // c:161-174 — retry as a prefix condition on the left word, with
13414            // the operator word moved into its place.
13415            let s = std::mem::replace(&mut strs[0], name); // c:173
13416            name = s; // c:174
13417            cd = if is_dash(&name) { lookup(0, &name) } else { None }; // c:176-177
13418            match cd.as_ref() {
13419                Some(c) if l < c.min || (c.max >= 0 && l > c.max) => {
13420                    // c:178-181
13421                    crate::ported::utils::zerr(&format!("unknown condition: {}", errname));
13422                    COND_BAD_PATTERN.with(|c| c.set(true));
13423                    return Value::Bool(false);
13424                }
13425                Some(_) => {}
13426                None => {
13427                    // c:186-192 — `zwarnnam(…, "unknown condition: %s",
13428                    // errname)` then `return 2;`, the status the shared
13429                    // BUILTIN_COND_STATUS_FROM_BOOL tail turns into an error.
13430                    crate::ported::utils::zerr(&format!("unknown condition: {}", errname));
13431                    COND_BAD_PATTERN.with(|c| c.set(true));
13432                    return Value::Bool(false);
13433                }
13434            }
13435        }
13436        // c:158 / c:185 — `return !cd->handler(strs, cd->condid);`
13437        let r = cd
13438            .and_then(|c| c.handler.map(|h| h(&strs, c.condid)))
13439            .unwrap_or(0);
13440        Value::Bool(r != 0)
13441    });
13442
13443    vm.register_builtin(BUILTIN_COND_UNKNOWN, |vm, _argc| {
13444        // c:Src/cond.c:150-188 — `zwarnnam(fromtest, "unknown condition: %s",
13445        // name)` for a `-X` op with no matching cond module. Like a cond
13446        // syntax error it yields status 2 and aborts: arm COND_BAD_PATTERN so
13447        // the downstream BUILTIN_COND_STATUS_FROM_BOOL carries the 2 across the
13448        // Bool-shaped stack and runs the shared errflag+set_last_status(2)+abort
13449        // path (c:Src/exec.c:5216-5221). Returns Bool(false) as the operand.
13450        let op = vm.pop().to_str();
13451        crate::ported::utils::zerr(&format!("unknown condition: {}", op));
13452        COND_BAD_PATTERN.with(|c| c.set(true));
13453        Value::Bool(false)
13454    });
13455    vm.register_builtin(BUILTIN_COND_STATUS_FROM_BOOL, |vm, _argc| {
13456        // `${~pat}` / `${(P)~pat}` inside a `[[ … ]]` operand flips
13457        // GLOB_SUBST on via the tilde carrier so the pattern match sees
13458        // active metacharacters. In C that flag is prefork-scoped and
13459        // gone once the operand is consumed; zshrs restores it at the
13460        // next command-dispatch boundary, but a bare `[[ … ]]` has no
13461        // trailing assignment to trigger that — so globsubst leaked ON
13462        // into the NEXT command's word expansion, filename-generating a
13463        // scalar value it should not (p10k `_p9k_set_prompt`: line 45
13464        // `[[ … != ${(P)~disabled} ]]` leaked into line 46's
13465        // `local val=$arr[idx]`, whose glob-char-laden value then hit
13466        // "no matches found" and aborted the whole prompt build →
13467        // garbled 25-line prompt / interactive hang). Consume the
13468        // carrier here: this builtin ends EVERY `[[ … ]]`, and runs
13469        // after the operands (and their pattern match) are done.
13470        consume_tilde_globsubst_carrier();
13471        let ok = vm.pop().to_int() != 0;
13472        let bad = COND_BAD_PATTERN.with(|c| {
13473            let b = c.get();
13474            c.set(false);
13475            b
13476        });
13477        if bad {
13478            // c:Src/exec.c:5216-5221 — `stat = evalcond(...);
13479            //   /* 2 indicates a syntax error. For compatibility,
13480            //      turn this into a shell error. */
13481            //   if (stat == 2) errflag |= ERRFLAG_ERROR;`
13482            // The errflag abort exits the script with lastval (2),
13483            // matching `zsh -fc '[[ x == [a- ]]; print rc=$?'`
13484            // printing nothing after the diagnostic and exiting 2.
13485            crate::ported::utils::errflag.fetch_or(
13486                crate::ported::zsh_h::ERRFLAG_ERROR,
13487                std::sync::atomic::Ordering::Relaxed,
13488            );
13489            with_executor(|exec| exec.set_last_status(2));
13490            return Value::Int(2); // c:Src/cond.c:316 `return 2;`
13491        }
13492        let status: i32 = if ok { 0 } else { 1 };
13493        // c:Src/exec.c:5216 — `lastval = evalcond(...)`: the conditional's
13494        // result IS the command's lastval, and c:Src/cond.c's evalcond
13495        // never inspects errflag while evaluating. So when a `[[ … ]]`
13496        // operand raised errflag (e.g. a nounset "parameter not set" zerr
13497        // on `${arr[99]}` under NO_UNSET), zsh STILL completes the test and
13498        // exits with the cond result; the errflag only aborts the FOLLOWING
13499        // commands. Sync the result to the executor's live lastval HERE —
13500        // the nounset site left it at a transient 1, and the next
13501        // BUILTIN_ERREXIT_CHECK reads the executor (not vm.last_status), so
13502        // without this sync `setopt NO_UNSET; [[ -z ${arr[99]} ]]` exited 1
13503        // instead of 0. The Op::SetStatus that follows sets vm.last_status;
13504        // this keeps the executor coherent with it before the abort check.
13505        with_executor(|exec| exec.set_last_status(status));
13506        Value::Int(status as i64)
13507    });
13508    // c:Src/exec.c:2796 (execcmd_analyse) / c:1368 (execsimple) — a simple
13509    // command that carries assignments starts with `cmdoutval = 0;`.
13510    vm.register_builtin(BUILTIN_CMDOUTVAL_RESET, |_vm, _argc| {
13511        crate::ported::exec::cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
13512        Value::Status(0)
13513    });
13514    vm.register_builtin(BUILTIN_USE_CMDOUTVAL_RESET, |_vm, _argc| {
13515        crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
13516        Value::Status(0)
13517    });
13518
13519    // See BUILTIN_COND_PCRE_MATCH.
13520    vm.register_builtin(BUILTIN_COND_PCRE_MATCH, |vm, _argc| {
13521        let re = crate::lex::untokenize(&vm.pop().to_str());
13522        let subject = crate::lex::untokenize(&vm.pop().to_str());
13523        let hit = crate::ported::modules::pcre::cond_pcre_match(
13524            &[subject, re],
13525            crate::ported::modules::pcre::CPCRE_PLAIN,
13526        ) != 0;
13527        Value::Bool(hit)
13528    });
13529    // See BUILTIN_AUTOCONTINUE_FORCE.
13530    vm.register_builtin(BUILTIN_AUTOCONTINUE_FORCE, |vm, _argc| {
13531        thread_local! {
13532            static SAVED: std::cell::RefCell<Vec<bool>> = const { std::cell::RefCell::new(Vec::new()) };
13533        }
13534        if vm.pop().to_int() != 0 {
13535            let old = opt_state_get("autocontinue").unwrap_or(false); // c:3021 oautocont
13536            SAVED.with(|s| s.borrow_mut().push(old));
13537            crate::ported::options::opt_state_set_via_alias("autocontinue", true); // c:3022
13538        } else if let Some(old) = SAVED.with(|s| s.borrow_mut().pop()) {
13539            crate::ported::options::opt_state_set_via_alias("autocontinue", old);
13540        }
13541        Value::Int(0)
13542    });
13543    // See BUILTIN_CMD_NAME_GLOB_MARK.
13544    vm.register_builtin(BUILTIN_CMD_NAME_GLOB_MARK, |vm, _argc| {
13545        let more_words = vm.pop().to_int() != 0;
13546        // A NOMATCH / bad pattern zerrs (errflag set); a CSH_NULL_GLOB drop is
13547        // silent and leaves the word list to carry on.
13548        let failed = with_executor(|exec| exec.current_command_glob_failed.get())
13549            && crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
13550                & crate::ported::zsh_h::ERRFLAG_ERROR
13551                != 0;
13552        // A CSH_NULL_GLOB drop of the name leaves the other words to the
13553        // globlist at c:Src/exec.c:3757, which restarts the badcshglob count
13554        // (c:Src/subst.c:498): the dropped name must not later read as
13555        // "every glob of this command failed".
13556        if !failed && more_words {
13557            crate::ported::glob::BADCSHGLOB.store(0, std::sync::atomic::Ordering::Relaxed);
13558        }
13559        CMD_NAME_GLOB_FAILED.with(|c| c.set(failed));
13560        Value::Int(0)
13561    });
13562    // See BUILTIN_MARK_CURSH.
13563    vm.register_builtin(BUILTIN_MARK_CURSH, |_vm, _argc| {
13564        crate::exec_jobs::mark_thisjob_cursh(true); // c:Src/exec.c:3674-3682
13565        Value::Int(0)
13566    });
13567    // See BUILTIN_EXEC_DYNAMIC_REDIRS.
13568    vm.register_builtin(BUILTIN_EXEC_DYNAMIC_REDIRS, |_vm, _argc| {
13569        EXEC_DYNAMIC_REDIRS.with(|c| c.set(true));
13570        Value::Int(0)
13571    });
13572    vm.register_builtin(BUILTIN_EXEC_DYNAMIC, |vm, argc| {
13573        let raw = pop_args(vm, argc);
13574        // Taken first so every return path below clears it.
13575        let had_redirs = EXEC_DYNAMIC_REDIRS.with(|c| c.replace(false));
13576        // Flatten Array entries into argv slots (matches fusevm
13577        // Op::Exec's flatten at vm.rs:1660-1665) so `${arr[@]}` /
13578        // splice expansions produce one argv slot per element.
13579        let args: Vec<String> = raw.into_iter().collect();
13580        // c:Src/subst.c paramsubst — when `${var:?msg}` or
13581        // `${var?msg}` set errflag, the expansion may produce empty
13582        // argv[0] which would fall into the EACCES/permission-denied
13583        // path below, masking the real paramsubst diagnostic with a
13584        // spurious "permission denied:" line and rc=126. Honour
13585        // errflag so the simple command ends with the paramsubst
13586        // error as the sole diagnostic, rc=1. Bug #86.
13587        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
13588            & crate::ported::zsh_h::ERRFLAG_ERROR)
13589            != 0
13590        {
13591            if PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.get()) {
13592                settle_prefix_assign_exit(args.first().map(String::as_str).unwrap_or(""));
13593            }
13594            // c:Src/exec.c:4343-4350 — when the error came from the prefix
13595            // assignments and the expanded name is an external command, they
13596            // ran in the forked child (`addvars(…); if (errflag) _exit(1);`):
13597            // the shell sees status 1 and its errflag is untouched.
13598            if PREFIX_ASSIGN_FAILED.with(|c| c.replace(false)) {
13599                if let Some(name) = args.first() {
13600                    let is_external = !with_executor(|exec| exec.function_exists(name))
13601                        && !crate::ported::builtin::createbuiltintable().contains_key(name.as_str());
13602                    if is_external {
13603                        // Both bits: `${name?msg}` also sets ERRFLAG_HARD.
13604                        crate::ported::utils::errflag.fetch_and(
13605                            !(crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
13606                            std::sync::atomic::Ordering::Relaxed,
13607                        );
13608                        with_executor(|exec| exec.set_last_status(1));
13609                    }
13610                }
13611            }
13612            // c:Src/exec.c:3719 + 3755-3763 — the argument glob of an
13613            // external runs in the forked child as well (`x=cat; $x
13614            // *nomatch; print after` prints `after`).
13615            if let Some(name) = args.first() {
13616                let is_external = !with_executor(|exec| exec.function_exists(name))
13617                    && (files_builtin_runs_from_path(name)
13618                        || !crate::ported::builtin::createbuiltintable().contains_key(name.as_str()));
13619                if is_external {
13620                    if let Some(status) = external_glob_failure_status() {
13621                        return Value::Status(status);
13622                    }
13623                }
13624            }
13625            return Value::Status(1);
13626        }
13627        if args.is_empty() {
13628            // c:Src/exec.c:3442 — a command whose words expand to ZERO
13629            // words is a NULL command: `cmdoutval = use_cmdoutval ?
13630            // lastval : 0`. `use_cmdoutval` is set (below, in
13631            // BUILTIN_CMD_SUBST_TEXT) only when a command substitution
13632            // ran during this command's word expansion, so:
13633            //   `false; $(exit 5)`  → keep the subst status (5)
13634            //   `false; $nonexistent` → reset to 0 (null command).
13635            // The previous port unconditionally kept `$?`, so
13636            // `false; $unset` wrongly stayed 1 (A01grammar.ztst:5).
13637            let keep =
13638                crate::ported::exec::use_cmdoutval.load(std::sync::atomic::Ordering::Relaxed) != 0;
13639            let status = if keep { vm.last_status } else { 0 };
13640            crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
13641            return Value::Status(status);
13642        }
13643        if args[0].is_empty() {
13644            // Explicit empty command word — exec returns EACCES.
13645            let script_name =
13646                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
13647            let lineno: u64 = with_executor(|exec| {
13648                exec.scalar("LINENO")
13649                    .and_then(|s| s.parse::<u64>().ok())
13650                    .unwrap_or(1)
13651            });
13652            eprintln!("{}:{}: permission denied: ", script_name, lineno);
13653            return Value::Status(126);
13654        }
13655        // AOP intercepts (zshrs extension, no C counterpart) — same
13656        // gate as host_exec_external (the static-head path): dynamic
13657        // command names (`cmd=/bin/echo; $cmd payload`) must consult
13658        // registered intercepts before dispatch, else `intercept
13659        // before /bin/echo ...` fires for the literal spelling but
13660        // not the variable one. run_intercepts runs before-advice
13661        // in-place and returns None to continue; Some(status) means
13662        // an around/after advice fully handled the command.
13663        let intercepted = with_executor(|exec| {
13664            if exec.intercepts.is_empty() {
13665                return None;
13666            }
13667            let full_cmd = if args.len() == 1 {
13668                args[0].clone()
13669            } else {
13670                args.join(" ")
13671            };
13672            let rest: Vec<String> = args[1..].to_vec();
13673            exec.run_intercepts(&args[0], &full_cmd, &rest)
13674        });
13675        if let Some(result) = intercepted {
13676            return Value::Status(result.unwrap_or(127));
13677        }
13678        // zshrs-original builtins (async, doctor, peach, …, and the daemon
13679        // z* family) reached via a run-time-resolved head (`$var`): they
13680        // are absent from the static BUILTINS port table / builtintab, so
13681        // execcmd_exec below would treat the head as external and report
13682        // "command not found" — even though `whence` calls it a builtin and
13683        // a literal head runs it via CallBuiltin. Dispatch by name here, but
13684        // ONLY when the head is neither
13685        // a user function nor a ported builtin, so the shell's
13686        // function -> builtin -> external order is preserved.
13687        if let Some(head) = args.first() {
13688            let is_fn = with_executor(|e| e.function_exists(head));
13689            let is_ported =
13690                crate::ported::builtin::createbuiltintable().contains_key(head.as_str());
13691            if !is_fn && !is_ported {
13692                let status = run_daemon_builtin(head, &args[1..])
13693                    .or_else(|| try_run_registered_builtin(head, &args[1..]));
13694                if let Some(status) = status {
13695                    crate::ported::builtin::LASTVAL
13696                        .store(status, std::sync::atomic::Ordering::Relaxed);
13697                    return Value::Status(status);
13698                }
13699            }
13700        }
13701        // c:Src/exec.c:2900 execcmd_exec — canonical simple-command
13702        // dispatcher. Runs precmd-modifier walk (c:3013-3091), then
13703        // dispatches to execbuiltin (c:4233) / runshfunc (c:3431+) /
13704        // execute (c:4314) per the resolved head. zshrs's bytecode VM
13705        // expanded the args before reaching here; we feed them in via
13706        // eparams.args and let execcmd_exec do the rest exactly as C
13707        // does for static heads. Without this, `c=builtin; $c source X`
13708        // skipped the precmd walk and emitted "command not found:
13709        // builtin".
13710        let mut state = crate::ported::zsh_h::estate {
13711            prog: Box::<crate::ported::zsh_h::eprog>::default(),
13712            pc: 0,
13713            strs: None,
13714            strs_offset: 0,
13715        };
13716        let mut eparams = crate::ported::zsh_h::execcmd_params {
13717            args: Some(args),
13718            redir: None,
13719            beg: 0,
13720            varspc: None,
13721            assignspc: None,
13722            typ: crate::ported::zsh_h::WC_SIMPLE as i32,
13723            postassigns: 0,
13724            htok: 0,
13725        };
13726        // input/output=0 → no pipe redirection (use shell stdio
13727        // directly); `output != 0` at c:2988 forks immediately. last1=2
13728        // (c:Src/exec.c:2014 `last1 ? 1 : 2`): terminal pipe stage but
13729        // the shell IS needed afterward — the VM keeps executing
13730        // bytecode after this op. last1=1 would arm the fake-exec
13731        // optimization (c:3646-3651, gate at c:3662 `last1 != 1`),
13732        // making `execute()` execve THIS process for external heads:
13733        // `p=/bin/echo; $p hi; echo after` replaced the shell and
13734        // `after` never ran (D04parameter chunk 11 shell-killer).
13735        // c:Src/exec.c:1690-1700 — execpline's job frame: save thisjob
13736        // (`pj = thisjob`) and allocate the jobtab slot that
13737        // execcmd_fork's addproc (c:2853) hangs the child pid off.
13738        // Without a live thisjob, the fork at c:3662 (last1 != 1 →
13739        // external must fork) registers no proc, nothing waits, and
13740        // the child races the rest of the script.
13741        let pj = {
13742            use crate::ported::jobs;
13743            *jobs::THISJOB
13744                .get_or_init(|| std::sync::Mutex::new(-1))
13745                .lock()
13746                .unwrap_or_else(|e| e.into_inner())
13747        };
13748        // The pipeline's job frame (BUILTIN_EXECPLINE_CHILD_BLOCK) already
13749        // holds this command's slot; only a chunk compiled without one
13750        // allocates here.
13751        let held = crate::exec_jobs::execpline_slot_current();
13752        let newjob = held.unwrap_or_else(|| {
13753            use crate::ported::jobs;
13754            let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
13755            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
13756            jobs::initjob(&mut tab) // c:1700 `thisjob = newjob = initjob()`
13757        });
13758        {
13759            use crate::ported::jobs;
13760            *jobs::THISJOB
13761                .get_or_init(|| std::sync::Mutex::new(-1))
13762                .lock()
13763                .unwrap_or_else(|e| e.into_inner()) = newjob as i32;
13764        }
13765        crate::ported::exec::REDIRS_PREAPPLIED.with(|c| c.set(had_redirs));
13766        crate::ported::exec::execcmd_exec(
13767            &mut state,
13768            &mut eparams,
13769            0,                                   // input  (c:2989)
13770            0,                                   // output (c:2988)
13771            crate::ported::zsh_h::Z_SYNC as i32, // how
13772            2,                                   // last1=2 — shell continues (c:2014)
13773            -1,                                  // close_if_forked
13774        );
13775        crate::ported::exec::REDIRS_PREAPPLIED.with(|c| c.set(false));
13776        // c:Src/exec.c:1828-1835 — execpline's Z_SYNC tail: waitjobs()
13777        // reaps the forked external. c:Src/jobs.c:487-495 + 551-552 —
13778        // the job's LAST proc sets lastval (0200|sig when signalled,
13779        // else WEXITSTATUS). Builtin/shfunc heads never forked (job
13780        // has no procs) — LASTVAL was already set by execbuiltin /
13781        // doshfunc inside execcmd_exec; skip the wait.
13782        {
13783            use crate::ported::jobs;
13784            let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
13785            let forked = jobs::hasprocs(&table.lock().unwrap_or_else(|e| e.into_inner()), newjob);
13786            if forked {
13787                // waitjobs() reads thisjob and holds no JOBTAB guard across the wait.
13788                *jobs::THISJOB
13789                    .get_or_init(|| std::sync::Mutex::new(-1))
13790                    .lock()
13791                    .unwrap_or_else(|e| e.into_inner()) = newjob as i32;
13792                jobs::waitjobs(); // c:1835
13793            }
13794            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
13795            if forked {
13796                if let Some(p) = tab[newjob].procs.last() {
13797                    let val = if p.is_signaled() {
13798                        0o200 | p.term_sig() // c:Src/jobs.c:489-490
13799                    } else {
13800                        p.exit_status() // c:Src/jobs.c:494
13801                    };
13802                    crate::ported::builtin::LASTVAL
13803                        .store(val, std::sync::atomic::Ordering::Relaxed);
13804                }
13805            }
13806            // c:1977-1979 — `deletejob(jn, 0)` once done; c:1981
13807            // `thisjob = pj` restores the caller's job. A held slot is the
13808            // job frame's to delete when the pipeline closes.
13809            if held.is_none() && newjob < tab.len() {
13810                jobs::deletejob(&mut tab, newjob, false);
13811            }
13812            *jobs::THISJOB
13813                .get_or_init(|| std::sync::Mutex::new(-1))
13814                .lock()
13815                .unwrap_or_else(|e| e.into_inner()) = pj;
13816        }
13817        let status = crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
13818        crate::ported::jobs::waitonejob(-1);
13819        Value::Status(status)
13820    });
13821    // c:Src/exec.c:3386-3419 — `< file` / `> file` with no command
13822    // word. Resolves NULLCMD/READNULLCMD at runtime, then dispatches the
13823    // resulting word the way execcmd's fall-through does (shell function →
13824    // builtin → external). Redirects are already applied by the surrounding
13825    // WithRedirectsBegin scope.
13826    vm.register_builtin(BUILTIN_NULLCMD_CHECK, |vm, _argc| {
13827        use std::sync::atomic::Ordering;
13828        let nullcmd = crate::ported::params::getsparam("NULLCMD");
13829        // c:3395 — `!nullcmd || !*nullcmd || opts[CSHNULLCMD]`
13830        if nullcmd.as_deref().map_or(true, str::is_empty)
13831            || crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLCMD)
13832        {
13833            crate::ported::utils::zerr("redirection with no command"); // c:3397
13834            crate::ported::builtin::LASTVAL.store(1, Ordering::Relaxed); // c:3398
13835            vm.last_status = 1;
13836            crate::ported::utils::errflag
13837                .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed); // c:3399
13838            return Value::Bool(false);
13839        }
13840        Value::Bool(true)
13841    });
13842    vm.register_builtin(BUILTIN_NULLCMD_EXEC, |vm, argc| {
13843        let args = pop_args(vm, argc);
13844        let is_single_read = args
13845            .first()
13846            .map(|s| s != "0" && !s.is_empty())
13847            .unwrap_or(false);
13848        // c:Src/exec.c — when the surrounding redir-open failed
13849        // (e.g. `< /nonexistent`), zerr already printed the diag
13850        // and set redirect_failed. Don't invoke NULLCMD — return
13851        // status 1 like the wordcode path does.
13852        let redir_failed = with_executor(|exec| {
13853            let f = exec.redirect_failed;
13854            exec.redirect_failed = false;
13855            f
13856        });
13857        if redir_failed {
13858            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
13859            return Value::Status(1);
13860        }
13861        // c:3395-3399 — the no-NULLCMD / CSH_NULLCMD refusal already ran in
13862        // BUILTIN_NULLCMD_CHECK, before the redirections opened.
13863        let nullcmd = crate::ported::params::getsparam("NULLCMD");
13864        let nc_str = nullcmd.as_deref().unwrap_or("");
13865        // c:3350 — SHNULLCMD → run `:`.
13866        let cmd: String = if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHNULLCMD) {
13867            ":".to_string()
13868        } else if is_single_read {
13869            // c:3354-3359 — single REDIR_READ + READNULLCMD set → readnullcmd.
13870            let rnc = crate::ported::params::getsparam("READNULLCMD");
13871            let rnc_str = rnc.as_deref().unwrap_or("");
13872            if !rnc_str.is_empty() {
13873                rnc_str.to_string()
13874            } else {
13875                nc_str.to_string() // c:3360-3363 fallback
13876            }
13877        } else {
13878            nc_str.to_string() // c:3360-3363
13879        };
13880        // c:Src/exec.c:3408/3414/3418 — C does not "run NULLCMD" as a special
13881        // case: it APPENDS the word to the command's arg list
13882        // (`addlinknode(args, dupstring(nullcmd))`) and falls through to
13883        // execcmd's ORDINARY dispatch, which resolves that word in this order:
13884        //   c:3484-3487 `shfunctab->getnode(shfunctab, cmdarg)` → shell function
13885        //   c:3489      `builtintab->getnode(builtintab, cmdarg)` → builtin
13886        //   otherwise   → external command (PATH lookup).
13887        // `host_exec_external` already implements the shell-function arm and
13888        // the external arm (plus the AOP intercepts and the module-builtin
13889        // name arms), so only the builtintab arm has to be decided here.
13890        //
13891        // The builtintab question must be asked of the TABLE.
13892        // `builtin_in_builtintab` alone is NOT a membership test — it is the
13893        // module *gate*, and `builtin_owning_module` returns None for any name
13894        // it does not know, whose `None => true` arm
13895        // (src/extensions/ext_builtins.rs:179-182) then reports EVERY string as
13896        // an available builtin. With that as the only predicate, the default
13897        // `READNULLCMD=more` (config.h DEFAULT_READNULLCMD), a user's
13898        // `READNULLCMD=less`, `NULLCMD=/bin/cat` and every other external name
13899        // were classified as core builtins and handed to
13900        // `dispatch_builtin_raw("more", vec![])`, which cannot work: plain
13901        // `< file` printed NOTHING and returned 1, and a missing NULLCMD
13902        // returned a silent 1 instead of `command not found` / 127.
13903        //
13904        // Membership first, gate second. The zshrs-original coreutils-shaped
13905        // builtins (`cat`, `basename`, … EXT_BUILTIN_NAMES) are not entries of
13906        // `createbuiltintable()` at all, so the documented `NULLCMD=cat` /
13907        // `READNULLCMD=cat` idioms keep reaching the real `/bin/cat` the way
13908        // `zsh -f` does without needing an explicit exclusion.
13909        //
13910        // `dispatch_builtin` (not `dispatch_builtin_raw`) is the correct entry:
13911        // C's `builtintab->getnode` filters DISABLED nodes, so `disable :;
13912        // NULLCMD=:; > f` must fall through to PATH — the raw dispatcher
13913        // deliberately bypasses that set (it is what `builtin NAME` uses).
13914        let is_shfunc = with_executor(|exec| exec.function_exists(&cmd)); // c:3485
13915        let is_builtin = crate::ported::builtin::createbuiltintable().contains_key(&cmd)
13916            && crate::extensions::ext_builtins::builtin_in_builtintab(&cmd); // c:3489
13917        let status = if !is_shfunc && is_builtin {
13918            dispatch_builtin(&cmd, Vec::new()) // c:3489-3504
13919        } else {
13920            with_executor(|exec| exec.host_exec_external(&[cmd]))
13921        };
13922        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
13923        Value::Status(status)
13924    });
13925    // c:Src/exec.c:3342 — `zerr("redirection with no command")`.
13926    // Bare prefix-keyword (`builtin`, `command`, `exec`, `noglob`,
13927    // `nocorrect`) with a redirect but no command word. Emits the
13928    // canonical diagnostic via zerr (which sets errflag) and
13929    // returns Status(1). Bug #534.
13930    // c:Src/exec.c:3104-3290 then c:3380-3402 — the precommand walk for a
13931    // `builtin`/`command`/`exec` head that carries redirections, run on the
13932    // expanded words BEFORE the redirect scope opens (c:3720), as C orders
13933    // it. Operands: [prefix, words…]. Returns the words without the prefix
13934    // as one array for the prefix opcode; BUILTIN_PRECMD_CHECK_OK then says
13935    // whether the walk let the command run. A failed walk has already
13936    // printed its diagnostic to the still-unredirected stderr.
13937    vm.register_builtin(BUILTIN_PRECMD_CHECK, |vm, argc| {
13938        let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
13939        for _ in 0..argc {
13940            popped.push(vm.pop());
13941        }
13942        popped.reverse();
13943        let mut full: Vec<String> = Vec::with_capacity(popped.len());
13944        for v in popped {
13945            match v {
13946                Value::Array(items) => full.extend(items.iter().map(|i| i.to_str())),
13947                other => full.push(other.to_str()),
13948            }
13949        }
13950        let words: Vec<Value> = full.iter().skip(1).map(|s| Value::str(s.as_str())).collect();
13951        let ok = precmd_walk_ok(&full);
13952        if !ok {
13953            // c:3205 / c:3398 — `lastval = 1;` before `goto done` / return.
13954            vm.last_status = 1;
13955            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
13956        }
13957        PRECMD_CHECK_PASSED.with(|c| c.set(ok));
13958        Value::array(words)
13959    });
13960    vm.register_builtin(BUILTIN_PRECMD_CHECK_OK, |_vm, _argc| {
13961        Value::Bool(PRECMD_CHECK_PASSED.with(|c| c.replace(true)))
13962    });
13963    vm.register_builtin(BUILTIN_REDIR_NO_CMD, |_vm, _argc| {
13964        crate::ported::utils::zerr("redirection with no command");
13965        Value::Status(1)
13966    });
13967    vm.register_builtin(BUILTIN_DEBUG_TRAP, |vm, _argc| {
13968        // c:Src/signals.c:1245 dotrap(SIGDEBUG) — fires the DEBUG
13969        // trap body once per statement. The body sees the parent
13970        // shell's $? (LASTVAL). Guard against re-entry: commands
13971        // inside the DEBUG trap body would otherwise trigger
13972        // DEBUG_TRAP recursively → stack overflow. zsh guards via
13973        // its in_trap counter; we mirror with a thread-local Cell.
13974        //
13975        // c:Src/exec.c::trapcmd — before dotrap, the C source sets
13976        // `ZSH_DEBUG_CMD` to the about-to-run command text via
13977        // `dupstring(text)`. The trap body reads the parameter;
13978        // C unsets it after the trap returns. compile_list emits
13979        // the rendered statement text as the single arg here so the
13980        // shell-visible parameter reflects the command. Bug #263 in
13981        // docs/BUGS.md.
13982        // Return value: `Int(1)` tells the emit-side JumpIfTrue to SKIP the
13983        // statement this trap ran in front of — C's `donedebug == 2`
13984        // (c:Src/exec.c:1499/1511/1519-1529) plus the forced-return case
13985        // where C's list loop (`while (… && !retflag …)`, c:1443) never
13986        // reaches the command. `Int(0)` = run it.
13987        //
13988        // Two call sites, distinguished by the `mode` operand:
13989        //   0 — c:1476-1502, the DEBUG_BEFORE_CMD block that runs BEFORE the
13990        //       sublist (only when the option is set),
13991        //   1 — c:1628-1644, the `sublist_done:` block that runs AFTER it
13992        //       (only when the option is NOT set).
13993        // Firing the pre-sublist arm in both modes made the default
13994        // (post-command) DEBUG trap observe the NEXT statement's `$LINENO`
13995        // — A05execution:19 saw "Line 2 / Line 3" for a trap zsh reports as
13996        // "Line 1 / Line 2".
13997        let mode = vm.pop().to_int();
13998        let cmd_text = vm.pop().to_str();
13999        let before = mode == 0;
14000        DEBUG_TRAP_REENTRY.with(|c| {
14001            if c.get() {
14002                return Value::Int(0);
14003            }
14004            // c:1476 `isset(DEBUGBEFORECMD)` / c:1628 `!isset(DEBUGBEFORECMD)`.
14005            // !!! BASH-MODE !!! bash fires DEBUG BEFORE each command, never after,
14006            // sets $BASH_COMMAND, skips function definitions, and does not
14007            // descend into function bodies unless `set -T` (functrace).
14008            let bash = crate::extensions::dash_mode::bash_mode();
14009            if before != (isset(crate::ported::zsh_h::DEBUGBEFORECMD) || bash) {
14010                return Value::Int(0);
14011            }
14012            if bash
14013                && before
14014                && (crate::extensions::dash_mode::is_funcdef_text(&cmd_text)
14015                    || (crate::ported::params::locallevel.load(std::sync::atomic::Ordering::Relaxed) > 0
14016                        && !crate::extensions::dash_mode::bash_set_o_get("functrace")))
14017            {
14018                return Value::Int(0);
14019            }
14020            // c:Src/exec.c:1423 — `if (sigtrapped[SIGDEBUG] &&
14021            // isset(DEBUGBEFORECMD) && !intrap)`. Bug #573: without
14022            // this gate, every sublist boundary called
14023            // setsparam("ZSH_DEBUG_CMD", ...) even when no DEBUG trap
14024            // was set, polluting the param table and (under
14025            // WARN_CREATE_GLOBAL) emitting a spurious
14026            // `scalar parameter ZSH_DEBUG_CMD created globally`
14027            // warning at every function call.
14028            //
14029            // Two trap registries exist (per signals.rs:1481-1511 dotrap):
14030            //   - settrap path → sigtrapped[SIGDEBUG] bits set
14031            //   - bin_trap path → traps_table["DEBUG"] populated, sigtrapped untouched
14032            // Mirror the dotrap dispatch decision: skip only when BOTH
14033            // are absent.
14034            let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
14035            let debug_trapped = crate::ported::signals::sigtrapped
14036                .lock()
14037                .map(|v| v.get(sig_debug).copied().unwrap_or(0))
14038                .unwrap_or(0);
14039            let debug_in_table = crate::ported::builtin::traps_table()
14040                .lock()
14041                .map(|t| t.contains_key("DEBUG"))
14042                .unwrap_or(false);
14043            if debug_trapped == 0 && !debug_in_table {
14044                return Value::Int(0);
14045            }
14046            c.set(true);
14047            // c:1478-1481 — `int oerrexit_opt = opts[ERREXIT]; Param pm;
14048            // opts[ERREXIT] = 0; noerrexit |= NOERREXIT_EXIT |
14049            // NOERREXIT_RETURN;`. ERREXIT is forced OFF across the trap
14050            // body so the option can be used as the "skip this command"
14051            // signal (c:1499) without the body's own failing commands
14052            // exiting the shell.
14053            let oerrexit_opt = isset(crate::ported::zsh_h::ERREXIT); // c:1478
14054            crate::ported::options::opt_state_set("errexit", false); // c:1480
14055            let oldnoerrexit =
14056                crate::ported::exec::noerrexit.load(std::sync::atomic::Ordering::Relaxed);
14057            crate::ported::exec::noerrexit.store(
14058                oldnoerrexit
14059                    | crate::ported::zsh_h::NOERREXIT_EXIT
14060                    | crate::ported::zsh_h::NOERREXIT_RETURN,
14061                std::sync::atomic::Ordering::Relaxed,
14062            ); // c:1481
14063               // c:Src/exec.c:1484 — set ZSH_DEBUG_CMD scalar (PM_READONLY
14064               // is NOT set on ZSH_DEBUG_CMD, so the canonical
14065               // setsparam path is fine here — no direct paramtab
14066               // mutation needed).
14067               // c:1636 — the post-sublist arm has no ZSH_DEBUG_CMD assignment;
14068               // the parameter is a DEBUG_BEFORE_CMD feature only.
14069            if before {
14070                // c:1484-1485 — `getpermtext(state->prog, pc2, 0)`: the
14071                // PERMANENT text, with newlines and tab indents (tnewlins=1,
14072                // c:Src/text.c:279), so `while false; do :; done` reads
14073                // "while false\ndo\n\t:\ndone". The compiled operand is the
14074                // one-line job text; re-parse it and render it through the
14075                // ported getpermtext, as printshfuncnode does for a function
14076                // body (hashtable.rs). The text is already alias-expanded, so
14077                // the re-lex must not expand again, and RCQUOTES must not
14078                // fold a `''` pair the first lex kept as written.
14079                let noalias_save = crate::ported::lex::noaliases();
14080                let rcquotes_save = isset(crate::ported::zsh_h::RCQUOTES);
14081                // A text the wordcode parser rejects falls back to the job
14082                // text silently: C never parses it, so no diagnostic or
14083                // errflag may escape.
14084                let noerrs_save = *crate::ported::utils::noerrs_lock().lock().unwrap();
14085                let errflag_save =
14086                    crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
14087                crate::ported::utils::set_noerrs(1);
14088                crate::ported::lex::set_noaliases(true);
14089                crate::ported::options::opt_state_set("rcquotes", false);
14090                let permtext = crate::ported::exec::parse_string(&cmd_text, 0)
14091                    .map(|p| crate::ported::text::getpermtext(Box::new(p), None, 0));
14092                crate::ported::options::opt_state_set("rcquotes", rcquotes_save);
14093                crate::ported::lex::set_noaliases(noalias_save);
14094                crate::ported::utils::set_noerrs(noerrs_save);
14095                crate::ported::utils::errflag
14096                    .store(errflag_save, std::sync::atomic::Ordering::Relaxed);
14097                let text = permtext.filter(|t| !t.is_empty()).unwrap_or(cmd_text);
14098                crate::ported::params::setsparam("ZSH_DEBUG_CMD", &text);
14099                if bash {
14100                    crate::ported::params::setsparam("BASH_COMMAND", &text);
14101                }
14102            }
14103            // c:1488/1636 — `exiting = donetrap;` … c:1493/1641 `donetrap = exiting;`
14104            let exiting = crate::ported::exec::DONETRAP.load(std::sync::atomic::Ordering::Relaxed);
14105            let ret = crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed); // c:1489
14106            let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGDEBUG);
14107            // c:1491-1492 — `if (!retflag) lastval = ret;`. A trap that
14108            // ran `return N` keeps the forced status; anything else
14109            // leaves the pre-trap `$?` alone.
14110            let retflag =
14111                crate::ported::builtin::RETFLAG.load(std::sync::atomic::Ordering::Relaxed) != 0;
14112            if !retflag {
14113                crate::ported::builtin::LASTVAL.store(ret, std::sync::atomic::Ordering::Relaxed);
14114                // c:1492
14115            }
14116            crate::ported::exec::noerrexit
14117                .store(oldnoerrexit, std::sync::atomic::Ordering::Relaxed); // c:1494
14118                                                                            // c:1499 — `donedebug = isset(ERREXIT) ? 2 : 1;`. The trap
14119                                                                            // setting ERREXIT is zsh's documented "skip this command"
14120                                                                            // signal (zshmisc(1), DEBUG trap).
14121            let donedebug2 = before && isset(crate::ported::zsh_h::ERREXIT); // c:1499
14122            crate::ported::options::opt_state_set("errexit", oerrexit_opt); // c:1500/1643
14123            crate::ported::exec::DONETRAP.store(exiting, std::sync::atomic::Ordering::Relaxed); // c:1493/1641
14124                                                                                                // c:Src/exec.c:1501-1502 — `if (pm) unsetparam_pm(pm, 0, 1);`
14125            if before {
14126                crate::ported::params::unsetparam("ZSH_DEBUG_CMD");
14127            }
14128            c.set(false);
14129            if retflag {
14130                // c:1443 — the enclosing list loop stops on retflag, so the
14131                // command never runs. Mirror the forced status into the VM's
14132                // own counter (the ported LASTVAL atomic is a separate store)
14133                // and report "skip" so the emit-side jump lands past the
14134                // statement, where the RETFLAG escape unwinds the function.
14135                let forced =
14136                    crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
14137                vm.last_status = forced;
14138                with_executor(|exec| exec.set_last_status(forced));
14139                return Value::Int(1);
14140            }
14141            if donedebug2 {
14142                // c:1511 — `if (donedebug != 2) execsimple(state);` and
14143                // c:1519-1529 — the compound form skips the whole sublist and
14144                // sets `donetrap = 1`.
14145                crate::ported::exec::DONETRAP.store(1, std::sync::atomic::Ordering::Relaxed); // c:1527
14146                return Value::Int(1);
14147            }
14148            Value::Int(0)
14149        })
14150    });
14151
14152    // Fatal-only abort check emitted between the pipes of an `&&` / `||`
14153    // chain, where the full errexit check is suppressed. Mirrors ONLY the
14154    // errflag arm of BUILTIN_ERREXIT_CHECK below: an errflag abandons the
14155    // list in zsh, and no connector can consume it.
14156    vm.register_builtin(BUILTIN_FATAL_ABORT_CHECK, |vm, _argc| {
14157        use std::sync::atomic::Ordering;
14158        // c:Src/exec.c:1390 — `while (wc_code(code) == WC_LIST && !breaks &&
14159        // !retflag && !errflag)`: the enclosing list loops test the WHOLE
14160        // errflag. A user interrupt sets ERRFLAG_INT and never ERRFLAG_ERROR
14161        // (signals.c:457), so masking here let the rest of the list run after
14162        // an interrupt:
14163        //   TRAPINT() { print T; return 1 }
14164        //   f() { print A; kill -INT $$; print C }; f; print B
14165        //   zsh: A T      zshrs: A T B
14166        //
14167        // The gate is also NOT conditional on the shell being
14168        // non-interactive. C's list loop is the same loop in both cases; an
14169        // interactive shell survives the abort because `loop(toplevel=1)`
14170        // resets the flag before it reads the next command line
14171        // (c:Src/init.c:139/150 `errflag = 0;`, ported at
14172        // ported/init.rs:2384/2394) — not because the list kept running. The
14173        // `isset(INTERACTIVE)` exemption that used to sit here was the reason
14174        // an interrupt was recorded and then ignored:
14175        //   kill -INT $$; print survived
14176        //   zsh: (nothing, exit 130)      zshrs: survived, exit 0
14177        // and it is why installing C's SIGINT handler (`intr()`,
14178        // c:Src/init.c:1442) was unsafe before this — the handler sets
14179        // ERRFLAG_INT and expects THIS gate to end the list.
14180        let errflag_set = crate::ported::utils::errflag.load(Ordering::Relaxed) != 0;
14181        if !errflag_set {
14182            return Value::Int(0);
14183        }
14184        // CONTINUE_ON_ERROR does not reach here: C's list loop
14185        // (c:Src/exec.c:1443) breaks on errflag whatever the option says,
14186        // and only zsh_main's outer do-while re-enters loop() for the NEXT
14187        // top-level line (c:Src/init.c:1960-1968, ported in
14188        // vm_helper.rs execode's toplevel arm).
14189        // Abort the chain with the failing command's own status intact —
14190        // a cond syntax error left lastval=2 (c:Src/exec.c:5216-5221), and
14191        // that 2 is what zsh exits with. Reading the executor's live
14192        // lastval (not forcing 1) is the same rule the full check uses.
14193        //
14194        // An INTERRUPT is the one case where the executor's cell is not the
14195        // live lastval. C has a single `lastval` global, and the SIGINT
14196        // handler writes it directly (`lastval = 128 + SIGINT`,
14197        // c:Src/signals.c:463) — as does a forced-return INT trap
14198        // (`lastval = new_trap_return`, c:Src/signals.c:1202). zshrs splits
14199        // that global in two (see BUILTIN_SUBLIST_FINISH): the handler and
14200        // `dotrapargs` write `builtin::LASTVAL`, while the executor's cell
14201        // holds whatever the interrupted command itself returned — 0 for the
14202        // `kill` that raised the signal. Reading the executor here therefore
14203        // threw the interrupt status away:
14204        //   <shell> -f -i -c 'kill -INT $$; print survived'
14205        //   zsh exit 130      zshrs exit 0
14206        // and the same for an INT trap that returns non-zero (zsh exit 1).
14207        // Take the handler's write for an interrupt, the executor's for
14208        // everything else.
14209        vm.last_status = if (crate::ported::utils::errflag.load(Ordering::Relaxed)
14210            & crate::ported::zsh_h::ERRFLAG_INT)
14211            != 0
14212        {
14213            crate::ported::builtin::LASTVAL.load(Ordering::Relaxed)
14214        } else {
14215            with_executor(|exec| exec.last_status())
14216        };
14217        Value::Int(1)
14218    });
14219    vm.register_builtin(BUILTIN_PRINT_EXIT_VALUE, |vm, argc| {
14220        // c:Src/exec.c:5498-5505 — the ANONYMOUS-FUNCTION report in
14221        // execfuncdef:
14222        //     execshfunc(shf, args);
14223        //     ret = lastval;
14224        //     if (isset(PRINTEXITVALUE) && isset(SHINSTDIN) && lastval) {
14225        //         fprintf(stderr, "zsh: exit %lld\n", lastval);
14226        // It has NO `!subsh` term, unlike execcmd_exec's site at
14227        // c:4308-4309. `argc == 1` marks that call site (the compiler
14228        // pushes a 1 before it); `argc == 0` is the per-command site.
14229        let anon_site = argc >= 1 && vm.pop().to_int() != 0;
14230        // c:Src/exec.c:4308-4316 — `if (isset(PRINTEXITVALUE) &&
14231        // isset(SHINSTDIN) && lastval && !subsh) fprintf(stderr,
14232        // "zsh: exit %lld\n", lastval);`
14233        //
14234        // SHINSTDIN keeps this to a shell reading its program from stdin
14235        // (`zsh -f < script`, the interactive shell) — `-c` and script-file
14236        // runs never report. `subsh` keeps it out of forked pipeline stages
14237        // and `(...)` subshells, which is why zsh prints nothing for
14238        // `false | true` or `(exit 3)`. A function BODY is silent for a
14239        // different reason: c:Src/exec.c:6037 `opts[PRINTEXITVALUE] = 0`
14240        // in doshfunc (ported at exec.rs), restored at c:6158.
14241        let lastval = vm.last_status; // c:4309 lastval
14242        if crate::ported::zsh_h::isset(crate::ported::zsh_h::PRINTEXITVALUE) // c:4308
14243            && crate::ported::zsh_h::isset(crate::ported::zsh_h::SHINSTDIN)  // c:4308
14244            && lastval != 0                                                  // c:4309
14245            && (anon_site
14246                || crate::ported::exec::subsh.load(std::sync::atomic::Ordering::Relaxed) == 0)
14247        // c:4309 (`!subsh`; absent at the c:5498 anon-function site)
14248        {
14249            eprintln!("zsh: exit {lastval}"); // c:4311/4313
14250            let _ = std::io::Write::flush(&mut std::io::stderr()); // c:4315 fflush(stderr)
14251        }
14252        Value::Status(0)
14253    });
14254    vm.register_builtin(BUILTIN_ERREXIT_CHECK, |vm, _argc| {
14255        // Returns Value::Int(1) when the caller should jump to the
14256        // current scope's return-patch landing (subshell-end / func-
14257        // end / chunk-end). Returns Value::Int(0) otherwise. Emit
14258        // side at `emit_errexit_check` pairs this with a JumpIfTrue
14259        // → return_patches pattern so the caller can short-circuit.
14260        //
14261        // Four triggers:
14262        //   1. RETFLAG set by a nested `return` / `exit` (eval,
14263        //      sourced file, called function). Unwind THIS scope so
14264        //      the flag propagates outward until something clears it.
14265        //   2. EXIT_PENDING set (mostly subshell-context exits). Same
14266        //      propagation logic.
14267        //   3. `set -e` + nonzero status — the classic errexit path.
14268        //   4. errflag set in non-interactive mode — readonly
14269        //      reassign, bad redirect, parse error mid-expansion etc.
14270        //      Aborts the script (c:Src/init.c loop()).
14271        use std::sync::atomic::Ordering;
14272        let retflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
14273        // c:Src/exec.c:6198-6201 — "If we are in an exit trap, finish it
14274        // first... we wouldn't set exit_pending if we were already in one."
14275        // C's list loop (c:1443) never consults exit_pending at all;
14276        // EXIT_PENDING is zshrs's own deferred-exit channel, and leaving it
14277        // armed while the EXIT trap body runs made every command after the
14278        // trap's FIRST one get skipped:
14279        //   h(){ echo a; echo b }; trap h EXIT; f(){ exit }; f
14280        //   zsh: a b      zshrs: a
14281        // `in_exit_trap` is the same counter C tests at c:6201.
14282        let exit_pending = if crate::ported::signals::in_exit_trap.load(Ordering::Relaxed) != 0 {
14283            0
14284        } else {
14285            crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed)
14286        };
14287        // c:Src/exec.c:1571-1603 — `sublist_done:` runs the ZERR trap for
14288        // the sublist that just failed. It is NOT gated on retflag: C only
14289        // consults retflag at the TOP of the list loop (c:1370 `while
14290        // (wc_code(code) == WC_LIST && !breaks && !retflag && !errflag)`),
14291        // which stops the NEXT sublist — the current one still completes
14292        // its sublist_done. So `return 5` fires the ERR trap on its way out.
14293        //
14294        // zshrs's escape short-circuit below returns before ever reaching
14295        // the ZERR fire, so a `return N` inside a try-list skipped the trap:
14296        //   f() { { return 5 } always { print fin } }; f
14297        // printed `fin / err=5` where zsh prints `err=5 / fin / err=5`.
14298        // (Plain `f() { return 5 }` matched by luck — the inner fire was
14299        // missing but the OUTER sublist fired instead, since doshfunc had
14300        // cleared retflag by then and DONETRAP was still 0.)
14301        //
14302        // `exit` is deliberately excluded: C's `exit` goes zexit() →
14303        // realexit(), leaving the process without ever reaching
14304        // sublist_done. Verified: `zsh -fc 'trap "print err" ERR; f(){ exit
14305        // 5 }; f'` prints nothing.
14306        if retflag != 0 && exit_pending == 0 {
14307            let last = vm.last_status;
14308            // c:1651 — `!donetrap` as it stood when this sublist finished.
14309            let donetrap_at_entry = crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
14310            // c:1598-1603 — same DONETRAP gate as the non-escape path below.
14311            // ksh93 does not fire ERR for `return N` itself; the call site does
14312            // (doshfunc re-arms donetrap for ksh93).
14313            if last != 0
14314                && !donetrap_at_entry
14315                && !crate::extensions::dash_mode::ksh93_mode()
14316                && zerr_sigtrapped()
14317            {
14318                // c:Src/signals.c:1085-1087 — `int obreaks = breaks; int
14319                // oretflag = retflag; int olastval = lastval;` and c:1220-1222
14320                // — `breaks += obreaks; retflag = oretflag;`. dotrapargs
14321                // brackets EVERY trap dispatch with this save/restore because
14322                // the trap body runs as a normal list and would otherwise
14323                // consume the caller's control-flow flags. That matters
14324                // exactly here: we are firing ZERR while retflag is SET, and a
14325                // FUNCTION-form trap (`TRAPZERR() { … }`) goes through
14326                // doshfunc, whose epilogue eats retflag outright
14327                // (c:Src/exec.c:6047-6052 `if (retflag) { retflag = 0; breaks
14328                // = funcsave->breaks; }`). Without the bracket the pending
14329                // `return 5` was swallowed by its own ERR trap and the
14330                // function ran on:
14331                //   TRAPZERR() { print z }; f() { { return 2 } always { : }
14332                //                             print after }; f
14333                // printed `after`, where zsh returns from f.
14334                //
14335                // zshrs's `dotrap` inlines the dispatch and does not carry
14336                // dotrapargs' save/restore, so the bracket lives at this call
14337                // site. lastval is restored too (c:1087 / c:1213 `lastval =
14338                // olastval`) — the trap body's own commands must not become
14339                // the caller's `$?`.
14340                let obreaks = crate::ported::builtin::BREAKS.load(Ordering::Relaxed); // c:1085
14341                let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed); // c:1086
14342                let olastval = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1087
14343                let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1601
14344                crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed); // c:1602
14345                crate::ported::builtin::BREAKS.store(obreaks, Ordering::Relaxed); // c:1220
14346                crate::ported::builtin::RETFLAG.store(oretflag, Ordering::Relaxed); // c:1222
14347                crate::ported::builtin::LASTVAL.store(olastval, Ordering::Relaxed);
14348                // c:1213
14349            }
14350            // c:1660-1680 — the same sublist_done then applies ERR_EXIT /
14351            // ERR_RETURN to the `return N` that ended it: `setopt errexit;
14352            // trap 'echo e' ZERR; return 1` inside a function exits there.
14353            if last != 0 && !donetrap_at_entry {
14354                let _ = errexit_tail(last);
14355            }
14356        }
14357        if retflag != 0 || exit_pending != 0 {
14358            if exit_pending != 0 {
14359                // c:Src/builtin.c zexit — the deferred exit carries its
14360                // status in EXIT_VAL; sync it into the VM counter so
14361                // the top-level unwind reports it as the script's exit
14362                // (run_chunk returns vm.last_status). Without this, a
14363                // POSIX-fatal `.` failure exited 127 (bin_dot's return)
14364                // instead of C's exit(1) at Src/exec.c:4383.
14365                vm.last_status = crate::ported::builtin::EXIT_VAL.load(Ordering::Relaxed) & 0xFF;
14366            }
14367            return Value::Int(1);
14368        }
14369        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
14370            & crate::ported::zsh_h::ERRFLAG_ERROR)
14371            != 0;
14372        // c:Src/init.c:1960-1968 — CONTINUE_ON_ERROR is decided by
14373        // zsh_main's outer do-while, which re-enters loop() for the next
14374        // top-level LINE; the current list (and every enclosing list,
14375        // loop and function body) still breaks on errflag here. The
14376        // re-entry is ported in vm_helper.rs execode's toplevel arm.
14377        if errflag_set && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE) {
14378            // c:Src/exec.c execlist — every enclosing list loop runs
14379            // `while (... && !errflag)`, so a set errflag breaks the
14380            // CURRENT scope and the check in the enclosing scope
14381            // breaks THAT one, all the way out. Leave errflag SET —
14382            // do NOT convert it to EXIT_PENDING: a process-exit
14383            // signal tunnels through the containment boundaries C
14384            // has, namely eval (Src/builtin.c:6221 `errflag &=
14385            // ~ERRFLAG_ERROR`), source (Src/init.c:1663 same), fork
14386            // boundaries (subshell/cmdsubst — child's errflag dies
14387            // with the child), and the interactive toplevel
14388            // (Src/init.c:139). Those boundaries clear errflag
14389            // themselves and execution continues past them; with
14390            // EXIT_PENDING armed here, `eval 'assoc=(odd)'; echo
14391            // after` aborted the whole script where zsh 5.9 prints
14392            // `after` (eval status 1). Bug #74's function case
14393            // (`f() { local -r x=5; x=10; }; f; echo after`) still
14394            // aborts: the function scope unwinds on THIS check, and
14395            // the caller's next ERREXIT_CHECK sees the still-set
14396            // errflag and unwinds too — exactly C's propagation.
14397            //
14398            // c:Src/init.c:234 — loop() BREAKS on errflag and
14399            // zsh_main exits with the UNTOUCHED lastval, NOT a
14400            // forced 1: `typeset -i x=3#8` (math error during the
14401            // assignment, before typeset sets a status) exits 0 in
14402            // zsh; a cond syntax error set lastval=2 (exec.c:5216-
14403            // 5221) and zsh exits 2; the readonly-reassign case
14404            // exits 1 because ITS lastval is 1. Sync the VM counter
14405            // from the executor's live lastval instead of
14406            // overwriting.
14407            vm.last_status = with_executor(|exec| exec.last_status());
14408            // c:Src/exec.c:1598-1603 — `sublist_done:` runs the ZERR trap
14409            // for the failed sublist BEFORE the enclosing list loop breaks
14410            // on errflag (`while (... && !errflag)` at c:1370). So an
14411            // errflag-setting command (readonly reassign, bad redirect)
14412            // must fire ZERR on its way out, exactly like the retflag
14413            // escape above and the non-escape fall-through below. Without
14414            // this the errflag early-return pre-empted the ZERR block
14415            // further down, so `TRAPZERR() { … }; typeset -r ro=1; ro=2`
14416            // aborted the script (correct) but never fired the trap. Same
14417            // DONETRAP gate + dotrapargs save/restore bracket
14418            // (c:signals.c:1085-1087 / 1213-1222) as the retflag branch:
14419            // a function-form TRAPZERR runs through doshfunc and would
14420            // otherwise consume the caller's breaks/retflag/lastval.
14421            let last = with_executor(|exec| exec.last_status());
14422            if last != 0 && crate::ported::exec::DONETRAP.load(Ordering::Relaxed) == 0 && zerr_sigtrapped() {
14423                let obreaks = crate::ported::builtin::BREAKS.load(Ordering::Relaxed); // c:1085
14424                let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed); // c:1086
14425                let olastval = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1087
14426                                                                                        // c:Src/signals.c:1101 — dotrapargs returns early if errflag
14427                                                                                        // is set, and c:1174/1205-1218 brackets the dispatch with
14428                                                                                        // `traperr = errflag` … restore. The failing assignment left
14429                                                                                        // errflag SET, so the trap body (`print zerr`) would itself
14430                                                                                        // bail on the first op. Clear errflag across the dispatch so
14431                                                                                        // the body runs, then restore it so the script still aborts.
14432                let oerrflag = crate::ported::utils::errflag.load(Ordering::Relaxed); // c:1174
14433                crate::ported::utils::errflag.store(0, Ordering::Relaxed);
14434                let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1601
14435                crate::ported::utils::errflag.store(oerrflag, Ordering::Relaxed); // c:1216
14436                crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed); // c:1602
14437                crate::ported::builtin::BREAKS.store(obreaks, Ordering::Relaxed); // c:1220
14438                crate::ported::builtin::RETFLAG.store(oretflag, Ordering::Relaxed); // c:1222
14439                crate::ported::builtin::LASTVAL.store(olastval, Ordering::Relaxed);
14440                // c:1213
14441            }
14442            return Value::Int(1);
14443        }
14444        let last = vm.last_status;
14445        if last == 0 {
14446            return Value::Int(0);
14447        }
14448        // c:Src/exec.c:1598 `if (!this_noerrexit && !donetrap &&
14449        // !this_donetrap)` — gate the ZERR trap fire on DONETRAP so
14450        // an inner sublist (e.g. `false` inside a function) that
14451        // already fired ZERR doesn't fire it AGAIN at the outer
14452        // sublist's post-command check (after the function
14453        // returned non-zero). Bug #303 in docs/BUGS.md. DONETRAP
14454        // is reset at top-level statement boundaries via
14455        // BUILTIN_DONETRAP_RESET (compile_list emit at
14456        // compile_zsh.rs).
14457        let already_done = crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
14458        // bash `set -E`: the failing function call fires ERR again after the
14459        // inner command already did (`f() { false; }; f` runs the trap twice).
14460        let already_done = already_done
14461            && !(crate::extensions::dash_mode::bash_mode()
14462                && crate::extensions::dash_mode::bash_set_o_get("errtrace"));
14463        // c:Src/exec.c:1652-1653 —
14464        //     if (sigtrapped[SIGZERR] && lastval &&
14465        //         !(noerrexit & NOERREXIT_EXIT)) {
14466        // The ZERR half of the check is gated on the SAME runtime bit as
14467        // the errexit half below. Without this an `&&`/`||` operand — or
14468        // anything it calls — still fired ZERR, so
14469        //   TRAPZERR(){ print E }; f(){ print f; false; }; f && t
14470        // printed E where zsh is silent (C03traps:14, E01options:18).
14471        let zerr_suppressed = (crate::ported::exec::noerrexit.load(Ordering::Relaxed)
14472            & crate::ported::zsh_h::NOERREXIT_EXIT)
14473            != 0; // c:1653
14474        // c:1652 — `sigtrapped[SIGZERR] &&`: with no ZERR trap the block
14475        // never runs, so `donetrap` stays 0 and cannot spare a caller's
14476        // errexit / errreturn below.
14477        if !already_done && !zerr_suppressed && zerr_sigtrapped() {
14478            // c:Src/signals.c:1245 dotrap(SIGZERR) — canonical ZERR
14479            // trap dispatch. Fires whenever a command exits
14480            // non-zero.
14481            let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
14482            // c:Src/exec.c:1654-1657 — `int eflag = errflag; errflag = 0;
14483            // dotrap(SIGZERR); errflag = eflag;`: an error raised INSIDE
14484            // the trap body (a `set -u` failure) ends the trap, not the
14485            // list — `set -u; trap 'print t; : $u; print x' ZERR; false;
14486            // print after` prints `after`. Without the restore the
14487            // trap's errflag aborted the enclosing list.
14488            let eflag = crate::ported::utils::errflag.load(Ordering::Relaxed); // c:1654
14489            crate::ported::utils::errflag.store(0, Ordering::Relaxed); // c:1655
14490            let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1656
14491            // c:Src/subst.c:3355-3366 — the one error the restore must not
14492            // drop: `${v?}` in a NON-interactive shell never gets here in C,
14493            // it has already left through `zexit(1)`. The subst port defers
14494            // that exit through ERRFLAG_HARD, so carry the bit across.
14495            let hard = if isset(crate::ported::zsh_h::INTERACTIVE) {
14496                0
14497            } else {
14498                crate::ported::utils::errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_HARD
14499            };
14500            crate::ported::utils::errflag.store(eflag | hard, Ordering::Relaxed); // c:1657
14501            // c:1602 — `donetrap = 1;` after firing.
14502            crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed);
14503            // c:Src/signals.c:1201-1203 — a trap body that ran `return N`
14504            // comes back with `lastval = new_trap_return` and `retflag =
14505            // 1`; C's enclosing `while (wc_code(code) == WC_LIST &&
14506            // !breaks && !retflag && !errflag)` (c:Src/exec.c:1443) then
14507            // abandons the rest of the list and the containing function
14508            // returns that status. zshrs's list loop is the VM, which
14509            // reads `vm.last_status` / the executor's counter rather than
14510            // the ported LASTVAL atomic — so mirror the forced status
14511            // across and report "abort" to the caller. Without this
14512            //     fn(){ trap 'print t; return 42' ZERR; false; print B }
14513            // ran `print B` and returned 0.
14514            if oretflag == 0 && crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) != 0 {
14515                let forced = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1201
14516                vm.last_status = forced;
14517                with_executor(|exec| exec.set_last_status(forced));
14518                return Value::Int(1); // c:1443 — list loop stops on retflag
14519            }
14520        }
14521        // c:Src/exec.c:1651 — `if (!this_noerrexit && !donetrap &&
14522        // !this_donetrap)` gates the errexit / errreturn half too, not only
14523        // ZERR: a ZERR trap that already fired inside a function called by
14524        // this sublist also spares the caller the top-level ERR_RETURN-as-
14525        // ERR_EXIT (`setopt errreturn; trap 'echo z' ZERR; f() { false };
14526        // f; echo s` prints `z` then `s`).
14527        if already_done {
14528            return Value::Int(0);
14529        }
14530        errexit_tail(last)
14531    });
14532
14533    // BUILTIN_ASSIGN_ONLY_STATUS — status of an assignment-only
14534    // simple command. c:Src/exec.c:3393-3396 (execcmd_exec, no
14535    // command word + varspc): `if (errflag) lastval = 1; else
14536    // lastval = cmdoutval;`; same shape at c:1322 (execsimple
14537    // WC_ASSIGN: `lv = (errflag ? errflag : cmdoutval)`) and
14538    // c:3977 (nullexec=2 redir variant). cmdoutval is the exit of
14539    // a `$()` that ran in an RHS (already in vm.last_status via
14540    // compile_assign's per-assign SetStatus), 0 otherwise. The
14541    // store goes to the canonical LASTVAL too — that IS C's single
14542    // `lastval` global; without it the errflag-abort path
14543    // (BUILTIN_ERREXIT_CHECK trigger 4) syncs vm.last_status from
14544    // a stale LASTVAL and `readonly r=1; r=2` exited 0, not 1.
14545    vm.register_builtin(BUILTIN_ASSIGN_ONLY_STATUS, |vm, _argc| {
14546        use std::sync::atomic::Ordering;
14547        let had_cmd_subst = vm.pop().to_int() != 0;
14548        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
14549            & crate::ported::zsh_h::ERRFLAG_ERROR)
14550            != 0;
14551        // c:Src/exec.c addvars — `if (!pm) { lastval = 1; if
14552        // (!cmdoutval) cmdoutval = 1; }` (assignment-failed cheat).
14553        let assign_failed = ASSIGN_FAILED_FLAG.swap(false, std::sync::atomic::Ordering::Relaxed);
14554        let _ = had_cmd_subst;
14555        if assign_failed && crate::ported::exec::cmdoutval.load(Ordering::Relaxed) == 0 {
14556            // c:Src/exec.c:2662-2663 — `if (!cmdoutval) cmdoutval = 1;`
14557            crate::ported::exec::cmdoutval.store(1, Ordering::Relaxed);
14558        }
14559        let status = if errflag_set || assign_failed {
14560            1 // c:Src/exec.c:3394 `lastval = 1` / addvars cmdoutval=1
14561        } else {
14562            // c:3396 `lastval = cmdoutval` — reset to 0 when the command
14563            // started (c:2796 / c:1368, BUILTIN_CMDOUTVAL_RESET), then set by
14564            // any `$(...)` that ran in it, RHS or subscript alike.
14565            crate::ported::exec::cmdoutval.load(Ordering::Relaxed)
14566        };
14567        with_executor(|exec| exec.set_last_status(status));
14568        // c:Src/jobs.c deletefilelist — a `=(cmd)` temp file is bound to the
14569        // JOB of the command that created it and unlinked when that command
14570        // completes (Src/exec.c:5588 for the shfunc case; the simple-command
14571        // job's filelist likewise). An assignment-only command like
14572        // `f==(cmd)` has no consuming builtin/exec, so the PsubFdGuard that
14573        // cleans consuming commands never fires — the temp leaked and a later
14574        // `$(<$f)` / `[[ -f $f ]]` still saw it, where zsh deletes it at the
14575        // end of the assignment (verified: even `f==(x) && cat $f` fails).
14576        // Clean here so the assignment command is the temp's job boundary.
14577        close_pending_psub_fds();
14578        Value::Status(status)
14579    });
14580
14581    // `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
14582    // Pops [name, op_byte, rhs] (rhs popped first). Returns the modified
14583    // value as Value::Str. Handles unset/empty distinction (`:-` etc.
14584    // treat empty same as unset, matching POSIX).
14585    // BUILTIN_PARAM_DEFAULT_FAMILY — `${var-x}` / `${var:-x}` / `${var=x}` /
14586    // `${var:=x}` / `${var?x}` / `${var:?x}` / `${var+x}` / `${var:+x}`.
14587    // PURE PASSTHRU: pop name + op + rhs, reconstruct the canonical
14588    // brace expression, hand to `subst::paramsubst` (C port of
14589    // `Src/subst.c::paramsubst`). All "missing vs empty" gating,
14590    // nounset suppression, default-evaluation, and elide-empty-words
14591    // semantics live inside paramsubst.
14592    vm.register_builtin(BUILTIN_PARAM_DEFAULT_FAMILY, |vm, argc| {
14593        // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
14594        // compile_zsh::emit_param_modifier (see the note there).
14595        let pf_flags = if argc as usize > 3 && vm.pop().to_int() != 0 {
14596            crate::ported::zsh_h::PREFORK_SINGLE
14597        } else {
14598            0
14599        };
14600        let rhs = vm.pop().to_str();
14601        let op = vm.pop().to_int() as u8;
14602        let name = vm.pop().to_str();
14603        // c:Src/params.c:428-430 — `IPDEF9("*", &pparams, …)` and
14604        // `IPDEF9("argv", &pparams, …)` are ONE parameter, and the only shape
14605        // discriminator in the read path is c:Src/params.c:2251
14606        // `isvarat = (t[0] == '@' && !t[1])`, which the name `argv` misses just
14607        // as `*` does. So `${argv:-…}` IS `${*:-…}`. The rebuilt body below
14608        // reaches `paramsubst`, whose "is the parameter set" probe for this
14609        // operator family tests only the literal `@` / `*` names — `argv` fell
14610        // through to an ordinary lookup, came back UNSET, and
14611        // `set -- x y; ${argv:-nope}` took the default where zsh takes the
14612        // positionals. Normalising the spelling reuses every already-correct
14613        // `$*` arm, the same way the compiler's `bare_target` does for `$argv`
14614        // / `${argv}`. Every other modifier family (`#`, `%`, `/`, `:off:len`)
14615        // already resolves `argv` and is left alone.
14616        //
14617        // NOT the assigning ops. c:Src/subst.c:3690-3697 routes `${name:=…}` /
14618        // `${name=…}` through `setsparam`/`setaparam` on the NAME, and zsh
14619        // rejects the `*` spelling there ("not an identifier: *"), while
14620        // `${argv=…}` assigns the positional list. `${+name}` is the same
14621        // story from the other side — zsh answers "bad substitution" for
14622        // `${+*}` but 0/1 for `${+argv}`. Those three keep the literal name.
14623        let name = if name == "argv" && !matches!(op, 1 | 5 | 8) {
14624            "*".to_string()
14625        } else {
14626            name
14627        };
14628        // op=8 is the `${+name}` set-test prefix form (distinct from the
14629        // `${name+rhs}` substitute-if-set suffix form which is op=7).
14630        // Per compile_zsh.rs::parse_param_modifier: the `+` is emitted as
14631        // a leading sigil and `rhs` is empty.
14632        let body = if op == 8 {
14633            format!("${{+{}}}", name)
14634        } else {
14635            let op_str = match op {
14636                0 => ":-",
14637                1 => ":=",
14638                2 => ":?",
14639                3 => ":+",
14640                4 => "-",
14641                5 => "=",
14642                6 => "?",
14643                7 => "+",
14644                _ => "-",
14645            };
14646            format!("${{{}{}{}}}", name, op_str, rhs)
14647        };
14648        paramsubst_to_value_status(vm, &body, pf_flags)
14649    });
14650
14651    // `${var:offset[:length]}` — substring. Pops [name, offset, length].
14652    // length == -1 means "rest of string". Negative offset counts from end.
14653    // BUILTIN_PARAM_SUBSTRING — `${var:offset:length}` literal-int form.
14654    // PURE PASSTHRU: reconstruct `${name:offset:length}` and route
14655    // through `subst::paramsubst`. Length sentinel `i64::MIN` =
14656    // "no length given" (omit the `:length` portion).
14657    //
14658    // c:Src/subst.c:1571,3781 — `${name:-N}` is the colon-default
14659    // operator, NOT a substring with negative offset. zsh's lexical
14660    // rule disambiguates via a literal space: `${name: -N}` (space
14661    // before `-`) is the substring form. The reconstructed body MUST
14662    // preserve that space when offset < 0; otherwise paramsubst's
14663    // `:-` dispatch fires on the synthesized `${name:-N}` body and
14664    // returns N as the unset-default instead of slicing the last N
14665    // chars. Length-form `${name:-N:M}` has the same trap.
14666    vm.register_builtin(BUILTIN_PARAM_SUBSTRING, |vm, argc| {
14667        // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
14668        // compile_zsh::emit_param_modifier (see the note there).
14669        let pf_flags = if argc as usize > 3 && vm.pop().to_int() != 0 {
14670            crate::ported::zsh_h::PREFORK_SINGLE
14671        } else {
14672            0
14673        };
14674        let length = vm.pop().to_int();
14675        let offset = vm.pop().to_int();
14676        let name = vm.pop().to_str();
14677        // !!! DASH-STRICT GATE !!! dash/ash have no `${var:offset:length}`
14678        // substring expansion (it is a "Bad substitution"); bash/ksh/sh do.
14679        if crate::dash_mode::dash_strict() {
14680            crate::ported::utils::zerr("bad substitution");
14681            crate::ported::utils::errflag.fetch_or(
14682                crate::ported::zsh_h::ERRFLAG_ERROR,
14683                std::sync::atomic::Ordering::Relaxed,
14684            );
14685            with_executor(|exec| exec.set_last_status(1));
14686            return Value::str("");
14687        }
14688        let off_sep = if offset < 0 { " " } else { "" };
14689        let body = if length == i64::MIN {
14690            format!("${{{}:{}{}}}", name, off_sep, offset)
14691        } else {
14692            format!("${{{}:{}{}:{}}}", name, off_sep, offset, length)
14693        };
14694        paramsubst_to_value_status(vm, &body, pf_flags)
14695    });
14696
14697    // BUILTIN_PARAM_SUBSTRING_EXPR — `${var:offset_expr[:length_expr]}` form.
14698    // PURE PASSTHRU: rebuild `${name:offset:length}` using the
14699    // expression text verbatim (paramsubst's offset/length
14700    // parser evaluates arith / param refs itself).
14701    //
14702    // c:Src/subst.c:1571,3781 — same `:-` disambiguation trap as
14703    // BUILTIN_PARAM_SUBSTRING. The expression text may itself start
14704    // with `-` (e.g. `${VAR:$((-1))}` arith resolves at the body-
14705    // assembly layer in some upstream paths, leaving `-1` in
14706    // off_expr). Insert a leading space when off_expr starts with
14707    // `-` so paramsubst's check_colon_subscript (subst.c:1571)
14708    // accepts the operand as a math expression instead of the
14709    // `:-` operator catching it.
14710    vm.register_builtin(BUILTIN_PARAM_SUBSTRING_EXPR, |vm, argc| {
14711        // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
14712        // compile_zsh::emit_param_modifier (see the note there).
14713        let pf_flags = if argc as usize > 4 && vm.pop().to_int() != 0 {
14714            crate::ported::zsh_h::PREFORK_SINGLE
14715        } else {
14716            0
14717        };
14718        let has_len = vm.pop().to_int() != 0;
14719        let len_expr = vm.pop().to_str();
14720        let off_expr = vm.pop().to_str();
14721        let name = vm.pop().to_str();
14722        let off_sep = if off_expr.starts_with('-') { " " } else { "" };
14723        let body = if has_len {
14724            format!("${{{}:{}{}:{}}}", name, off_sep, off_expr, len_expr)
14725        } else {
14726            format!("${{{}:{}{}}}", name, off_sep, off_expr)
14727        };
14728        paramsubst_to_value_status(vm, &body, pf_flags)
14729    });
14730
14731    // `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}`
14732    // Pops [name, pattern, op_byte]. op: 0=`#` short-prefix, 1=`##` long,
14733    // 2=`%` short-suffix, 3=`%%` long. Glob-pattern matching via the
14734    // existing glob_match_static helper.
14735    // BUILTIN_PARAM_STRIP — `${var#pat}` / `${var##pat}` / `${var%pat}` /
14736    // `${var%%pat}`. PURE PASSTHRU: reconstruct the brace expression
14737    // and route through `subst::paramsubst`. (M)/(S) flags arrive
14738    // through SUB_FLAGS (already inside paramsubst's scope), so we
14739    // just clear the bridge-side cached read.
14740    vm.register_builtin(BUILTIN_PARAM_STRIP, |vm, argc| {
14741        // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
14742        // compile_zsh::emit_param_modifier (see the note there).
14743        let pf_flags = if argc as usize > 4 && vm.pop().to_int() != 0 {
14744            crate::ported::zsh_h::PREFORK_SINGLE
14745        } else {
14746            0
14747        };
14748        let _dq_flag = vm.pop().to_int() != 0;
14749        let op = vm.pop().to_int() as u8;
14750        let pattern = vm.pop().to_str();
14751        let name = vm.pop().to_str();
14752        let op_str = match op {
14753            0 => "#",
14754            1 => "##",
14755            2 => "%",
14756            3 => "%%",
14757            _ => "#",
14758        };
14759        let body = format!("${{{}{}{}}}", name, op_str, pattern);
14760        paramsubst_to_value_status(vm, &body, pf_flags)
14761    });
14762
14763    // `$((expr))` — pops [expr_string], evaluates via MathEval which
14764    // honors integer-vs-float distinction (zsh-compatible). Returns
14765    // the result as Value::Str so it can be Concat'd into surrounding
14766    // word context.
14767    vm.register_builtin(BUILTIN_ARITH_LASTBASE, |vm, _argc| {
14768        // See [`BUILTIN_ARITH_LASTBASE`]: `lastbase = -1` at the head of an
14769        // evaluation, and the literal's own base where the compiler lexed one.
14770        let base = vm.pop();
14771        let base = match base {
14772            Value::Int(n) => n as i32,
14773            other => other.to_str().parse::<i32>().unwrap_or(-1),
14774        };
14775        crate::ported::math::set_lastbase(base);
14776        Value::Int(i64::from(base))
14777    });
14778    vm.register_builtin(BUILTIN_ARITH_EVAL, |vm, _argc| {
14779        // Pure path: evaluate expr, return string. errflag may be
14780        // set by arithsubst on math error; the caller decides
14781        // whether to clear it. For `(( ... ))` (math command) the
14782        // compile_arith path clears via BUILTIN_ARITH_CMD_FINISH;
14783        // for `$((... ))` (substitution inside another command)
14784        // errflag stays set so the surrounding command aborts —
14785        // matches c:Src/math.c "math errors propagate as errflag
14786        // through the containing word expansion".
14787        let expr = vm.pop().to_str();
14788        let result = crate::ported::subst::arithsubst(&expr, "", "");
14789        let _ = vm; // silence unused warning when no math error path mutates
14790        Value::str(result)
14791    });
14792
14793    // After-call hook used by compile_arith's `(( ... ))` path: when
14794    // arithsubst set errflag (math error), clear it and signal
14795    // status=2 in vm.last_status — matches zsh's c:exec.c arith-
14796    // failure: the math command exits 2 and the script continues.
14797    vm.register_builtin(BUILTIN_ARITH_CMD_FINISH, |vm, _argc| {
14798        use std::sync::atomic::Ordering;
14799        let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
14800        let err = live & crate::ported::zsh_h::ERRFLAG_ERROR;
14801        let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
14802        if err != 0 {
14803            // c:Src/subst.c:3344 — when `${var:?msg}` fires, errflag
14804            // is OR'd with ERRFLAG_HARD to signal a script-abort
14805            // error (vs a recoverable math error like `$((1/0))`).
14806            // Clear only the ERRFLAG_ERROR bit; preserve
14807            // ERRFLAG_HARD so the next ERREXIT_CHECK aborts the
14808            // script. Bug #193 in docs/BUGS.md.
14809            if hard != 0 {
14810                // Keep ERRFLAG_HARD AND ERRFLAG_ERROR set so the
14811                // script-abort gate downstream still fires.
14812                vm.last_status = 2;
14813                Value::Status(2)
14814            } else {
14815                crate::ported::utils::errflag
14816                    .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
14817                vm.last_status = 2;
14818                Value::Status(2)
14819            }
14820        } else {
14821            Value::Status(vm.last_status)
14822        }
14823    });
14824
14825    // `$(cmd)` — pops [cmd_string], routes through
14826    // run_command_substitution which performs an in-process pipe-capture.
14827    // Avoids the Op::CmdSubst sub-chunk word-emit bug
14828    // (`printf "a\nb"` produced "anb" via that path). Returns trimmed
14829    // output (trailing newlines stripped per POSIX cmd-sub semantics).
14830    vm.register_builtin(BUILTIN_CMD_SUBST_TEXT, |vm, _argc| {
14831        let cmd = vm.pop().to_str();
14832        // c:Src/exec.c:4727-4729 (getoutput) — `if (!isset(EXECOPT)) return
14833        // newlinklist();`: under NO_EXEC the substitution yields nothing and
14834        // runs nothing, `$(<file)` included (that test is ahead of c:4731).
14835        if !isset(crate::ported::zsh_h::EXECOPT) {
14836            return Value::str(String::new());
14837        }
14838        // The enclosing word's `BUILTIN_WORD_DEFER_EMPTIES` declaration
14839        // covers that word's own segments, not the words of a command run
14840        // inside it (`PRE$(f)${a}POST` — `f`'s own words are finished words
14841        // and owe c:183-186 where they stand). Suspend it for the call.
14842        let saved_defer = crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get());
14843        crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(0));
14844        // Inherit live $? into the inner shell so cmd-subst sees the
14845        // parent's most recent exit. Same rationale as the mode-3
14846        // backtick path above.
14847        let live_status = vm.last_status;
14848        let result = with_executor(|exec| {
14849            exec.set_last_status(live_status);
14850            exec.run_command_substitution(&cmd)
14851        });
14852        // Mirror run_command_substitution's exec.last_status side
14853        // effect into the VM's live counter so a containing
14854        // assignment's BUILTIN_SET_VAR — which reads vm.last_status
14855        // — sees the cmd-subst's exit. Without this, `a=$(false);
14856        // echo $?` reads stale 0 (vm.last_status was zeroed by
14857        // compile_assign's prelude SetStatus, and run_cmd_subst only
14858        // updated exec.last_status). Pull the value back through
14859        // exec since it owns the canonical post-subst record.
14860        let cs_status = with_executor(|exec| exec.last_status());
14861        vm.last_status = cs_status;
14862        // c:Src/exec.c — a command substitution running during a
14863        // command's word expansion makes its exit the status of an
14864        // otherwise-empty command (`$(exit 5)` → 5). Flag it so
14865        // BUILTIN_EXEC_DYNAMIC's null-command branch keeps `$?` instead
14866        // of resetting to 0.
14867        crate::ported::exec::use_cmdoutval.store(1, std::sync::atomic::Ordering::Relaxed);
14868        // Hand the enclosing word's declaration back — its remaining
14869        // segments are still part of that word.
14870        crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(saved_defer));
14871        Value::str(result)
14872    });
14873
14874    // Text-based word expansion. Pops [preserved_text, mode_byte].
14875    // mode_byte:
14876    //   0 = Default — expand_string + xpandbraces + expand_glob
14877    //   1 = DoubleQuoted — strip outer `"…"`, expand_string only
14878    //         (no brace, no glob — DQ semantics)
14879    //   2 = SingleQuoted — strip outer `'…'`, no expansion
14880    //         (kept for symmetry; Snull early-return covers most SQ)
14881    //   3 = AltBackquote — strip backticks, run as cmd-sub
14882    //   7 = RedirTarget — same as Default but glob gated on MULTIOS
14883    //         (c:Src/glob.c:2161-2167 xpandredir)
14884    //   8 = unquoted assignment VALUE — same as 6 plus PREFORK_SINGLE
14885    //         (c:Src/exec.c:2603 / :4239-4241)
14886    //   9 = singsub word — mode 0 plus PREFORK_SINGLE and nothing else
14887    //         (c:Src/subst.c:514-520 `prefork(&foo, PREFORK_SINGLE, NULL)`:
14888    //          the `case` WORD c:Src/loop.c:611, every `[[ … ]]` operand
14889    //          c:Src/cond.c:53). No PREFORK_ASSIGN — filesub's colon-walk
14890    //          is assignment-only (c:Src/subst.c:689).
14891    // Single result → Value::str; multi → Value::Array.
14892    vm.register_builtin(BUILTIN_EXPAND_TEXT, |vm, _argc| {
14893        let mode = vm.pop().to_int() as u8;
14894        let text = vm.pop().to_str();
14895        // Sync vm.last_status → exec.last_status so cmd-subst (mode 3)
14896        // and any nested $? reads inside singsub see the live `$?`
14897        // from the most recent VM op. Without this, cmd-subst inside
14898        // arg-eval saw a stale exec.last_status that was zeroed at
14899        // the start of the current statement. Direct port of zsh's
14900        // pre-cmdsubst lastval propagation per Src/exec.c:4770.
14901        let live_status = vm.last_status;
14902        with_executor(|exec| exec.set_last_status(live_status));
14903        let result_value = with_executor(|exec| match mode {
14904            // Mode 1 = DoubleQuoted (argument context).
14905            // Mode 5 = DoubleQuoted in scalar-assignment context.
14906            // Both share the same DQ unescape pre-processing; mode 5
14907            // additionally bumps `in_scalar_assign` so subst_port's
14908            // paramsubst sees ssub=true and suppresses split flags
14909            // `(f)` / `(s:STR:)` / `(0)` per Src/subst.c:1759 +
14910            // Src/exec.c::addvars line 2546 (the PREFORK_SINGLE bit
14911            // C zsh sets when prefork-ing the assignment RHS).
14912            1 | 5 => {
14913                // DoubleQuoted: strip outer `"…"` if present. In DQ
14914                // context, `\` escapes the DQ-special chars `$`, `` ` ``,
14915                // `"`, `\`. zsh's expand_string expects the lexer's
14916                // `\0X` literal-marker for an already-escaped char, so
14917                // we pre-process: `\$` → `\0$`, `\\` → `\0\`, etc. Then
14918                // expand_string handles the rest.
14919                let inner = if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
14920                    &text[1..text.len() - 1]
14921                } else {
14922                    text.as_str()
14923                };
14924                // The lexer's dquote_parse (Src/lex.c) already tokenized
14925                // DQ contents: `$` → Qstring (\u{e18c}), `\$`/`\\`/`\"`/
14926                // `` \` `` → Bnull (\u{e19f}) + literal. Stringsubst /
14927                // multsub recognize these markers natively. We pass
14928                // `inner` through verbatim — no re-tokenization needed.
14929                let prepped: String = inner.to_string();
14930                // Tell parameter-flag application that we're inside
14931                // double quotes — array-only flags ((o), (O), (n),
14932                // (i), (M), (u)) must be no-ops here per zsh.
14933                exec.in_dq_context += 1;
14934                if mode == 5 {
14935                    exec.in_scalar_assign += 1;
14936                }
14937                // Mode 1 = argv DQ word; mode 5 = scalar-assign RHS.
14938                // In C zsh, the corresponding prefork-on-list paths
14939                // are: argv → `prefork(argv_list, 0)` returns multi-
14940                // word LinkList (Src/exec.c::execcmd), assignment →
14941                // `prefork(rhs_list, PREFORK_SINGLE|PREFORK_ASSIGN)`
14942                // returns single-word (Src/exec.c::addvars line
14943                // 2546). zshrs's `multsub` (Src/subst.c:544) is the
14944                // multi-result variant; `singsub` (Src/subst.c:514)
14945                // asserts ≤1 node. Mode 5 keeps singsub; mode 1
14946                // switches to multsub so `"${(@)arr}"`/`"$@"`/
14947                // `"${arr[@]}"` in argv context emit multiple words
14948                // as the C path would.
14949                // c:Src/lex.c untokenize — the final argv pass C runs
14950                // on every expanded word (glob.c:1862 / exec.c) drops
14951                // the Nularg empty-word sentinel remnulargs left in
14952                // place and folds any remaining token chars. Without
14953                // it, quoted splits with empty pieces
14954                // ("${(s:|:)x}" on "|a|b|") leak Nularg into argv.
14955                let result_value = if mode == 5 {
14956                    let out = crate::ported::subst::singsub(&prepped);
14957                    if crate::ported::subst::PREFORK_STOPPED.with(|c| c.get()) {
14958                        // Stopped by an `(e)` NULL: the node keeps its quote
14959                        // tokens (no remnulargs), rendered by C's untokenize
14960                        // (c:Src/exec.c:2134): `v="pre${(e)a}post"` → `"pre`.
14961                        Value::str(crate::ported::lex::untokenize_ztokens(&out))
14962                    } else {
14963                        Value::str(crate::ported::lex::untokenize(&out))
14964                    }
14965                } else {
14966                    // c:Src/subst.c:3892-3895 — `if (isarr) l->list.flags |=
14967                    // LF_ARRAY; else l->list.flags &= ~LF_ARRAY;`. Clear the
14968                    // carrier BEFORE the expansion so a word that runs no
14969                    // paramsubst at all reads `false` rather than some earlier
14970                    // expansion's stamp — the same reset the unquoted arm does
14971                    // before its own multsub.
14972                    crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.set(false));
14973                    let (_first, nodes, _ms_ws, _ret) = crate::ported::subst::multsub(&prepped, 0);
14974                    // Read immediately: anything below can re-enter paramsubst
14975                    // and overwrite the cell. This is C's `isarr` for the
14976                    // OUTERMOST expansion of this word — c:4245 `if (isarr)`.
14977                    let seg_is_array = crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.get());
14978                    // c:Src/subst.c:655 — multsub returns Vec::new()
14979                    // for zero-word results (quoted array splat that
14980                    // resolved to empty array). Surface as
14981                    // Value::Array(vec![]) so the downstream array
14982                    // assignment / argv flattening sees ZERO args.
14983                    // Previous Rust port returned Value::str("") which
14984                    // surfaced as ONE empty arg. Bug #120 in
14985                    // docs/BUGS.md.
14986                    if crate::ported::subst::PREFORK_STOPPED.with(|c| c.get()) {
14987                        // An `(e)` NULL stopped prefork: every node already in
14988                        // the list stays (c:Src/subst.c:4383-4394 inserted the
14989                        // elements before the failing one, the first joined to
14990                        // the text before the `$`), unexpanded, with its quote
14991                        // tokens rendered by untokenize (c:Src/exec.c:2134).
14992                        stopped_nodes_value(nodes)
14993                    } else if nodes.is_empty() {
14994                        // c:Src/subst.c:4245 `if (isarr)` gates the array emit
14995                        // block, and c:4362-4365 `if (plan9) { uremnode(l, n);
14996                        // return n; }` deletes the WHOLE word from inside it —
14997                        // so whether a zero-word expansion removes its word is
14998                        // decided by THIS expansion's own `isarr`, never by the
14999                        // previous one's. `Value::Array(vec![])` cannot carry
15000                        // the bit, so record it here exactly as the unquoted
15001                        // arm does (`note_empty_is_scalar(!(seg_zero_words &&
15002                        // seg_is_array))`). Without the write the cell still
15003                        // held an unrelated earlier expansion's value, and a
15004                        // preceding empty-SCALAR read (`local x=$a[2]`, which
15005                        // leaves it `true`) resurrected a quoted empty-array
15006                        // word as one empty word under RC_EXPAND_PARAM:
15007                        // `e=(); r=( q "${e[@]:/A/B}" )` counted 2 where zsh
15008                        // counts 1. See EMPTY_EXPANSION_IS_SCALAR.
15009                        note_empty_is_scalar(!seg_is_array);
15010                        Value::array(Vec::new())
15011                    } else if nodes.len() == 1 {
15012                        Value::str(crate::ported::lex::untokenize(
15013                            &nodes.into_iter().next().unwrap(),
15014                        ))
15015                    } else {
15016                        Value::array(
15017                            nodes
15018                                .into_iter()
15019                                .map(|n| Value::str(crate::ported::lex::untokenize(&n)))
15020                                .collect(),
15021                        )
15022                    }
15023                };
15024                if mode == 5 {
15025                    exec.in_scalar_assign -= 1;
15026                }
15027                exec.in_dq_context -= 1;
15028                // c:Src/subst.c:326-327 → prefork c:142-147 — subst.rs's prefork
15029                // stopped on an `(e)` NULL: the word is what preceded the `$`
15030                // (c:1878). The lexer's opening Dnull stays in that node and
15031                // untokenizes to a literal `"` when the word itself opened a
15032                // double quote; this handler stripped those outer quotes above,
15033                // so put it back. Mode 5 also carries UNQUOTED scalar-assignment
15034                // words (`v=${(e)a}`), which have no quote to restore.
15035                // PREFORK_CUT tells the words that follow to stay unexpanded.
15036                if crate::ported::subst::PREFORK_STOPPED.with(|c| c.replace(false)) {
15037                    PREFORK_CUT.with(|c| c.set(true));
15038                    // The stopped nodes are rendered WITH their quote tokens
15039                    // (stopped_nodes_value, or ztokens for mode 5), so a lexer
15040                    // Dnull already shows as `"`. Only an ASCII `"` that this
15041                    // handler stripped above still has to be put back.
15042                    let opened_dq = text.starts_with('"');
15043                    let quote = if opened_dq { "\"" } else { "" };
15044                    // The quote belongs to the FIRST node, the one holding the
15045                    // text before the `$`; later nodes are whole elements.
15046                    return match result_value {
15047                        Value::Array(items) if !items.is_empty() => {
15048                            let mut words: Vec<Value> = items.iter().cloned().collect();
15049                            words[0] = Value::str(format!("{}{}", quote, words[0].to_str()));
15050                            Value::array(words)
15051                        }
15052                        other => Value::str(format!("{}{}", quote, other.to_str())),
15053                    };
15054                }
15055                result_value
15056            }
15057            2 => {
15058                // SingleQuoted: pure literal, strip outer `'…'`.
15059                let inner = if text.len() >= 2 && text.starts_with('\'') && text.ends_with('\'') {
15060                    &text[1..text.len() - 1]
15061                } else {
15062                    text.as_str()
15063                };
15064                Value::str(inner.to_string())
15065            }
15066            3 => {
15067                // Backquote command sub: strip outer backticks.
15068                // Word-split the result on IFS when the surrounding
15069                // word is unquoted — zsh: `print -l \`echo a b c\``
15070                // emits one arg per word. The $(…) path applies the
15071                // same split via BUILTIN_WORD_SPLIT after capture; do
15072                // the equivalent here for the `…` form.
15073                let inner = if text.len() >= 2 && text.starts_with('`') && text.ends_with('`') {
15074                    &text[1..text.len() - 1]
15075                } else {
15076                    text.as_str()
15077                };
15078                // Apply the live VM status before running the inner
15079                // shell so the inherited $? matches zsh's lastval
15080                // propagation.
15081                exec.set_last_status(live_status);
15082                // c:Src/exec.c:4727-4729 — getoutput returns an empty list
15083                // under NO_EXEC.
15084                let captured = if isset(crate::ported::zsh_h::EXECOPT) {
15085                    exec.run_command_substitution(inner)
15086                } else {
15087                    String::new()
15088                };
15089                let trimmed = captured.trim_end_matches('\n');
15090                if exec.in_dq_context > 0 {
15091                    Value::str(trimmed.to_string())
15092                } else {
15093                    let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
15094                    let parts: Vec<Value> = trimmed
15095                        .split(|c: char| ifs.contains(c))
15096                        .filter(|s| !s.is_empty())
15097                        .map(|s| Value::str(s.to_string()))
15098                        .collect();
15099                    if parts.is_empty() {
15100                        Value::str(String::new())
15101                    } else if parts.len() == 1 {
15102                        parts.into_iter().next().unwrap()
15103                    } else {
15104                        Value::array(parts)
15105                    }
15106                }
15107            }
15108            4 => {
15109                // HeredocBody: expand variables / command-subst / arith
15110                // but NOT glob or brace. Heredoc lines like `[42]` must
15111                // pass through verbatim — running them through the
15112                // default pipeline triggers NOMATCH on the literal.
15113                Value::str(crate::ported::subst::singsub(&text))
15114            }
15115            _ => {
15116                // Default (unquoted): the lexer's gettokstr already
15117                // tokenized backslash-escapes (`\$` → Bnull+$, etc).
15118                // Pass `text` through verbatim — multsub/stringsubst
15119                // recognize the markers natively. No bridge-side
15120                // re-tokenization needed.
15121                //
15122                // Mode 6 = unquoted RHS in scalar-assign context.
15123                // Pass PREFORK_ASSIGN so prefork's filesub colon-walk
15124                // fires per c:Src/exec.c:2546.
15125                let prepped: String = text.clone();
15126                if std::env::var("ZSHRS_TRACE_DEFP").is_ok() {
15127                    eprintln!(
15128                        "[TRACE_DEFP] text={:?} prepped={:?} mode={}",
15129                        text, prepped, mode
15130                    );
15131                }
15132                // Mode 8 = the unquoted VALUE of a `NAME=VALUE` assignment
15133                // (bare statement or typeset-family argument). C preforks
15134                // exactly that with `PREFORK_SINGLE|PREFORK_ASSIGN`
15135                // (c:Src/exec.c:2603 and c:Src/exec.c:4239-4241); the
15136                // PREFORK_SINGLE half is paramsubst's `ssub`
15137                // (c:Src/subst.c:1761), which gates off the forced split at
15138                // c:Src/subst.c:3913.
15139                // Mode 9 = a word C expands with `singsub` (c:Src/subst.c:520
15140                // `prefork(&foo, PREFORK_SINGLE, NULL)`) — the `case` WORD
15141                // (c:Src/loop.c:611) and every `[[ … ]]` operand
15142                // (c:Src/cond.c:53). PREFORK_SINGLE alone: it is paramsubst's
15143                // `ssub` (c:Src/subst.c:1761), which takes the c:4226
15144                // `if (isarr && ssub)` IFS[0] join BEFORE the c:4256 array-emit
15145                // block, so the c:4264 unique fold and the c:4301-4326 sort do
15146                // not run; it also turns off `xpandbraces` (c:Src/subst.c:170)
15147                // and c:3913's `force_split`. NO PREFORK_ASSIGN — a `case`
15148                // word / cond operand gets no `filesub` colon-walk.
15149                // Mode 12 = the same `singsub`, for a `[[ … == pat ]]` RHS or a
15150                // `case` arm. It differs from mode 9 only in what it hands
15151                // BACK: the word keeps its tokens (see the untokenize below),
15152                // because a pattern operand's guard still has to tell a
15153                // metacharacter `shtokenize` armed from a raw byte of a value.
15154                let pf_flags = if mode == 8 {
15155                    crate::ported::zsh_h::PREFORK_SINGLE | crate::ported::zsh_h::PREFORK_ASSIGN
15156                } else if mode == 9 || mode == 12 {
15157                    crate::ported::zsh_h::PREFORK_SINGLE
15158                } else if mode == 6 {
15159                    crate::ported::zsh_h::PREFORK_ASSIGN
15160                } else if mode == 13 {
15161                    // Mode 13 = a command argument under MAGIC_EQUAL_SUBST +
15162                    // KSH_TYPESET: c:Src/exec.c:3353-3355 preforks it with
15163                    // `esprefork = PREFORK_TYPESET`, whose c:Src/subst.c:103
15164                    // `asssub` stops word splitting after the first `=`.
15165                    crate::ported::zsh_h::PREFORK_TYPESET
15166                } else {
15167                    0
15168                };
15169                // c:Src/subst.c:544+ — `multsub(&prepped, 0)` is the
15170                // unquoted-argv equivalent of zsh's `prefork(list,
15171                // 0, NULL)` for a single-element list. Returns the
15172                // post-expansion node list (Vec<String>) so array-
15173                // shape results (e.g. `${a:e}`, `${a[@]}`,
15174                // `${(s::)str}`) splat into multiple argv words.
15175                // singsub() collapses to one string and discards the
15176                // splat — parity bug #28 (whole-array modifier).
15177                // c:Src/subst.c:3929-3932 — `if (isarr) l->list.flags |=
15178                // LF_ARRAY; else l->list.flags &= ~LF_ARRAY;`. C's paramsubst
15179                // holds the LinkList and stamps its `isarr` on it directly;
15180                // the Rust port hands the same bit back through the
15181                // `PARAMSUBST_LF_ARRAY` thread-local (subst.rs:20511) — set at
15182                // subst.rs:17796 (`isarr != 0 && !forced_split_to_one`) and
15183                // reset to false at the top of EVERY paramsubst
15184                // (subst.rs:3891 / subst.rs:17445).
15185                //
15186                // Clear it BEFORE multsub so a segment that runs no paramsubst
15187                // at all reads false instead of some earlier expansion's
15188                // value. `multsub`'s own `isarr` return cannot be used for
15189                // this: an unquoted `$(cmd)` / `` `cmd` `` sets LF_ARRAY
15190                // unconditionally (subst.rs:897 / :1159, c:Src/subst.c:285-286
15191                // `if (!qt) list->list.flags |= LF_ARRAY;` and c:331), so
15192                // `x$(true)y` would look array-shaped when zsh keeps that
15193                // word (verified: it is the single word `xy`).
15194                //
15195                // Every paramsubst re-initialises the cell on entry, so
15196                // clearing it here cannot disturb subst.rs's own readers
15197                // (stringsubst reads it immediately after each paramsubst
15198                // call, subst.rs:1094).
15199                crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.set(false));
15200                // Mode 10 (a deferred command argument): c:Src/subst.c:165-191
15201                // runs filesub in prefork's second pass, after stringsubst has
15202                // run over EVERY word, so `print =nosuchcmd $(cmd)` runs `cmd`
15203                // before `=nosuchcmd` fails. This handler expands one word, so
15204                // the word's filesub is left to BUILTIN_GLOBLIST's all-words
15205                // pass (the word comes back with its Equals/Tilde token).
15206                let saved_skip_filesub = crate::ported::subst::SKIP_FILESUB.with(|c| c.get());
15207                if mode == 10 {
15208                    crate::ported::subst::SKIP_FILESUB.with(|c| c.set(true));
15209                }
15210                let (_first, nodes, _ms_ws, _ret) =
15211                    crate::ported::subst::multsub(&prepped, pf_flags);
15212                crate::ported::subst::SKIP_FILESUB.with(|c| c.set(saved_skip_filesub));
15213                // c:Src/subst.c:326-327 → prefork c:142-147 — an `(e)` NULL ended
15214                // prefork: the word is the text before the `$` (c:1878), and the
15215                // brace / filesub / remnulargs passes after the substitution loop
15216                // never ran. PREFORK_CUT keeps the later words unexpanded.
15217                if crate::ported::subst::PREFORK_STOPPED.with(|c| c.replace(false)) {
15218                    PREFORK_CUT.with(|c| c.set(true));
15219                    // remnulargs (c:170) never ran either, so C's nodes still
15220                    // hold their quote tokens and untokenize (c:Src/exec.c:2134)
15221                    // renders them through ztokens. Every node before the cut
15222                    // stays: `(@e)` inserted the earlier elements
15223                    // (c:Src/subst.c:4383-4394), so `A${(@e)arr}B` with
15224                    // arr=(p x '$(') is `Ap` and `x`.
15225                    return stopped_nodes_value(nodes);
15226                }
15227                // Read immediately: brace expansion / filesub / glob below can
15228                // re-enter paramsubst and overwrite the cell. `seg_is_array` is
15229                // the array-ness of the OUTERMOST paramsubst in this segment —
15230                // C's c:4245 `if (isarr)` for the same expansion. Paired with
15231                // `seg_zero_words` it separates the two empty shapes for the
15232                // empty-result arm further down (c:4362 vs c:4464).
15233                let seg_is_array = crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.get());
15234                let seg_zero_words = nodes.is_empty();
15235                if std::env::var("ZSHRS_TRACE_MULTSUB").is_ok() {
15236                    eprintln!("[TRACE_MULTSUB] prepped={:?} nodes={:?}", prepped, nodes);
15237                }
15238                // c:Src/subst.c:166 — xpandbraces runs AFTER prefork's
15239                // substitution pass and BEFORE untokenize/glob. Per
15240                // word, scan for Inbrace TOKEN and expand. Words that
15241                // don't contain Inbrace TOKEN pass through unchanged.
15242                // Brace expansion is done here (inside the bridge
15243                // default arm) instead of via a post-EXPAND_TEXT
15244                // BRACE_EXPAND emit because untokenize (line below)
15245                // strips TOKEN bytes, after which the strict-TOKEN
15246                // xpandbraces gate would no longer match.
15247                let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
15248                // c:Src/options.c — `no_brace_expand` (negated
15249                // `braceexpand`) gates brace expansion entirely.
15250                // When off, `{a,b}` stays literal.
15251                // c:Src/subst.c:170 — `if (unset(IGNOREBRACES) && !(flags &
15252                // PREFORK_SINGLE))` guards the `xpandbraces` loop, so a word
15253                // preforked as a scalar (assignment VALUE, mode 8) is NEVER
15254                // brace-expanded: `local x={a,b}` stores the five literal
15255                // characters. This pass stands in for prefork's loop, so it
15256                // owes the same guard.
15257                let brace_expand = opt_state_get("braceexpand").unwrap_or(true)
15258                    && (pf_flags & crate::ported::zsh_h::PREFORK_SINGLE) == 0; // c:170
15259                let pre_brace: Vec<String> = if nodes.is_empty() {
15260                    vec![String::new()]
15261                } else {
15262                    nodes
15263                };
15264                let brace_expanded: Vec<String> = pre_brace
15265                    .into_iter()
15266                    .flat_map(|w| {
15267                        if brace_expand && w.contains('\u{e18f}') {
15268                            crate::ported::glob::xpandbraces(&w, brace_ccl)
15269                                .into_iter()
15270                                .filter(|x| !(x.is_empty() && crate::dash_mode::bash_mode()))
15271                                .collect()
15272                        } else {
15273                            vec![w]
15274                        }
15275                    })
15276                    .collect();
15277                // zsh stores the option as `glob` (default ON);
15278                // `setopt noglob` writes `glob=false`. Honor either
15279                // form so the dispatcher behaves the same as zsh.
15280                // Mode 7 = redirect-target word: glob only under
15281                // MULTIOS (c:Src/glob.c:2161-2167 xpandredir,
15282                // "Globbing is only done for multios.").
15283                let noglob = opt_state_get("noglob").unwrap_or(false)
15284                    || opt_state_get("GLOB").map(|v| !v).unwrap_or(false)
15285                    || !opt_state_get("glob").unwrap_or(true)
15286                    || (mode == 7 && !opt_state_get("multios").unwrap_or(true))
15287                    // Mode 9 = singsub. c:Src/subst.c:514-525 runs `prefork`
15288                    // and NOTHING else — `globlist` is never called, so a
15289                    // `case` word (c:Src/loop.c:610-612) and a `[[ … ]]`
15290                    // operand (c:Src/cond.c:53) are never filename-generated.
15291                    || mode == 9
15292                    // Mode 12 = the pattern-operand spelling of mode 9, so it
15293                    // owes the same "singsub never reaches globlist" rule.
15294                    || mode == 12
15295                    // Mode 11 = a segment of a larger word (see compile_zsh's
15296                    // text-expansion arm): c:Src/exec.c:3755-3757 globs the
15297                    // assembled word, so the segment itself is not globbed.
15298                    || mode == 11
15299                    // c:Src/exec.c:2603-2613 — a scalar `NAME=VALUE` is
15300                    // preforked PREFORK_SINGLE|PREFORK_ASSIGN (mode 8) and
15301                    // reaches globlist only under GLOB_ASSIGN; without it the
15302                    // value is never globbed. Missing this term, a value the
15303                    // compiler did not DQ-wrap globbed here: `x=q(` said "bad
15304                    // pattern: q(", `x=q(a)` "number expected", `x=q(|b)` "no
15305                    // matches found".
15306                    || (mode == 8 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBASSIGN));
15307                // c:Src/subst.c:168 — prefork's `if (*(char *)getdata(node))`
15308                // runs on the node AS IT STANDS, i.e. still carrying the quote
15309                // markers the lexer left and the `Nularg` that c:169's
15310                // `remnulargs` re-inserts for a node made of nothing else
15311                // (c:Src/glob.c:3683-3686). `untokenize` drops that sentinel
15312                // (c:Src/exec.c:2143), but only much later, once the node is
15313                // already a word. Testing emptiness AFTER the untokenize below
15314                // inverted that order and deleted the word C keeps:
15315                // `unset u; r=( q ${u:-""} )` counted 1 where zsh counts 2
15316                // (same for `${u:-''}`, `${u-""}`, `${1:-""}`, `${a[@]:+""}`).
15317                // Record C's answer here and let the empty-result arm consult
15318                // it.
15319                let nonempty_before_untokenize = brace_expanded.iter().any(|w| !w.is_empty());
15320                let parts: Vec<String> = brace_expanded
15321                    .into_iter()
15322                    .flat_map(|s| {
15323                        // The lexer leaves glob metacharacters in their
15324                        // META-encoded form: `*` → `\u{e187}`, `?` →
15325                        // `\u{e186}`, `[` → `\u{e191}`, etc. expand_string
15326                        // doesn't untokenize them, so the literal-char
15327                        // checks below (`s.contains('*')`) would miss
15328                        // every real glob and skip expand_glob — that
15329                        // bug let `echo *.toml` print the literal
15330                        // `*.toml` because the META `\u{e187}` never
15331                        // matched the literal `*`. Untokenize once so
15332                        // the metacharacter checks see the canonical
15333                        // form. zsh's pattern.c expects `*` etc. as
15334                        // bare chars at the glob layer.
15335                        // c:Src/pattern.c:4306 haswilds on the still-
15336                        // TOKENIZED word (pre-untokenize), matching C's
15337                        // zglob entry gate (Src/glob.c:1230) which runs
15338                        // on the lexer-tokenized string. haswilds
15339                        // matches ONLY token codes: source-level
15340                        // `*.toml` carries Star and fires; bare literal
15341                        // `[`/`*`/`?` from `$'...'` decode, `:-`
15342                        // default values, or nested-substitution
15343                        // results were never shtokenize'd (C
15344                        // subst.c:3231 sets globsubst=0 in the `:-`
15345                        // arm) and stay literal — bug #625. Plain
15346                        // multibyte text (`↔`) never matches a token
15347                        // codepoint — bug #627.
15348                        let is_glob_pre = !noglob && crate::ported::pattern::haswilds(&s);
15349                        // c:Src/glob.c:1230 — zglob receives the word in
15350                        // LEXER-TOKENIZED form and only untokenizes it when
15351                        // it declines to glob (c:1232) or falls back to the
15352                        // literal (c:1884). The token form is what makes a
15353                        // QUOTED metachar distinguishable from an active one:
15354                        // in `*(.e['[[ $REPLY == a* ]]'])` the body's `]`
15355                        // bytes are raw ASCII while the qualifier's real
15356                        // closer is `Outbrack`, which is exactly how
15357                        // checkglobqual (c:1163/1170, testing Outpar/Inpar)
15358                        // and get_strarg's tokenized delimiter half
15359                        // (Src/subst.c:1379-1390) find the true end. zshrs
15360                        // untokenized here, one line before the glob layer,
15361                        // so every quoted metachar became indistinguishable
15362                        // from an active one and the qualifier parser closed
15363                        // on the first quoted `]`. Keep the tokenized word
15364                        // for the glob call; the untokenized form still
15365                        // drives the non-glob arms below.
15366                        let s_tok = s.clone();
15367                        // c:36 `nulstring` — a segment (mode 11) of a word
15368                        // whose end-of-word drop is still to run keeps the
15369                        // Nularg that marks a split's empty field, as
15370                        // `paramsubst_to_value_pf` does; untokenize would fold
15371                        // it to "", which that drop cannot tell from a truly
15372                        // empty node. The drop removes the marker (c:170).
15373                        let nul = crate::ported::zsh_h::Nularg;
15374                        let s = if mode == 11
15375                            && s.contains(nul)
15376                            && crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get()) > 0
15377                        {
15378                            s.split(nul)
15379                                .map(crate::lex::untokenize)
15380                                .collect::<Vec<_>>()
15381                                .join(&nul.to_string())
15382                        } else if mode == 12 {
15383                            // c:Src/glob.c:3640-3645 — `shtokenize` says "this
15384                            // metacharacter is ACTIVE" by rewriting it to a
15385                            // TOKEN and says "this one is data" by leaving the
15386                            // raw byte, and C carries both spellings into
15387                            // `patcompile` unchanged. Mode 12 is a `[[ … ]]` /
15388                            // `case` PATTERN operand, the one word where that
15389                            // distinction still has a consumer: untokenizing
15390                            // here collapses the two, and the guard that runs
15391                            // next can then only escape ALL of the word's metas
15392                            // or none of them. c:Src/subst.c:1551-1552 puts
15393                            // both halves in one string — the `(~j)` separator
15394                            // is tokenized, the elements it joins are not — so
15395                            // neither answer is right. Hand the word on
15396                            // tokenized; BUILTIN_GLOB_SUBST_GUARD /
15397                            // BUILTIN_PAT_DATA_BACKSLASH run the untokenize
15398                            // once they have escaped the raw half.
15399                            s
15400                        } else {
15401                            crate::lex::untokenize(&s)
15402                        };
15403                        // c:Src/exec.c:2603-2613 — only an assignment VALUE
15404                        // skips globlist without GLOB_ASSIGN, and that value
15405                        // arrives here in mode 8, which `noglob` above already
15406                        // covers. The word's text says nothing about its role:
15407                        // a text-shape test (`[A-Za-z_][A-Za-z0-9_]*=`) that
15408                        // used to skip the glob here also kept `print a=zzq*`,
15409                        // `set -- a=zzq*`, `for w in a=zzq*`, `> a=zzq*` and
15410                        // the array element `x=(a=zzq*)` from globbing, where
15411                        // zsh reports "no matches found".
15412                        // Glob-trigger decision: pre-untokenize
15413                        // haswilds_tokens_only result (computed above
15414                        // before the untokenize that collapses META
15415                        // tokens to their ASCII forms). The TOKEN-only
15416                        // gate matches C `Src/pattern.c:4306-4376`
15417                        // exactly — only Inbrack/Star/Quest/Inpar/Bar/
15418                        // Inang/Pound/Hat token codes count as wild,
15419                        // not their literal ASCII counterparts. Source-
15420                        // level `*.toml` carries Star token so globs;
15421                        // `$'…'`-decoded `[abc]` carries bare `[` so
15422                        // stays literal. Bug #625.
15423                        // c:Src/subst.c:677-678 — `filesub`'s own precondition
15424                        // for the PREFORK_TYPESET arm is `(*namptr)[1] &&
15425                        // strchr(*namptr + 1, Equals)`: an `=` anywhere but
15426                        // position 0. There is NO identifier test. Gating the
15427                        // arm below on an assignment-shape test — which demanded a
15428                        // leading `[A-Za-z_][A-Za-z0-9_]*` — therefore lost every
15429                        // word whose `=` is not preceded by a bare identifier:
15430                        //   setopt magicequalsubst; print -r -- x:y=~/z
15431                        //   zsh: x:y=/home/u/z      zshrs: x:y=~/z
15432                        // Same for `ME:a=~/x`, `1abc=~/x` and `-o=~/x`, the last
15433                        // of which is the common `--prefix=~/dir` shape.
15434                        // c:Src/subst.c:678 — filesub's own gate is
15435                        // `strchr(*namptr + 1, Equals)`: the Equals TOKEN,
15436                        // which the lexer emits ONLY for an unquoted `=`.
15437                        // Test the still-TOKENIZED word so a quoted `=` does
15438                        // not arm the arm below.
15439                        let has_nonleading_equals = s_tok
15440                            .chars()
15441                            .skip(1)
15442                            .any(|c| c == crate::ported::zsh_h::Equals);
15443                        // c:Src/subst.c:180 `filesub(&cptr, flags & …)` acts on a
15444                        // leading Tilde / Equals token; mode 10 deferred it above.
15445                        let filesub_deferred = mode == 10
15446                            && (s_tok.starts_with(crate::ported::zsh_h::Tilde)
15447                                || s_tok.starts_with(crate::ported::zsh_h::Equals));
15448                        if (is_glob_pre || filesub_deferred) && mode == 10 {
15449                            // Mode 10: the command's BUILTIN_GLOBLIST runs this
15450                            // word's filesub and glob after every argument has
15451                            // been expanded (c:Src/subst.c:165-191, then
15452                            // c:Src/exec.c:3755-3757).
15453                            vec![s_tok]
15454                        } else if is_glob_pre {
15455                            // c:Src/exec.c:3755-3762 — an error raised here is a
15456                            // globlist error (`lastval = 1`); see GLOB_WORD_ERRFLAG.
15457                            let ef = || {
15458                                crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
15459                                    & crate::ported::zsh_h::ERRFLAG_ERROR
15460                            };
15461                            let before = ef();
15462                            // c:Src/glob.c:2164-2166 — a redirect target
15463                            // (mode 7) is globbed with `in_expandredir` set,
15464                            // so a NULL_GLOB no-match is `redirection failed
15465                            // (no match)` (c:1888-1894), not a dropped word.
15466                            let redir = (mode == 7) as i32;
15467                            crate::ported::glob::IN_EXPANDREDIR.store(redir, std::sync::atomic::Ordering::SeqCst);
15468                            let globbed = exec.expand_glob(&s_tok);
15469                            crate::ported::glob::IN_EXPANDREDIR.store(0, std::sync::atomic::Ordering::SeqCst);
15470                            if before == 0 && ef() != 0 {
15471                                GLOB_WORD_ERRFLAG.with(|c| c.set(true));
15472                            }
15473                            globbed
15474                        } else if has_nonleading_equals
15475                            && crate::ported::zsh_h::isset(crate::ported::zsh_h::MAGICEQUALSUBST)
15476                        {
15477                            // c:Src/exec.c:3353 — when MAGIC_EQUAL_SUBST is set
15478                            // on a non-typeset command, esprefork = PREFORK_TYPESET,
15479                            // so every arg runs through
15480                            // filesub(PREFORK_TYPESET): the `~`/`=` after the
15481                            // first `=` (and after each `:`) undergo filename
15482                            // expansion. `print foo=~/bar` → `foo=$HOME/bar`.
15483                            // filesub (subst.c:667) keys on the Tilde/Equals
15484                            // TOKENS, not the literal chars, because that is
15485                            // exactly what tells a quoted `~`/`=` from a live
15486                            // one. This arm used to `untokenize` the word and
15487                            // then `shtokenize` it back, which re-marks EVERY
15488                            // literal `~`/`=` as a token — including the ones
15489                            // that came out of quotes. With MAGIC_EQUAL_SUBST
15490                            // set, `print -r -- --a='b:=c'` then did `=`
15491                            // filename expansion on the quoted `=c` and failed
15492                            // with `c not found` (zsh prints `--a=b:=c`), and
15493                            // `A=( --height='${X:=75%}' )` — fzf-tab.plugin.zsh
15494                            // sh:117-128 — errored `75%} not found` at plugin
15495                            // load, aborting the rest of the zinit turbo
15496                            // `atload` chain. Hand filesub the word C hands it:
15497                            // the one the lexer produced.
15498                            let exp = crate::ported::subst::filesub(
15499                                &s_tok,
15500                                crate::ported::zsh_h::PREFORK_TYPESET,
15501                            );
15502                            vec![crate::lex::untokenize(&exp).to_string()]
15503                        } else {
15504                            vec![s]
15505                        }
15506                    })
15507                    .collect();
15508                if parts.len() == 1 {
15509                    let only = parts.into_iter().next().unwrap_or_default();
15510                    // Empty unquoted expansion → drop the arg entirely
15511                    // (zsh "remove empty unquoted words" rule). Returning
15512                    // an empty Value::Array makes pop_args contribute zero
15513                    // items. Direct port of subst.c's empty-elide pass at
15514                    // the end of multsub which removes empty linknodes
15515                    // from unquoted contexts. Quoted DQ/SQ paths (modes
15516                    // 1/2/5) take separate arms above and always emit
15517                    // Value::Str so the empty arg survives.
15518                    //
15519                    // c:Src/subst.c:4437 + 1650-1656 — a word CONTAINING a
15520                    // quoted span never drops: `x"${v[-1]}"y` (v empty)
15521                    // is the scalar "xy", and a standalone `"${v[-1]}"`
15522                    // is ONE empty arg. The lexer marks DQ/SQ spans with
15523                    // Dnull(\u{e19e})/Snull(\u{e19d})/Qstring(\u{e18c})/
15524                    // Bnull(\u{e19f}); their presence in the SOURCE word
15525                    // means qt semantics apply. Without this gate, zpwr's
15526                    // global `setopt rc_expand_param` turned autopair's
15527                    // `local lchar="${LBUFFER[-1]}"` (empty prompt +
15528                    // backspace) into an ARGLESS `local` — the full
15529                    // parameter-table dump the user saw per keystroke.
15530                    if only.is_empty() && !nonempty_before_untokenize {
15531                        // A quote span that WRAPS the expansion keeps the
15532                        // empty arg (`"${v[-1]}"` → one empty arg). But a
15533                        // quote INSIDE the `${…}` braces — e.g. the alternate
15534                        // of `${x:+'q'}` or `${x:-'d'}` — does NOT: when that
15535                        // branch isn't taken the result is a plain unquoted
15536                        // empty and must ELIDE, matching zsh (`a=(A ${x:+'q'}
15537                        // C)` → 2 elements, not 3). So only count quote
15538                        // markers at brace-depth 0 (outside `${…}`). Inbrace
15539                        // = \u{e18f}, Outbrace = \u{e190}.
15540                        let mut depth = 0i32;
15541                        let mut word_has_quoted_span = false;
15542                        for c in text.chars() {
15543                            match c {
15544                                '\u{e18f}' => depth += 1,
15545                                '\u{e190}' => depth -= 1,
15546                                '\u{e19e}' | '\u{e19d}' | '\u{e18c}' | '\u{e19f}' | '"' | '\''
15547                                    if depth <= 0 =>
15548                                {
15549                                    word_has_quoted_span = true;
15550                                    break;
15551                                }
15552                                _ => {}
15553                            }
15554                        }
15555                        if word_has_quoted_span {
15556                            // Returns a SCALAR Value, so the empty-Array
15557                            // shape bit does not describe it — leave the
15558                            // cell alone. Overwriting it here would let a
15559                            // trailing quoted-empty segment resurrect a word
15560                            // an EARLIER empty array already deleted:
15561                            // `setopt rcexpandparam; a=(); x${a}"${P}"y` is
15562                            // ZERO words in zsh, and concat_plan9's
15563                            // `(Array(empty), scalar)` arm returns the scalar
15564                            // when the bit says "scalar".
15565                            Value::str(String::new())
15566                        } else {
15567                            // The empty `Value::Array` below stands for TWO
15568                            // different C shapes, and under RC_EXPAND_PARAM
15569                            // they behave OPPOSITELY:
15570                            //
15571                            //   c:Src/subst.c:4362-4365 (plan9, empty ARRAY)
15572                            //     if (plan9) { uremnode(l, n); return n; }
15573                            //   → the whole word is deleted:
15574                            //     `a=(); x${a}y` and `x${${a}}y` are 0 words.
15575                            //
15576                            //   c:Src/subst.c:4438-4467 (scalar arm, empty
15577                            //   SCALAR) — c:4464
15578                            //     *str = strcatsub(&y, ostr, aptr, x, xlen,
15579                            //                      fstr, globsubst, copied);
15580                            //   then c:4467 `setdata(n, (void *) y);`
15581                            //   → the surrounding text survives:
15582                            //     `unset P; x${P}y` and `x${${P}}y` are the
15583                            //     single word `xy`.
15584                            //
15585                            // Nesting is NOT the discriminator — shape is.
15586                            // The word is deleted only when the expansion was
15587                            // ARRAY-shaped (c:4245 `if (isarr)`) AND produced
15588                            // zero words, i.e. C's `while ((x = *aval++))`
15589                            // loop at c:4327 never ran and left `plan9`
15590                            // non-zero at c:4362. Anything else empty — an
15591                            // unset/empty scalar, a nested subexp that
15592                            // resolved to a scalar, a command substitution
15593                            // with no output — takes c:4438's scalar arm and
15594                            // keeps the word.
15595                            note_empty_is_scalar(!(seg_zero_words && seg_is_array));
15596                            Value::array(Vec::new())
15597                        }
15598                    } else {
15599                        Value::str(only)
15600                    }
15601                } else {
15602                    Value::array(parts.into_iter().map(Value::str).collect())
15603                }
15604            }
15605        });
15606        // Pull any inner cmd-subst (`` `cmd` `` via mode 3 or via
15607        // mode 0/6 multsub → getoutput, `$(cmd)` via the default
15608        // arm's multsub path, nested `$()`s reached through
15609        // stringsubst) back into vm.last_status so a containing
15610        // assignment's BUILTIN_SET_VAR — which reads vm.last_status —
15611        // sees the cmd-subst's exit. Without this, backtick
15612        // assignments (`a=\`false\`; echo $?`) reported 0 because the
15613        // ported LASTVAL update never reached the VM-side counter.
15614        let cs_status = with_executor(|exec| exec.last_status());
15615        vm.last_status = cs_status;
15616        result_value
15617    });
15618
15619    // `${#name}` — pops [name]. Returns the value's element count for
15620    // arrays (indexed and assoc) or character length for scalars.
15621    // BUILTIN_PARAM_LENGTH — `${#name}`. PURE PASSTHRU.
15622    vm.register_builtin(BUILTIN_PARAM_LENGTH, |vm, _argc| {
15623        let name = vm.pop().to_str();
15624        // PARAM_LENGTH's empty-result semantics differ from
15625        // paramsubst_to_value: 0 nodes → "0" (numeric length), not
15626        // empty array. paramsubst on `${#X}` always returns at least
15627        // one node in practice (the length string); the empty case
15628        // is defensive.
15629        let mut ret_flags: i32 = 0;
15630        let (_full, _pos, nodes) = crate::ported::subst::paramsubst(
15631            &format!("${{#{}}}", name),
15632            0,
15633            false,
15634            0i32,
15635            &mut ret_flags,
15636        );
15637        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
15638            with_executor(|exec| exec.set_last_status(1));
15639        }
15640        if nodes.is_empty() {
15641            Value::str("0")
15642        } else {
15643            nodes_to_value(nodes)
15644        }
15645    });
15646
15647    // `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
15648    // `${var/%pat/repl}` — Pops [name, pattern, replacement, op_byte].
15649    // op: 0=first, 1=all, 2=anchor-prefix (`/#`), 3=anchor-suffix (`/%`).
15650    // BUILTIN_PARAM_REPLACE — `${var/pat/repl}` / `${var//pat/repl}` /
15651    // `${var/#pat/repl}` / `${var/%pat/repl}`. PURE PASSTHRU.
15652    vm.register_builtin(BUILTIN_PARAM_REPLACE, |vm, argc| {
15653        // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
15654        // compile_zsh::emit_param_modifier (see the note there).
15655        let pf_flags = if argc as usize > 5 && vm.pop().to_int() != 0 {
15656            crate::ported::zsh_h::PREFORK_SINGLE
15657        } else {
15658            0
15659        };
15660        let dq_flag = vm.pop().to_int() != 0;
15661        let op = vm.pop().to_int() as u8;
15662        let repl = vm.pop().to_str();
15663        let pattern = vm.pop().to_str();
15664        let name = vm.pop().to_str();
15665        // !!! DASH-STRICT GATE !!! dash / ash have no `${var/pat/repl}`
15666        // pattern-replacement expansion — it is a "Bad substitution" error
15667        // (bash/ksh/POSIX-sh support it, so those modes fall through). Raise
15668        // the canonical zsh diagnostic + exit 1 to match /bin/dash's failure.
15669        if crate::dash_mode::dash_strict() {
15670            crate::ported::utils::zerr("bad substitution");
15671            crate::ported::utils::errflag.fetch_or(
15672                crate::ported::zsh_h::ERRFLAG_ERROR,
15673                std::sync::atomic::Ordering::Relaxed,
15674            );
15675            with_executor(|exec| exec.set_last_status(1));
15676            return Value::str("");
15677        }
15678        // DQ context: C's lexer marks every `$` inside double quotes
15679        // as the Qstring token (Src/lex.c dquote_parse) and keeps `'`
15680        // a plain char — so a DQ replacement's `$'…'` is LITERAL in
15681        // C (Src/subst.c:301 decodes only the tokenized Snull form;
15682        // `"${a/X/$'\0'}"` keeps the five chars `$'\0'`). The body
15683        // rebuilt below re-enters stringsubst as raw text, which
15684        // would mis-decode `$'…'` as ANSI-C; stamp the Qstring
15685        // marker on the repl's `$` so stringsubst sees the same DQ
15686        // signal C's tokens carry. The PATTERN side keeps decoding
15687        // (matches observed zsh: the pattern's `$'\0'` matches a
15688        // real NUL while the repl's stays literal).
15689        let repl = if dq_flag {
15690            repl.replace('$', "\u{e18c}")
15691        } else {
15692            repl
15693        };
15694        // op encoding: 0 = first `/`, 1 = all `//`, 2 = anchor-prefix
15695        // `/#`, 3 = anchor-suffix `/%`. The brace form distinguishes
15696        // first-vs-all by single vs doubled slash, and anchored by
15697        // a `#` or `%` immediately after the slash(es).
15698        let body = match op {
15699            0 => format!("${{{}/{}/{}}}", name, pattern, repl),
15700            1 => format!("${{{}//{}/{}}}", name, pattern, repl),
15701            2 => format!("${{{}/#{}/{}}}", name, pattern, repl),
15702            3 => format!("${{{}/%{}/{}}}", name, pattern, repl),
15703            _ => format!("${{{}/{}/{}}}", name, pattern, repl),
15704        };
15705        // c:Src/subst.c:1625 — paramsubst's qt flag. The compiler
15706        // threads the word's DQ context onto the stack; dropping it
15707        // (the old `let _dq_flag`) ran the rebuilt body with qt=false
15708        // whenever the opcode fired outside an EXPAND_TEXT scope, so
15709        // DQ-only semantics inside the replacement (e.g. `$'` staying
15710        // literal per Src/subst.c:301 — `"${a/x/$'\t'q}"`) were lost.
15711        // Bump in_dq_context exactly like EXPAND_TEXT mode 1 so
15712        // paramsubst_to_value's qt probe sees the right context.
15713        if dq_flag {
15714            with_executor(|exec| exec.in_dq_context += 1);
15715        }
15716        let ret = paramsubst_to_value_status(vm, &body, pf_flags);
15717        if dq_flag {
15718            with_executor(|exec| exec.in_dq_context -= 1);
15719        }
15720        ret
15721    });
15722
15723    vm.register_builtin(BUILTIN_REGISTER_COMPILED_FN, |vm, argc| {
15724        let args = pop_args(vm, argc);
15725        // c:Src/exec.c:5389-5394 — `if (htok && names) { execsubst(names);
15726        // if (errflag) { state->pc = end; return 1; } }`: the name words are
15727        // prefork-expanded and globbed (c:2741-2746), so a NOMATCH defines
15728        // nothing and fails the definition.
15729        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
15730            & crate::ported::utils::ERRFLAG_ERROR
15731            != 0
15732        {
15733            return Value::Status(1); // c:5393
15734        }
15735        // c:5399 — `while ((s = (char *) ugetnode(names)))`: one function per
15736        // name the expansion produced (`function a* { … }` defines one per
15737        // matching file). The names lead the argument list; the five
15738        // definition fields follow.
15739        let nnames = args.len().saturating_sub(5);
15740        let (names, fields) = args.split_at(nnames);
15741        let mut status = Value::Status(0);
15742        for name in names {
15743            let mut one = Vec::with_capacity(fields.len() + 1);
15744            one.push(name.clone());
15745            one.extend_from_slice(fields);
15746            status = register_compiled_fn(vm, one);
15747        }
15748        status
15749    });
15750    fn register_compiled_fn(vm: &mut fusevm::VM, args: Vec<String>) -> Value {
15751        let mut iter = args.into_iter();
15752        let name = iter.next().unwrap_or_default();
15753        let body_b64 = iter.next().unwrap_or_default();
15754        let body_source = iter.next().unwrap_or_default();
15755        let line_base_str = iter.next().unwrap_or_default();
15756        let line_base: i64 = line_base_str.parse().unwrap_or(0);
15757        // c:Src/exec.c:5384-5388 — `shf->lineno = (funcstack && (tp == FS_FUNC ||
15758        // tp == FS_EVAL)) ? funcstack->flineno + lineno : lineno;`. Inside an
15759        // `eval` the compiled line is relative to the eval text, so add the
15760        // eval frame's file offset. (A function body is compiled with file
15761        // lines already, so the FS_FUNC arm needs no adjustment here.)
15762        let line_base: i64 = match crate::ported::modules::parameter::FUNCSTACK
15763            .lock()
15764            .ok()
15765            .and_then(|stk| stk.last().map(|top| (top.tp, top.flineno)))
15766        {
15767            Some((tp, flineno)) if tp == crate::ported::zsh_h::FS_EVAL && line_base >= 0 => {
15768                flineno + line_base // c:5387
15769            }
15770            _ => line_base,
15771        };
15772        // c:Src/exec.c:5382 `do_tracing = *state->pc++;` — the `-T` of
15773        // `function -T name { … }`, carried across from compile_funcdef.
15774        let tracing_text = iter.next().unwrap_or_default();
15775        let do_tracing = tracing_text.starts_with('1'); // c:5382
15776        // RUST-ONLY: a trailing `k` = defined with the `function name {` keyword.
15777        crate::extensions::dash_mode::note_function_spelling(&name, tracing_text.ends_with('k'));
15778                                                                         // c:Src/exec.c:5451-5456 — `shf->redir = <redir_prog>`: the rendered
15779                                                                         // text of the definition's trailing redirections (empty when there
15780                                                                         // were none). See `shfunc::redir_text`.
15781        let redir_text = iter.next().unwrap_or_default(); // c:5453
15782                                                          // c:5387 — `tracing_flags = do_tracing ? PM_TAGGED_LOCAL : 0;`
15783        let tracing_flags: u32 = if do_tracing {
15784            crate::ported::zsh_h::PM_TAGGED_LOCAL
15785        } else {
15786            0
15787        }; // c:5387
15788        // `#<idx>` — the body lives in the ENCLOSING chunk's `sub_chunks` and
15789        // only its index travelled through the constant pool (see
15790        // compile_zsh.rs `compile_funcdef`). `vm.chunk` is the chunk currently
15791        // executing this CallBuiltin, i.e. the one that owns the sub-chunk:
15792        // at top level the script chunk, and inside a function body the body
15793        // chunk that `vm_pool::acquire` (vm_helper.rs:4098) is running.
15794        //
15795        // Anything else is the historical `base64(bincode(chunk))` payload —
15796        // still accepted so a chunk restored from a bytecode cache written by
15797        // an older binary keeps working. `#` is outside the base64 alphabet so
15798        // the two forms cannot be confused.
15799        let sub_chunk = body_b64
15800            .strip_prefix('#')
15801            .and_then(|n| n.parse::<usize>().ok())
15802            .map(|idx| vm.chunk.sub_chunks.get(idx).cloned().ok_or(()));
15803        let decoded = match sub_chunk {
15804            Some(Ok(chunk)) => Ok(chunk),
15805            Some(Err(())) => Err(()),
15806            None => {
15807                let bytes = base64_decode(&body_b64);
15808                bincode::deserialize::<fusevm::Chunk>(&bytes).map_err(|_| ())
15809            }
15810        };
15811        let status = match decoded {
15812            Ok(chunk) => with_executor(|exec| {
15813                // c:Src/exec.c:5383 — `shf->filename =
15814                // ztrdup(scriptfilename);` — the function's
15815                // definition-file is read from the canonical
15816                // file-scope `scriptfilename` global at compile
15817                // time, NOT from a per-executor struct field.
15818                // exec.scriptfilename is seeded once at
15819                // bins/zshrs.rs:1717 to the bin basename ("zsh")
15820                // and never updates on source/dot, so reading from
15821                // it left every user function's def_file as "zsh".
15822                // Route through scriptfilename_get() so source /
15823                // dot's set_scriptfilename calls propagate.
15824                let def_file = crate::ported::utils::scriptfilename_get()
15825                    .or_else(|| exec.scriptfilename.clone());
15826                let def_file_for_prov = def_file.clone();
15827                if !body_source.is_empty() {
15828                    exec.function_source
15829                        .insert(name.clone(), body_source.clone());
15830                }
15831                exec.function_line_base.insert(name.clone(), line_base);
15832                exec.function_def_file.insert(name.clone(), def_file);
15833                // PFA-SMR aspect: every `name() {}` / `function name { }`
15834                // funnels through here at compile time. Emit one record
15835                // with the function name + raw body source.
15836                #[cfg(feature = "recorder")]
15837                if crate::recorder::is_enabled() {
15838                    let ctx = exec.recorder_ctx();
15839                    let body = if body_source.is_empty() {
15840                        None
15841                    } else {
15842                        Some(body_source.as_str())
15843                    };
15844                    crate::recorder::emit_function(&name, body, ctx);
15845                }
15846                // c:Src/exec.c:5516-5531 — `TRAP<SIG>() { ... }` is the
15847                // function-named trap install. zsh detects the `TRAP`
15848                // prefix at func-def time and calls
15849                // `settrap(signum, NULL, ZSIG_FUNC)` so the next
15850                // dispatch of that signal routes to the named shfunc.
15851                // Bug #157 in docs/BUGS.md — fusevm_bridge's funcdef
15852                // opcode skipped this dispatch entirely, so TRAPEXIT /
15853                // TRAPUSR1 / TRAPZERR / TRAPDEBUG never fired.
15854                //
15855                // ORDER MATTERS. C runs settrap at c:5518 and
15856                // `shfunctab->addnode` only at c:5539, and dosavetrap
15857                // says why (c:634-637): "Get the old function: this
15858                // assumes we haven't added the new one yet." Running
15859                // the install first made settrap→unsettrap→removetrap→
15860                // dosavetrap snapshot the NEW body, so endtrapscope
15861                // "restored" the inner definition and a nested
15862                // `TRAPEXIT() { … }` permanently clobbered the outer one.
15863                // BUGS.md #1114 — the TRAPxxx() FUNCTION-trap form is zsh-only.
15864                // bash, ksh and dash have no such concept: there `TRAPINT` is an
15865                // ordinary function whose name merely begins with TRAP, and SIGINT
15866                // keeps its default disposition. Recognising it in a drop-in mode
15867                // also SILENTLY DESTROYS an already-installed list trap for that
15868                // signal, since the two forms are alternatives in zsh.
15869                // posix_faithful() is raised only for a bare drop-in flag, so
15870                // --zsh, native zshrs and `emulate sh` are untouched.
15871                if !crate::extensions::dash_mode::posix_faithful() && name.len() > 4 && name.starts_with("TRAP") {
15872                    if let Some(sn) = crate::ported::jobs::getsigidx(&name[4..]) {
15873                        let _ = crate::ported::signals::settrap(
15874                            sn,
15875                            None,
15876                            crate::ported::zsh_h::ZSIG_FUNC as i32,
15877                        );
15878                        // c:5530 — `removetrapnode(signum);` "Remove the
15879                        // old node explicitly in case it has an
15880                        // alternative name". NOT mirrored here: zshrs's
15881                        // `jobs::removetrapnode` routes through
15882                        // `hashtable::removeshfuncnode`, which calls back
15883                        // into `unsettrap` (C explicitly avoids that path
15884                        // — see the comment at Src/signals.c:836-838) and
15885                        // would tear down the trap `settrap` just armed.
15886                        // The `tab.add` below overwrites the canonical
15887                        // `TRAP<SIG>` node anyway; only the alt-name case
15888                        // (TRAPCLD vs TRAPCHLD) is left unhandled.
15889                    }
15890                }
15891                // Mirror into canonical shfunctab so scanfunctions /
15892                // ${(k)functions} / functions builtin see user defs.
15893                // C: exec.c:funcdef → shfunctab->addnode(ztrdup(name),shf).
15894                if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
15895                    let mut shf = crate::ported::hashtable::shfunc_with_body(&name, &body_source);
15896                    // c:Src/exec.c:5453/5455 — `shf->redir = …`. Stored as
15897                    // rendered text (see `shfunc::redir_text`); `None` is C's
15898                    // `shf->redir == NULL`.
15899                    shf.redir_text = if redir_text.is_empty() {
15900                        None
15901                    } else {
15902                        Some(redir_text.clone())
15903                    };
15904                    // c:Src/exec.c:5437 — `shf->node.flags = tracing_flags;`
15905                    shf.node.flags |= tracing_flags as i32; // c:5437
15906                                                            // c:5532-5538 — /* Is this function traced and redefining
15907                                                            //                  itself? */
15908                                                            //     if (funcstack && funcstack->tp == FS_FUNC &&
15909                                                            //             !strcmp(s, funcstack->name)) {
15910                                                            //         Shfunc old = ((Shfunc)shfunctab->getnode(shfunctab, s));
15911                                                            //         if (old)
15912                                                            //             shf->node.flags |= old->node.flags &
15913                                                            //                                (PM_TAGGED|PM_TAGGED_LOCAL);
15914                                                            //     }
15915                                                            // The ported walker does this at exec.rs:7387, but fusevm —
15916                                                            // not that walker — is what registers a `name() { … }`, so a
15917                                                            // `functions -T f`-tagged function that redefined itself
15918                                                            // came back untraced (E02xtrace:6).
15919                    if let Ok(stk) = crate::ported::modules::parameter::FUNCSTACK.lock() {
15920                        if let Some(top) = stk.last() {
15921                            // c:5533
15922                            if top.tp == crate::ported::zsh_h::FS_FUNC && top.name == name {
15923                                // c:5535 — `Shfunc old = shfunctab->getnode(shfunctab, s);`
15924                                // Read through the write guard already held
15925                                // above: `shfunctab_lock()` is a std RwLock and
15926                                // is NOT reentrant, so re-acquiring it here
15927                                // self-deadlocked on exactly the
15928                                // self-redefinition case (C04funcdef:30 hung).
15929                                if let Some(old) = tab.get(&name) {
15930                                    // c:5537
15931                                    shf.node.flags |= old.node.flags
15932                                        & (crate::ported::zsh_h::PM_TAGGED as i32
15933                                            | crate::ported::zsh_h::PM_TAGGED_LOCAL as i32);
15934                                }
15935                            }
15936                        }
15937                    }
15938                    // `shfunc_with_body` stamps the AMBIENT `scriptfilename`
15939                    // (c:Src/exec.c:5383). That is right for an ordinary
15940                    // definition but wrong twice over for an autoloaded one:
15941                    //
15942                    //  * c:Src/exec.c:5622-5630 — while an autoload file's text
15943                    //    runs, C sets `scriptfilename = getshfuncfile(shf)`, so a
15944                    //    `name() { … }` INSIDE it records the fpath file. zshrs
15945                    //    runs that body on the normal VM path with the CALLER's
15946                    //    scriptfilename still in place, so a ksh-style autoload
15947                    //    file — one that defines the function and then calls it —
15948                    //    got attributed to whoever triggered the load.
15949                    //  * the function is then re-registered UNCHANGED when its
15950                    //    chunk is compiled at call time, and that second stamp
15951                    //    would overwrite the first even if the first were right.
15952                    //
15953                    // Result before this: `whence -v f` said "from ./caller.zsh"
15954                    // where zsh says "from dir/f", and `funcsourcetrace[1]`
15955                    // pointed at the caller — which compsys reads directly
15956                    // (`_git` locates git-completion.bash through
15957                    // `"$(dirname ${funcsourcetrace[1]%:*})"`).
15958                    if let Some(f) = crate::vm_helper::autoload_def_file(&name) {
15959                        shf.filename = Some(f); // c:5625 getshfuncfile(shf)
15960                    } else if let Some(prev) = tab.get(&name) {
15961                        // Re-registration of an unchanged body relabels nothing.
15962                        if prev.body.as_deref() == Some(body_source.as_str()) {
15963                            shf.filename = prev.filename.clone();
15964                            shf.node.flags |=
15965                                prev.node.flags & crate::ported::zsh_h::PM_LOADDIR as i32;
15966                        }
15967                    }
15968                    // c:Src/exec.c:5409 — `shf->lineno = lineno;`. Use
15969                    // the same max(1, line_base) clamp as the synth_shf
15970                    // in vm_helper::dispatch_function_call. Bug #396.
15971                    shf.lineno = std::cmp::max(1, line_base);
15972                    // c:Src/exec.c:5402 — `shfunc_set_sticky(shf);`
15973                    // stamps the definition-time `sticky` emulation
15974                    // snapshot onto the function so a later call can
15975                    // re-enter that emulation (doshfunc c:5978). The
15976                    // ported execfuncdef does this at exec.rs:7141, but
15977                    // fusevm — not that walker — is what actually
15978                    // registers a `name() { … }`, so `emulate sh -c
15979                    // 'f() { … }'` produced a function with no sticky
15980                    // emulation at all (B07emulate.ztst:6,7,8,12,13,14).
15981                    crate::ported::exec::shfunc_set_sticky(&mut shf);
15982                    // zshrs re-runs this registration when a function's
15983                    // chunk is (re)compiled at call time — see the
15984                    // filename note above. That second pass happens with
15985                    // the AMBIENT sticky (normally none), which would
15986                    // erase the definition-time stamp. An unchanged body
15987                    // is not a redefinition, so keep what it had.
15988                    if shf.sticky.is_none() {
15989                        if let Some(prev) = tab.get(&name) {
15990                            if prev.body.as_deref() == Some(body_source.as_str()) {
15991                                shf.sticky = prev
15992                                    .sticky
15993                                    .as_deref()
15994                                    .map(|b| crate::ported::exec::sticky_emulation_dup(b, 0));
15995                            }
15996                        }
15997                    }
15998                    // docs/BUGS.md #1105 — C settles every lexer-time
15999                    // decision when the definition runs (c:Src/exec.c:5389
16000                    // `shf->funcdef = dupeprog(…)`) and prints that wordcode
16001                    // back unchanged (c:Src/hashtable.c:954). zshrs stores
16002                    // the raw source and re-lexes it to print, so the one
16003                    // lexer-time option that survives into a deparse —
16004                    // RCQUOTES, c:Src/lex.c:1328 — has to be recorded here
16005                    // or a later `setopt rcquotes` rewrites the listing of a
16006                    // function defined long before it. Skipped for the
16007                    // re-registration of an unchanged body (the same test
16008                    // the filename and sticky stamps above use): that second
16009                    // pass runs at CALL time, whose option state is not the
16010                    // definition's.
16011                    if tab
16012                        .get(&name)
16013                        .is_none_or(|prev| prev.body.as_deref() != Some(body_source.as_str()))
16014                    {
16015                        crate::vm_helper::funcdef_note_rcquotes(
16016                            &name,
16017                            &body_source,
16018                            crate::ported::zsh_h::isset(crate::ported::zsh_h::RCQUOTES),
16019                        );
16020                    }
16021                    tab.add(shf);
16022                }
16023                // Lineage tap: this is where a `name() { … }` actually
16024                // lands in the VM path — `execfuncdef`'s shfunctab
16025                // install only runs for the interpreter path. The first
16026                // definition is the function's origin; a later one is a
16027                // `redefine` op on the same chain.
16028                if crate::provenance::active() {
16029                    crate::provenance::on_func_define(
16030                        &name,
16031                        Some(body_source.as_str()),
16032                        def_file_for_prov.as_deref(),
16033                        std::cmp::max(1, line_base),
16034                    );
16035                }
16036                exec.functions_compiled.insert(name, chunk);
16037                0
16038            }),
16039            Err(_) => 1,
16040        };
16041        Value::Status(status)
16042    }
16043
16044    // Wire the ShellHost so direct shell ops (Op::Glob, Op::TildeExpand,
16045    // Op::ExpandParam, Op::CmdSubst, Op::CallFunction, etc.) route through
16046    // ZshrsHost back into the executor.
16047    vm.set_shell_host(Box::new(ZshrsHost));
16048}
16049
16050impl ZshrsHost {
16051    /// True iff `c` can be a `(j:…:)` / `(s:…:)` delimiter — non-alphanumeric,
16052    /// non-underscore. Restricting to punctuation avoids `(jL)` consuming `L`
16053    /// as a delim instead of as the next flag.
16054    fn is_zsh_flag_delim(c: char) -> bool {
16055        !c.is_ascii_alphanumeric() && c != '_'
16056    }
16057}
16058
16059/// Shared `${name[idx]}` subscript dispatch for BUILTIN_ARRAY_INDEX
16060/// and the KSHARRAYS-unset arm of BUILTIN_ARRAY_INDEX_UNBRACED.
16061///
16062/// c:Src/subst.c subscript parsing — when paramsubst re-parses the
16063/// synthesized `${name[idx]}` body, characters like `'` `"` `\` `$`
16064/// etc. are LEXER-active inside the `[…]` and get reinterpreted
16065/// (quote-strip, paramsubst recursion, …). For PRE-EVALUATED key
16066/// strings (the dynamic-key fast path at compile_zsh.rs:3234 already
16067/// expanded `$k` via EXPAND_TEXT), the idx is a literal string that
16068/// must match the stored key byte-for-byte — no further
16069/// reinterpretation. Direct assoc lookup bypasses the lexer for this
16070/// case, avoiding the quote-strip bug where `h[a'b]` failed to
16071/// resolve because paramsubst's subscript lexer treated the `'` as a
16072/// quote. Bug #338. Only fires for simple assoc-name + non-flag idx
16073/// (no outer-flag sentinels, no `(…)` flag prefix on idx, no splat
16074/// operator). Other paths (slice, splat, flag-based search,
16075/// magic-assoc) still flow through paramsubst.
16076///
16077/// `ssub` is c:Src/subst.c:1761 `(pf_flags & PREFORK_SINGLE)` — see the
16078/// `BUILTIN_ARRAY_INDEX` argc contract. It only reaches the textual
16079/// `paramsubst` rebuild at the bottom: every direct-lookup shortcut above it
16080/// resolves a single assoc value, which c:4226's join leaves alone.
16081/// Spell an ALREADY-EXPANDED subscript so a flat `${name[…]}` rebuild reads it
16082/// back as the key/index it is.
16083///
16084/// c:Src/params.c:2047-2054 — `getindex` decides the whole-parameter splat on
16085/// the RAW bracket text, before `getarg` runs `parsestr`/`singsub` on it:
16086///
16087/// ```c
16088///     s = *pptr + 1;
16089///     if ((s[0] == '*' || s[0] == '@') && s + 1 == tbrack) {
16090///         ...  v->start = 0; v->end = -1;  s += 2;
16091///     } else {
16092///         start = getarg(&s, &inv, v, 0, &we, ...);
16093///     }
16094/// ```
16095///
16096/// So in C a subscript that merely EXPANDS to `@` or `*` can never be the
16097/// splat — the text at `s[0]` is still `$k`. zshrs expands the subscript in the
16098/// VM and then rebuilds the flat `${name[VALUE]}` string for `paramsubst`,
16099/// which puts that `@` exactly where c:2048 reads it as the splat: `k='@';
16100/// ${h[$k]}` enumerated the whole hash instead of reading the key `@`.
16101///
16102/// Every compiler route that reaches a rebuild rejects a SOURCE-literal
16103/// `[@]`/`[*]` first (`braced_subscript_ref`, `braced_subscript_dynamic_ref`
16104/// and `parse_zsh_flag_subscript` all bail on `key == "@" || key == "*"`), so
16105/// an `@`/`*` seen here is always an expansion result. Re-spell it with C's
16106/// exact-match subscript group (c:Src/params.c:1449-1450 `(e)`), which sidesteps
16107/// c:2048 — `s[0]` is `(` — and then keys on the literal text. `${h[(e)@]}` is
16108/// byte-identical to `k='@'; ${h[$k]}` on zsh 5.9 for every shape measured:
16109/// hash key (`AT`), array/scalar (`bad math expression`), `(k)` (`@`),
16110/// `${#…}` (`2`) and `${+…}` (`1`).
16111fn expanded_subscript_text(idx: &str) -> std::borrow::Cow<'_, str> {
16112    if idx == "@" || idx == "*" {
16113        std::borrow::Cow::Owned(format!("(e){}", idx)) // c:1449-1450
16114    } else {
16115        std::borrow::Cow::Borrowed(idx)
16116    }
16117}
16118
16119/// The value of one assoc element read by `array_index_lookup`. A MISSING
16120/// key reads as the empty string (c:Src/params.c:1606-1610 creates a
16121/// PM_UNSET node), and an unquoted empty word is then deleted by prefork
16122/// (c:Src/subst.c:183-186) — `print -l -- $h[missing] x` is ONE word. Returning
16123/// `Value::str("")` directly skipped that drop, so a missing key became an
16124/// empty argument; `nodes_to_value` is where the bridge applies it.
16125fn assoc_key_value(v: Option<String>) -> Value {
16126    match v {
16127        Some(s) if !s.is_empty() => Value::str(s),
16128        _ => nodes_to_value(vec![String::new()]),
16129    }
16130}
16131
16132/// `assoc_key_value` for a key read that answered from the plain hash
16133/// store. c:Src/params.c:1597-1606 hands back no node for a missing key, so
16134/// c:Src/subst.c:2804-2807 sets `vunset`, and with NO_UNSET the reference
16135/// is an error (c:3608-3613): `zerr("%s: parameter not set", idbeg)`. The
16136/// fast paths below answer before paramsubst runs, so they owe that check.
16137/// The alias views (getpmalias, c:Src/Modules/parameter.c) return an
16138/// unset-flagged node that zsh does not report, so `direct_assoc_key_get`'s
16139/// alias arms keep plain `assoc_key_value`.
16140fn hash_key_value(name: &str, idx: &str, v: Option<String>) -> Value {
16141    if v.is_none() && !crate::ported::zsh_h::isset(crate::ported::zsh_h::UNSET) {
16142        crate::ported::utils::zerr(&format!("{}[{}]: parameter not set", name, idx)); // c:3612
16143        crate::ported::utils::errflag
16144            .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, std::sync::atomic::Ordering::Relaxed);
16145        with_executor(|exec| exec.set_last_status(1));
16146        return Value::str(String::new());
16147    }
16148    assoc_key_value(v)
16149}
16150
16151/// One element of a plain indexed array by integer subscript, without the
16152/// textual `${name[idx]}` rebuild — which clones the WHOLE array on every
16153/// read, making a loop over an N-element array quadratic (8000 elements:
16154/// 2.7s against zsh's 0.017s; zpwr's zshRegenSearchableEnv.zsh indexes
16155/// `$lines[i]` over every autoload file and never finished).
16156///
16157/// `Some(elem)` only for the unambiguous case — a non-special `PM_ARRAY` in
16158/// the live paramtab, a nonzero integer subscript (a literal, or a bare
16159/// parameter holding one), KSH_ARRAYS off, and a non-empty element. Anything
16160/// else (flags, ranges, magic arrays, namerefs, empty elements whose word
16161/// elision depends on quoting) returns `None` and keeps the general path.
16162/// zshrs-original — no C counterpart (c:Src/params.c:getarg is O(1)).
16163fn array_elem_fast(name: &str, idx: &str) -> Option<String> {
16164    use crate::ported::zsh_h::{PM_ARRAY, PM_SPECIAL};
16165    if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
16166        return None;
16167    }
16168    let n: i64 = match idx.parse() {
16169        Ok(n) => n,
16170        Err(_) => {
16171            // A bare identifier holding an integer (`$a[i]`).
16172            let bare = idx
16173                .chars()
16174                .next()
16175                .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
16176                && idx.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
16177            if !bare {
16178                return None;
16179            }
16180            crate::ported::params::getsparam(idx)?.trim().parse().ok()?
16181        }
16182    };
16183    if n == 0 || name.is_empty() || matches!(name, "argv" | "@" | "*") {
16184        return None;
16185    }
16186    if crate::ported::params::is_nameref(name) {
16187        return None;
16188    }
16189    let tab = crate::ported::params::paramtab().read().ok()?;
16190    let pm = tab.get(name)?;
16191    let visible = crate::ported::modules::param_private::getprivatenode(&**pm as *const _);
16192    if visible.is_null() || !std::ptr::eq(visible, &**pm as *const _) {
16193        return None;
16194    }
16195    let flags = pm.node.flags as u32;
16196    if crate::ported::zsh_h::PM_TYPE(flags) != PM_ARRAY || flags & PM_SPECIAL != 0 {
16197        return None;
16198    }
16199    let arr = pm.u_arr.as_ref()?;
16200    let at = if n > 0 {
16201        usize::try_from(n - 1).ok()?
16202    } else {
16203        arr.len().checked_sub(usize::try_from(-n).ok()?)?
16204    };
16205    let elem = arr.get(at)?;
16206    (!elem.is_empty()).then(|| elem.clone())
16207}
16208
16209fn array_index_lookup(name: &str, idx: &str, ssub: bool) -> Value {
16210    let idx_is_simple = !idx.starts_with('(') && idx != "@" && idx != "*" && !idx.contains(',');
16211    if idx_is_simple {
16212        if let Some(elem) = array_elem_fast(name, idx) {
16213            return Value::str(elem);
16214        }
16215        // assoc_key_hit: single-lock O(1) probe — exec.assoc() clones
16216        // the WHOLE map per lookup (O(n), quadratic in shell loops).
16217        // When `name` IS an assoc, exact-key semantics apply to EVERY
16218        // plain key: hit → value, miss → empty (C `${assoc[missing]}`).
16219        // Never fall through to the textual `${name[key]}` rebuild —
16220        // keys carrying `{`/`}`/`[`/`]` (zsh-autopair probes
16221        // `${AUTOPAIR_LBOUNDS[$pair]}` with pair='{') re-parse as
16222        // broken syntax there ("failed to compile regex: repetition
16223        // quantifier…" + a `}` appended per keystroke).
16224        if let Some((_, v)) = crate::vm_helper::assoc_key_hit(name, idx) {
16225            return hash_key_value(name, idx, v);
16226        }
16227    }
16228    // c:Src/params.c:1449-1450 getindex — a leading `(e)`/`(E)` flag
16229    // group makes the subscript LITERAL (group consumed, exact key).
16230    // The textual rebuild below re-parses a FLAT `${name[(e)KEY]}`
16231    // string, so a `]` / `}` that arrived via `$key` expansion
16232    // terminates the subscript / brace early — "bad substitution" or
16233    // spilled-junk values (zpwr expandstats iterates alias keys
16234    // containing brackets). C never re-parses: getarg scans the
16235    // TOKENIZED source where expanded data brackets are inert. Do the
16236    // exact-match lookup directly against the assoc (plain or magic
16237    // alias tables); search groups ((r)/(i)/(k)/…) and other targets
16238    // keep the textual path.
16239    if let Some(rest) = idx.strip_prefix('(') {
16240        if let Some(close) = rest.find(')') {
16241            let grp = &rest[..close];
16242            if !grp.is_empty() && grp.chars().all(|ch| ch == 'e' || ch == 'E') {
16243                let key = &rest[close + 1..];
16244                if let Some(hit) = direct_assoc_key_get(name, key) {
16245                    return if is_alias_view(name) { assoc_key_value(hit) } else { hash_key_value(name, idx, hit) };
16246                }
16247            }
16248        }
16249    }
16250    // Plain assoc key that the flat rebuild would mangle (`]` closes
16251    // the subscript, `}` closes the brace): direct lookup. On a miss
16252    // return empty — the textual fallback cannot represent the key.
16253    if (idx.contains(']') || idx.contains('}')) && !idx.starts_with('(') {
16254        if let Some(hit) = direct_assoc_key_get(name, idx) {
16255            return if is_alias_view(name) { assoc_key_value(hit) } else { hash_key_value(name, idx, hit) };
16256        }
16257    }
16258    let body = format!("${{{}[{}]}}", name, expanded_subscript_text(idx));
16259    // The key here is already expanded; C validated only the raw text
16260    // (c:Src/params.c:2029-2045). See subst::SUBSCRIPT_PREEXPANDED.
16261    crate::ported::subst::SUBSCRIPT_PREEXPANDED.with(|c| c.set(true));
16262    // c:Src/subst.c:4226 — `if (isarr && ssub) { val = sepjoin(aval, NULL, 1);
16263    // isarr = 0; }`. `PREFORK_SINGLE` is the whole of `ssub` (c:1761), and the
16264    // ported `paramsubst` already carries that gate (`ssub_c4226` /
16265    // `ssub_join_c3903` in `src/ported/subst.rs`); this fast path just had no
16266    // way to say it.
16267    let v = paramsubst_to_value_pf(
16268        &body,
16269        if ssub {
16270            crate::ported::zsh_h::PREFORK_SINGLE
16271        } else {
16272            0
16273        },
16274    );
16275    // A body that never reached the subscript check must not leave the
16276    // carrier armed for the next, unrelated subscript.
16277    crate::ported::subst::SUBSCRIPT_PREEXPANDED.with(|c| c.set(false));
16278    v
16279}
16280
16281/// The `zsh/parameter` alias views `direct_assoc_key_get` serves itself.
16282fn is_alias_view(name: &str) -> bool {
16283    matches!(
16284        name,
16285        "aliases" | "galiases" | "saliases" | "dis_aliases" | "dis_galiases" | "dis_saliases"
16286    )
16287}
16288
16289/// Exact-key read against an assoc-like target WITHOUT the textual
16290/// `${name[key]}` reparse (see array_index_lookup — expanded `]`/`}`
16291/// in keys break the flat form). `Some(hit)` when `name` is a target
16292/// this helper understands (plain assoc, or the alias magic assocs of
16293/// zsh/parameter — Src/Modules/parameter.c getpmalias family);
16294/// `None` = not direct-capable, caller keeps the textual path.
16295fn direct_assoc_key_get(name: &str, key: &str) -> Option<Option<String>> {
16296    use crate::ported::zsh_h::{ALIAS_GLOBAL, DISABLED};
16297    // c:Src/Modules/parameter.c:1247+ getpmalias / getpmgalias /
16298    // getpmsalias — each view filters its table by flags.
16299    let alias_view = |global: bool, suffix: bool, disabled: bool| -> Option<String> {
16300        let tab = if suffix {
16301            crate::ported::hashtable::sufaliastab_lock()
16302        } else {
16303            crate::ported::hashtable::aliastab_lock()
16304        };
16305        tab.read().ok().and_then(|t| {
16306            t.iter().find_map(|(k, a)| {
16307                let f = a.node.flags as u32;
16308                if k == key
16309                    && ((f & ALIAS_GLOBAL as u32 != 0) == global || suffix)
16310                    && ((f & DISABLED as u32 != 0) == disabled)
16311                {
16312                    Some(a.text.clone())
16313                } else {
16314                    None
16315                }
16316            })
16317        })
16318    };
16319    match name {
16320        "aliases" => Some(alias_view(false, false, false)),
16321        "galiases" => Some(alias_view(true, false, false)),
16322        "saliases" => Some(alias_view(false, true, false)),
16323        "dis_aliases" => Some(alias_view(false, false, true)),
16324        "dis_galiases" => Some(alias_view(true, false, true)),
16325        "dis_saliases" => Some(alias_view(false, true, true)),
16326        _ => {
16327            // Single-lock O(1) probe (see assoc_key_hit) — the previous
16328            // double exec.assoc() cloned the whole map twice per lookup.
16329            crate::vm_helper::assoc_key_hit(name, key).map(|(_, v)| v)
16330        }
16331    }
16332}
16333
16334/// KSHARRAYS bare-`$name` expansion words for the unbraced
16335/// no-subscript form (BUILTIN_ARRAY_INDEX_UNBRACED's KSHARRAYS arm).
16336///
16337/// - `@` / `*` stay the full positional list (the c:Src/params.c:
16338///   2293-2296 first-element collapse is gated on
16339///   `itype_end(t, IIDENT, 1) != t` — an identifier-shaped name —
16340///   which `@`/`*` are not). The literal `[idx]` then joins the LAST
16341///   word, matching zsh 5.9: `setopt ksharrays; set -- p q;
16342///   print -- $@[0]` → `zsh:1: no matches found: q[0]`.
16343/// - Identifier-named arrays collapse to the FIRST element
16344///   (c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`).
16345/// - Assocs collapse to the first value in scan order; the `options`
16346///   magic assoc's scan order is `OPTIONTAB` bucket order (first key
16347///   `posixargzero`), matching zsh 5.9: `emulate sh -L;
16348///   print $options[posixargzero]` → `off[posixargzero]`.
16349/// - Scalars / unset names expand to their value / empty (zsh 5.9:
16350///   `setopt ksharrays; print -- $unsetvar[0]` →
16351///   `zsh:1: no matches found: [0]`).
16352fn ksharrays_bare_words(name: &str) -> Vec<String> {
16353    if name == "@" || name == "*" {
16354        return with_executor(|exec| exec.pparams());
16355    }
16356    // Magic special-parameter lookups first — mirrors the
16357    // BUILTIN_GET_VAR precedence (partab before executor tables).
16358    if let Some(vals) = crate::vm_helper::partab_array_get(name) {
16359        return vec![vals.into_iter().next().unwrap_or_default()];
16360    }
16361    if let Some(keys) = crate::vm_helper::partab_scan_keys(name) {
16362        // c:Src/params.c:2342-2350 — under KSH emulation a bare special
16363        // hash is `${hash[0]}`, same arm as the plain assoc below.
16364        if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
16365            return vec![crate::vm_helper::partab_get(name, "0").unwrap_or_default()];
16366        }
16367        let v = keys
16368            .first()
16369            .and_then(|k| crate::vm_helper::partab_get(name, k))
16370            .unwrap_or_default();
16371        return vec![v];
16372    }
16373    let arr_or_assoc = with_executor(|exec| {
16374        if let Some(arr) = exec.array(name) {
16375            // c:Src/params.c:2293-2296 — first element only.
16376            return Some(arr.first().cloned().unwrap_or_default());
16377        }
16378        if let Some(map) = exec.assoc(name) {
16379            // c:Src/params.c:2351-2358 — under KSH EMULATION a bare
16380            // `$assoc` is `${assoc[0]}` (KEY-"0" lookup), EMPTY unless the
16381            // hash has a key "0": `emulate -L ksh; typeset -A h=(a 1 b 2);
16382            // print $h` is empty. Every other mode (`setopt ksharrays`,
16383            // `emulate sh`) falls through to the first bucket value below.
16384            if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
16385                return Some(map.get("0").cloned().unwrap_or_default());
16386            }
16387            // Mirrors BUILTIN_GET_VAR's bare-assoc ordering
16388            // (sorted keys, first value).
16389            let mut keys: Vec<&String> = map.keys().collect();
16390            keys.sort();
16391            return Some(
16392                keys.first()
16393                    .and_then(|k| map.get(*k).cloned())
16394                    .unwrap_or_default(),
16395            );
16396        }
16397        None
16398    });
16399    if let Some(v) = arr_or_assoc {
16400        return vec![v];
16401    }
16402    vec![with_executor(|exec| exec.get_variable(name))]
16403}
16404
16405/// Run `body` through `crate::ported::subst::paramsubst` and convert
16406/// the resulting node list into a fusevm `Value`. Centralises the
16407/// pattern duplicated across ~10 BUILTIN_* handlers:
16408///   - build a `${...}` body string from opcode operands
16409///   - paramsubst the body
16410///   - propagate errflag to `exec.last_status`
16411///   - delegate the LinkList → Value conversion to `nodes_to_value`
16412///
16413/// **Extension** — Rust-only helper. No direct C analog because C
16414/// zsh uses LinkList everywhere; the conversion happens at the
16415/// boundary back into the VM's stack.
16416fn paramsubst_to_value(body: &str) -> Value {
16417    paramsubst_to_value_pf(body, 0)
16418}
16419
16420/// `paramsubst_to_value` with an explicit `pf_flags` (`PREFORK_*`) set.
16421///
16422/// c:Src/subst.c:1627 — `paramsubst(l, n, str, qt, pf_flags, ret_flags)`.
16423/// The only caller that needs a non-zero set today is the `${(flags)NAME}`
16424/// fast path when the word is a scalar-assignment VALUE: C preforks that with
16425/// `PREFORK_SINGLE|PREFORK_ASSIGN` (c:Src/exec.c:2603 for `x=…`,
16426/// c:Src/exec.c:4239-4241 for the typeset-family `NAME=…` argument), and
16427/// `PREFORK_SINGLE` is the `ssub` that turns off c:3913's forced split.
16428fn paramsubst_to_value_pf(body: &str, pf_flags: i32) -> Value {
16429    // c:Src/subst.c:1625 paramsubst's `qt` flag is the C signal that
16430    // the current expansion is inside `"…"`. The fast-path bridges
16431    // (BUILTIN_PARAM_*, BUILTIN_BRIDGE_BRACE_ARRAY) used to hardcode
16432    // qt=false, which silently broke DQ-only semantics inside
16433    // `${arr:^other}` / `${arr:^^other}` (Src/subst.c:3456-3520).
16434    // The executor's `in_dq_context` counter is bumped by EXPAND_TEXT
16435    // mode 1 / mode 5 before the bridge fires, so reading it here
16436    // propagates the DQ flag without changing every bridge call site.
16437    let qt = with_executor(|exec| exec.in_dq_context > 0);
16438    // c:Src/subst.c:318-324 — this is the bridge's stand-in for stringsubst's
16439    // paramsubst call, and that call site is where SH_WORD_SPLIT enters:
16440    //     if ((isset(SHWORDSPLIT) && !(pf_flags & PREFORK_NOSHWORDSPLIT)) ||
16441    //         (pf_flags & PREFORK_SPLIT))
16442    //         pf_flags |= PREFORK_SHWORDSPLIT;
16443    //     node = paramsubst(list, node, &str, qt,
16444    //         pf_flags & (PREFORK_SINGLE|PREFORK_SHWORDSPLIT|PREFORK_SUBEXP), …);
16445    // paramsubst's `spbreak` (c:1707) reads only that flag, never the option.
16446    // Passing the callers' bare flags left every compiled `${…}` fast path
16447    // unsplit: `setopt shwordsplit; s='a o b'; print -l ${s/o/x}` printed one
16448    // word where zsh prints `a` `x` `b` (same for `${s:-x}`, `${s#a}`,
16449    // `${s:1}`, `${(U)s}`, `${${s}/o/ }`). A scalar-assignment value still
16450    // passes PREFORK_SINGLE, which keeps `spbreak` off there.
16451    let pf_flags = {
16452        use crate::ported::zsh_h::{
16453            PREFORK_NOSHWORDSPLIT, PREFORK_SHWORDSPLIT, PREFORK_SINGLE, PREFORK_SPLIT,
16454            PREFORK_SUBEXP, SHWORDSPLIT,
16455        };
16456        let mut f = pf_flags;
16457        if (crate::ported::zsh_h::isset(SHWORDSPLIT) && f & PREFORK_NOSHWORDSPLIT == 0)
16458            || f & PREFORK_SPLIT != 0
16459        {
16460            f |= PREFORK_SHWORDSPLIT; // c:321
16461        }
16462        f & (PREFORK_SINGLE | PREFORK_SHWORDSPLIT | PREFORK_SUBEXP) // c:324-325
16463    };
16464    let mut ret_flags: i32 = 0;
16465    // c:Src/subst.c:183-186 — `body` is the `${…}` BODY, not the word: the
16466    // caller's literals are attached by its own concat opcodes afterwards.
16467    // `PARAMSUBST_AFFIXES_DEFERRED` (set by `BUILTIN_WORD_DEFER_EMPTIES`)
16468    // says the caller will also run the end-of-word empty-node removal, so
16469    // `paramsubst` must not run it early — see that declaration in
16470    // `src/ported/subst.rs`.
16471    //
16472    // The declaration belongs to the OUTERMOST expansion of the word only.
16473    // A bridge expansion re-entered from inside this one is its own
16474    // finished word with nobody left to attach anything to it, so it keeps
16475    // the removal — `IN_BRIDGE_PARAMSUBST` is that re-entry guard (the
16476    // ported `IN_PARAMSUBST_NEST` covers only the `stringsubst` route).
16477    // Both cells are restored afterwards so the word's remaining segments
16478    // still defer (`A${a%z}B${b%z}C`).
16479    let reentered = IN_BRIDGE_PARAMSUBST.with(|c| c.replace(true));
16480    let saved_defer = crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get());
16481    if reentered {
16482        crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(0));
16483    }
16484    let (_full, _pos, nodes) =
16485        crate::ported::subst::paramsubst(body, 0, qt, pf_flags, &mut ret_flags);
16486    let null_return = take_paramsubst_null();
16487    crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(saved_defer));
16488    IN_BRIDGE_PARAMSUBST.with(|c| c.set(reentered));
16489    if null_return {
16490        // c:Src/subst.c:1878/326-327 — the word ends at this `$`. For a scalar
16491        // nothing of this expansion is kept. For an array, c:4383-4394 had
16492        // already inserted the elements evaluated before the failing one, so
16493        // those survive as words (`arr=(one '$(' two); ${(e)arr}` is `one`).
16494        // See BUILTIN_PREFORK_CUT_CHECK.
16495        let kept: Vec<String> = nodes
16496            .into_iter()
16497            .filter(|n| !n.is_empty())
16498            .map(|n| crate::ported::lex::untokenize(&n).to_string())
16499            .collect();
16500        return if kept.is_empty() {
16501            Value::str(String::new())
16502        } else {
16503            nodes_to_value(kept)
16504        };
16505    }
16506    // c:Src/exec.c:3523-3525 — an expansion error aborts the command with
16507    // `if (errflag) { if (!lastval) lastval = 1; …`: a non-zero status from
16508    // the previous command survives (`nosuchcmd; print ${.bad}` exits 127).
16509    if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
16510        with_executor(|exec| {
16511            if exec.last_status() == 0 {
16512                exec.set_last_status(1);
16513            }
16514        });
16515    }
16516    // c:Src/lex.c untokenize — the final argv pass C runs on every
16517    // expanded word (glob.c:1862 / exec.c) DROPS the Nularg
16518    // empty-word sentinel (c:2089 `if (c != Nularg)`) that
16519    // remnulargs faithfully leaves in place (glob.c:3673 re-adds it
16520    // for all-empty results). These fast-path bridges are terminal —
16521    // their output lands directly in argv slots — so apply it here.
16522    // Without it, quoted splits with empty pieces ("${(s:|:)x}" on
16523    // "|a|b|") leak Nularg into argv.
16524    // c:Src/subst.c:178-181 — `prefork` runs `filesub` on the word AFTER
16525    // `stringsubst` and BEFORE anything untokenizes it, which is what makes a
16526    // `~` inside an expansion body expand: `filesubstr` (c:741) matches the
16527    // lexer's `Tilde` TOKEN, never an ASCII `~`. These bridges are terminal
16528    // and the untokenize below folds that token back to `~`, so the filesub
16529    // the word path runs afterwards had nothing left to act on and
16530    // `${XDG_CACHE_HOME:-~/.cache}` came back literal (powerlevel10k's dump
16531    // path, powerlevel10k.zsh-theme:62). Run C's order here: filesub first,
16532    // untokenize second.
16533    //
16534    // Gated exactly like the prefork site (subst.rs:396): not under
16535    // `SHFILEEXPANSION` (whose file expansion runs BEFORE substitution, so an
16536    // expansion result is never tilde-expanded), not inside `"…"` (`qt`), and
16537    // not while `SKIP_FILESUB` marks a `${var/pat/repl}` pattern context where
16538    // a literal `~` must survive.
16539    // The word this expansion belongs to runs its own end-of-word drop.
16540    // A lone node that is NOTHING BUT the c:36 `nulstring` marker is an
16541    // empty FIELD a split produced, not an empty word. C drops the marker
16542    // in its final untokenize pass (c:Src/lex.c:2089), but prefork's
16543    // node-removal decision at c:183 was made before that, with the marker
16544    // still in place, so the word survives as "". Folding it here instead
16545    // hands `nodes_to_value` a node it cannot tell from a genuine empty,
16546    // and its single-node drop deletes it — see the trace there. `IFS=:;
16547    // v=":"; set -- $v` under `--dash` reported `$#` as 0 where dash, bash
16548    // and ksh all report 1.
16549    // Only outside `"…"`. A quoted expansion keeps its empty string on its
16550    // own (`nodes_to_value`'s `in_dq_context` arm), so there is no drop to
16551    // protect it from — and the paths a quoted word takes do not all run
16552    // remnulargs afterwards, so keeping the marker there put a literal
16553    // Nularg in the output: `b=(1 2 3 4 5); c=(2 4 6);
16554    // print -r -- "[${(j: :)b:*c}]"` printed `[¡]` instead of `[]`.
16555    let lone_marker = !qt
16556        && nodes.len() == 1
16557        && nodes[0].chars().eq(std::iter::once(crate::ported::zsh_h::Nularg));
16558    let keep_nulargs = (saved_defer > 0 && !reentered) || lone_marker;
16559    let do_filesub = !qt
16560        && !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHFILEEXPANSION)
16561        && !crate::ported::subst::SKIP_FILESUB.with(|c| c.get());
16562    let nodes: Vec<String> = nodes
16563        .into_iter()
16564        .map(|n| {
16565            let n = if do_filesub {
16566                crate::ported::subst::filesub(
16567                    &n,
16568                    pf_flags
16569                        & (crate::ported::zsh_h::PREFORK_TYPESET
16570                            | crate::ported::zsh_h::PREFORK_ASSIGN),
16571                )
16572            } else {
16573                n
16574            };
16575            // c:36 `nulstring` survives for the word's end-of-word drop (see
16576            // BUILTIN_WORD_ELIDE_EMPTY / BUILTIN_ARRAY_DROP_EMPTY): untokenize
16577            // would fold it to "", which that drop cannot tell from a real
16578            // empty node.
16579            if keep_nulargs && n.contains(crate::ported::zsh_h::Nularg) {
16580                n.split(crate::ported::zsh_h::Nularg)
16581                    .map(crate::ported::lex::untokenize)
16582                    .collect::<Vec<_>>()
16583                    .join(&crate::ported::zsh_h::Nularg.to_string())
16584            } else {
16585                crate::ported::lex::untokenize(&n)
16586            }
16587        })
16588        .collect();
16589    let value = nodes_to_value(nodes);
16590    // Provenance: this is the funnel every `${...}` bytecode fast path
16591    // reaches, so it is where a tracked parameter's chain is handed to
16592    // the value the expansion produced.
16593    if crate::provenance::active() {
16594        if let Some(name) = prov_subst_name(body) {
16595            crate::provenance::on_param_read(&name, &value);
16596        }
16597    }
16598    value
16599}
16600
16601/// !!! RUST-ONLY ADAPTER — the dual `$?` store !!!
16602///
16603/// `paramsubst_to_value_pf` for the compiled `${…}` fast-path builtins, whose
16604/// body can run a command substitution (`${y:-$(exit 4)}`, `${y#$(…)}`).
16605/// C has ONE `lastval`: getoutput's `lastval = cmdoutval` (c:Src/exec.c:4775)
16606/// is what a later `$?` in the same command reads, so
16607/// `print ${y:-$(exit 4)} $?` prints `4`. zshrs also keeps `vm.last_status`,
16608/// which `BUILTIN_GET_VAR` treats as authoritative, so the inner status has
16609/// to be handed back to it — the same two-way mirror `BUILTIN_CMD_SUBST_TEXT`
16610/// and `BUILTIN_EXPAND_TEXT` do around their own cmd-subst runs.
16611fn paramsubst_to_value_status(vm: &mut fusevm::VM, body: &str, pf_flags: i32) -> Value {
16612    // The substitution sees the live `$?` (`false; print ${y:-$(print $?)}`).
16613    let live_status = vm.last_status;
16614    with_executor(|exec| exec.set_last_status(live_status));
16615    let v = paramsubst_to_value_pf(body, pf_flags);
16616    vm.last_status = with_executor(|exec| exec.last_status()); // c:4775
16617    v
16618}
16619
16620/// Parameter name inside a `${...}` expansion body, for the provenance
16621/// tap in `paramsubst_to_value_pf`. Skips a leading `(flags)` group and
16622/// the `#`/`+`/`^`/`=`/`~` prefix sigils, then takes the identifier —
16623/// `${(k)assoc[key]}` yields `assoc`, `${#F}` yields `F`. Returns `None`
16624/// for the forms with no single named source (`$(...)`, `${(%)...}`).
16625fn prov_subst_name(body: &str) -> Option<String> {
16626    let mut rest = body.strip_prefix("${").or_else(|| body.strip_prefix('$'))?;
16627    rest = rest.trim_end_matches('}');
16628    // Skip one leading `(flags)` group.
16629    if let Some(after) = rest.strip_prefix('(') {
16630        rest = after.split_once(')').map(|(_, r)| r)?;
16631    }
16632    rest = rest.trim_start_matches(['#', '+', '^', '=', '~']);
16633    let name: String = rest
16634        .chars()
16635        .take_while(|c| c.is_alphanumeric() || *c == '_')
16636        .collect();
16637    if name.is_empty() {
16638        None
16639    } else {
16640        Some(name)
16641    }
16642}
16643
16644/// Wrap a `Vec<String>` (e.g. paramsubst nodes, multsub parts,
16645/// xpandbraces output) into a fusevm `Value`: 0 → empty Array, 1 →
16646/// Str, >1 → Array. Same unwrap idiom every handler that calls a
16647/// canonical Vec-returning fn does.
16648/// c:Src/subst.c:1663 — `int plan9 = isset(RCEXPANDPARAM);`
16649///
16650/// zsh calls the RC_EXPAND_PARAM word shape "plan9" after the rc(1)
16651/// shell it comes from. The option is read fresh on every expansion
16652/// (`setopt` mid-script changes the very next word), so the concat
16653/// builtins must consult it at RUNTIME, not bake it in at compile time.
16654fn plan9_active() -> bool {
16655    with_executor(|_exec| opt_state_get("rcexpandparam").unwrap_or(false))
16656}
16657
16658thread_local! {
16659    /// The `isarr` bit that `Value` cannot carry.
16660    ///
16661    /// c:Src/subst.c:4245 `if (isarr)` gates the whole array emit block,
16662    /// so plan9's word-removal rule (c:4362 `uremnode`) only ever applies
16663    /// to an ARRAY-valued expansion. An empty SCALAR has `isarr == 0`,
16664    /// takes the c:4437 scalar branch, and leaves the surrounding text
16665    /// intact — `setopt rcexpandparam; v=; print -rl -- x$v y` prints `x`
16666    /// and `y`, while the same line with an empty ARRAY prints only `y`.
16667    ///
16668    /// zshrs collapses BOTH shapes to `Value::Array(vec![])`: a real empty
16669    /// array, and an unquoted empty scalar (collapsed so a standalone `$v`
16670    /// contributes zero argv words, mirroring prefork's `uremnode` at
16671    /// c:Src/subst.c:184-187). The two are indistinguishable by the time
16672    /// they reach a concat builtin, so the expansion builtins record here
16673    /// whether the empty Array they just produced came from a scalar.
16674    ///
16675    /// Sticky until the next expansion overwrites it — a word folds its
16676    /// segments left-associatively (`concat(concat(x, $e), y)`), so the
16677    /// propagating second concat must still see the first one's bit.
16678    /// Consequence: only a builtin that RETURNS an empty `Value::Array` may
16679    /// write the cell. A builtin returning an empty SCALAR `Value` must leave
16680    /// it alone, or it resurrects a word an earlier empty array deleted
16681    /// (`a=(); x${a}"${P}"y` is zero words in zsh) — see the
16682    /// `word_has_quoted_span` arm of `BUILTIN_EXPAND_TEXT`.
16683    static EMPTY_EXPANSION_IS_SCALAR: std::cell::Cell<bool> =
16684        const { std::cell::Cell::new(false) };
16685}
16686
16687/// Record whether the empty expansion just produced was a scalar (`true`)
16688/// or a genuine array (`false`). See `EMPTY_EXPANSION_IS_SCALAR`.
16689fn note_empty_is_scalar(is_scalar: bool) {
16690    EMPTY_EXPANSION_IS_SCALAR.with(|c| c.set(is_scalar));
16691}
16692
16693/// True when the empty `Value::Array` about to be concatenated stands for
16694/// an empty SCALAR (c:4438-4467), not an empty array (c:4362).
16695fn empty_is_scalar() -> bool {
16696    EMPTY_EXPANSION_IS_SCALAR.with(|c| c.get())
16697}
16698
16699thread_local! {
16700    /// The other `isarr` bit `Value` cannot carry: whether an empty element
16701    /// of the array now on the stack is ELIDABLE.
16702    ///
16703    /// c:Src/subst.c:36 `char nulstring[] = {Nularg, '\0'};` — zsh does not
16704    /// represent a split-produced empty field as the empty string. Every
16705    /// place that manufactures one (the SH_WORD_SPLIT scalar split at
16706    /// c:3919 `sepsplit`, command substitution's split, the quoted-array
16707    /// retention at c:4354 `if (qt && !*y && isarr != 2) y =
16708    /// dupstring(nulstring)`) stores `nulstring`, whose node text is
16709    /// NON-empty — so prefork's node-removal test at c:183
16710    /// `if (*(char *)getdata(node))` KEEPS it, and `remnulargs` (c:170)
16711    /// turns it back into "" afterwards. Only an empty element that came
16712    /// from a genuine ARRAY read is stored as a true empty string, and only
16713    /// that one reaches c:186 `uremnode`.
16714    ///
16715    /// zshrs renders both shapes as `String::new()`, so the marker C tests
16716    /// on is gone by the time a word is assembled. This cell carries it
16717    /// instead: `get_var_impl` sets it on the unquoted array/assoc read
16718    /// paths (C's `isarr` != 2 array reference), and `BUILTIN_WORD_ELIDE_EMPTY`
16719    /// consumes it. Without the distinction the elide either dropped
16720    /// split fields zsh keeps (`setopt shwordsplit; IFS=:; s='a::b';
16721    /// print -rl -- ${s}` is `a` `` `b`; `IFS=:; print -rl -- $(printf
16722    /// 'a::b')` likewise) or kept array empties zsh drops.
16723    ///
16724    /// Sticky across a word's segment folds, cleared when an end-of-word
16725    /// drop builtin consumes it.
16726    static ARRAY_EMPTIES_ELIDABLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
16727}
16728
16729thread_local! {
16730    /// The opposite bit: this word's array came out of a QUOTED splat whose
16731    /// empty elements C stores as `nulstring`, so the end-of-word drop must
16732    /// KEEP them.
16733    ///
16734    /// c:Src/subst.c:4387, :4404, :4426 — every node the array emit block
16735    /// produces runs `if (qt && !*y && isarr != 2) y = dupstring(nulstring)`.
16736    /// `nulstring` (c:36 `{Nularg, '\0'}`) is NON-empty, so c:183's
16737    /// `if (*(char *)getdata(node))` keeps the node and c:186's `uremnode`
16738    /// never sees it; `remnulargs` (c:170) turns it back into `""`.
16739    ///
16740    /// The rule has a RUN-time cut-off that no compile-time gate can express:
16741    /// c:4261 `if ((!aval[0] || !aval[1]) && !plan9)` claims the empty and
16742    /// single-element cases FIRST and strips the surrounding `Dnull` markers
16743    /// (c:4272-4274 `*--aptr = '\0', fstr++`), so those nodes really are empty
16744    /// and c:186 deletes them. That is why `a=(); b=(); "${a[@]}""${b[@]}"` is
16745    /// ZERO words while `a=(x '' y); PRE"${a[@]}"POST` is three. Hence the bit
16746    /// is set by the READ (which knows the element count) and only when the
16747    /// compile site marked the splat quoted (`BUILTIN_ARRAY_ALL` argc 4, or
16748    /// `BUILTIN_GET_VAR_DQ` on the `@` positional).
16749    ///
16750    /// Word-scoped: cleared when a word opens (`BUILTIN_WORD_DEFER_EMPTIES`
16751    /// argc 0), cleared by any UNQUOTED array read
16752    /// (`note_array_empties_elidable`, its exact complement), and consumed by
16753    /// the end-of-word drop.
16754    ///
16755    /// Known residue: the bit is per-WORD, while C's `nulstring` is per-NODE,
16756    /// so a word mixing a quoted and an unquoted splat
16757    /// (`PRE"${a[@]}"${b[@]}POST`) keeps both sets of empties where C keeps
16758    /// only the quoted one.
16759    static QUOTED_SPLICE_KEEPS_EMPTIES: std::cell::Cell<bool> =
16760        const { std::cell::Cell::new(false) };
16761}
16762
16763/// Record that the value just read is a genuine ARRAY reference, so an empty
16764/// element of it is a true empty word. See `ARRAY_EMPTIES_ELIDABLE`.
16765fn note_array_empties_elidable() {
16766    ARRAY_EMPTIES_ELIDABLE.with(|c| c.set(true));
16767    // c:4387's `qt` half is false for this read, so nothing it produced is
16768    // `nulstring`. The two bits are complements; do not let a previous word's
16769    // quoted splat keep this one's empties.
16770    QUOTED_SPLICE_KEEPS_EMPTIES.with(|c| c.set(false));
16771}
16772
16773/// Read and clear the elidable-empties bit. See `ARRAY_EMPTIES_ELIDABLE`.
16774fn take_array_empties_elidable() -> bool {
16775    ARRAY_EMPTIES_ELIDABLE.with(|c| c.replace(false))
16776}
16777
16778/// c:Src/subst.c:4387 — record that a QUOTED splat of `n` elements was read.
16779/// c:4261's cut-off means the `nulstring` rewrite is only reachable for
16780/// `n >= 2`; below that the emit block strips the quotes and the node is a
16781/// true empty. See `QUOTED_SPLICE_KEEPS_EMPTIES`.
16782fn note_quoted_splice_elems(n: usize) {
16783    QUOTED_SPLICE_KEEPS_EMPTIES.with(|c| c.set(n >= 2));
16784}
16785
16786/// Read and clear the quoted-splat bit. See `QUOTED_SPLICE_KEEPS_EMPTIES`.
16787fn take_quoted_splice_keeps_empties() -> bool {
16788    QUOTED_SPLICE_KEEPS_EMPTIES.with(|c| c.replace(false))
16789}
16790
16791/// Declare that the word now being assembled attaches its own literals, so
16792/// `paramsubst` must leave c:183-186's empty-node removal to this word's
16793/// end-of-word drop. See `BUILTIN_WORD_DEFER_EMPTIES` and, in
16794/// `src/ported/subst.rs`, `PARAMSUBST_AFFIXES_DEFERRED`.
16795fn open_deferred_affix_word() {
16796    crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(c.get() + 1));
16797    // c:4387's `qt`/`nulstring` bit belongs to the word being assembled, so a
16798    // word that opens starts from a clean one — a preceding whole-word
16799    // `"${a[@]}"` emits no end-of-word drop and would otherwise leave it set.
16800    QUOTED_SPLICE_KEEPS_EMPTIES.with(|c| c.set(false));
16801}
16802
16803/// End the declaration `open_deferred_affix_word` made — the marker's
16804/// closing half (argc 1), emitted by `compile_zsh` immediately after this
16805/// word's drop. Pairing it with the OPEN rather than with the drop itself
16806/// matters: `BUILTIN_ARRAY_DROP_EMPTY` is also emitted for a bare splice
16807/// segment that opened nothing (`P${b[@]}${a%z}Q` emits one for `${b[@]}`),
16808/// and letting that close the enclosing word ended the declaration before
16809/// `${a%z}` was reached.
16810///
16811/// The `PARAMSUBST_EMPTIES_DEFERRED` report is cleared only when the
16812/// OUTERMOST word closes: the drop that consumes it is that word's.
16813fn close_deferred_affix_word() {
16814    let depth = crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| {
16815        c.set((c.get() - 1).max(0));
16816        c.get()
16817    });
16818    if depth == 0 {
16819        crate::ported::subst::PARAMSUBST_EMPTIES_DEFERRED.with(|c| c.set(false));
16820    }
16821}
16822
16823thread_local! {
16824    /// True while `paramsubst_to_value_pf` is inside `paramsubst`.
16825    ///
16826    /// The deferral `BUILTIN_WORD_DEFER_EMPTIES` declares is a property of
16827    /// the WORD the compiler is assembling, so it applies to that word's own
16828    /// segment expansions and to nothing they in turn expand. A bridge
16829    /// expansion re-entered from inside one (a nested body the compiler
16830    /// routed through its own opcodes, or anything the ported code drives
16831    /// back through this funnel) is a finished word already, so c:183-186's
16832    /// removal stays where it is for that one.
16833    static IN_BRIDGE_PARAMSUBST: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
16834}
16835
16836/// Re-assert `was_scalar` when `v` is an empty result.
16837///
16838/// For a pass-through stage — one that rewrites word TEXT but cannot turn a
16839/// scalar into an array or vice versa — the shape bit belongs to whichever
16840/// expansion produced the value, not to the stage. Such stages usually end in
16841/// `nodes_to_value`, which records "array" for an empty result, so without
16842/// this the producer's bit is lost before `concat_plan9` reads it.
16843/// Non-empty results carry their shape in the `Value` itself and are
16844/// returned untouched.
16845fn restore_empty_shape(v: Value, was_scalar: bool) -> Value {
16846    if matches!(&v, Value::Array(a) if a.is_empty()) {
16847        note_empty_is_scalar(was_scalar);
16848    }
16849    v
16850}
16851
16852/// `concat_splice` with the provenance tap applied to the result. Every
16853/// concat bytecode op routes through this (or `concat_plan9_prov`), so a
16854/// word built out of a tracked parameter keeps that parameter's lineage
16855/// even though the concatenated bytes are a fresh allocation.
16856fn concat_splice_prov(lhs: Value, rhs: Value) -> Value {
16857    if !crate::provenance::active() {
16858        return concat_splice(lhs, rhs);
16859    }
16860    let (l, r) = (lhs.clone(), rhs.clone());
16861    let out = concat_splice(lhs, rhs);
16862    crate::provenance::on_concat(&l, &r, &out);
16863    out
16864}
16865
16866/// `concat_plan9` with the provenance tap. See `concat_splice_prov`.
16867fn concat_plan9_prov(lhs: Value, rhs: Value) -> Value {
16868    if !crate::provenance::active() {
16869        return concat_plan9(lhs, rhs);
16870    }
16871    let (l, r) = (lhs.clone(), rhs.clone());
16872    let out = concat_plan9(lhs, rhs);
16873    crate::provenance::on_concat(&l, &r, &out);
16874    out
16875}
16876
16877/// c:Src/subst.c:4366-4437 — the NON-plan9 arm of paramsubst's array
16878/// emit block: "simply join the first and last values."
16879///
16880/// The word prefix (`ostr..aptr`) is concatenated onto element 0
16881/// (c:4386 `strcatsub(&y, ostr, aptr, x, xlen, NULL, …)`), the interior
16882/// elements are emitted bare (c:4393-4412), and the word suffix (`fstr`)
16883/// is concatenated onto the final element (c:4414-4429). Applied left-
16884/// associatively across a word's segments this reproduces zsh's
16885/// `pre${arr}post` → `prep` / `q` / `rpost`.
16886///
16887/// An EMPTY array never reaches this arm in C: c:4261
16888/// `if ((!aval[0] || !aval[1]) && !plan9)` collapses it to the scalar ""
16889/// first, so the surrounding text survives as one word (`x$e y` → `x`).
16890/// That is what the empty-Array arms below reproduce.
16891fn concat_splice(lhs: Value, rhs: Value) -> Value {
16892    match (lhs, rhs) {
16893        (Value::Array(la), Value::Array(ra)) => {
16894            if la.is_empty() {
16895                return Value::Array(ra);
16896            }
16897            if ra.is_empty() {
16898                return Value::Array(la);
16899            }
16900            // Last of la merges with first of ra; rest unchanged.
16901            let mut la = la.to_vec();
16902            let last_l = la.pop().unwrap();
16903            let mut ra_iter = ra.iter().cloned();
16904            let first_r = ra_iter.next().unwrap();
16905            let l_s = last_l.as_str_cow();
16906            let r_s = first_r.as_str_cow();
16907            let mut merged = String::with_capacity(l_s.len() + r_s.len());
16908            merged.push_str(&l_s);
16909            merged.push_str(&r_s);
16910            la.push(Value::str(merged));
16911            la.extend(ra_iter);
16912            Value::array(la)
16913        }
16914        (Value::Array(la), rhs_scalar) => {
16915            // c:4261 — empty array + empty surrounding text is zero
16916            // words, not one empty word. Bug #120 in docs/BUGS.md:
16917            // `b=("${a[@]:0:-1}")` gave len=1 instead of zsh's len=0.
16918            let rhs_s = rhs_scalar.as_str_cow();
16919            if la.is_empty() {
16920                if rhs_s.is_empty() {
16921                    return Value::array(Vec::new());
16922                }
16923                return Value::str(rhs_s.to_string());
16924            }
16925            let mut la = la.to_vec();
16926            let last = la.pop().unwrap();
16927            let l_s = last.as_str_cow();
16928            let mut s = String::with_capacity(l_s.len() + rhs_s.len());
16929            s.push_str(&l_s);
16930            s.push_str(&rhs_s);
16931            la.push(Value::str(s));
16932            Value::array(la)
16933        }
16934        (lhs_scalar, Value::Array(ra)) => {
16935            let lhs_s = lhs_scalar.as_str_cow();
16936            if ra.is_empty() {
16937                // Symmetric c:4261 empty-array rule; see the arm above.
16938                if lhs_s.is_empty() {
16939                    return Value::array(Vec::new());
16940                }
16941                return Value::str(lhs_s.to_string());
16942            }
16943            let mut ra = ra.to_vec();
16944            let first = ra.remove(0);
16945            let r_s = first.as_str_cow();
16946            let mut s = String::with_capacity(lhs_s.len() + r_s.len());
16947            s.push_str(&lhs_s);
16948            s.push_str(&r_s);
16949            let mut out = Vec::with_capacity(ra.len() + 1);
16950            out.push(Value::str(s));
16951            out.extend(ra);
16952            Value::array(out)
16953        }
16954        (lhs_s, rhs_s) => {
16955            let l = lhs_s.as_str_cow();
16956            let r = rhs_s.as_str_cow();
16957            let mut s = String::with_capacity(l.len() + r.len());
16958            s.push_str(&l);
16959            s.push_str(&r);
16960            Value::str(s)
16961        }
16962    }
16963}
16964
16965/// c:Src/subst.c:4316-4365 — the plan9 (RC_EXPAND_PARAM) arm of
16966/// paramsubst's array emit block.
16967///
16968/// Every element gets the FULL word prefix and suffix
16969/// (c:4341 `strcatsub(&y, ostr, aptr, x, xlen, y + 1, …)` inside the
16970/// per-element loop), giving the cross product with the surrounding
16971/// text: `pre${arr}post` → `preppost` / `preqpost` / `prerpost`.
16972///
16973/// An EMPTY array removes the WHOLE word: the c:4327
16974/// `while ((x = *aval++))` loop body never runs, so `plan9` is still
16975/// non-zero at c:4362 and the node is deleted —
16976/// `if (plan9) { uremnode(l, n); return n; }` (c:4362-4365). `e=();
16977/// setopt RC_EXPAND_PARAM; print -rl -- x$e y` prints only `y`. This is
16978/// the opposite of the non-plan9 rule at c:4261, which keeps `x`.
16979fn concat_plan9(lhs: Value, rhs: Value) -> Value {
16980    // c:4245 `if (isarr)` — an empty SCALAR never enters the array emit
16981    // block, so it contributes "" and the word survives (c:4437). Only a
16982    // real empty ARRAY reaches c:4362's `uremnode`. `Value` cannot tell
16983    // the two apart; EMPTY_EXPANSION_IS_SCALAR carries the missing bit.
16984    let scalar_empty = empty_is_scalar();
16985    match (lhs, rhs) {
16986        // c:4362-4365 — an empty array on either side deletes the word.
16987        // Propagated as an empty Array so a later concat in the same word
16988        // (`x${e[@]}y` folds twice) keeps the word deleted; pop_args
16989        // splats an empty Array into zero argv words.
16990        (Value::Array(la), rhs_v) if la.is_empty() => {
16991            if scalar_empty {
16992                // Empty scalar prefix: "" + rhs (c:4437 strcatsub).
16993                return match rhs_v {
16994                    Value::Array(ra) if ra.is_empty() => Value::array(Vec::new()),
16995                    other => other,
16996                };
16997            }
16998            Value::array(Vec::new())
16999        }
17000        (lhs_v, Value::Array(ra)) if ra.is_empty() => {
17001            if scalar_empty {
17002                // Empty scalar suffix: lhs + "" (c:4437 strcatsub).
17003                return lhs_v;
17004            }
17005            Value::array(Vec::new())
17006        }
17007        (Value::Array(la), Value::Array(ra)) => {
17008            let mut out = Vec::with_capacity(la.len() * ra.len());
17009            for a in la.iter() {
17010                let a_s = a.as_str_cow();
17011                for b in ra.iter() {
17012                    let b_s = b.as_str_cow();
17013                    let mut s = String::with_capacity(a_s.len() + b_s.len());
17014                    s.push_str(&a_s);
17015                    s.push_str(&b_s);
17016                    out.push(Value::str(s));
17017                }
17018            }
17019            Value::array(out)
17020        }
17021        (Value::Array(la), rhs_scalar) => {
17022            let r = rhs_scalar.as_str_cow();
17023            let out: Vec<Value> = la
17024                .iter()
17025                .map(|a| {
17026                    let a_s = a.as_str_cow();
17027                    let mut s = String::with_capacity(a_s.len() + r.len());
17028                    s.push_str(&a_s);
17029                    s.push_str(&r);
17030                    Value::str(s)
17031                })
17032                .collect();
17033            Value::array(out)
17034        }
17035        (lhs_scalar, Value::Array(ra)) => {
17036            let l = lhs_scalar.as_str_cow();
17037            let out: Vec<Value> = ra
17038                .iter()
17039                .map(|b| {
17040                    let b_s = b.as_str_cow();
17041                    let mut s = String::with_capacity(l.len() + b_s.len());
17042                    s.push_str(&l);
17043                    s.push_str(&b_s);
17044                    Value::str(s)
17045                })
17046                .collect();
17047            Value::array(out)
17048        }
17049        (lhs_s, rhs_s) => {
17050            // Both scalar: nothing to distribute (c:4444 scalar branch).
17051            let l = lhs_s.as_str_cow();
17052            let r = rhs_s.as_str_cow();
17053            let mut s = String::with_capacity(l.len() + r.len());
17054            s.push_str(&l);
17055            s.push_str(&r);
17056            Value::str(s)
17057        }
17058    }
17059}
17060
17061/// Flatten one word-segment `Value` into its element strings: an Array splats
17062/// to its items, a scalar is a single element.
17063fn word_seg_elems(v: &Value) -> Vec<String> {
17064    match v {
17065        Value::Array(items) => items.iter().map(|i| i.as_str_cow().into_owned()).collect(),
17066        other => vec![other.as_str_cow().into_owned()],
17067    }
17068}
17069
17070/// Assemble a DQ word from its segments, mixing plan9 (`^`, cross-product) and
17071/// non-plan9 (splice) segments in one pass — see BUILTIN_WORD_ASSEMBLE_PLAN9.
17072///
17073/// c:Src/subst.c:4316-4437 — zsh threads a "growing edge" (`aptr`/`fstr`)
17074/// through the whole word: an element stays active until a splice freezes all
17075/// but the last. `active_lo` is the index where that active tail begins.
17076///   * plan9 segment  → every active element crosses with EVERY new element;
17077///     all results stay active (c:4316-4350 cartesian). An empty plan9 array
17078///     deletes the word (c:4362 `uremnode`).
17079///   * splice segment → every active element takes the FIRST new element, the
17080///     remaining new elements append as fresh words; the last becomes the new
17081///     growing edge (c:4366-4437 first/last join). A single-element splice keeps
17082///     the whole active tail active (nothing frozen). An empty splice array
17083///     contributes nothing and leaves the word intact.
17084fn word_assemble_plan9(segments: &[Value], plan9_flags: &[bool]) -> Value {
17085    let mut words: Vec<String> = Vec::new();
17086    let mut active_lo: usize = 0;
17087    let mut started = false;
17088    for (i, seg) in segments.iter().enumerate() {
17089        let plan9 = plan9_flags.get(i).copied().unwrap_or(false);
17090        let elems = word_seg_elems(seg);
17091        if plan9 && elems.is_empty() {
17092            // c:4362-4365 — plan9 empty array deletes the whole word.
17093            return Value::array(Vec::new());
17094        }
17095        if !started {
17096            started = true;
17097            // c:Src/subst.c:4261 — `if ((!aval[0] || !aval[1]) && !plan9)`.
17098            // A NON-plan9 EMPTY expansion (empty array, or an empty scalar
17099            // that zshrs collapsed to the same empty `Value::Array`) is
17100            // folded into the word text as the empty string and the node
17101            // SURVIVES (c:4268-4274 `strcatsub` of prefix + "" + suffix).
17102            // Only the plan9 arm deletes the word, and that is the
17103            // `uremnode` case already returned above (c:4362-4365).
17104            //
17105            // Seeding `words` with that single empty element is what keeps a
17106            // growing edge alive for the segments that follow. Leaving
17107            // `words` empty instead made `words[active_lo..]` an empty slice
17108            // forever, so every later segment cross-multiplied against
17109            // nothing and the whole word vanished: `n=""; a=(x y z);
17110            // print -rl -- $n${^a}` printed nothing where zsh prints
17111            // `x`/`y`/`z`, and `$n$a${^a}` dropped the leading `x`. Only a
17112            // word whose FIRST segment was the empty one was affected —
17113            // `pre$n${^a}` already started from the literal and hit the
17114            // "contributes nothing" `continue` below.
17115            words = if elems.is_empty() {
17116                vec![String::new()]
17117            } else {
17118                elems
17119            };
17120            // plan9 → the whole first array is the growing edge; splice/scalar
17121            // → only its last element grows, earlier ones are finalized words.
17122            active_lo = if plan9 {
17123                0
17124            } else {
17125                words.len().saturating_sub(1)
17126            };
17127            continue;
17128        }
17129        if plan9 {
17130            let mut new_active = Vec::with_capacity(words[active_lo..].len() * elems.len());
17131            for a in &words[active_lo..] {
17132                for r in &elems {
17133                    new_active.push(format!("{a}{r}"));
17134                }
17135            }
17136            let frozen_len = active_lo;
17137            words.truncate(frozen_len);
17138            words.extend(new_active);
17139            active_lo = frozen_len; // all cross-products stay active
17140        } else {
17141            if elems.is_empty() {
17142                // Non-plan9 empty array contributes nothing; word survives.
17143                continue;
17144            }
17145            let frozen_len = active_lo;
17146            let r0 = &elems[0];
17147            let head: Vec<String> = words[active_lo..]
17148                .iter()
17149                .map(|a| format!("{a}{r0}"))
17150                .collect();
17151            words.truncate(frozen_len);
17152            words.extend(head);
17153            words.extend(elems[1..].iter().cloned());
17154            active_lo = if elems.len() == 1 {
17155                frozen_len // single-element splice: head stays the growing edge
17156            } else {
17157                words.len() - 1 // multi: only the last appended word grows
17158            };
17159        }
17160    }
17161    match words.len() {
17162        0 => Value::array(Vec::new()),
17163        1 => Value::str(words.pop().unwrap()),
17164        _ => Value::array(words.into_iter().map(Value::str).collect()),
17165    }
17166}
17167
17168fn nodes_to_value(nodes: Vec<String>) -> Value {
17169    // c:Src/glob.c:3649 remnulargs — strip the Nularg (`\u{e1a1}`)
17170    //   sentinel and other INULL bytes that paramsubst's splat block
17171    //   emits for empty array elements (so prefork's empty-node-delete
17172    //   pass doesn't drop them). Downstream consumers (cond `-z`/`-n`,
17173    //   command args, etc.) must see the post-remnulargs strings. Bug
17174    //   #185 in docs/BUGS.md: `[[ -z "${b[@]}" ]]` for b=("") returned
17175    //   false because the leftover `\u{e1a1}` had StringLen=1.
17176    //
17177    // ORDER MATTERS, and the single-node drop below is the second half of
17178    // it. C tests a node's emptiness at c:183 BEFORE remnulargs runs at
17179    // c:170, and `nulstring` is one byte long, so a node holding only the
17180    // marker is NON-empty at that test. The drop has ALREADY happened by
17181    // the time the marker becomes "", and nothing removes the node twice.
17182    //
17183    // Traced for `IFS=:; v=":"; set -- $v` under `--dash`:
17184    //   spacesplit(":")  -> ["<nul>", ""]   leading marker, trailing empty
17185    //   paramsubst c:183 -> ["<nul>"]       the real empty goes, marker stays
17186    //   caller's fold    -> [""]            untokenize folds the marker
17187    //   here, unguarded  -> []              dropped a second time
17188    // `$#` was 0 where dash, bash and ksh all report 1. Two or more nodes
17189    // never reach the single-node drop, which is why `::` was already
17190    // right and only the lone separator was wrong.
17191    let node_was_marked: bool = nodes.len() == 1 && !nodes[0].is_empty();
17192    let stripped: Vec<String> = nodes
17193        .into_iter()
17194        .map(|mut s| {
17195            crate::ported::glob::remnulargs(&mut s);
17196            s
17197        })
17198        .collect();
17199    if stripped.is_empty() {
17200        // Zero nodes = an ARRAY-shaped expansion that produced no words
17201        // (empty array splat, empty slice). c:4245 `if (isarr)` holds, so
17202        // plan9 deletes the surrounding word (c:4362).
17203        note_empty_is_scalar(false);
17204        Value::array(Vec::new())
17205    } else if stripped.len() == 1 {
17206        let only = stripped.into_iter().next().unwrap();
17207        // c:Src/subst.c:183-186 — `else if (!(flags & PREFORK_SINGLE)
17208        // && !(*ret_flags & PREFORK_KEY_VALUE) && !keep)
17209        //   uremnode(list, node);`
17210        // C zsh's prefork removes empty linknodes from the result
17211        // list when in non-SINGLE (argv-context) mode. The ported
17212        // prefork at subst.rs:388-396 honors the same delete-empty
17213        // pass, but some paramsubst paths land here with a single-
17214        // empty-string Vec instead of an empty Vec (paramsubst's
17215        // slice / substring / parameter-flag branches allocate a
17216        // result before checking emptiness). Mirror the prefork
17217        // drop at this layer: single-empty under !in_dq_context
17218        // collapses to Value::Array(empty), and pop_args (line 6243)
17219        // splats the empty Array → zero argv words. DQ context
17220        // (in_dq_context > 0) keeps the empty string so
17221        // `echo "${UNSET}"` still produces an empty arg per zsh's
17222        // quoting rules (c:Src/subst.c:1650-1656 isarr comment).
17223        if only.is_empty() && !node_was_marked {
17224            let in_dq = with_executor(|exec| exec.in_dq_context > 0);
17225            if !in_dq {
17226                // One empty node = a SCALAR-shaped empty result (c:4437),
17227                // not an empty array — see EMPTY_EXPANSION_IS_SCALAR.
17228                note_empty_is_scalar(true);
17229                return Value::array(Vec::new());
17230            }
17231        }
17232        Value::str(only)
17233    } else {
17234        Value::array(stripped.into_iter().map(Value::str).collect())
17235    }
17236}
17237
17238fn pop_args(vm: &mut fusevm::VM, argc: u8) -> Vec<String> {
17239    let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
17240    for _ in 0..argc {
17241        popped.push(vm.pop());
17242    }
17243    popped.reverse();
17244    let mut args: Vec<String> = Vec::with_capacity(popped.len());
17245    for v in popped {
17246        match v {
17247            Value::Array(items) => {
17248                for item in items.iter() {
17249                    args.push(item.to_str());
17250                }
17251            }
17252            other => args.push(other.to_str()),
17253        }
17254    }
17255    // `expand_glob` set the glob-failed cell when a no-match glob
17256    // triggered nomatch (c:Src/glob.c:1877). Signal the failure via
17257    // last_status + the per-command glob_failed cell; the dispatcher
17258    // (`host_exec_external`) consumes + clears it and returns status 1
17259    // without running the command body.
17260    if with_executor(|exec| exec.current_command_glob_failed.get()) {
17261        with_executor(|exec| exec.set_last_status(1));
17262    }
17263    // c:Src/exec.c:2709 setunderscore / c:Src/params.c:252 underscore_gsu
17264    // — `$_` has exactly ONE store in zsh, the `zunderscore` global
17265    // (`Src/init.c:49`), read back through `underscoregetfn`. It is
17266    // never written into the parameter table: the `_` Param created by
17267    // `IPDEF2("_", underscore_gsu, PM_DONTIMPORT)` (c:Src/params.c:326)
17268    // carries `nullstrsetfn` as its setfn, so even `_=x` stores nothing.
17269    //
17270    // The deferred `pending_underscore` → `set_scalar("_")` promotion
17271    // that used to live here was a second, contradictory store: it wrote
17272    // the paramtab node, which (a) CLEARED the PM_UNSET that `unset _`
17273    // had just set — resurrecting the parameter, so `${+_}` flipped back
17274    // to 1 and `$_` kept reading a value where zsh reports empty — and
17275    // (b) shadowed the canonical zunderscore value. Every dispatch path
17276    // now calls `set_zunderscore` (the `setunderscore` equivalent) just
17277    // before running its command, which is where C sets it
17278    // (execcmd_exec, c:3545-3547), so the deferral is unnecessary as
17279    // well: argument expansion has already happened by then, exactly as
17280    // in C.
17281    args
17282}
17283
17284/// zsh dispatch order is alias → function → builtin → external. The
17285/// compiler emits direct CallBuiltin ops for known builtin names for
17286/// perf, which silently skips a user function that shadows the same
17287/// name (e.g. `echo() { ... }; echo hi` would run the C builtin
17288/// without this check). Returns Some(status) when the call is routed
17289/// to the user function; the builtin handler should fall through to
17290/// its native impl when None.
17291/// Fork+exec a system binary by name. Used by `reg_overridable!` as
17292/// the fall-through path when `[builtins].coreutils_shadows = off`
17293/// (the default) — runs the canonical `/bin/X` instead of zshrs's
17294/// in-process shadow so old scripts hit zero behavioral divergence.
17295///
17296/// Inherits stdin/stdout/stderr from the parent so pipelines work
17297/// transparently. Resolves the binary via PATH; mirrors what zsh's
17298/// own external-command dispatch would do. Returns the child's exit
17299/// status (or 127 if PATH lookup fails — the standard "command not
17300/// found" code).
17301/// RAII guard that queues signals for the lifetime of a synchronous
17302/// foreground `waitpid` (via `std::process::Command::status`/`wait`).
17303///
17304/// zshrs installs a process-wide SIGCHLD handler (`zhandler` →
17305/// `wait_for_processes` → `waitpid(-1, WNOHANG)`) that reaps EVERY
17306/// exited child to drive the job table. `std::process::Command` does
17307/// its own targeted `waitpid(pid)`; when the reaper fires on any
17308/// thread between the fork and that wait, it reaps the child first and
17309/// `Command::status()` fails with ECHILD ("No child processes (os
17310/// error 10)"). This surfaced as `zshrs: hostname: No child processes
17311/// (os error 10)` when a coreutils shadow (`coreutils_shadows = off`
17312/// default) fork-execs `/usr/bin/hostname` while a background prewarm
17313/// child exits at the same instant.
17314///
17315/// Holding this guard bumps `queueing_enabled` (a global SeqCst atomic
17316/// that `zhandler` honors on every thread), so a SIGCHLD arriving
17317/// during the wait is pushed onto the deferred queue instead of being
17318/// reaped — `Command::status()` reaps its own child and reads the real
17319/// status. On drop, `unqueue_signals()` drains the queue, so any
17320/// genuine background children that exited meanwhile still get reaped
17321/// and routed to the job table. This is the same queue_signals /
17322/// unqueue_signals fencing zsh uses around its own foreground waits
17323/// (Src/exec.c). Panic-safe via `Drop`.
17324/// Apply `entersubsh`'s trap reset to the CURRENT process state.
17325///
17326/// !!! WARNING: RUST-ONLY HELPER !!!
17327/// C does this inline inside `entersubsh` (c:Src/exec.c:1088-1092), which
17328/// every subshell — `( … )`, `$( … )` AND each forked pipeline stage —
17329/// funnels through. zshrs enters a subshell context in three separate
17330/// places (the in-process `subshell_begin`, the in-process
17331/// `run_command_substitution`, and the pipeline stage fork), so the reset
17332/// lives here to keep them from drifting apart.
17333///
17334/// ```c
17335/// if (!(flags & ESUB_KEEPTRAP))
17336///     for (sig = 0; sig <= SIGCOUNT; sig++)
17337///         if (!(sigtrapped[sig] & ZSIG_FUNC) &&
17338///             !(isset(POSIXTRAPS) && (sigtrapped[sig] & ZSIG_IGNORED)))
17339///             unsettrap(sig);
17340/// ```
17341///
17342/// `unsettrap` clears BOTH the body and the sigtrapped flags, so both
17343/// stores are reset here. Function-form traps (ZSIG_FUNC, kept in
17344/// shfunctab as TRAPxxx) survive by construction; under POSIX_TRAPS an
17345/// IGNORED trap survives too. The loop bound stops below the pseudo
17346/// signals (c:Src/signals.h:34-35), so ERR/ZERR and DEBUG survive while
17347/// SIGEXIT (sig 0) is cleared.
17348pub(crate) fn entersubsh_reset_traps() {
17349    let posixtraps = crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXTRAPS);
17350    // ksh93 and mksh reset EVERY trap in a subshell, ERR and DEBUG included (zsh keeps
17351    // the pseudo-signals): `trap "print e" ERR; (false)` prints one ERR, from
17352    // the parent, and `(false; true)` / `$(false)` print none.
17353    let korn = crate::extensions::dash_mode::korn_mode();
17354    if let Ok(mut tbl) = crate::ported::builtin::traps_table().lock() {
17355        tbl.retain(|name, body| {
17356            // Above SIGCOUNT — outside c:1088's loop entirely.
17357            if !korn && (name == "ERR" || name == "ZERR" || name == "DEBUG") {
17358                return true;
17359            }
17360            // c:1090-1092 — otherwise keep ONLY (POSIXTRAPS && ignored).
17361            posixtraps && body.is_empty()
17362        });
17363    }
17364    if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
17365        let count = crate::ported::signals_h::SIGCOUNT as usize;
17366        for sig in 0..if korn { st.len() } else { st.len().min(count + 1) } {
17367            let state = st[sig];
17368            if state == 0 {
17369                continue;
17370            }
17371            if (state & crate::ported::zsh_h::ZSIG_FUNC) != 0 {
17372                continue; // c:1090
17373            }
17374            if posixtraps && (state & crate::ported::zsh_h::ZSIG_IGNORED) != 0 {
17375                continue; // c:1091
17376            }
17377            st[sig] = 0; // c:1092 unsettrap(sig)
17378        }
17379    }
17380}
17381
17382// ---------------------------------------------------------------------------
17383// In-process subshell: signal delivery belongs to the PARENT.
17384// ---------------------------------------------------------------------------
17385
17386/// Depth of nested in-process `( … )` subshells, for the signal
17387/// bookkeeping below.
17388///
17389/// !!! WARNING: RUST-ONLY HELPER !!!
17390/// C has no counter because it forks: `entersubsh` (`Src/exec.c:1123`)
17391/// runs in a fresh process whose signal state is a private copy. This
17392/// counter exists only so `subshell_signal_enter` / `_leave` can tell
17393/// the OUTERMOST boundary — the one where `$$` stops naming the shell
17394/// that is running the body — from a nested one.
17395static SUBSH_SIGNAL_DEPTH: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
17396
17397/// The top-level shell's `sigtrapped[]` while an in-process subshell
17398/// runs; `None` when no subshell is active.
17399///
17400/// !!! WARNING: RUST-ONLY HELPER !!!
17401/// This is C's `sigtrapped[]` (`Src/signals.c:39`) as the PARENT still
17402/// sees it — in C that array simply stays put in the parent process
17403/// while the forked child mutates its own copy. zshrs has one process
17404/// and one array, so the parent's view has to be kept beside it.
17405static SUBSH_PARENT_SIGTRAPPED: std::sync::Mutex<Option<Vec<i32>>> = std::sync::Mutex::new(None);
17406
17407/// Signals delivered to the process while an in-process subshell was
17408/// running, which the PARENT traps. Replayed at the outermost
17409/// `subshell_end`.
17410///
17411/// !!! WARNING: RUST-ONLY HELPER !!!
17412/// The C analogue is `trap_queue[]` (`Src/signals.c:92`): the parent
17413/// sets `trap_queueing_enabled` in `zwaitjob` (`Src/jobs.c:1688`
17414/// `queue_traps(wait_cmd)`) for as long as the child runs, so a trap
17415/// that arrives meanwhile is deferred and dispatched by
17416/// `unqueue_traps()` (`Src/jobs.c:1751` → `Src/signals.c:1041-1047`)
17417/// once the child is reaped. A separate queue is needed here because
17418/// zshrs's `trap_queue` gate (`handletrap`, c:974) tests the LIVE
17419/// `sigtrapped[]`, which the in-process subshell has already cleared.
17420static SUBSH_DEFERRED_SIGS: std::sync::Mutex<Vec<i32>> = std::sync::Mutex::new(Vec::new());
17421
17422/// Called from `zhandler` (`src/ported/signals.rs`, at C's c:410
17423/// queueing check) before any trap dispatch. Returns 1 when the
17424/// delivery has been recorded for the parent and the handler must
17425/// return without acting.
17426///
17427/// !!! WARNING: RUST-ONLY HELPER !!!
17428/// C needs nothing here. `( … )` forks (`Src/exec.c:2922`
17429/// `entersubsh(flags, &esret)`), so `kill -USR1 $$` inside the
17430/// subshell names the PARENT process: the child's handlers are never
17431/// entered, and the parent — which `entersubsh` never touched —
17432/// runs its own trap once `zwaitjob` drains the trap queue. zshrs runs
17433/// the body in the same process, so the one set of handlers has to
17434/// answer for both roles. Recording the delivery and replaying it
17435/// against the parent's restored table at `subshell_end` reproduces
17436/// both the disposition zsh picks and the point at which it runs.
17437///
17438/// SIGCHLD and SIGWINCH are never deferred, matching the two signals C
17439/// itself excludes from the `settrap` / `removetrap` disposition
17440/// switches (`Src/signals.c:714-718`, `Src/signals.c:810-814`): job
17441/// reaping and resize redisplay have to stay live inside the body.
17442///
17443/// When the parent has NO trap for the signal this returns 0 and the
17444/// handler proceeds normally. That is the one leg an in-process
17445/// subshell cannot reproduce: C's parent would take the DEFAULT action
17446/// (`( trap 'echo IN' USR1; kill -USR1 $$ )` kills zsh outright), which
17447/// here would have to kill the shell out from under a body that C
17448/// leaves running.
17449pub fn subshell_defer_signal(sig: libc::c_int) -> i32 {
17450    if sig == libc::SIGCHLD || sig == libc::SIGWINCH {
17451        return 0;
17452    }
17453    let idx = crate::ported::signals_h::SIGIDX(sig) as usize;
17454    // `try_lock`, not `lock`: this runs inside a signal handler, so a
17455    // delivery that lands while the SAME thread is mid-`subshell_signal_enter`
17456    // would self-deadlock on a blocking acquire. A miss degrades to normal
17457    // handling, which is what happened before this hook existed.
17458    let parent = match SUBSH_PARENT_SIGTRAPPED.try_lock() {
17459        Ok(g) => match g.as_ref() {
17460            Some(v) => v.get(idx).copied().unwrap_or(0),
17461            None => return 0,
17462        },
17463        Err(_) => return 0,
17464    };
17465    if parent == 0 {
17466        return 0;
17467    }
17468    if (parent & crate::ported::zsh_h::ZSIG_IGNORED) != 0 {
17469        // The parent ignores it; C's parent process would drop it on
17470        // the floor (`Src/signals.c:713-719` installed SIG_IGN).
17471        return 1;
17472    }
17473    match SUBSH_DEFERRED_SIGS.try_lock() {
17474        Ok(mut q) => q.push(sig),
17475        // Could not record it; fall through to normal handling rather
17476        // than swallow the delivery outright.
17477        Err(_) => return 0,
17478    }
17479    1
17480}
17481
17482/// Arm parent-side signal delivery for the duration of an in-process
17483/// `( … )` or `$( … )`. Must run BEFORE `entersubsh_reset_traps` clears
17484/// the table.
17485///
17486/// !!! WARNING: RUST-ONLY HELPER !!!
17487/// See `subshell_defer_signal`. Only the OUTERMOST subshell records the
17488/// snapshot: `$$` names the top-level shell at every nesting level, so
17489/// a nested `( ( … ) )` still routes deliveries to the same table.
17490pub(crate) fn subshell_signal_enter() {
17491    if SUBSH_SIGNAL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
17492        let snap = crate::ported::signals::sigtrapped
17493            .lock()
17494            .map(|g| g.clone())
17495            .unwrap_or_default();
17496        if let Ok(mut p) = SUBSH_PARENT_SIGTRAPPED.lock() {
17497            *p = Some(snap);
17498        }
17499        if let Ok(mut q) = SUBSH_DEFERRED_SIGS.lock() {
17500            q.clear();
17501        }
17502    }
17503}
17504
17505/// Disarm parent-side delivery and dispatch whatever arrived. Must run
17506/// AFTER `subshell_end` has put the parent's `sigtrapped[]` and
17507/// `traps_table` back.
17508///
17509/// !!! WARNING: RUST-ONLY HELPER !!!
17510/// The dispatch itself is C's `unqueue_traps()` tail
17511/// (`Src/signals.c:1043-1047`): `while (trap_queue_front !=
17512/// trap_queue_rear) handletrap(trap_queue[…])`, run at the point
17513/// `zwaitjob` reaches after the child is reaped (`Src/jobs.c:1751`).
17514pub(crate) fn subshell_signal_leave() {
17515    if SUBSH_SIGNAL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::SeqCst) != 1 {
17516        return;
17517    }
17518    if let Ok(mut p) = SUBSH_PARENT_SIGTRAPPED.lock() {
17519        *p = None;
17520    }
17521    let pending: Vec<i32> = match SUBSH_DEFERRED_SIGS.lock() {
17522        Ok(mut q) => std::mem::take(&mut *q),
17523        Err(_) => return,
17524    };
17525    for sig in pending {
17526        // c:Src/signals.c:1046 — `(void) handletrap(trap_queue[…]);`
17527        let _ = crate::ported::signals::handletrap(sig);
17528    }
17529}
17530
17531/// Put the process's signal DISPOSITIONS back the way the parent had
17532/// them, for every signal whose trap state the subshell changed.
17533///
17534/// !!! WARNING: RUST-ONLY HELPER !!!
17535/// C gets this from the fork: `trap 'echo IN' USR1` inside `( … )`
17536/// calls `settrap` → `install_handler` (`Src/signals.c:724-732`) in the
17537/// CHILD, so the parent's `SIG_DFL` survives untouched and a later
17538/// `kill -USR1 $$` still kills the shell. zshrs shares the process, so
17539/// the child's `sigaction` leaked out and swallowed the signal. The
17540/// branch order below is `removetrap`'s tail verbatim
17541/// (`Src/signals.c:801-815`) for the "parent had no trap" case, and
17542/// `settrap`'s two installs (`Src/signals.c:713-719` ignored,
17543/// `Src/signals.c:724-732` trapped) for the others.
17544pub(crate) fn subshell_restore_signal_dispositions(parent: &[i32], child: &[i32]) {
17545    let count = crate::ported::signals_h::SIGCOUNT;
17546    for sig in 1..=count {
17547        let i = sig as usize;
17548        let p = parent.get(i).copied().unwrap_or(0);
17549        let c = child.get(i).copied().unwrap_or(0);
17550        if p == c {
17551            continue;
17552        }
17553        // c:714-718 / c:810-814 — C leaves these two alone in both
17554        // the install and the reset switch.
17555        if sig == libc::SIGWINCH || sig == libc::SIGCHLD {
17556            continue;
17557        }
17558        if (p & crate::ported::zsh_h::ZSIG_IGNORED) != 0 {
17559            crate::ported::signals_h::signal_ignore(sig); // c:719
17560        } else if p != 0 {
17561            crate::ported::signals::install_handler(sig); // c:732
17562        } else if sig == libc::SIGINT && crate::ported::signals::is_interact() {
17563            crate::ported::signals::intr(); // c:804
17564            crate::ported::signals::noholdintr(); // c:805
17565        } else if sig == libc::SIGHUP {
17566            crate::ported::signals::install_handler(sig); // c:807
17567        } else if sig == libc::SIGPIPE
17568            && crate::ported::signals::is_interact()
17569            && crate::ported::exec::FORKLEVEL.load(std::sync::atomic::Ordering::Relaxed) == 0
17570        {
17571            crate::ported::signals::install_handler(sig); // c:809
17572        } else {
17573            crate::ported::signals_h::signal_default(sig); // c:815
17574        }
17575    }
17576}
17577
17578/// `waitpid(pid, &status, 0)` that retries on `EINTR`.
17579///
17580/// !!! WARNING: RUST-ONLY HELPER !!!
17581/// No C counterpart — C zsh reaches the same place by blocking signals
17582/// around its foreground waits (`queue_signals` / `unqueue_signals`
17583/// fencing in `Src/exec.c`), so its `waitpid` is never interrupted in
17584/// the first place.
17585///
17586/// zshrs installs a process-wide SIGCHLD handler (`zhandler` →
17587/// `wait_for_processes`). When it fires while the shell is blocked in
17588/// this wait, `waitpid` returns -1/`EINTR` WITHOUT touching `status`.
17589/// The pipeline reap loop ignored the return value and read the
17590/// still-zero `status`, so `WIFEXITED(0)` was true and every FORKED
17591/// stage reported exit 0: `false | true` published `$pipestatus` as
17592/// `0 0` instead of zsh's `1 0`, and `setopt pipefail` had no non-zero
17593/// entry left to promote (c:Src/jobs.c:434-435 `if (jpipestats[i])
17594/// pipefail = jpipestats[i];`, applied at c:451-454).
17595///
17596/// The handler can also win outright: if the child exits while the last
17597/// pipeline stage is still running IN THIS PROCESS, SIGCHLD is delivered
17598/// and `wait_for_processes` reaps it long before this loop is reached,
17599/// so `waitpid` here fails with `ECHILD` and the stage's status is gone.
17600/// That window is as wide as the last stage's whole run — `(exit 5) |
17601/// (sleep 0.5; exit 3)` published `$pipestatus` as `0 3` where zsh says
17602/// `5 3`. The reaper publishes every status it takes
17603/// (`extensions/reaped_status.rs`), which is what C gets for free by
17604/// having only one collector, so claim it back here.
17605///
17606/// Returns the raw wait status, or `None` if the child could not be
17607/// reaped and the reaper has no record of it either.
17608fn waitpid_eintr(pid: libc::pid_t) -> Option<i32> {
17609    loop {
17610        let mut status: i32 = 0;
17611        let rc = unsafe { libc::waitpid(pid, &mut status, 0) };
17612        if rc >= 0 {
17613            return Some(status);
17614        }
17615        let err = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
17616        if err != libc::EINTR {
17617            // The SIGCHLD reaper collected this child first; take the
17618            // status it recorded instead of reporting a clean exit.
17619            return crate::reaped_status::take(pid);
17620        }
17621    }
17622}
17623
17624/// [`waitpid_eintr`] that also returns the child's resource usage.
17625///
17626/// c:Src/signals.c:279 — the SIGCHLD reaper collects with `wait3(&status,
17627/// WAITFLAGS, &ru)` and stores it on the process, `pn->ti = ru` (c:352),
17628/// which is where `dumptime`'s per-process user/system times come from. A
17629/// child the reaper got to first is claimed back from `reaped_status` with
17630/// the times it recorded.
17631fn wait4_eintr(pid: libc::pid_t) -> Option<(i32, libc::rusage)> {
17632    loop {
17633        let mut status: i32 = 0;
17634        let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
17635        let rc = unsafe { libc::wait4(pid, &mut status, 0, &mut ru) };
17636        if rc >= 0 {
17637            return Some((status, ru));
17638        }
17639        let err = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
17640        if err != libc::EINTR {
17641            return crate::reaped_status::take_rusage(pid).map(|(st, utime, stime)| {
17642                let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
17643                ru.ru_utime = usec_timeval(utime);
17644                ru.ru_stime = usec_timeval(stime);
17645                (st, ru)
17646            });
17647        }
17648    }
17649}
17650
17651/// The processes of a `time`d multi-stage pipeline, collected for
17652/// `dumptime` (c:Src/jobs.c:1029-1037).
17653///
17654/// !!! WARNING: RUST-ONLY SHAPE — C KEEPS THESE ON THE JOB'S PROCS !!!
17655/// In C every forked stage is `addproc`ed onto `jobtab[thisjob]` with its
17656/// text (c:Src/exec.c:2907) and the timed job is dumped when it finishes.
17657/// zshrs's foreground pipeline has no job-table entry, so BUILTIN_TIME_SUBLIST
17658/// hands the per-stage texts to BUILTIN_RUN_PIPELINE through
17659/// [`TIMED_PIPELINE`] and reads the filled-in procs back.
17660pub(crate) struct TimedPipeline {
17661    /// One job text per stage, in pipeline order.
17662    texts: Vec<String>,
17663    /// The in-shell last stage is a process of the job (c:Src/exec.c:3690
17664    /// `!is_cursh` — an external command or a subshell).
17665    last_forks: bool,
17666    /// Filled by the pipeline driver.
17667    procs: Vec<crate::ported::zsh_h::process>,
17668}
17669
17670thread_local! {
17671    /// Set by BUILTIN_TIME_SUBLIST around a timed pipeline's body; TAKEN by
17672    /// the first BUILTIN_RUN_PIPELINE that runs, so a pipeline nested inside
17673    /// one of its stages is not mistaken for it.
17674    static TIMED_PIPELINE: std::cell::RefCell<Option<TimedPipeline>> =
17675        const { std::cell::RefCell::new(None) };
17676}
17677
17678/// c:Src/exec.c:3690 — `is_cursh = (is_builtin || is_shfunc || nullexec ||
17679/// type >= WC_CURSH)` for the compiler's hint (compile_zsh.rs
17680/// `command_cursh_hint`): 1 = current shell, 0 = forks, 2 = the command word
17681/// decides at run time.
17682fn time_body_is_cursh(hint: i64, name: &str) -> bool {
17683    match hint {
17684        1 => true,
17685        2 => {
17686            // c:3488-3491 — shfunctab is consulted BEFORE builtintab.
17687            let is_shfunc = crate::ported::hashtable::shfunctab_lock()
17688                .read()
17689                .map(|t| t.get(name).is_some())
17690                .unwrap_or(false);
17691            is_shfunc || crate::ported::builtin::createbuiltintable().contains_key(name)
17692        }
17693        _ => false,
17694    }
17695}
17696
17697/// `a - b` for rusage times — c:Src/jobs.c `dtime_tv`.
17698fn timeval_sub(a: libc::timeval, b: libc::timeval) -> libc::timeval {
17699    let mut sec = a.tv_sec - b.tv_sec;
17700    let mut usec = a.tv_usec as i64 - b.tv_usec as i64;
17701    if usec < 0 {
17702        sec -= 1;
17703        usec += 1_000_000;
17704    }
17705    libc::timeval {
17706        tv_sec: sec,
17707        tv_usec: usec as libc::suseconds_t,
17708    }
17709}
17710
17711/// Microseconds as a `timeval`.
17712fn usec_timeval(us: u64) -> libc::timeval {
17713    libc::timeval {
17714        tv_sec: (us / 1_000_000) as libc::time_t,
17715        tv_usec: (us % 1_000_000) as libc::suseconds_t,
17716    }
17717}
17718
17719/// A `process` entry for [`TimedPipeline`]: C's addproc fields (c:Src/jobs.c
17720/// 1473-1486) plus the wait's status and rusage (update_process).
17721fn timed_proc(
17722    pid: libc::pid_t,
17723    text: &str,
17724    status: i32,
17725    ru: &libc::rusage,
17726    bgtime: std::time::Instant,
17727) -> crate::ported::zsh_h::process {
17728    crate::ported::zsh_h::process {
17729        pid,
17730        text: text.to_string(),
17731        status,
17732        ti: crate::ported::zsh_h::timeinfo::from_rusage(ru),
17733        bgtime: Some(bgtime),
17734        endtime: Some(std::time::Instant::now()),
17735    }
17736}
17737
17738/// The level bookkeeping `entersubsh` does in a freshly forked child.
17739///
17740/// c:Src/exec.c:1221 — `forklevel = locallevel;`. `bin_break`'s `exit` arm
17741/// (c:Src/builtin.c:5870-5894) only defers the exit while `locallevel >
17742/// forklevel`; in a forked child the function it was called from is the
17743/// child's outermost level, so `exit N` ends the child with status N.
17744///
17745/// !!! WARNING: RUST-ONLY LINE — SUBSHELL_DEPTH HAS NO C COUNTERPART !!!
17746/// `SUBSHELL_DEPTH` counts the in-process `( … )` bodies this process is
17747/// inside, and `zexit` defers to EXIT_PENDING while it is non-zero because
17748/// exiting would take the parent shell with it. After a fork that reason is
17749/// gone: the child is its own process, as C's always is. Left at its
17750/// inherited value, `exit 2` in a pipeline stage or background job forked
17751/// from inside a function or `( … )` only unwound the child's VM, which then
17752/// exited 0 — `(setopt pipefail; false | exit 2 | true; print $?)` printed
17753/// 1 where zsh prints 2.
17754thread_local! {
17755    /// c:Src/exec.c:3063 — `last1 = forked = 1;`: execcmd_exec has already
17756    /// forked for the command it is about to run (a non-last pipeline stage,
17757    /// an `&` job, a coproc, the sh-emulation last stage).
17758    ///
17759    /// !!! WARNING: RUST-ONLY FLAG — C KEEPS `forked` AS AN execcmd_exec LOCAL !!!
17760    /// zshrs forks in the pipeline/bg/coproc drivers, one level above the
17761    /// stage chunk, so the fact crosses into the chunk through this flag. The
17762    /// chunk's leading BUILTIN_EXECCMD_FORKED_LEVEL consumes it.
17763    static EXECCMD_FORKED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
17764    /// The VM (by address) whose chunk head consumed EXECCMD_FORKED for a
17765    /// non-subshell command. BUILTIN_EXEC_FORKED_SIMPLE honours it only from
17766    /// that same VM, so a `$( … )` or function body run while the forked
17767    /// command's words expand — each on its own VM — never execs in its place.
17768    ///
17769    /// !!! WARNING: RUST-ONLY FLAG — C KEEPS `forked` AS AN execcmd_exec LOCAL !!!
17770    static FORKED_SIMPLE_VM: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
17771    /// The VM whose chunk head consumed EXECCMD_FORKED (subshell or not).
17772    /// BUILTIN_FORKED_REDIRS honours it only from that same VM.
17773    ///
17774    /// !!! WARNING: RUST-ONLY FLAG — C KEEPS `forked` AS AN execcmd_exec LOCAL !!!
17775    static FORKED_REDIR_VM: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
17776    /// Depth of the redirect scope holding the forked command's OWN
17777    /// redirections (0: none). c:Src/exec.c:2478 — addfd saves the original
17778    /// fd only `if (!forked && save[fd1] == -2)`, so those redirections park
17779    /// nothing: the child exits after the command and never restores.
17780    ///
17781    /// !!! WARNING: RUST-ONLY FLAG — C PASSES `forked` TO EVERY addfd CALL !!!
17782    static FORKED_REDIR_SCOPE: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
17783}
17784
17785/// c:Src/exec.c:2478 — `if (!forked && save[fd1] == -2)`: true when the
17786/// redirect scope at `depth` (the innermost open one) belongs to the command
17787/// this child was forked for, whose redirections save no original fd. A
17788/// saved dup there outlived the command in the child: `{ sleep 2 } >/dev/null
17789/// &` kept the caller's stdout open for two seconds, so `$( … & )` and a
17790/// pipe reader waited for it.
17791fn redir_scope_forked(depth: usize) -> bool {
17792    depth != 0 && FORKED_REDIR_SCOPE.with(|c| c.get()) == depth
17793}
17794
17795/// c:Src/exec.c:2440 — `fdtable[fdN] |= FDT_SAVED_MASK;` on the copy
17796/// movefd made FDT_INTERNAL (c:Src/utils.c:2006). The mark is how a forked
17797/// child's entersubsh finds the copies it will never restore and closes them
17798/// (c:1214-1217). Unmarked, a job forked inside `{ … } 2>/dev/null` kept the
17799/// caller's stderr open until it finished. The restore's zclose clears it.
17800pub(crate) fn mark_saved_fd(saved: i32) {
17801    if saved >= 0 {
17802        crate::ported::utils::fdtable_set(
17803            saved,
17804            crate::ported::zsh_h::FDT_INTERNAL | crate::ported::zsh_h::FDT_SAVED_MASK,
17805        );
17806    }
17807}
17808
17809/// Head of a stage / `&` / coproc chunk: account for execcmd_fork's
17810/// `entersubsh` (c:Src/exec.c:1200 `zsh_subshell++;`) when the driver forked.
17811///
17812/// C forks once per such command. For `( … )` that one fork IS the subshell:
17813/// with `forked` already set, the `if (!forked)` block (c:3715-3752) is
17814/// skipped and the body runs in this child without a second fork, so
17815/// `(print $ZSH_SUBSHELL) | cat` prints 1. zshrs's `( … )` always bumps in
17816/// subshell_begin, so for a subshell the fork's bump is left to it; any other
17817/// command takes it here. Without a fork (the in-shell last stage) nothing
17818/// happens. The bump is never undone: the child exits.
17819fn execcmd_forked_level(is_subsh: bool, vm_addr: usize) {
17820    if !EXECCMD_FORKED.with(|f| f.replace(false)) {
17821        return;
17822    }
17823    // The chunk's top command is the one forked for; see BUILTIN_FORKED_REDIRS.
17824    FORKED_REDIR_VM.with(|f| f.set(vm_addr));
17825    // c:Src/exec.c:1245-1246 — `if (!job_control_ok) opts[MONITOR] = 0;` in
17826    // entersubsh. job_control_ok (c:1133) needs ESUB_JOB_CONTROL, which
17827    // execcmd_fork passes only for a non-async `( … )` (c:2919-2920), and
17828    // POSIX_JOBS. Without this a pipeline stage kept MONITOR and ran
17829    // update_job's foreground tail for its own children:
17830    // `{ sh -c 'kill -INT $$'; echo in } | cat` lost `in`.
17831    let job_control_ok = is_subsh && crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXJOBS);
17832    if !job_control_ok && crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) {
17833        crate::ported::options::dosetopt(crate::ported::zsh_h::MONITOR, 0, 0);
17834    }
17835    // The chunk's own top command is the one this child was forked for; see
17836    // BUILTIN_EXEC_FORKED_SIMPLE. A `( … )` is too: its body runs as
17837    // `execlist(state, 0, 1)` (c:Src/exec.c:4379), so the body's last command
17838    // takes the fake exec in this child (compile_zsh exiting_tail_list).
17839    FORKED_SIMPLE_VM.with(|f| f.set(vm_addr));
17840    if !is_subsh {
17841        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
17842            if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
17843                pm.u_val += 1; // c:1200
17844                pm.u_str = Some(pm.u_val.to_string());
17845                pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
17846            }
17847        }
17848    }
17849}
17850
17851fn forked_child_subsh_levels() {
17852    use std::sync::atomic::Ordering;
17853    crate::ported::exec::FORKLEVEL.store(
17854        crate::ported::params::locallevel.load(Ordering::Relaxed),
17855        Ordering::Relaxed,
17856    ); // c:1221
17857    // c:Src/exec.c:1263-1264 (55061) — `ancestor_loops += loops; loops = 0;`
17858    crate::ported::builtin::ANCESTOR_LOOPS.fetch_add(
17859        crate::ported::builtin::LOOPS.swap(0, Ordering::Relaxed),
17860        Ordering::Relaxed,
17861    );
17862    crate::ported::builtin::SUBSHELL_DEPTH.store(0, Ordering::Relaxed);
17863    // !!! RUST-ONLY: a forked child is no longer inside the in-process
17864    // `$( … )` it was forked from; its signal traps print to its own fd 1,
17865    // as in C, and the parent's saved fds are gone (mark_saved_fd).
17866    CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().clear());
17867}
17868
17869/// Holds SIGCHLD blocked across the span in which the VM forks children.
17870///
17871/// C: `execpline` opens that span with `child_block()` (c:Src/exec.c:1748),
17872/// before any stage is forked, and closes it with `child_unblock()` only
17873/// after the job is registered (c:1815, the async arm) or after the wait
17874/// (c:2017). Its comment says why: "We need to block SIGCHLD in case the
17875/// process we are spawning terminates before the job table is set up to
17876/// handle it" (c:2339-2343). `getoutputfile` does the same around its own
17877/// fork (c:4999/c:5030).
17878///
17879/// zshrs has a second, harder reason. The reaper allocates
17880/// (`wait_for_processes` builds a `Vec`), and libmalloc holds its fork
17881/// lock inside `fork()`. A SIGCHLD from an earlier stage delivered there
17882/// re-enters malloc on that lock and the process dies with SIGKILL
17883/// ("BUG IN CLIENT OF LIBPLATFORM: Trying to recursively lock an
17884/// os_unfair_lock", faulting stack `fork → _sigtramp → zhandler →
17885/// wait_for_processes → Vec::push → _xzm_fork_lock_wait`). With the
17886/// reaper armed and no block, `repeat 300 { true | false | true }; print
17887/// ok` was killed before printing on every run.
17888///
17889/// !!! WARNING: RUST-ONLY SHAPE — C CALLS child_block/child_unblock !!!
17890/// This guard restores the PRIOR state on drop instead of unconditionally
17891/// unblocking. C's execpline recursion never forks a pipeline from inside
17892/// another pipeline's still-blocked span in the same process, but the VM
17893/// does: the last stage of a pipeline runs in the parent, and a nested
17894/// pipeline or process substitution there would otherwise lift the outer
17895/// block before the outer wait. So SIGCHLD is unblocked on drop only if it
17896/// was unblocked on entry. A forked child never drops the guard (it leaves
17897/// through `exit`/`_exit`), so it inherits the blocked mask, as C's child
17898/// of `zfork` does until `execute` unblocks before `execve` (c:793).
17899#[cfg(unix)]
17900pub(crate) struct ChildBlockSpan {
17901    was_blocked: bool,
17902}
17903
17904#[cfg(unix)]
17905impl ChildBlockSpan {
17906    pub(crate) fn enter() -> Self {
17907        let mask = crate::ported::signals::signal_mask(libc::SIGCHLD);
17908        let old = crate::ported::signals::signal_block(&mask); // c:Src/signals.h:52
17909        let was_blocked = unsafe { libc::sigismember(&old, libc::SIGCHLD) } == 1;
17910        ChildBlockSpan { was_blocked }
17911    }
17912}
17913
17914#[cfg(unix)]
17915impl Drop for ChildBlockSpan {
17916    fn drop(&mut self) {
17917        if !self.was_blocked {
17918            crate::ported::signals_h::child_unblock(); // c:Src/signals.h:53
17919        }
17920    }
17921}
17922
17923pub(crate) struct ForegroundWaitGuard {
17924    /// c:Src/jobs.c:1638 — `int first = 1, q = queue_signal_level();`
17925    q: i32,
17926}
17927
17928impl ForegroundWaitGuard {
17929    #[inline]
17930    pub(crate) fn enter() -> Self {
17931        // c:Src/jobs.c:1638-1642 — `waitforpid`'s prologue verbatim:
17932        //     int first = 1, q = queue_signal_level();
17933        //     dont_queue_signals();
17934        //     child_block();          /* unblocked in signal_suspend() */
17935        // `zwaitjob` opens the same window at c:1684-1689.  Both then block
17936        // in `signal_suspend(SIGCHLD, wait_cmd)` (c:1658 / c:1710), and that
17937        // call's sigsuspend mask is EMPTY — c:Src/signals.c:216-232 does
17938        // `sigemptyset(&set)` and adds at most a conditional SIGINT.  So a
17939        // foreground wait is a delivery point for every other signal,
17940        // SIGWINCH included: the shell's standing `winch_block()`
17941        // (c:Src/init.c:1458, re-armed by preprompt at c:Src/utils.c:1541)
17942        // is lifted for exactly as long as the shell waits.  It is the one
17943        // window C reliably opens in the middle of a command, which is why
17944        // a resize during a completion reaches `$COLUMNS` while the
17945        // completion is still running.
17946        //
17947        // This guard used to be `queue_signals()` alone.  That kept the
17948        // SIGCHLD reaper off the child by QUEUEING the delivery in
17949        // userspace rather than blocking it, and left SIGWINCH blocked for
17950        // the whole of a completion no matter how many children it forked
17951        // — a queued SIGWINCH is not dispatched until the queue level
17952        // reaches zero, which for a completion is after the widget has
17953        // returned.  Measured with a completer that read `$COLUMNS`, ran
17954        // `sleep 1` and read it again, across a 24x80 -> 24x60 resize: zsh
17955        // reported 80 then 60, zshrs 80 then 80.
17956        //
17957        // The visible cost was the prompt row.  `asklist`'s `trashzle`
17958        // (c:Src/Zle/compresult.c:1922 → c:Src/Zle/zle_main.c:2071-2095)
17959        // redraws the command line only when the handler has already raised
17960        // `resetneeded`/`winchanged` (c:Src/utils.c:1956-1958), so a
17961        // row-shrink during `git <TAB>` had zsh repainting `READY% git`
17962        // above the query and zshrs repainting nothing at all.
17963        //
17964        // `child_block` gives the same protection the queueing did and
17965        // gives it the way C does: the reaper cannot take the child out
17966        // from under `Child::wait` while SIGCHLD is blocked at the OS
17967        // level, and SIGWINCH is free to arrive.
17968        let q = crate::ported::signals_h::queue_signal_level(); // c:1638
17969        crate::ported::signals_h::dont_queue_signals(); // c:1641
17970        crate::ported::signals_h::child_block(); // c:1642
17971                                                 // c:1643 — `queue_traps(wait_cmd);`.  Signals are dispatched during
17972                                                 // the wait but user TRAPS are held until the child is reaped; the
17973                                                 // old `queue_signals` deferred both together, so this keeps the
17974                                                 // trap half of that behaviour where C keeps it.  `wait_cmd` is 0:
17975                                                 // this is not the `wait` builtin.
17976        crate::ported::signals::queue_traps(0); // c:1643
17977        crate::ported::signals_h::winch_unblock(); // c:Src/signals.c:232
17978        ForegroundWaitGuard { q }
17979    }
17980}
17981
17982impl Drop for ForegroundWaitGuard {
17983    #[inline]
17984    fn drop(&mut self) {
17985        // c:Src/jobs.c:1667-1669 — `unqueue_traps(); child_unblock();
17986        // restore_queue_signals(q);`.  The `winch_block` ahead of them puts
17987        // back the standing mask that c:Src/utils.c:1541 leaves in place
17988        // between prompts.
17989        crate::ported::signals_h::winch_block(); // c:Src/utils.c:1541
17990        crate::ported::signals::unqueue_traps(); // c:1667
17991        crate::ported::signals_h::child_unblock(); // c:1668
17992        crate::ported::signals_h::restore_queue_signals(self.q); // c:1669
17993    }
17994}
17995
17996/// `Command::status()` plus the reaper-stole-it recovery that the forked
17997/// pipeline stages already get from [`waitpid_eintr`].
17998///
17999/// [`ForegroundWaitGuard`] alone is not enough. Its `child_block` masks
18000/// SIGCHLD for the CALLING thread, and zshrs is multi-threaded: the signal
18001/// is delivered to any thread that has not blocked it, whose `zhandler`
18002/// runs `wait_for_processes` → `waitpid(-1, WNOHANG)` and takes the child.
18003/// `Command::status()` then fails with ECHILD, and because it owns the
18004/// child and never hands back the pid, the status the reaper published
18005/// (`extensions/reaped_status.rs`) cannot be claimed — the caller can only
18006/// report a failure for a command that ran fine.
18007///
18008/// That is what `echo a b c | wc -w` hit: the last stage runs in THIS
18009/// process, so it goes through here rather than through `waitpid_eintr`.
18010/// Measured in a Debian container before this helper existed, it printed
18011/// the correct `3` and then exited 127 with
18012/// `zshrs: wc: No child processes (os error 10)` on 136 of 200 runs.
18013///
18014/// Spawning explicitly keeps the pid, so the losing collector can claim
18015/// the status exactly as the pipeline's targeted wait does. The status is
18016/// rebuilt with `ExitStatus::from_raw`, so every caller keeps its own
18017/// mapping of exit codes and signals.
18018#[cfg(unix)]
18019pub(crate) fn foreground_status(
18020    command: &mut std::process::Command,
18021) -> std::io::Result<std::process::ExitStatus> {
18022    let _wait_guard = ForegroundWaitGuard::enter();
18023    let child = command.spawn()?;
18024    wait_pid_status(child.id() as libc::pid_t)
18025}
18026
18027/// Wait for the foreground child `pid`, claiming its status back from the
18028/// SIGCHLD reaper when the reaper collected it first (ECHILD). The caller
18029/// holds a `ForegroundWaitGuard` from before the spawn. Shared by
18030/// `foreground_status` and the `posix_spawn` path in
18031/// `vm_helper::execute_external_bg`, which has a pid but no `Child`.
18032pub(crate) fn wait_pid_status(pid: libc::pid_t) -> std::io::Result<std::process::ExitStatus> {
18033    use std::os::unix::process::ExitStatusExt as _;
18034    loop {
18035        let mut raw: libc::c_int = 0;
18036        let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
18037        if unsafe { libc::wait4(pid, &mut raw, 0, &mut ru) } == pid {
18038            let usec = |tv: libc::timeval| tv.tv_sec as u64 * 1_000_000 + tv.tv_usec as u64;
18039            fg_child_times_add(usec(ru.ru_utime), usec(ru.ru_stime));
18040            return Ok(std::process::ExitStatus::from_raw(raw));
18041        }
18042        let e = std::io::Error::last_os_error();
18043        match e.raw_os_error() {
18044            Some(libc::EINTR) => continue,
18045            Some(libc::ECHILD) => {
18046                return match take_reaped_status(pid) {
18047                    Some((raw, utime, stime)) => {
18048                        fg_child_times_add(utime, stime);
18049                        Ok(std::process::ExitStatus::from_raw(raw))
18050                    }
18051                    None => Err(e),
18052                }
18053            }
18054            _ => return Err(e),
18055        }
18056    }
18057}
18058
18059thread_local! {
18060    /// User / system CPU (microseconds) of the foreground externals this
18061    /// thread has waited for since the collector was opened, or `None` when
18062    /// nothing collects. A timed pipeline's in-shell last stage opens it: C
18063    /// forks that stage and times it as one process (`pn->ti = ru`,
18064    /// c:Src/signals.c:352), zshrs runs it here and the externals it spawns
18065    /// are what that process would have been.
18066    ///
18067    /// !!! WARNING: RUST-ONLY COLLECTOR — C READS THE STAGE'S OWN wait3 RUSAGE !!!
18068    static FG_CHILD_TIMES: std::cell::Cell<Option<(u64, u64)>> = const { std::cell::Cell::new(None) };
18069}
18070
18071fn fg_child_times_add(utime: u64, stime: u64) {
18072    FG_CHILD_TIMES.with(|c| {
18073        if let Some((u, s)) = c.get() {
18074            c.set(Some((u + utime, s + stime)));
18075        }
18076    });
18077}
18078
18079/// Claim the reaper's record for `pid`, allowing for the fact that ECHILD
18080/// arrives BEFORE the record is published.
18081///
18082/// `wait_for_processes` takes the child with `waitpid` and only then stores
18083/// the status (`signals.rs`, `reaped_status::record`). Everything between
18084/// those two is a window in which the losing collector already sees ECHILD
18085/// and the ring is still empty: reading it once left 8 of 400 runs of
18086/// `echo a b c | wc -w` reporting `No child processes` even with the
18087/// recovery in place. The child is known dead by then, so waiting out the
18088/// window costs nothing that is not already lost, and this runs only on the
18089/// lost-race path.
18090///
18091/// The budget is bounded because the record may never come: a collector
18092/// that does not publish (the `waitpid(-1)` in `bin_wait`, say) leaves
18093/// nothing to claim, and the caller must be allowed to report the error
18094/// rather than spin.
18095#[cfg(unix)]
18096pub(crate) fn take_reaped_status(pid: i32) -> Option<(i32, u64, u64)> {
18097    const TRIES: u32 = 200; // 200 x 100us = 20ms
18098    for _ in 0..TRIES {
18099        if let Some(rec) = crate::reaped_status::take_rusage(pid) {
18100            return Some(rec);
18101        }
18102        let ts = libc::timespec { tv_sec: 0, tv_nsec: 100_000 };
18103        unsafe { libc::nanosleep(&ts, std::ptr::null_mut()) };
18104    }
18105    None
18106}
18107
18108/// !!! WARNING: RUST-ONLY HELPER !!!
18109///
18110/// C has no counterpart: `fork()` gives a forked `(...)` subshell its own
18111/// copy of the fd table, so the flock fds a subshell opens (`Src/utils.c:2111`
18112/// `addlockfd` marks them `FDT_FLOCK` / `FDT_FLOCK_EXEC`) vanish with the
18113/// child and its `fcntl(F_SETLK)` locks are released. zshrs runs `(...)`
18114/// in-process, so it has to enumerate those slots at subshell entry and
18115/// close the new ones at subshell exit. Walks the same `fdtable` /
18116/// `max_zsh_fd` pair as `zcloselockfd` (`Src/utils.c:2155-2164`).
18117fn current_flock_fds() -> Vec<i32> {
18118    use crate::ported::zsh_h::{FDT_FLOCK, FDT_FLOCK_EXEC};
18119    let max_fd = crate::ported::utils::MAX_ZSH_FD.load(std::sync::atomic::Ordering::Relaxed);
18120    if max_fd < 0 {
18121        return Vec::new();
18122    }
18123    (0..=max_fd)
18124        .filter(|fd| {
18125            let slot = crate::ported::utils::fdtable_get(*fd);
18126            slot == FDT_FLOCK || slot == FDT_FLOCK_EXEC
18127        })
18128        .collect()
18129}
18130
18131/// `exec`'s external command: C's `execute(args, cflags, use_defpath)`
18132/// (c:Src/exec.c:4369, body c:729-881) through the same search and zexecve
18133/// as every other external (`vm_helper::execute_spawn`). `in_place` execs
18134/// over this process and only returns on failure; otherwise (an in-process
18135/// subshell, whose C counterpart was a forked child) the command is spawned
18136/// and waited for. Returns the command's status, or C's exit status for a
18137/// failed exec. `args` is the command and its arguments; `exec_argv0` the
18138/// `exec -a` name; `login` the `-`/`exec -l` flag.
18139fn exec_execute(
18140    args: &[String],
18141    exec_argv0: Option<String>,
18142    login: bool,
18143    use_defpath: bool,
18144    in_place: bool,
18145) -> i32 {
18146    let cmd = &args[0];
18147    crate::lowfd::cloexec_internal_fds(); // c:4352
18148    // c:3318-3328 — `exec -a NAME` travels as ARGV0 in the environment;
18149    // execute() takes argv[0] from it and unsets it (c:758-768), else
18150    // prefixes `-` for `exec -l` / `-` (c:772-776).
18151    let argv0_env = exec_argv0.or_else(|| std::env::var("ARGV0").ok());
18152    let mut argv: Vec<String> = Vec::with_capacity(args.len());
18153    argv.push(match &argv0_env {
18154        Some(z) => z.clone(),
18155        None if login => format!("-{}", cmd),
18156        None => cmd.clone(),
18157    });
18158    argv.extend(args[1..].iter().cloned());
18159    // c:810-819 — `command -p`: the default-path hit alone.
18160    let defpath = if use_defpath && !cmd.contains('/') {
18161        crate::ported::exec::search_defpath(cmd, libc::PATH_MAX as usize)
18162    } else {
18163        None
18164    };
18165    let _wait_guard = (!in_place).then(ForegroundWaitGuard::enter);
18166    let eno = if use_defpath && !cmd.contains('/') && defpath.is_none() {
18167        0 // c:815-819 — not on the default path
18168    } else {
18169        match crate::vm_helper::execute_spawn(
18170            cmd,
18171            &argv,
18172            defpath.as_deref(),
18173            None,
18174            argv0_env.is_some(),
18175            in_place,
18176        ) {
18177            Ok(pid) => {
18178                return match wait_pid_status(pid) {
18179                    Ok(status) => crate::exec_jobs::wait_status_val(status),
18180                    Err(_) => 127,
18181                }
18182            }
18183            Err(eno) => eno,
18184        }
18185    };
18186    drop(_wait_guard);
18187    // c:871-881
18188    if eno != 0 {
18189        crate::ported::utils::zerr(&format!(
18190            "{}: {}",
18191            crate::ported::utils::zsh_errno_msg(eno),
18192            cmd
18193        ));
18194        return if eno == libc::EACCES || eno == libc::ENOEXEC { 126 } else { 127 };
18195    }
18196    // c:873-874 — `if (commandnotfound(arg0, args) == 0) _realexit();`
18197    if let Some(rc) =
18198        with_executor(|exec| exec.dispatch_function_call("command_not_found_handler", args))
18199    {
18200        return rc;
18201    }
18202    crate::ported::utils::zerr(&format!(
18203        "command not found: {}",
18204        crate::ported::utils::nicedupstring(cmd)
18205    ));
18206    127
18207}
18208
18209/// Whether `exec` is running inside a subshell that zshrs keeps in this
18210/// process — `( … )` (the snapshot stack) or a command substitution
18211/// (`SubshStateGuard`, the in-process `entersubsh`). C forked both, so its
18212/// exec replaced only that child; replacing THIS process would end the
18213/// parent shell too (`x=$(exec echo hi)` ended the script).
18214fn exec_in_process_subshell() -> bool {
18215    with_executor(|exec| !exec.subshell_snapshots.is_empty())
18216        || crate::ported::exec::SUBSH_STATE_DEPTH.load(std::sync::atomic::Ordering::SeqCst) > 0
18217}
18218
18219/// Advice for an in-process command, run with the executor borrowed. `None`
18220/// means no intercept took over the call, so it proceeds normally (a `before`
18221/// advice has already run); `Some(status)` is the status an `around` / `after`
18222/// advice produced for the call.
18223fn run_command_intercepts(name: &str, args: &[String]) -> Option<i32> {
18224    with_executor(|exec| {
18225        if exec.intercepts.is_empty() {
18226            return None;
18227        }
18228        let full_cmd = std::iter::once(name)
18229            .chain(args.iter().map(String::as_str))
18230            .collect::<Vec<_>>()
18231            .join(" ");
18232        exec.run_intercepts(name, &full_cmd, args)
18233    })
18234    .map(|result| result.unwrap_or(127))
18235}
18236
18237/// Advice for the ALIAS a command was written as. The alias is gone from the text
18238/// by dispatch, so the compiler leaves it in [`ALIAS_ORIGIN`] together with the
18239/// command it expanded to; a marker for any other command is stale and dropped.
18240/// Advice sees only the arguments the user typed (those after the alias body),
18241/// while `intercept_proceed` and `after` advice run the expanded command.
18242fn intercept_alias_origin(name: &str, args: &[String]) -> Option<i32> {
18243    let (alias, body_words, command) = ALIAS_ORIGIN.with(|a| a.borrow_mut().take())?;
18244    // `alias ls="ls -G"`: the expanded command is advised under its own name.
18245    if command != name || alias == name || with_executor(|exec| exec.intercepts.is_empty()) {
18246        return None;
18247    }
18248    let typed = args.get(body_words.saturating_sub(1)..).unwrap_or(&[]);
18249    let full_cmd = std::iter::once(alias.as_str())
18250        .chain(typed.iter().map(String::as_str))
18251        .collect::<Vec<_>>()
18252        .join(" ");
18253    let original = (name.to_string(), args.to_vec());
18254    with_executor(|exec| exec.run_intercepts_with(&alias, &full_cmd, typed, Some(original)))
18255        .map(|result| result.unwrap_or(127))
18256}
18257
18258/// [`run_command_intercepts`] for a command word that is not an external: a
18259/// shell function, a ported or opcode builtin, a native command. Anonymous
18260/// functions are internal plumbing and never intercepted.
18261fn intercept_in_process_command(name: &str, args: &[String]) -> Option<i32> {
18262    if name.starts_with("_zshrs_anon_") || with_executor(|exec| exec.intercepts.is_empty()) {
18263        return None;
18264    }
18265    if !(with_executor(|exec| exec.function_exists(name)) || builtin_prefix_finds(name)) {
18266        return None;
18267    }
18268    run_command_intercepts(name, args)
18269}
18270
18271/// The opcode builtins (`echo`, `cd`, …) enter here before they run, so this is
18272/// where their advice runs; [`dispatch_builtin`] reaches the unadvised variant,
18273/// or the advice would fire twice for one command.
18274/// [`run_command_intercepts`] for builtins whose bare form reads $? afterwards
18275/// (`exit`, `return`, `exec`): the advice runs, then the status the command will
18276/// read is put back. `Some` is the value an `around` / `after` advice produced.
18277fn advise_keeping_status(vm: &mut fusevm::VM, name: &str, args: &[String]) -> Option<Value> {
18278    let live = vm.last_status;
18279    if let Some(status) = run_command_intercepts(name, args) {
18280        return Some(Value::Status(status));
18281    }
18282    vm.last_status = live;
18283    with_executor(|exec| exec.set_last_status(live));
18284    crate::ported::builtin::LASTVAL.store(live, std::sync::atomic::Ordering::Relaxed);
18285    None
18286}
18287
18288fn try_user_fn_override(name: &str, args: &[String]) -> Option<i32> {
18289    if let Some(status) = intercept_alias_origin(name, args) {
18290        return Some(status);
18291    }
18292    if let Some(status) = run_command_intercepts(name, args) {
18293        return Some(status);
18294    }
18295    user_fn_override_unadvised(name, args)
18296}
18297
18298fn user_fn_override_unadvised(name: &str, args: &[String]) -> Option<i32> {
18299    let has_fn = with_executor(|exec| {
18300        exec.functions_compiled.contains_key(name) || exec.function_exists(name)
18301    });
18302    if !has_fn {
18303        return None;
18304    }
18305    Some(with_executor(|exec| {
18306        exec.dispatch_function_call(name, args).unwrap_or(127)
18307    }))
18308}
18309
18310/// Builtin ID for `${name}` reads — routes through canonical
18311/// `getsparam` (Src/params.c:3076) via paramtab + env walk so nested
18312/// VMs (function calls) see the same storage.
18313pub const BUILTIN_GET_VAR: u16 = 283;
18314
18315/// Like `BUILTIN_GET_VAR` but forces double-quoted (DQ) semantics on
18316/// the read regardless of the runtime `in_dq_context`. The compiler
18317/// emits this for a QUOTED simple-var read (`"$name"`) — those compile
18318/// to a direct GET_VAR with no EXPAND_TEXT wrapper, so `in_dq_context`
18319/// is 0 and the plain GET_VAR would wrongly word-elide an array's empty
18320/// elements (`a=(1 "" 3); "$a"` must keep the empty → `1  3`, not
18321/// `1 3`). With force_dq the array joins via sepjoin keeping empties and
18322/// a scalar is returned verbatim (no empty-drop, no SH_WORD_SPLIT).
18323pub const BUILTIN_GET_VAR_DQ: u16 = 639;
18324
18325/// Builtin ID for `name=value` assignments — pops [name, value] and
18326/// routes through canonical `setsparam` (Src/params.c:3350).
18327pub const BUILTIN_SET_VAR: u16 = 284;
18328
18329/// Builtin ID that sets the thread-local [`SET_VAR_GLOB_ELIGIBLE`] flag true.
18330/// Emitted by the compiler immediately before a `BUILTIN_SET_VAR` whose scalar
18331/// RHS carried an UNQUOTED glob token, so the runtime knows the RHS is a literal
18332/// glob pattern eligible for GLOB_ASSIGN. Takes no stack args, pushes nothing.
18333pub const BUILTIN_MARK_GLOB_ELIGIBLE: u16 = 640;
18334
18335/// Builtin ID for pipeline execution. Pops N sub-chunk indices from the stack;
18336/// each index points into `vm.chunk.sub_chunks` (compiled stage bodies). Forks
18337/// N children, wires stdin/stdout between them via pipes, runs each stage's
18338/// bytecode on a fresh VM in its child, parent waits for all and pushes the
18339/// last stage's exit status. This is bytecode-native pipeline execution —
18340/// no tree-walker delegation.
18341pub const BUILTIN_RUN_PIPELINE: u16 = 285;
18342
18343/// Builtin ID for `Array → String` joining. Pops one value: if it's an Array,
18344/// joins its string-coerced elements with a single space; otherwise passes
18345/// through. Used after `Op::Glob` to convert the pattern's matched paths into
18346/// the single argv-token form the bytecode word model expects (no per-word
18347/// splitting yet — that's a future phase).
18348pub const BUILTIN_ARRAY_JOIN: u16 = 286;
18349
18350/// Builtin ID for `cmd &` background execution. IDs 287/288/289 are reserved
18351/// for the planned array work in Phase G1 (SET_ARRAY/SET_ASSOC/ARRAY_INDEX),
18352/// so this lands at 290. Pops the sub-chunk index then the job text; forks;
18353/// child detaches (`setsid`), runs the sub-chunk on a fresh VM, exits with
18354/// last_status; parent registers the job in the canonical JOBTAB
18355/// (initjob/addproc/spawnjob per c:Src/exec.c:1700-1758) so `jobs` / `wait
18356/// %N` / `kill %N` / `disown` and the zsh/parameter assocs all see it, then
18357/// returns Status(0) immediately.
18358pub const BUILTIN_RUN_BG: u16 = 290;
18359
18360/// Indexed-array assignment: `arr=(a b c)`. Compile_simple emits N element
18361/// pushes followed by name push, then `CallBuiltin(BUILTIN_SET_ARRAY, N+1)`.
18362/// The handler pops args (last popped = name in our pushing order) and stores
18363/// `Vec<String>` into `executor.arrays`. Tree-walker callers see the same
18364/// storage. Any prior scalar binding in `executor.variables` for `name` is
18365/// removed so `${name}` (scalar context) consistently reflects the array's
18366/// first element via `get_variable`.
18367pub const BUILTIN_SET_ARRAY: u16 = 287;
18368
18369/// Single-key set on an associative array: `foo[key]=val`. Stack (top-down):
18370/// [name, key, value]. Stores `value` into `executor.assoc_arrays[name][key]`,
18371/// creating the outer entry if missing. compile_simple detects `var[...]=...`
18372/// in assignments and emits this builtin.
18373pub const BUILTIN_SET_ASSOC: u16 = 288;
18374
18375/// `${arr[idx]}` — single-element array index. Pops two args:
18376///   stack: [name, idx_str]
18377/// Returns the indexed element as Value::str. Indexing semantics: zsh is
18378/// 1-based by default; bash is 0-based. We follow zsh.
18379/// Special idx values: `@` and `*` return the whole array as Value::Array
18380/// (which fuses correctly via the Op::Exec splice for argv splice).
18381pub const BUILTIN_ARRAY_INDEX: u16 = 289;
18382
18383/// `${#arr[@]}` and `${#arr}` (when arr is an array name) — array length.
18384/// Pops one arg: name. Returns Value::str of len.
18385
18386/// `${arr[@]}` — splice all elements as a Value::Array. Pops one arg: name.
18387/// The Array gets flattened by Op::Exec/ExecBg/CallFunction into argv.
18388pub const BUILTIN_ARRAY_ALL: u16 = 292;
18389
18390/// Flatten one level of Value::Array nesting. Pops N values; for each, if it's
18391/// a Value::Array, its elements are appended directly; otherwise the value is
18392/// appended as-is. Pushes a single Value::Array of the flattened result. Used
18393/// by the for-loop word-list compile path: when a word like `${arr[@]}`
18394/// produces a nested Array, this lets `for i in ${arr[@]}` iterate over the
18395/// inner elements rather than the outer single-element array.
18396pub const BUILTIN_ARRAY_FLATTEN: u16 = 293;
18397
18398/// `coproc [name] { body }` — bidirectional pipe to async child. Pops a name
18399/// (optional, "" for default) and a sub-chunk index. Creates two pipes, forks,
18400/// child redirects its fd 0/1 to the inner ends and runs the body, parent
18401/// stores [write_fd, read_fd] into the named array (default `COPROC`). Caller
18402/// closes the fds and `wait`s when done. Job-table integration deferred to
18403/// Phase G6 alongside the bg `&` work.
18404pub const BUILTIN_RUN_COPROC: u16 = 294;
18405
18406/// `arr+=(d e f)` — append N elements to an existing indexed array. Compile
18407/// emits N element pushes + name push, then `CallBuiltin(295, N+1)`. Handler
18408/// drains args (last popped = name), extends `executor.arrays[name]` (creates
18409/// the entry if missing). Mirrors zsh's `+=` semantics for indexed arrays.
18410pub const BUILTIN_APPEND_ARRAY: u16 = 295;
18411
18412/// `name[@]=(...)` / `name[*]=(...)` whole-array SET. Identical to
18413/// BUILTIN_SET_ARRAY for an indexed array / scalar (whole replace), but
18414/// rejects an associative target with "attempt to set slice of
18415/// associative array" (c:Src/params.c:3324-3327).
18416pub const BUILTIN_SET_ARRAY_AT: u16 = 633;
18417
18418/// `name[@]+=(...)` / `name[*]+=(...)` whole-array APPEND. Indexed
18419/// append (push), assoc target → same slice-of-assoc error as 633.
18420pub const BUILTIN_APPEND_ARRAY_AT: u16 = 634;
18421
18422/// `select var in words; do body; done` — interactive numbered-menu loop.
18423/// Compile emits N word pushes + var-name push + sub-chunk index push, then
18424/// `CallBuiltin(296, N+2)`. Handler prints `1) word1\n2) word2\n...` to
18425/// stderr, prints `$PROMPT3` (default `?# `) to stderr, reads a line from
18426/// stdin. On EOF returns 0. On a valid 1-based number, sets `var` to the
18427/// chosen word, runs the sub-chunk, then redisplays the menu and loops. On
18428/// invalid input redraws the menu without running the body. `break` from
18429/// inside the body exits the loop (handled by the body's own bytecode).
18430pub const BUILTIN_RUN_SELECT: u16 = 296;
18431
18432/// `m[k]+=value` — append onto an existing assoc-array value (string concat).
18433/// If the key doesn't exist, behaves like SET_ASSOC. Stack: [name, key, value].
18434
18435/// `break` from inside a body that runs on a sub-VM (select, future
18436/// loop-via-builtin constructs). Writes the canonical
18437/// `crate::ported::builtin::BREAKS` atomic (port of `Src/loop.c:46
18438/// breaks`). Outer-loop builtins drain BREAKS/CONTFLAG after each
18439/// body run, matching the loop.c:529-534 drain pattern.
18440pub const BUILTIN_SET_BREAK: u16 = 299;
18441
18442/// `continue` from inside a sub-VM body. Sets CONTFLAG=1 + bumps
18443/// BREAKS, matching `bin_break`'s WC_CONTINUE arm at Src/builtin.c
18444/// c:5836 `contflag = 1; FALLTHROUGH; breaks++;`.
18445pub const BUILTIN_SET_CONTINUE: u16 = 300;
18446
18447/// Brace expansion: `{a,b,c}` → 3 values, `{1..5}` → 5 values, `{01..05}` →
18448/// zero-padded numerics, `{a..e}` → letter range. Pops one string, returns
18449/// Value::Array of expansions (empty array → original string preserved).
18450pub const BUILTIN_BRACE_EXPAND: u16 = 301;
18451
18452/// Glob qualifier filter: `*(qualifier)` filters glob results by predicate.
18453/// Pops [pattern, qualifier_string]. Returns Value::Array of matching paths.
18454
18455/// Re-export the regex_match host method as a builtin so `[[ s =~ pat ]]`
18456/// works even when fusevm's Op::RegexMatch isn't routed (compat fallback).
18457
18458/// Word-split a string on IFS (default: whitespace). Pops one string,
18459/// returns Value::Array of fields. Used in array-literal context where
18460/// `arr=($(cmd))` should expand cmd's stdout into multiple elements.
18461pub const BUILTIN_WORD_SPLIT: u16 = 304;
18462
18463/// `${=name}` / SH_WORD_SPLIT forced IFS split — c:Src/subst.c:3920-3928
18464/// `aval = sepsplit(val, spsep, 0, 1);`.
18465///
18466/// Unlike BUILTIN_WORD_SPLIT (which routes through `multsub`'s
18467/// PREFORK_SPLIT walker — the c:553-620 loop that COLLAPSES runs of
18468/// separators and never emits an empty field), this is the *other* zsh
18469/// splitter: `sepsplit` → `Src/utils.c:3711 spacesplit(s, allownull=0)`,
18470/// which distinguishes the two IFS classes and preserves empty fields.
18471///
18472/// Stack: \[value\]. argc selects the empty-field rule:
18473///   * argc == 0 — the expansion is a bare unquoted word (`${=v}`): the
18474///     leading/trailing `""` fields spacesplit emits for skipped
18475///     IFS-WHITESPACE are empty argv words and prefork deletes them
18476///     (c:Src/subst.c:184-187 `uremnode`).
18477///   * argc == 1 — the expansion is quoted (`"${=v}"`) or has adjacent
18478///     word segments (`x${=v}y`): those fields survive, because in C the
18479///     word's `Dnull` quote markers / literal prefix+suffix attach to the
18480///     first and last elements (c:4386 / c:4429 strcatsub) and the node is
18481///     no longer empty. `v=$' a:b '` → `""`, `a:b`, `""` quoted; `x`,
18482///     `a:b`, `y` with surrounding text.
18483///
18484/// Empty fields that come from an IFS-NON-whitespace separator are the
18485/// `nulstring` (`Nularg`, c:Src/subst.c:36) and survive in BOTH cases —
18486/// `IFS=x; v=xaxbx` splits to `""`, `a`, `b`, `""` quoted or not.
18487pub const BUILTIN_FORCE_SPLIT: u16 = 643;
18488
18489/// Register a pre-compiled fusevm chunk as a function. Stack: [name,
18490/// base64-bincode-of-Chunk]. Used by compile_zsh's compile_funcdef to
18491/// register functions parsed via parse_init+parse without going through the
18492/// ShellCommand JSON serialization path.
18493pub const BUILTIN_REGISTER_COMPILED_FN: u16 = 305;
18494/// `BUILTIN_VAR_EXISTS` constant.
18495pub const BUILTIN_VAR_EXISTS: u16 = 306;
18496/// Native param-modifier builtins. Each takes a fixed argv shape and
18497/// returns the modified value as Value::Str.
18498///
18499/// `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
18500/// — pop [name, op_byte, rhs]. op_byte: 0=`:-`, 1=`:=`, 2=`:?`, 3=`:+`.
18501pub const BUILTIN_PARAM_DEFAULT_FAMILY: u16 = 307;
18502/// `${var:offset[:length]}` — pop [name, offset, length] (length=-1 means
18503/// "rest of value"; negative offset counts from end).
18504pub const BUILTIN_PARAM_SUBSTRING: u16 = 308;
18505/// `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}` — pop
18506/// [name, pattern, op_byte]. op_byte: 0=`#`, 1=`##`, 2=`%`, 3=`%%`.
18507pub const BUILTIN_PARAM_STRIP: u16 = 309;
18508/// `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
18509/// `${var/%pat/repl}` — pop [name, pattern, replacement, op_byte].
18510/// op_byte: 0=first, 1=all, 2=anchor-prefix, 3=anchor-suffix.
18511pub const BUILTIN_PARAM_REPLACE: u16 = 310;
18512/// `${#name}` — character length of a scalar value, or element count
18513/// of an indexed/assoc array. Pops \[name\], returns count as Value::Str.
18514pub const BUILTIN_PARAM_LENGTH: u16 = 311;
18515/// `$((expr))` arithmetic substitution. Pops \[expr_string\], evaluates
18516/// via the executor's MathEval (integer-aware), returns result as
18517/// Value::Str. Bypasses ArithCompiler's float-only Op::Div path so
18518/// `$((10/3))` returns "3" not "3.333...".
18519pub const BUILTIN_ARITH_EVAL: u16 = 312;
18520/// `(( ... ))` math command post-eval status hook. Pops nothing,
18521/// pushes Value::Status. If errflag is set (math error in the
18522/// preceding BUILTIN_ARITH_EVAL call), clears it and emits status=2
18523/// matching c:Src/math.c arith-failure semantics. Otherwise emits
18524/// the current vm.last_status. Used by compile_arith's `(( ... ))`
18525/// path so the math command swallows errors without halting the
18526/// script — `$((... ))` substitutions skip this hook so their
18527/// errflag propagates up to the containing command.
18528pub const BUILTIN_ARITH_CMD_FINISH: u16 = 527;
18529/// `$(cmd)` command substitution. Pops \[cmd_string\], runs through
18530/// `run_command_substitution` which compiles via parse_init+parse + ZshCompiler
18531/// and captures stdout via an in-process pipe. Returns trimmed output
18532/// as Value::Str. Avoids the sub-chunk word-emit quoting bug in the
18533/// raw Op::CmdSubst path.
18534pub const BUILTIN_CMD_SUBST_TEXT: u16 = 313;
18535/// Text-based word expansion. Pops \[preserved_text\]: the word with
18536/// quotes preserved (Dnull→`"`, Snull→`'`, Bnull→`\`), runs
18537/// `expand_string` (variable + cmd-sub + arith) then `xpandbraces`
18538/// then `expand_glob`. Returns Value::str (single match) or
18539/// Value::Array (multi-match brace/glob).
18540pub const BUILTIN_EXPAND_TEXT: u16 = 314;
18541
18542/// `[[ a -ef b ]]` — same-inode test. Stack: [a, b]. Pushes Bool true iff
18543/// both paths resolve to the same `(dev, inode)` pair (zsh + bash semantics).
18544pub const BUILTIN_SAME_FILE: u16 = 315;
18545
18546/// `[[ a -nt b ]]` — file `a` newer than file `b` (mtime strict).
18547/// Stack: [path_a, path_b]. Pushes Bool. zsh-compatible "missing"
18548/// rules: if both exist, compare mtime; if only `a` exists → true;
18549/// otherwise false.
18550pub const BUILTIN_FILE_NEWER: u16 = 324;
18551
18552/// `[[ a -ot b ]]` — mirror of `-nt`. If both exist, compare mtime;
18553/// if only `b` exists → true; otherwise false.
18554pub const BUILTIN_FILE_OLDER: u16 = 325;
18555
18556/// `[[ -k path ]]` — sticky bit (S_ISVTX) set on path.
18557pub const BUILTIN_HAS_STICKY: u16 = 326;
18558/// `[[ -u path ]]` — setuid bit (S_ISUID).
18559pub const BUILTIN_HAS_SETUID: u16 = 327;
18560/// `[[ -g path ]]` — setgid bit (S_ISGID).
18561pub const BUILTIN_HAS_SETGID: u16 = 328;
18562/// `[[ -O path ]]` — owned by effective UID.
18563pub const BUILTIN_OWNED_BY_USER: u16 = 329;
18564/// `[[ -G path ]]` — owned by effective GID.
18565pub const BUILTIN_OWNED_BY_GROUP: u16 = 330;
18566/// `[[ -N path ]]` — file modified since last accessed (atime <= mtime).
18567pub const BUILTIN_FILE_MODIFIED_SINCE_ACCESS: u16 = 341;
18568
18569/// `name+=val` (no parens) — runtime-dispatched append.
18570/// If name is an indexed array → push val as element.
18571/// If name is an assoc array → error (zsh requires `(k v)` form).
18572/// Else → scalar concat (existing SET_VAR behavior).
18573pub const BUILTIN_APPEND_SCALAR_OR_PUSH: u16 = 331;
18574
18575/// `[[ -c path ]]` — character device.
18576pub const BUILTIN_IS_CHARDEV: u16 = 332;
18577/// `[[ -b path ]]` — block device.
18578pub const BUILTIN_IS_BLOCKDEV: u16 = 333;
18579/// `[[ -p path ]]` — FIFO / named pipe.
18580pub const BUILTIN_IS_FIFO: u16 = 334;
18581/// `[[ -S path ]]` — socket.
18582pub const BUILTIN_IS_SOCKET: u16 = 335;
18583/// `BUILTIN_ERREXIT_CHECK` constant.
18584pub const BUILTIN_ERREXIT_CHECK: u16 = 336;
18585/// Fatal-error-only abort check, for use INSIDE an `&&` / `||` chain.
18586///
18587/// A chain suppresses the errexit check (a non-zero status is "consumed"
18588/// by the connector — `false && x` must not fire ERREXIT or the ZERR
18589/// trap). But an errflag — a *fatal* error such as a `[[ ]]` bad pattern
18590/// — is not a status the connector can consume: zsh abandons the whole
18591/// list. Without this, zshrs ran the `||` right-hand side after the
18592/// error (`[[ x = [a- ]] || touch f` created `f`; zsh does not) and the
18593/// aborted builtin then overwrote the cond's status 2 with 1.
18594///
18595/// Same errflag arm as `BUILTIN_ERREXIT_CHECK`, with the errexit/ZERR
18596/// half omitted.
18597pub const BUILTIN_FATAL_ABORT_CHECK: u16 = 641;
18598/// Post-`always`-arm checks for the canonical RETFLAG / BREAKS /
18599/// CONTFLAG atomics that mark try-block escapes. Each returns
18600/// Value::Int(1) when the corresponding atomic is set (and consumes
18601/// it so the next escape doesn't re-fire) and Value::Int(0) otherwise.
18602/// Paired with JumpIfFalse + Jump to outer return_patches /
18603/// break_patches / continue_patches by compile_zsh's `Try` arm.
18604pub const BUILTIN_RETFLAG_CHECK: u16 = 600;
18605/// `BUILTIN_BREAKS_CHECK` constant.
18606pub const BUILTIN_BREAKS_CHECK: u16 = 601;
18607/// `BUILTIN_CONTFLAG_CHECK` constant.
18608pub const BUILTIN_CONTFLAG_CHECK: u16 = 602;
18609/// `loops++` on entry to a compiled for/while/until/repeat
18610/// (c:Src/loop.c:114/427/523).
18611pub const BUILTIN_LOOP_ENTER: u16 = 656;
18612/// `loops--` on exit from a compiled for/while/until/repeat
18613/// (c:Src/loop.c:188/491/546).
18614pub const BUILTIN_LOOP_EXIT: u16 = 657;
18615/// Post-body `if (breaks) { breaks--; … }` drain (c:Src/loop.c:529-534).
18616/// Int(1) = terminate this loop, Int(0) = next iteration.
18617pub const BUILTIN_LOOP_BREAK_DRAIN: u16 = 658;
18618/// Non-consuming `breaks != 0` probe for execlist's per-statement gate
18619/// (c:Src/exec.c:1370).
18620pub const BUILTIN_BREAKS_PENDING: u16 = 659;
18621/// `shtokenize` the top-of-stack string in place — c:Src/subst.c:4419-4420
18622/// `if (globsubst) shtokenize(y)`, the step that makes a `${~spec}` /
18623/// `$~spec` value's metachars PATTERN-ACTIVE.
18624///
18625/// zshrs expands a `[[ ]]` operand at the VM level and hands `cond_str`
18626/// (c:Src/cond.c:525) a finished string, so the token state C carries in
18627/// the word itself has to be re-applied at the point of use. Without it a
18628/// module condition compiles the value as a literal: `[[ -prefix $~pat ]]`
18629/// (Completion/Base/Utility/_numbers sh:65) is the one shipped completer
18630/// that depends on it.
18631pub const BUILTIN_COND_SHTOKENIZE: u16 = 660;
18632/// Fire the DEBUG trap (SIGDEBUG) before each statement.
18633/// c:Src/exec.c:1357-1500 DEBUGBEFORECMD — when a "DEBUG" entry is
18634/// installed via `trap '...' DEBUG`, run the body just before the
18635/// next command. Cheap when no DEBUG trap is set (one hashmap lookup
18636/// returns None and we early-out).
18637pub const BUILTIN_DEBUG_TRAP: u16 = 603;
18638/// `set -n` / `set -o noexec` — parse but don't execute. Returns
18639/// Value::Int(1) when the noexec option is set so the caller's
18640/// JumpIfTrue skips the statement body. c:Src/exec.c:1390 main loop
18641/// check.
18642pub const BUILTIN_NOEXEC_CHECK: u16 = 604;
18643/// c:Src/exec.c:4051 — `else if (isset(EXECOPT) && !errflag)`: a simple
18644/// command's words are expanded (prefork c:3304, globlist c:3757) whether
18645/// or not NO_EXEC is set; only the execution is skipped. No args. Returns
18646/// Int(1) under NO_EXEC, after setting the status to 1 when the expansion
18647/// raised errflag (c:3760-3763); the caller's JumpIfTrue then drops the
18648/// expanded words without running the command. Int(0) otherwise.
18649pub const BUILTIN_NOEXEC_CMD_GATE: u16 = 748;
18650/// Block-level redirect-failure gate. Reads exec.redirect_failed
18651/// (set by host.redirect when a redirect open fails); returns
18652/// Value::Int(1) AND clears the flag if set, else 0. Emit-side at
18653/// compile_zsh.rs::compile_command's Redirected arm pairs with a
18654/// JumpIfTrue → WithRedirectsEnd to abandon the body. Without this,
18655/// a multi-statement block after a failed redir kept running every
18656/// statement after the first (the first builtin consumed the flag,
18657/// subsequent statements ran unimpeded).
18658pub const BUILTIN_REDIRECT_FAILED_CHECK: u16 = 605;
18659/// Drop-in replacement for fusevm's Op::Exec for the dynamic-first-
18660/// word path (`$cmd`, `$(cmd)`, `~/bin/foo`). Returns
18661/// Value::Status(vm.last_status) when post-expansion argv is empty
18662/// (preserves the inner cmd-subst's exit), Value::Status(126) with
18663/// "permission denied" when `argv[0]` is empty, otherwise routes
18664/// through executor.host_exec_external like Op::Exec did.
18665pub const BUILTIN_EXEC_DYNAMIC: u16 = 606;
18666/// Reset `use_cmdoutval` to 0 at the START of a dynamic command (before
18667/// its words expand), so a command substitution from a PREVIOUS command
18668/// can't leak into this command's null-command status decision
18669/// (c:Src/exec.c:3009 `use_cmdoutval = !args`). See BUILTIN_EXEC_DYNAMIC.
18670pub const BUILTIN_USE_CMDOUTVAL_RESET: u16 = 637;
18671/// Reset `cmdoutval` to 0 at the START of a simple command that carries
18672/// assignments, before any of its words or assignment values expand
18673/// (c:Src/exec.c:2796 execcmd_analyse, c:1368 execsimple WC_ASSIGN).
18674pub const BUILTIN_CMDOUTVAL_RESET: u16 = 731;
18675/// Tilde-expand a match pattern's leading `~`, the way `singsub` does
18676/// before the pattern reaches `patcompile`.
18677///
18678/// c:Src/cond.c:299-307 — `right = dupstring(ecrawstr(…)); singsub(&right);
18679/// … patcompile(right, …)`. `singsub` is `prefork(PREFORK_SINGLE)`
18680/// (c:Src/subst.c:520), and prefork runs `filesub` on every word, so an
18681/// unquoted `~` in a `[[ … == ~/* ]]` pattern — or one that arrives via
18682/// `${~var}` — is a home directory, not a literal character. `case`
18683/// patterns take the same route (c:Src/loop.c:610 `singsub(&pat)`).
18684/// zshrs untokenizes the pattern at compile time and re-tokenizes it in
18685/// the matcher, so the expansion has to happen here.
18686///
18687/// Only a LEADING `~` is considered, which is all `filesubstr`
18688/// (c:Src/subst.c:741) ever expands: a `~` elsewhere is EXTENDED_GLOB's
18689/// "except" operator and must survive untouched, and a quoted one has
18690/// already been backslash-escaped by `escape_quoted_glob_metas` so it
18691/// fails the leading-char test.
18692///
18693/// powerlevel10k's directory segment is the visible consumer: its
18694/// `_POWERLEVEL9K_DIR_CLASSES` walk matches `$PWD` against `~` and `~/*`
18695/// via `[[ $_p9k__cwd == ${~a} ]]` (internal/p10k.zsh:2029). Without the
18696/// expansion both classes missed, every path under `$HOME` fell through
18697/// to the `*` DEFAULT class, and the prompt showed the generic folder
18698/// icon in place of the home / home-subfolder one.
18699fn pattern_filesub(pattern: &str) -> String {
18700    let first = pattern.chars().next();
18701    if first != Some('~') && first != Some(crate::ported::zsh_h::Tilde) {
18702        return pattern.to_string();
18703    }
18704    // filesubstr keys on the Tilde TOKEN, so shtokenize first (a raw `~`
18705    // becomes Tilde; an already-tokenized one passes through), then
18706    // untokenize the surviving glob metas back for the matcher.
18707    let mut tok = pattern.to_string();
18708    crate::ported::glob::shtokenize(&mut tok);
18709    crate::ported::lex::untokenize(&crate::ported::subst::filesub(&tok, 0))
18710}
18711
18712/// `[[ lhs == pat ]]` / `!=` glob compare — cond-specific so the
18713/// bad-pattern diagnostic follows Src/cond.c:308-316: zwarnnam
18714/// "bad pattern: %s" WITHOUT errflag (the script continues) and the
18715/// cond statement exits 2. Stack: [lhs, pat] → Bool. On compile
18716/// failure pushes Bool(false) and arms COND_BAD_PATTERN so
18717/// BUILTIN_COND_STATUS_FROM_BOOL reports 2.
18718pub const BUILTIN_COND_STRMATCH: u16 = 624;
18719/// Pops the cond result Bool → Int status per Src/cond.c: true→0,
18720/// false→1, but 2 when COND_BAD_PATTERN was armed during this cond
18721/// (covers `!=` where LogNot flips the Bool before status time).
18722pub const BUILTIN_COND_STATUS_FROM_BOOL: u16 = 625;
18723/// `[[ ]]` unknown condition. Pops \[op_name\], emits `zerr("unknown
18724/// condition: %s")` and sets ERRFLAG_ERROR so the next BUILTIN_ERREXIT_CHECK
18725/// (trigger 4) aborts the input — matching zsh's COND_MODI "unknown condition"
18726/// path (Src/cond.c:150-188) for a `-X` op with no matching loadable module.
18727/// Returns Bool(false). Replaces a compile-time `eprintln!` hack that printed
18728/// the message but never set errflag (so the line didn't abort).
18729pub const BUILTIN_COND_UNKNOWN: u16 = 632;
18730/// Bare-`exec` redirect epilogue. Consumes `exec.redirect_failed` and
18731/// applies the C `done:` tail of execcmd_exec:
18732///   - c:Src/exec.c:252-259 execerr — `redir_err = lastval = 1` (the
18733///     failed redirect makes the exec statement exit 1, NOT fatal by
18734///     itself);
18735///   - c:Src/exec.c:4367-4386 — `if (isset(POSIXBUILTINS) && (cflags
18736///     & (BINF_PSPECIAL|BINF_EXEC)) ...) { if (redir_err || errflag)
18737///     { if (!isset(INTERACTIVE)) exit(1); } }` — POSIX_BUILTINS makes
18738///     a failed exec redirect fatal in a non-interactive shell.
18739/// Returns Value::Status(0|1) for the trailing SetStatus.
18740pub const BUILTIN_EXEC_REDIR_DONE: u16 = 626;
18741/// Assignment-prefix epilogue for bare `exec` redirects
18742/// (`x=$(cmd) exec >file`). c:Src/exec.c:3969-3976 — nullexec==1
18743/// runs addvars THEN, without POSIX_BUILTINS, restores the params
18744/// (`save_params` / `restore_params`): the RHS side effects fire but
18745/// the values don't persist. With POSIX_BUILTINS the assignments
18746/// stick. Pops the BEGIN_INLINE_ENV frame either way.
18747pub const BUILTIN_EXEC_INLINE_ENV_DONE: u16 = 627;
18748
18749/// `< file` / `> file` with no command word (NULLCMD path).
18750/// Resolves NULLCMD (default "cat") / READNULLCMD (default "more")
18751/// at runtime per Src/exec.c:3386-3419, then dispatches that word
18752/// exactly as execcmd's fall-through does: shell function (c:3485)
18753/// → builtin (c:3489) → external. Argc is 1: the int (0 or 1) on the
18754/// stack indicates whether this is a single REDIR_READ redirect
18755/// (selects READNULLCMD when set + non-empty).
18756pub const BUILTIN_NULLCMD_EXEC: u16 = 607;
18757/// c:Src/exec.c:3395-3399 — the redirection-only command's refusal,
18758/// taken before its redirections open: no NULLCMD, or CSH_NULLCMD set,
18759/// is `zerr("redirection with no command")` with `lastval = 1` and
18760/// ERRFLAG_ERROR. argc=0; Bool(true) = go on to NULLCMD_EXEC.
18761pub const BUILTIN_NULLCMD_CHECK: u16 = 760;
18762/// Emitted right before BUILTIN_EXEC_DYNAMIC when the simple command has
18763/// redirections. No args. The VM has already opened them, so the dispatch
18764/// passes `execcmd_exec` an empty `redir` list; this tells its AUTOCD test
18765/// (c:Src/exec.c:3596 `(!redir || empty(redir))`) that there were some.
18766pub const BUILTIN_EXEC_DYNAMIC_REDIRS: u16 = 761;
18767/// Emitted right before a command word compiled to a builtin opcode: the
18768/// command runs in the current shell, so execcmd_exec's is_cursh arm
18769/// (c:Src/exec.c:3674-3682) marks the pipeline's job STAT_CURSH, plus
18770/// STAT_NOPRINT while it has no processes. No args; pushes Int(0).
18771pub const BUILTIN_MARK_CURSH: u16 = 762;
18772/// Opens the window in which the prefix assignments of `X=… cmd` expand.
18773/// argc=1: the command word ("" when it is an expansion). Closed by
18774/// BUILTIN_SEAL_INLINE_ENV. See PREFIX_ASSIGN_CMD.
18775pub const BUILTIN_PREFIX_ASSIGNS_START: u16 = 763;
18776/// Emitted right after the command-name word of a dynamic simple command is
18777/// expanded. argc=1: Int, nonzero when more words follow. Pushes Int(0). Records whether globbing that word
18778/// failed (c:Src/exec.c:3350-3354 zglobs the name in the shell, before the
18779/// fork, so its errflag is not cleared like an argument glob failure is).
18780/// Read by [`take_cmd_name_glob_failed`].
18781pub const BUILTIN_CMD_NAME_GLOB_MARK: u16 = 764;
18782/// `[[ s -pcre-match re ]]` (Src/Modules/pcre.c:506 `cond_pcre_match`). Stack:
18783/// [lhs, rhs] -> Bool. Always the PCRE engine, whatever REMATCHPCRE says (that
18784/// option only picks the engine for `=~`, c:Src/cond.c:113-119).
18785pub const BUILTIN_COND_PCRE_MATCH: u16 = 765;
18786/// `cmd &!` where `cmd` is a `%job` word (c:Src/exec.c:3020-3023 + the matching
18787/// restore after the command): argc=1, Int. Nonzero saves AUTOCONTINUE and
18788/// turns it on; zero restores the saved value. Pushes Int(0).
18789pub const BUILTIN_AUTOCONTINUE_FORCE: u16 = 766;
18790/// `.` (dot) — alias of source/bin_dot but dispatches with the
18791/// literal name "." so the diagnostic prefix matches zsh's
18792/// (`zsh:.:1: …` vs source's `zsh:source:1: …`).
18793/// c:Src/builtin.c:9308 — `BUILTIN(".", BINF_PSPECIAL, bin_dot, …)`.
18794pub const BUILTIN_DOT: u16 = 608;
18795/// `logout` — fusevm maps this to BUILTIN_EXIT alongside `exit`/`bye`,
18796/// which drops the name and dispatches with BIN_EXIT funcid. zsh's
18797/// `logout` outside a login shell must emit "not login shell" + exit 1,
18798/// which only fires when bin_break sees BIN_LOGOUT funcid. Dedicated
18799/// opcode dispatches via BUILTINS table by literal name "logout".
18800pub const BUILTIN_LOGOUT: u16 = 610;
18801/// `BUILTIN_PARAM_SUBSTRING_EXPR` constant.
18802pub const BUILTIN_PARAM_SUBSTRING_EXPR: u16 = 337;
18803/// `BUILTIN_XTRACE_LINE` constant.
18804pub const BUILTIN_XTRACE_LINE: u16 = 338;
18805/// `BUILTIN_XTRACE_ARRAY_LINE` — xtrace an `arr=(...)` assignment from the
18806/// whole assembled `Value::Array` (see compile_zsh array-literal codegen).
18807pub const BUILTIN_XTRACE_ARRAY_LINE: u16 = 649;
18808/// `BUILTIN_MAKE_ARRAY_COUNTED` — like `Op::MakeArray(u16)` but the element
18809/// count is a runtime `Int` on the stack, so it is not capped at 65535. Used
18810/// by the array-literal codegen only when the literal has > u16::MAX elements
18811/// (e.g. a .zcompdump's ~103k-element `_comps=(...)`).
18812pub const BUILTIN_MAKE_ARRAY_COUNTED: u16 = 650;
18813/// `BUILTIN_ARGV_RFLATTEN` — pop one `Op::MakeArray`-packed argv bundle and
18814/// push it back as ONE recursively-flattened `Value::Array` of scalars. Emitted
18815/// by the simple-command codegen ONLY on the >255-arg overflow path: the
18816/// `Call`/`CallFunction`/`CallBuiltin` opcodes carry argc as a u8, so a command
18817/// with more than 255 args is packed into a single Array (dispatched with
18818/// argc=1) instead. But those call ops flatten their argv only ONE level, which
18819/// would stringify a nested Array (a brace/glob/`$arr` word contributes a
18820/// `Value::Array`). Pre-flattening here — same descent as
18821/// [`flatten_array_value`], the array-assignment path — makes the bundle flat
18822/// so the call op's single-level splat restores every positional arg. Bit
18823/// compsys: a completer's `_arguments <specs…>` with a large brace-form option
18824/// set (curl ships 59 `{-x,--long}` specs) dropped the long forms.
18825pub const BUILTIN_ARGV_RFLATTEN: u16 = 653;
18826/// `BUILTIN_ARRAY_JOIN_STAR` constant.
18827pub const BUILTIN_ARRAY_JOIN_STAR: u16 = 339;
18828/// `BUILTIN_SET_RAW_OPT` constant.
18829pub const BUILTIN_SET_RAW_OPT: u16 = 340;
18830
18831/// `time { compound; ... }` — wall-clock-time the sub-chunk and print
18832/// elapsed seconds. Stack: [sub_chunk_idx as Int]. Runs the sub-chunk
18833/// on the current VM (so positional/local state is shared) and prints
18834/// the timing summary to stderr in zsh's format. Pushes Status.
18835pub const BUILTIN_TIME_SUBLIST: u16 = 316;
18836
18837/// `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocation.
18838/// Stack: [path, varid, op_byte]. Opens `path` per `op_byte`, gets the
18839/// new fd (≥10 in zsh; we use libc::open with O_CLOEXEC bit cleared so
18840/// the inherited fd survives Command::new spawns), stores the fd number
18841/// as a string in `$varid`. Pushes Status (0 success, 1 error).
18842pub const BUILTIN_OPEN_NAMED_FD: u16 = 317;
18843
18844/// Word-segment concat that does cartesian-product distribution over
18845/// arrays. Stack: [lhs, rhs]. Used for RC_EXPAND_PARAM `${arr}` and
18846/// explicit-distribute forms (`${^arr}`, `${(@)…}`).
18847///
18848/// - both scalar: `Value::str(a + b)` (fast path, identical to Op::Concat)
18849/// - lhs Array, rhs scalar: `Value::Array([a + rhs for a in lhs])`
18850/// - lhs scalar, rhs Array: `Value::Array([lhs + b for b in rhs])`
18851/// - both Array: cartesian product `[a + b for a in lhs for b in rhs]`
18852pub const BUILTIN_CONCAT_DISTRIBUTE: u16 = 318;
18853
18854/// Forced-distribute concat — like `BUILTIN_CONCAT_DISTRIBUTE` but
18855/// always distributes cartesian regardless of the `rcexpandparam`
18856/// option. Emitted by the segments fast-path when an
18857/// `is_distribute_expansion` segment is present (`${^arr}`,
18858/// `${(@)arr}`, `${(s.…)arr}` etc.) per zsh: the source-level
18859/// distribution flag overrides the option default.
18860/// Direct port of Src/subst.c:1875 `case Hat: nojoin = 1` and the
18861/// `rcexpandparam` test bypass for the explicit-distribute flags.
18862pub const BUILTIN_CONCAT_DISTRIBUTE_FORCED: u16 = 522;
18863
18864/// Capture current `last_status` into the `TRY_BLOCK_ERROR` variable.
18865/// Emitted between the try block and the always block of `{ … } always
18866/// { … }` so the finally arm can read $TRY_BLOCK_ERROR.
18867pub const BUILTIN_SET_TRY_BLOCK_ERROR: u16 = 320;
18868/// `BUILTIN_RESTORE_TRY_BLOCK_STATUS` constant.
18869pub const BUILTIN_RESTORE_TRY_BLOCK_STATUS: u16 = 432;
18870/// `BUILTIN_BEGIN_INLINE_ENV` constant.
18871pub const BUILTIN_BEGIN_INLINE_ENV: u16 = 433;
18872/// `BUILTIN_END_INLINE_ENV` constant.
18873pub const BUILTIN_END_INLINE_ENV: u16 = 434;
18874/// Closes the current inline-env frame's save list. Emitted right
18875/// after the prefix assignments of `X=foo cmd` have committed and
18876/// before `cmd` dispatches, so assignments performed BY the command
18877/// are not recorded into (and therefore not reverted with) the frame.
18878/// c:Src/exec.c:4410 `save_params` snapshots only the parsed
18879/// WC_ASSIGN chain; the list is closed before the builtin/shell
18880/// function runs. Without the seal, `X=y . file` reverted every
18881/// global the sourced file assigned — which emptied git's
18882/// `git-completion.bash` option tables (`__git_log_common_options`
18883/// et al.) that `_git` sources via `GIT_SOURCING_ZSH_COMPLETION=y . …`.
18884pub const BUILTIN_SEAL_INLINE_ENV: u16 = 654;
18885
18886/// End-of-sublist `waitonejob` for a sublist that ran wholly in the
18887/// current shell. Emitted by `compile_zsh::compile_sublist` after each
18888/// element of the `&&`/`||` chain whose parse-time `cmplx` flag is set
18889/// (see `compile_zsh::sublist_elem_is_cmplx`) and whose top level is
18890/// NOT a multi-stage pipeline.
18891///
18892/// c:Src/exec.c:1489-1492 — `execlist` routes each sublist element on
18893/// the parse-time flag: `if (WC_SUBLIST_FLAGS(code) & WC_SUBLIST_SIMPLE)
18894/// execsimple(state); else execpline(state, code, ltype, ...);`. Only
18895/// the `execpline` arm builds a job, and `execpline` ends by calling
18896/// `waitonejob` on it.
18897///
18898/// c:Src/jobs.c:1748-1757 — `waitonejob(Job jn)`:
18899/// ```c
18900/// if (jn->procs || jn->auxprocs) zwaitjob(jn - jobtab, 0);
18901/// else { deletejob(jn, 0); pipestats[0] = lastval; numpipestats = 1; }
18902/// ```
18903/// A sublist that forked (a real multi-stage pipeline) takes the
18904/// `zwaitjob` arm, whose `storepipestats` (c:Src/jobs.c:420) publishes
18905/// the per-stage array — in zshrs that is `BUILTIN_RUN_PIPELINE`'s own
18906/// `set_array("pipestatus", ...)`. Every other cmplx sublist runs with
18907/// an empty proc list and takes the `else` arm, which is what this
18908/// builtin performs. Resolving which arm applies is a compile-time
18909/// decision in C (the parse-time flag) and is a compile-time decision
18910/// here too, so no marker is emitted for the pipeline case at all.
18911///
18912/// This is what makes a compound command publish `$pipestatus`:
18913/// `if ...; fi`, `for ...; done`, `case ... esac`, `while ...; done`,
18914/// `{ ... }`, `( ... )` and a bare command all reach `execpline` when
18915/// their body is cmplx, so zsh leaves `numpipestats == 1`. It also
18916/// makes the OUTER sublist win over an inner pipeline's array —
18917/// `if true; then true|false; fi` is `n=1 p=(1)`, not `(0 1)` — because
18918/// the outer procs-less job overwrites what the inner job stored.
18919///
18920/// Fires BEFORE the `!` negation (`emit_negate_status`): C applies
18921/// `WC_SUBLIST_NOT` inside `execpline` after the wait, so
18922/// `! [[ -z x ]]` records the PRE-negation status — `p=(1)` with `$?`
18923/// of 0.
18924pub const BUILTIN_SUBLIST_FINISH: u16 = 655;
18925
18926/// `[[ -o option ]]` — shell-option-set test. Stack: \[option_name\].
18927/// Normalizes the name (strip underscores, lowercase) and reads
18928/// `exec.options`. Pushes Bool.
18929pub const BUILTIN_OPTION_SET: u16 = 321;
18930/// Tri-state `[[ -o NAME ]]` — same lookup as BUILTIN_OPTION_SET
18931/// but returns a Value::Int (0=set, 1=unset, 3=invalid-name). The
18932/// 3-state code matches zsh's `[[ -o invalid ]]` exit (Src/cond.c
18933/// :502 `optison()`). Used by compile_cond's `-o` arm to skip the
18934/// generic bool→status conversion and preserve the invalid-name
18935/// signal in `$?`.
18936pub const BUILTIN_OPTION_CHECK_TRISTATE: u16 = 609;
18937
18938/// `${var:#pattern}` — array filter: remove elements matching `pattern`.
18939/// Stack: [name, pattern]. For scalar `var`, returns empty if it matches
18940/// the pattern, else the value. For array `var`, returns Array of
18941/// non-matching elements.
18942pub const BUILTIN_PARAM_FILTER: u16 = 322;
18943
18944/// `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()` —
18945/// subscripted-array assign with array-literal RHS. Stack:
18946/// [...elements, name, key]. Empty elements + single-int key `a[i]=()`
18947/// removes that element. Comma-key `a[i,j]=(...)` splices.
18948pub const BUILTIN_SET_SUBSCRIPT_RANGE: u16 = 323;
18949
18950/// `[[ -X file ]]` for unknown unary test op `-X`. Stack: \[op_name\].
18951/// Emits zsh's `unknown condition: -X` diagnostic to stderr and
18952/// pushes Bool(false). Without this, unknown conditions silently
18953/// returned false matching neither zsh's error format nor the
18954/// expected status code (zsh returns 2 for parse error).
18955
18956/// `[[ -t fd ]]` — fd-is-a-tty check. Stack: \[fd_string\].
18957/// Routes through libc::isatty. Pushes Bool.
18958///
18959/// ID 644 (unique, next free above the previous max of 643). This was
18960/// 325, which COLLIDED with BUILTIN_FILE_OLDER (also 325). The VM's
18961/// builtin table is last-registration-wins, and FILE_OLDER registered
18962/// after IS_TTY, so every `[[ -t fd ]]` silently dispatched to the
18963/// file-`-ot` handler: it compared the mtime of a file NAMED by the fd
18964/// string ("0", "1", …) — which never exists — so `[[ -t 0 ]]` was
18965/// always false. That broke interactive detection (`[[ -t 0 && -t 1 ]]`)
18966/// and any config gated on it. c:Src/cond.c:390 `return !isatty(...)`.
18967pub const BUILTIN_IS_TTY: u16 = 644;
18968/// Runtime rejection of a process substitution used inside a `[[ … ]]`
18969/// cond operand. c:Src/exec.c:4918/5040/5069 — `getoutputfile`/`getproc`
18970/// error `"process substitution %s cannot be used here"` when `thisjob ==
18971/// -1`, which is the case during cond evaluation. zshrs's THISJOB never
18972/// distinguishes that context at runtime, so the compiler emits this
18973/// builtin (gated on `in_cond_operand`) instead of the ProcessSubIn/Out
18974/// opcode. Pops the substitution text, zerrs, sets errflag (aborting the
18975/// statement → empty stdout, exit 1, matching zsh), returns empty.
18976pub const BUILTIN_PROCSUB_COND_ERROR: u16 = 645;
18977/// `${^arr}` cross-product concat — RC_EXPAND_PARAM forced ON by the `^` flag.
18978///
18979/// Distinct from BUILTIN_CONCAT_DISTRIBUTE_FORCED, which the other distribute
18980/// shapes (`${(@)a}`, `${(f)v}`, `${a[@]}`) share: those keep the word when the
18981/// array is EMPTY (`x${(@)a}y` → `xy`), but plan9 DELETES it
18982/// (c:Src/subst.c:4362-4365 `if (plan9) { uremnode(l, n); return n; }`), so
18983/// `x${^a}y` with `a=()` produces no word at all. One builtin cannot serve both
18984/// — the plan9-ness is known only at compile time, from the `^` flag itself.
18985/// Routes to `concat_plan9`, which already ports both c:4362's removal and the
18986/// c:4316-4350 cartesian emit, and is what the OPTION path
18987/// (`setopt rcexpandparam`) has always used.
18988pub const BUILTIN_CONCAT_PLAN9: u16 = 646;
18989/// `${^^arr}` concat — RC_EXPAND_PARAM forced OFF by the doubled flag
18990/// (c:Src/subst.c:2553-2555 `plan9 = 0`).
18991///
18992/// The mirror of BUILTIN_CONCAT_PLAN9. Needed because every other concat
18993/// builtin consults `plan9_active()` (the runtime OPTION) and so cross-products
18994/// anyway under `setopt rcexpandparam`, while `^^` must override the option:
18995///     setopt rcexpandparam; a=(a b c); print -rl -- ${^^a}.x
18996///     # zsh: `a`, `b`, `c.x`  — spliced, NOT `a.x b.x c.x`
18997/// The override is computed in paramsubst but the distribution call is made
18998/// here, so — like the `^` flag — the only place that knows is the compiler.
18999/// Routes straight to `concat_splice`, C's non-plan9 join-first-and-last path
19000/// (c:4366-4437).
19001pub const BUILTIN_CONCAT_SPLICE_NOPLAN9: u16 = 647;
19002/// Atomic word assembler for a DQ word that MIXES a plan9 (`^`) segment with a
19003/// non-plan9 (splice/scalar) segment — e.g. `"${(@)^a}${(@)b}"`.
19004///
19005/// The per-pair concat fold (CONCAT_PLAN9 / CONCAT_SPLICE picked ONCE for the
19006/// whole word) cannot express a word where segment A distributes but segment B
19007/// splices: a single operator does full-cross OR first/last-splice, never both,
19008/// and it loses track of which trailing elements are still the "growing edge".
19009/// zsh (Src/subst.c:4316-4437) instead threads a growing edge through the whole
19010/// word — an element is "active" until a splice freezes all but the last.
19011///
19012/// This builtin ports that edge-tracking directly. Stack (bottom→top):
19013///   descriptor, seg0, seg1, …, seg(n-1)     with argc = n + 1
19014/// where `descriptor` is an n-char string, one char per segment: `'1'` = plan9
19015/// (`^`), `'0'` = splice/scalar/literal. Each segment value is an Array (splat)
19016/// or scalar (1 element). Result is the assembled Array (or scalar / deleted).
19017pub const BUILTIN_WORD_ASSEMBLE_PLAN9: u16 = 652;
19018/// `break N`/`continue N` runtime-count validator (see registration).
19019pub const BUILTIN_BREAK_COUNT_VALIDATE: u16 = 648;
19020/// `[[ -r/-w/-x file ]]` via access(2) (doaccess) — see handler.
19021pub const BUILTIN_COND_ACCESS: u16 = 638;
19022
19023/// Evaluate a `[[ ]]` module/completion condition (`-prefix`/`-suffix`/
19024/// `-after`/`-between`). Stack (top-first): argc operand words, then the
19025/// operator word. Dispatches to `complete::eval_mod_cond`. Result pushed as
19026/// Bool (true = condition matched). Used by the `ZshCond::ModCond` compile arm.
19027pub const BUILTIN_COND_MOD: u16 = 651;
19028/// c:Src/cond.c:122-193 COND_MODI — an infix module condition `[[ a -op b ]]`
19029/// (Src/parse.c:2697-2701). Stack: left word, right word, operator word
19030/// (top). Looks the operator up as an infix condition, autoloading its
19031/// module (`getconddef(1, name + 1, 1)`, c:150); failing that, as a prefix
19032/// condition named by the left word (c:173-177). Bool result; an unknown
19033/// condition arms the status-2 carrier like BUILTIN_COND_MOD.
19034pub const BUILTIN_COND_MODI: u16 = 749;
19035/// c:Src/exec.c:3548-3579 — the `rm *` confirmation. Stack: the expanded
19036/// argv words of an `rm` (left in place), then a mask (top) of the words
19037/// whose source ends in an unquoted `*`. Sets ERRFLAG_ERROR when the user
19038/// declines (checkrmall returns 0), which aborts the command.
19039pub const BUILTIN_RM_STAR_CHECK: u16 = 750;
19040
19041/// `provenance` — report the lineage of a tracked parameter: where its
19042/// bytes entered the shell (command substitution, glob, heredoc, an
19043/// earlier assignment) and every bytecode-level op that touched them
19044/// since. Handler: `ShellExecutor::builtin_provenance`; engine:
19045/// `src/extensions/provenance.rs`. ID 661 is the first free slot above
19046/// the 653-660 block.
19047pub const BUILTIN_PROVENANCE: u16 = 661;
19048
19049/// PRINT_EXIT_VALUE report for one finished simple command. Direct port
19050/// of c:Src/exec.c:4308-4316 (`execcmd_exec`'s tail):
19051/// ```c
19052///     if (isset(PRINTEXITVALUE) && isset(SHINSTDIN) && lastval && !subsh)
19053///         fprintf(stderr, "zsh: exit %lld\n", lastval);
19054/// ```
19055/// The ported `execcmd_exec` carries the same code (exec.rs), but fusevm —
19056/// not that walker — is what actually runs a command, so the report never
19057/// fired. `compile_simple` emits this call right after the dispatch's
19058/// `Op::SetStatus` (both the builtin and the function/external arm), which
19059/// is exactly where the C line sits. Pushes Status(0), which the emit side
19060/// pops; `vm.last_status` is left untouched.
19061pub const BUILTIN_PRINT_EXIT_VALUE: u16 = 662;
19062
19063/// Update `$LINENO` to track the source line of the next statement.
19064/// Stack: \[n\] (the line number from `ZshPipe.lineno`). Direct port
19065/// of zsh's `lineno` global tracking (Src/input.c:330) — the
19066/// compiler emits one of these per top-level pipe so `$LINENO`
19067/// reflects the source position at runtime. ID 342 picked because
19068/// the previous `326` collided with `BUILTIN_HAS_STICKY` (the 325
19069/// collision between IS_TTY and FILE_OLDER has since been fixed by
19070/// moving IS_TTY to 644).
19071pub const BUILTIN_SET_LINENO: u16 = 342;
19072
19073/// Pop a scalar from the VM stack, run expand_glob on it, push the
19074/// result as Value::Array. Used by the segment-concat compile path
19075/// when var refs concatenate with glob meta literals (`$D/*`,
19076/// `${prefix}*`, etc.) — those skip the bridge's pathname-expansion
19077/// pass and would otherwise leak the glob meta to argv as a literal.
19078pub const BUILTIN_GLOB_EXPAND: u16 = 343;
19079
19080/// MULTIOS-gated glob expansion for redirect-target words
19081/// (c:Src/glob.c:2161-2167 xpandredir: "Globbing is only done for
19082/// multios."). Same stack shape as BUILTIN_GLOB_EXPAND; additionally
19083/// passes the word through literally when `unsetopt multios`.
19084// 624 is BUILTIN_COND_STRMATCH — the VM's builtin table is
19085// last-registration-wins, so a duplicate id silently shadows the
19086// earlier handler.
19087pub const BUILTIN_REDIR_GLOB_EXPAND: u16 = 628;
19088
19089/// Reset the default-word glob-pending carrier at the START of a word
19090/// whose source contains a glob metachar (so the flag never leaks from a
19091/// prior word/statement). Paired with BUILTIN_DEFAULT_WORD_GLOB.
19092pub const BUILTIN_DEFAULT_WORD_GLOB_RESET: u16 = 635;
19093
19094/// Filename-generate the ASSEMBLED word ONLY when the default/alternate
19095/// paramsubst arm took a SOURCE word carrying glob metachars
19096/// (subst::DEFAULT_WORD_GLOB_PENDING). A parameter VALUE never sets the
19097/// flag, so `x='*file'; ${x:-d}` stays literal while `${x:-*file}` /
19098/// `${x:-a*}bar` glob. Reads+clears the flag. c:Src/subst.c → globlist.
19099pub const BUILTIN_DEFAULT_WORD_GLOB: u16 = 636;
19100/// `BUILTIN_SET_LOOP_VAR` constant — for-loop variable binding via
19101/// `setloopvar` (Src/params.c:6362): a PM_NAMEREF loop var REBINDS
19102/// to each word (SETREFNAME + setscope) instead of assigning
19103/// through the chain. Returns Bool(false) when zerr fired
19104/// (read-only reference / invalid self reference) so the loop
19105/// driver aborts, mirroring C execfor's errflag check.
19106pub const BUILTIN_SET_LOOP_VAR: u16 = 629;
19107
19108/// Emitted once per typeset-family command, just before its first
19109/// `NAME=…` argument: from there on every word is one of C's postassigns
19110/// (c:Src/parse.c:1986-1989, c:2008-2050), globbed inside execcmd_exec's
19111/// builtin branch (c:Src/exec.c:4167-4285) where a NOMATCH leaves `lastval`
19112/// alone. Sets aside a NOMATCH the earlier command words raised
19113/// (c:3755-3763, `lastval = 1`).
19114///
19115/// Arg: the number of postassign words (dispatch leaves them out of `$_`).
19116pub const BUILTIN_TYPESET_POSTASSIGNS_BEGIN: u16 = 684;
19117/// Emitted after the last word of a command that emitted
19118/// BUILTIN_TYPESET_POSTASSIGNS_BEGIN: moves a postassign NOMATCH off the
19119/// per-command cell (so argument collection does not store status 1) and
19120/// puts back the command-word NOMATCH BEGIN set aside.
19121pub const BUILTIN_TYPESET_POSTASSIGNS_END: u16 = 685;
19122/// Emitted right before the dispatch of a command whose command word was the
19123/// enabled, unquoted TYPESET reserved word (C's WC_TYPESET, c:Src/parse.c:1931-1932).
19124/// c:Src/exec.c:3088-3096 — `if (type == WC_TYPESET && (hn =
19125/// builtintab->getnode2(builtintab, cmdarg)))`: "If reserved word for typeset
19126/// command found (and so enabled), use regardless of whether builtin is
19127/// enabled as we share the implementation." `getnode2` ignores DISABLED, so
19128/// `disable typeset` does not stop the reserved-word form. No args; sets the
19129/// carrier dispatch_builtin consumes before its disabled-builtin test.
19130pub const BUILTIN_TYPESET_RESWD: u16 = 686;
19131/// The `[` spelling of the test builtin (c:Src/builtin.c:7231 `bin_test`,
19132/// funcid BIN_BRACKET). fusevm's name table maps `[` and `test` to one
19133/// BUILTIN_TEST slot, which cannot tell them apart when the closing `]` is
19134/// missing; compile_zsh emits this id when the command word is `[`, so
19135/// bin_test sees BIN_BRACKET and reports "']' expected".
19136pub const BUILTIN_TEST_BRACKET: u16 = 690;
19137/// Emitted right before the dispatch of a command that carried the `-`
19138/// precommand modifier (c:Src/builtin.c:42 `BIN_PREFIX("-", BINF_DASH)`,
19139/// accumulated into `cflags` at c:Src/exec.c:3246). No args; sets the
19140/// EXEC_DASH carrier. c:Src/exec.c:772-776 — an external command then gets
19141/// argv[0] `-name`; a function or builtin runs unchanged.
19142pub const BUILTIN_EXEC_DASH: u16 = 687;
19143/// Emitted right before the external dispatch of the SIMPLE command at the
19144/// top of a chunk that a driver may run in a child forked for it (`cmd &`, a
19145/// pipeline stage, a coproc — compile_zsh.rs `forked_simple_exec`). No args.
19146/// When that chunk's BUILTIN_EXECCMD_FORKED_LEVEL did find the fork, it sets
19147/// the EXEC_CARRIER_FORKED bit: c:Src/exec.c:3063 `last1 = forked = 1` and
19148/// c:4369 `execute(args, cflags, use_defpath)` — the child becomes the command.
19149pub const BUILTIN_EXEC_FORKED_SIMPLE: u16 = 741;
19150/// Emitted right before the external/function dispatch of a simple command,
19151/// with the command's job text (c:Src/exec.c:3536-3538 `getjobtext`). Sets
19152/// the JOB_TEXT carrier the foreground wait reads when it reports a job that
19153/// died of a signal (exec_jobs::foreground_job_report).
19154pub const BUILTIN_JOB_TEXT: u16 = 744;
19155/// zshrs-only. Emitted right before the dispatch of a simple command whose
19156/// first word was an alias: stack `[alias_name, body_word_count, command_name]`, argc 3. Sets
19157/// the ALIAS_ORIGIN carrier that `call_function` takes for `intercept ALIAS`.
19158pub const BUILTIN_ALIAS_ORIGIN: u16 = 790;
19159/// c:Src/exec.c:3755-3757 `globlist(args, 0)` over the WHOLE argument list,
19160/// after prefork has expanded every word (c:3357-3359). Stack: the N
19161/// expanded word values in source order, then two Int bitmasks: bit i of the
19162/// first marks word i as an assembled value to glob like BUILTIN_GLOB_EXPAND,
19163/// bit i of the second marks it as a BUILTIN_EXPAND_TEXT mode-10 result that
19164/// still carries its glob tokens; argc = N + 2 (N <= 63). Globs the
19165/// eligible words in order and stops at the first error (c:Src/subst.c:494
19166/// `for (…; !errflag && node; …)`), leaving later words as they are. Pushes
19167/// one value per word (an Array where a glob expanded).
19168pub const BUILTIN_GLOBLIST: u16 = 688;
19169/// c:Src/subst.c:142-147 — `if (!(node = stringsubst(…))) return;`: prefork
19170/// stops at a NULL paramsubst (a failed `(e)` re-lex), and c:118-121 stops it
19171/// before the next word once errflag is set. No args; pushes
19172/// Bool(PREFORK_CUT || errflag & ERRFLAG_ERROR) without clearing either. The compiler checks it after each
19173/// expansion segment of a word (true: keep the prefix assembled so far, plus
19174/// a literal `"` for an opening DQ, and skip the word's remaining segments,
19175/// c:1878) and after each word (true: push the later words as their
19176/// unexpanded source text).
19177pub const BUILTIN_PREFORK_CUT_CHECK: u16 = 689;
19178
19179/// EXTEND step of typeset paren-init packing. Pops `argc` values:
19180/// [base, e1, …, eN] — base is either the opener (`name=(` /
19181/// `name+=(`) or a previous EXTEND result. Pushes base with
19182/// `\u{1f}` + element appended per element. Array values SPLICE
19183/// their items as separate elements (`typeset b=( x $arr )` splat);
19184/// an empty Array contributes nothing (unquoted-empty elision).
19185/// CallBuiltin's argc is u8, so the compiler emits one EXTEND per
19186/// ≤200-element chunk — p10k's 408-element `__p9k_colors=( … )`
19187/// overflowed a single-shot pack (argc wrapped mod 256 and the
19188/// stack spilled into the arg list: "not an identifier: 173…").
19189/// BUILTIN_TYPESET_PAREN_CLOSE appends the final `\u{1f})`,
19190/// yielding the exact REJOIN_SEP-delimited one-arg form
19191/// bin_typeset's single-arg splitter consumes (builtin.rs ~4891,
19192/// empties preserved, leading/trailing sentinel-empties trimmed
19193/// once). One arg in → one arg out: bin_typeset's multi-arg rejoin
19194/// (paren-depth scan, unsafe on EXPANDED paren-literal elements
19195/// like p10k's `')' ''`) never runs.
19196pub const BUILTIN_TYPESET_PAREN_PACK: u16 = 630;
19197
19198/// CLOSE step — pops the EXTEND chain's result, pushes it with
19199/// `\u{1f})` appended. See BUILTIN_TYPESET_PAREN_PACK.
19200pub const BUILTIN_TYPESET_PAREN_CLOSE: u16 = 631;
19201
19202/// Shared body of BUILTIN_GLOB_EXPAND / BUILTIN_REDIR_GLOB_EXPAND.
19203/// c:Src/glob.c:1872 — `zglob` runs per-word in the argv pipeline.
19204/// When the upstream EXPAND_TEXT returned an array (e.g. `${a:e}`
19205/// splat → ["txt","md"]), glob each element separately, not a
19206/// sepjoin'd scalar. `skip_glob` short-circuits to a literal
19207/// pass-through (noglob, or a redirect target under
19208/// `unsetopt multios`).
19209fn glob_expand_word_value(raw: Value, skip_glob: bool) -> Value {
19210    let patterns: Vec<String> = match raw {
19211        Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
19212        other => vec![other.to_str()],
19213    };
19214    // c:Src/glob.c:1230 — zglob returns before globbing when
19215    // `unset(EXECOPT)`, so NO_EXEC never reports a NOMATCH.
19216    if skip_glob || !crate::ported::zsh_h::isset(crate::ported::zsh_h::EXECOPT) {
19217        return if patterns.is_empty() {
19218            Value::array(Vec::new())
19219        } else if patterns.len() == 1 {
19220            Value::str(patterns.into_iter().next().unwrap())
19221        } else {
19222            Value::array(patterns.into_iter().map(Value::str).collect())
19223        };
19224    }
19225    let mut out: Vec<String> = Vec::with_capacity(patterns.len());
19226    for pattern in &patterns {
19227        // c:Src/subst.c — filename generation runs `filesub` (tilde/`=`
19228        // expansion) BEFORE globbing. A `~`/`=` reaching this word-glob op
19229        // comes from `${~spec}` / GLOB_SUBST marking a substituted VALUE:
19230        // literal and quoted `~` words are filesub'd (or skip glob) upstream
19231        // and never arrive here. filesubstr matches the Tilde TOKEN, so
19232        // shtokenize first (raw `~`->Tilde; already-Tilde `${~a[@]}` results
19233        // pass through), run filesub, then untokenize surviving glob metas
19234        // for expand_glob. Gated on `~`/`=` (raw or token) so ordinary
19235        // substituted words skip the roundtrip. Fixes `${~x}` x="~/foo".
19236        // c:178 — prefork's second-pass filesub only runs when
19237        // `unset(SHFILEEXPANSION)` (see the GLOB_SUBST arm above).
19238        let filesubbed = if (pattern.contains('~')
19239            || pattern.contains('=')
19240            || pattern.contains(crate::ported::zsh_h::Tilde)
19241            || pattern.contains(crate::ported::zsh_h::Equals))
19242            && !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHFILEEXPANSION)
19243        {
19244            let mut tok = pattern.clone();
19245            crate::ported::glob::shtokenize(&mut tok);
19246            crate::ported::lex::untokenize(&crate::ported::subst::filesub(&tok, 0))
19247        } else {
19248            pattern.clone()
19249        };
19250        let matches = with_executor(|exec| exec.expand_glob(&filesubbed));
19251        if matches.is_empty() {
19252            // c:1872 nullglob — drop this word, don't emit a hole
19253            continue;
19254        }
19255        for m in matches {
19256            out.push(m);
19257        }
19258    }
19259    if out.is_empty() {
19260        return Value::array(Vec::new());
19261    }
19262    if patterns.len() == 1 && out.len() == 1 && out[0] == patterns[0] {
19263        // No real matches; expand_glob returned the literal. Pass
19264        // back as scalar so downstream ops don't re-flatten.
19265        return Value::str(out.into_iter().next().unwrap());
19266    }
19267    Value::array(out.into_iter().map(Value::str).collect())
19268}
19269
19270/// Push a `CmdState` token onto the command-context stack. Direct
19271/// port of zsh's `cmdpush(int cmdtok)` (Src/prompt.c:1623). The
19272/// stack is consulted by `%_` in PS4/prompt expansion to produce
19273/// the cumulative control-flow-context labels (`if`, `then`,
19274/// `cmdand`, `cmdor`, `cmdsubst`, …) that `zsh -x` xtrace shows
19275/// in the trace prefix. Compile_zsh emits push/pop pairs around
19276/// each compound command (if/while/[[…]]/((…))/$(…) etc.).
19277/// Token is a `CmdState as u8`.
19278pub const BUILTIN_CMD_PUSH: u16 = 344;
19279
19280/// Pop the top of the command-context stack. Direct port of zsh's
19281/// `cmdpop(void)` (Src/prompt.c:1631).
19282pub const BUILTIN_CMD_POP: u16 = 345;
19283
19284/// Emit an xtrace line built from the top `argc` values on the VM
19285/// stack, peeked WITHOUT consuming. Used to trace simple commands
19286/// AFTER expansion, so `echo for $i` shows as `echo for a` / `echo
19287/// for b`. Direct port of Src/exec.c:2055-2066.
19288pub const BUILTIN_XTRACE_ARGS: u16 = 346;
19289
19290/// Trace one assignment: emits `name=<quoted-value> ` (no newline)
19291/// to xtrerr if XTRACE is on. Coalesces with subsequent
19292/// XTRACE_ASSIGN / XTRACE_ARGS calls onto the SAME line via the
19293/// `XTRACE_DONE_PS4` flag so `a=1 b=2 echo $a $b` produces:
19294///   `<PS4>a=1 b=2 echo 1 2\n`
19295/// matching C zsh's `execcmd_exec` body (Src/exec.c:2517-2582):
19296///   xtr = isset(XTRACE);
19297///   if (xtr) { printprompt4(); doneps4 = 1; }
19298///   while (assign) {
19299///       if (xtr) fprintf(xtrerr, "%s=", name);
19300///       ... eval value ...
19301///       if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
19302///   }
19303///
19304/// Stack contract on entry: [..., name, value]. Both peeked, NOT
19305/// consumed (the matching SET_VAR call pops them after). argc = 2.
19306pub const BUILTIN_XTRACE_ASSIGN: u16 = 525;
19307
19308/// Emit a trailing `\n` + flush iff XTRACE is on AND PS4 was
19309/// emitted by an earlier XTRACE_ASSIGN this line. Used at the end
19310/// of compile_simple's assignment-only path so the trace line gets
19311/// terminated. Mirrors C's exec.c:3397-3399 (the assign-only return
19312/// path through execcmd_exec which does `fputc('\n', xtrerr);
19313/// fflush(xtrerr)`).
19314///
19315/// Stack: untouched. argc = 0.
19316pub const BUILTIN_XTRACE_NEWLINE: u16 = 526;
19317
19318/// Push the live `xtrace` opt-state as `Value::Int(1)` (on) or
19319/// `Value::Int(0)` (off). Used by `compile_cond` to gate the
19320/// trace-string-building block on xtrace state at runtime — without
19321/// this the trace path's `compile_word_str` on each operand re-
19322/// evaluates side-effectful expressions (`$((i++))`) once for the
19323/// trace string and once for the actual condition, doubling the
19324/// effective increment. Bug #159 in docs/BUGS.md.
19325///
19326/// Stack: pushes Int(0|1). argc = 0.
19327pub const BUILTIN_XTRACE_IS_ON: u16 = 611;
19328
19329/// Reset the `DONETRAP` flag at the start of each top-level statement
19330/// (sublist boundary). Mirrors C `Src/exec.c:1455` — `donetrap = 0`.
19331/// Stack: untouched. argc = 0. Bug #303 in docs/BUGS.md.
19332pub const BUILTIN_DONETRAP_RESET: u16 = 612;
19333
19334/// c:Src/exec.c:1700-1706 — the `exiting` epilogue of execlist, run at the end
19335/// of a forked compound command (see compile_zsh `emit_exiting_exit_trap`):
19336/// `if (exiting && sigtrapped[SIGEXIT]) { errflag = 0; dotrap(SIGEXIT);
19337/// sigtrapped[SIGEXIT] = 0; errflag = eflag; }`. Stack: pushes Int(0). argc = 0.
19338pub const BUILTIN_EXITING_EXIT_TRAP: u16 = 729;
19339
19340/// Head of a pipeline-stage / `&` / coproc chunk: `execcmd_forked_level`.
19341/// Stack: pops Int(is_subsh), pushes Int(0). argc = 1.
19342pub const BUILTIN_EXECCMD_FORKED_LEVEL: u16 = 740;
19343/// Emitted right before the WithRedirectsBegin of the command at the top of
19344/// a chunk a driver may run in a child forked for it, when that command has
19345/// redirections. No args. When the chunk's BUILTIN_EXECCMD_FORKED_LEVEL found
19346/// the fork, the scope it opens saves no fds (c:Src/exec.c:2478
19347/// `if (!forked && …)`); see redir_scope_forked.
19348pub const BUILTIN_FORKED_REDIRS: u16 = 745;
19349/// c:Src/exec.c:1748 — execpline's `child_block();` ahead of a synchronous
19350/// pipeline, and its job frame (c:1656-1666 `pj = thisjob; thisjob = newjob =
19351/// initjob()`). Stack: \[depth\], the pipeline's nesting depth in its chunk.
19352/// Paired with BUILTIN_EXECPLINE_CHILD_UNBLOCK.
19353pub const BUILTIN_EXECPLINE_CHILD_BLOCK: u16 = 746;
19354/// c:Src/exec.c:2017 — execpline's `child_unblock();` once the pipeline
19355/// has run (and been waited for), after the job frame closes (the procs-less
19356/// job is deleted, `thisjob = pj`). Stack: \[depth\]. Unconditional, as in C: a
19357/// pipeline nested inside another one's command (a function body, `eval`)
19358/// leaves SIGCHLD unblocked for the rest of the outer command.
19359pub const BUILTIN_EXECPLINE_CHILD_UNBLOCK: u16 = 747;
19360
19361/// Take a copy of stderr for this simple command's xtrace output before
19362/// its redirections apply (c:Src/exec.c:3765-3773). Released when the
19363/// redirect scope it precedes ends (c:4445-4452).
19364///
19365/// Stack: untouched. argc = 0.
19366pub const BUILTIN_XTRERR_COPY: u16 = 730;
19367
19368/// c:Src/exec.c:1417 (`int oldnoerrexit = noerrexit;`) + c:1536-1538
19369/// (`if (isandor || isnot) noerrexit |= NOERREXIT_EXIT|NOERREXIT_RETURN;`).
19370/// Saves the current `noerrexit` on a per-thread stack and ORs in the two
19371/// suppression bits for the duration of one `&&`/`||` chain operand (or a
19372/// `!`-negated command). Stack: untouched. argc = 0.
19373pub const BUILTIN_NOERREXIT_SUPPRESS: u16 = 665;
19374
19375/// c:Src/exec.c:1621 / c:1626 — `noerrexit = oldnoerrexit;`. Pops the
19376/// matching save pushed by [`BUILTIN_NOERREXIT_SUPPRESS`].
19377/// Stack: untouched. argc = 0.
19378pub const BUILTIN_NOERREXIT_RESTORE: u16 = 666;
19379
19380/// c:Src/loop.c:144 + :201 (execfor), :480 (execwhile), :536 (execrepeat) —
19381/// `lastval = 1;` on the `errflag` exit from a loop body. Emitted only on the
19382/// fatal-abort path of a compiled for/while/until/repeat; `execselect` has no
19383/// such assignment in C and never emits it.
19384/// Stack: pushes Int(0). argc = 0.
19385pub const BUILTIN_LOOP_ERRFLAG_STATUS: u16 = 667;
19386
19387/// `BUILTIN_LOOP_ERRFLAG_BREAK` — the loop's own post-body errflag guard,
19388/// `c:Src/loop.c:479-482` (execwhile) / `:198-203` (execfor) / `:535-538`
19389/// (execrepeat) / `:329-330` (execselect). Returns 1 when the loop must
19390/// BREAK. argc = 0.
19391///
19392/// This is NOT the same test as the per-statement prologue. C's list loop
19393/// (`c:Src/exec.c:1443`, `while (… && !breaks && !retflag && !errflag)`)
19394/// ENDS the list on errflag; the prologue gate only SKIPS one statement.
19395/// Outside a loop those are indistinguishable, which is why the gap survived:
19396/// inside one, every statement is skipped, the loop's own exit test still
19397/// runs, and a residual `$? == 0` means it NEVER terminates.
19398pub const BUILTIN_LOOP_ERRFLAG_BREAK: u16 = 671;
19399
19400// ───────────────────────────────────────────────────────────────────────
19401// Compiled-arithmetic runtime ops.
19402//
19403// `ArithCompiler` lowers `(( … ))` and `$(( … ))` straight to fusevm ops.
19404// Six operators have no faithful fusevm op, so they lower to these
19405// instead — one indirect call per OCCURRENCE of the operator, against
19406// the whole-expression re-lex `BUILTIN_ARITH_EVAL` pays on EVERY
19407// evaluation.
19408// ───────────────────────────────────────────────────────────────────────
19409
19410/// `a / b` — c:Src/math.c:1237-1257. Integer division when both operands
19411/// are integers (`Op::Div` is unconditionally float), `notzero`'s
19412/// "division by zero" on an integer zero divisor, and the c:1252
19413/// `b == -1` special case that avoids the `INT_MIN / -1` trap. argc = 2.
19414pub const BUILTIN_ARITH_DIV: u16 = 672;
19415
19416/// `a % b` — c:Src/math.c:1258-1272. Same zero-divisor diagnostic as
19417/// DIV (`Op::Mod` silently yields 0), `fmod` on floats, and c:1269's
19418/// `b == -1 → 0`. argc = 2.
19419pub const BUILTIN_ARITH_MOD: u16 = 673;
19420
19421/// `a ** b` — c:Src/math.c:1335-1358. Integer power by repeated
19422/// multiplication when both operands are integers (`Op::Pow` is
19423/// `powf`, which returns a float and loses precision above 2^53),
19424/// float promotion on a negative exponent (c:1337-1342), and the
19425/// c:1352 "imaginary power" error. argc = 2.
19426pub const BUILTIN_ARITH_POW: u16 = 674;
19427
19428/// `a && b` in its NON-short-circuiting form — the `&&=` compound
19429/// assignment (c:Src/math.c:40 DANDEQ), where both sides are already on
19430/// the stack. Result is integer 1/0, c:1200's `BOOL` type. argc = 2.
19431pub const BUILTIN_ARITH_LOGAND: u16 = 675;
19432
19433/// `a || b` for `||=` — c:Src/math.c:41 DOREQ. argc = 2.
19434pub const BUILTIN_ARITH_LOGOR: u16 = 676;
19435
19436/// `a ^^ b` — c:Src/math.c:26 DXOR, logical xor. No C equivalent and no
19437/// fusevm op; both operands always evaluate. argc = 2.
19438pub const BUILTIN_ARITH_LOGXOR: u16 = 677;
19439
19440/// Write one arithmetic result back to a parameter — c:Src/math.c:972
19441/// `setmathvar`, reached from c:1364-1372 when the operator was
19442/// `OP_E2`/`OP_E2IO`. Pops \[name, value\].
19443///
19444/// Distinct from `BUILTIN_SET_VAR`, which stores a shell scalar: only
19445/// `setmathvar` applies the PM_INTEGER / FORCEFLOAT coercions and the
19446/// `lastbase` display base that make `typeset -i x; (( x = 3.7 ))`
19447/// store 3 rather than "3.7". argc = 2.
19448pub const BUILTIN_SET_MATH_VAR: u16 = 678;
19449
19450/// Finish a compiled `(( … ))` math command. Pops the numeric result,
19451/// records it as `lastmathval` (c:Src/math.c:1500, which
19452/// `callmathfunc`'s MFF_USERFUNC branch returns at c:1115), and sets
19453/// the command status from it: c:Src/exec.c:5267 `val == 0`, i.e. 0
19454/// when the expression is non-zero and 1 when it is zero — or 2, with
19455/// `errflag` cleared, when the expression raised a math error.
19456///
19457/// Replaces the `LoadConst("0") / StrEq / …six status ops… /
19458/// BUILTIN_ARITH_CMD_FINISH` tail the string-evaluator path emits, and
19459/// keeps the numeric result numeric instead of round-tripping it
19460/// through its printed form. argc = 1.
19461pub const BUILTIN_ARITH_CMD_FINISH_VAL: u16 = 679;
19462
19463/// `lastbase = <n>` for the COMPILED arithmetic path. Pops \[base\], sets the
19464/// math layer's input base, returns it.
19465///
19466/// c:Src/math.c:367 `mathevall` opens every evaluation with `lastbase = -1;`
19467/// and `lexconstant` (c:462) sets the base of each literal it reads. The
19468/// interpreted evaluator is that code; the compiled path never enters it, so
19469/// nothing reset the base between evaluations and `assignsparam`'s
19470/// `pm.base == 0 ? lastbase` inheritance (c:Src/params.c:2801) handed the next
19471/// freshly created integer whatever base the LAST evaluation had left behind:
19472///
19473/// ```text
19474/// typeset -i iv="2#101"   # sets lastbase = 2, for `iv`
19475/// (( c += 3 ))            # compiled: no literal, no reset
19476/// print -r -- "$c"        # zsh: 3      zshrs: 2#11
19477/// ```
19478///
19479/// Emitted once at the head of every compiled expression with `-1`, and again
19480/// by [`crate::arith_compiler`] with the base of a `0x`/`0b`/`0o` literal it
19481/// lexes — which is what `lexconstant` does, at the point the literal is read.
19482/// argc = 1.
19483pub const BUILTIN_ARITH_LASTBASE: u16 = 683;
19484
19485/// The whole quiet-case statement prologue in one call.
19486///
19487/// c:Src/exec.c:1451-1502 runs four things before every sublist: the
19488/// `lineno` write (c:1451), `donetrap = 0` (c:1455), the
19489/// DEBUG_BEFORE_CMD block (c:1476-1502) and the `noexec` gate (c:1390).
19490/// In C those are inline field writes and flag tests inside `execlist`;
19491/// zshrs emitted them as four separate `CallBuiltin`s plus the constant
19492/// load for `$ZSH_DEBUG_CMD`'s text, on EVERY statement, whether or not
19493/// a DEBUG trap existed to read any of it.
19494///
19495/// This op does the two unconditional writes and then reports whether
19496/// the other two blocks can be skipped entirely: `Bool(true)` when no
19497/// DEBUG trap is installed in EITHER registry (`sigtrapped[SIGDEBUG]`
19498/// or `traps_table["DEBUG"]` — the same pair `BUILTIN_DEBUG_TRAP`
19499/// consults), `noexec` is unset, and `errflag` is clear. Those are
19500/// exactly the conditions under which `BUILTIN_DEBUG_TRAP` returns
19501/// `Int(0)` and `BUILTIN_NOEXEC_CHECK` returns `Int(0)`, so a `true`
19502/// answer means the skipped ops were provably no-ops. On `false` the
19503/// compiler's slow path runs both blocks unchanged.
19504///
19505/// argc = 1: the line number, or -1 for a construct that records none
19506/// (c:Src/parse.c:2112's braceless short-function body), which C
19507/// signals with `code == 0`.
19508pub const BUILTIN_STMT_PROLOGUE_FAST: u16 = 680;
19509
19510/// `BUILTIN_WORD_DEFER_EMPTIES` constant — opens a word whose surrounding
19511/// literals are attached by the compiler's own concat opcodes, not by
19512/// `paramsubst`.
19513///
19514/// c:Src/subst.c:183-186 removes an assembled list node whose text is
19515/// empty, and `stringsubst` has already glued the word's prefix and
19516/// suffix onto the array elements by then (c:4386 / c:4414, or every
19517/// element under plan9 at c:4341). `src/ported/subst.rs`'s port of that
19518/// removal is correctly ordered when the caller hands it the whole word,
19519/// but the compiled fast paths hand it only the `${…}` body. This marker
19520/// tells it so (`PARAMSUBST_AFFIXES_DEFERRED`); the matching end-of-word
19521/// `BUILTIN_WORD_ELIDE_EMPTY` / `BUILTIN_ARRAY_DROP_EMPTY` then performs
19522/// the removal on the finished word, which is C's order.
19523///
19524/// argc 0 opens the word, argc 1 closes it; the pushed status is popped by
19525/// the emitter in both halves. Emitted only where a drop will run.
19526pub const BUILTIN_WORD_DEFER_EMPTIES: u16 = 681;
19527
19528/// c:`Src/subst.c:3030-3033` + c:3912-3930 for the BARE-name `${=NAME}` /
19529/// `${==NAME}` / `$=NAME` spelling — everything up to, but not including,
19530/// c:3932's `sepsplit`, which the compile site emits as `BUILTIN_FORCE_SPLIT`.
19531///
19532/// argc is two bits:
19533///   bit 0  the `spbreak`/`nojoin` pair the spelling sets — set for `${=NAME}`
19534///          (c:2566-2570 `spbreak = 2; if (nojoin < 2) nojoin = !(ifs && *ifs);`)
19535///          and clear for `${==NAME}` (c:2561-2565 `spbreak = 0; if (nojoin < 2)
19536///          nojoin = 0;`).
19537///   bit 1  `qt` — the word is double-quoted, which c:3033 needs. The runtime
19538///          `in_dq_context` counter cannot answer it: a whole-word `"${=a}"`
19539///          compiles to this call with no `BUILTIN_EXPAND_TEXT` wrapper around
19540///          it, so the counter is 0 there. Same dual check the bare `$NAME`
19541///          fast path makes (`compile_zsh.rs`, `word_is_single_dq_span`).
19542///
19543/// The `ssub` callers do NOT come here: c:4226 joins and stops for them, and
19544/// the compile site keeps them on `BUILTIN_GET_VAR_DQ`.
19545pub const BUILTIN_BARE_SPBREAK: u16 = 682;
19546
19547thread_local! {
19548    /// c:Src/exec.c:1417 — C keeps `oldnoerrexit` as an execlist-local
19549    /// automatic, so the save/restore pairs nest with the C call stack.
19550    /// zshrs's compiler emits the two halves as separate ops, so the saved
19551    /// values need an explicit stack. Thread-local because `noerrexit`
19552    /// itself is per-shell state and worker threads run their own lists.
19553    static NOERREXIT_SAVES: std::cell::RefCell<Vec<i32>> =
19554        const { std::cell::RefCell::new(Vec::new()) };
19555}
19556
19557/// `[[ -z X ]]` / `[[ -n X ]]` operand-empty test that honours zsh's
19558/// array-splice semantics. C zsh evaluates `[[ -z X ]]` per
19559/// `Src/cond.c:347` (case 'z'): `s` is the SCALAR operand passed
19560/// through `cond_str`'s singsub. For `"${arr[@]}"` zsh expands per
19561/// `Src/subst.c:multsub` which yields each element as its own word
19562/// list node; cond.c then sees the joined-or-single-element form.
19563///
19564/// The compile-side `-z` shortcut at `compile_zsh.rs:5371` used
19565/// `Op::StringLen` which calls `Value::len` — for `Value::Array`
19566/// that returns ARRAY LENGTH, not string length. `b=("")` produced
19567/// `Value::Array([""])` → `len = 1` → `-z` returned false.
19568///
19569/// This builtin pops one `Value` and pushes `1` (empty) or `0`
19570/// (non-empty) per the cond context:
19571///   - `Value::Str(s)` → s.is_empty()
19572///   - `Value::Array([])` → true (zero words → vacuous-empty)
19573///   - `Value::Array([s])` → s.is_empty() (single-word case)
19574///   - `Value::Array([_; n>=2])` → false (multiple non-empty
19575///     words; zsh would raise "unknown condition" but the
19576///     observable test result is non-empty/false)
19577///
19578/// Companion to BUILTIN_COND_STR_NONEMPTY (#185 in docs/BUGS.md).
19579pub const BUILTIN_COND_STR_EMPTY: u16 = 613;
19580
19581/// `[[ -n X ]]` operand-non-empty test (logical complement of
19582/// BUILTIN_COND_STR_EMPTY).
19583pub const BUILTIN_COND_STR_NONEMPTY: u16 = 614;
19584
19585/// `N<<<"str"` / `N<<HERE` — here-string or here-document redirect to
19586/// an explicit fd. Pops `[content, fd, from_heredoc]` from the stack;
19587/// creates a temp file, writes the body, reopens read-only, dup2's to
19588/// `fd`, unlinks the temp path so it disappears on close. Mirrors C
19589/// `Src/exec.c:4655 getherestr` + `addfd(forked, save, mfds, fn->fd1,
19590/// fil, 0, NULL)` at c:3766-3780. Bug #205 in docs/BUGS.md.
19591///
19592/// `from_heredoc` is C's `REDIRF_FROM_HEREDOC` (set at
19593/// c:Src/parse.c:2970-2971): 0 appends the trailing newline of
19594/// c:4671-4672, 1 passes the body through byte-for-byte.
19595///
19596/// The fd itself is parked in the enclosing redirect scope via
19597/// `save_fd_for_scope` unless a bare `exec` is being applied
19598/// (c:3978-3986 nullexec==1), so `cmd N<<E` is undone at the end of
19599/// the command while `exec N<<E` persists.
19600///
19601/// Stack: pushes `Value::Status(0)` on success, `Status(1)` on
19602/// failure. argc = 3.
19603pub const BUILTIN_EXEC_HERESTR_FD: u16 = 615;
19604
19605/// MULTIOS write/append fan-out for `cmd > a > b` / `cmd > a >> b`
19606/// style redirects (Bug #36 in docs/BUGS.md). zsh's MULTIOS option
19607/// (Src/exec.c:2418 `mfds[fd1]` check + addfd splice) creates a
19608/// pipe at fd1, spawns an internal "tee" process that copies
19609/// stdin → every collected target file. Without this, only the
19610/// LAST redirect target survives because each dup2 overwrites the
19611/// previous binding.
19612///
19613/// Stack layout (pushed by compile_zsh's compile_redirs coalescing
19614/// pass): `[target_1, op_byte_1, target_2, op_byte_2, …, target_N,
19615/// op_byte_N, fd]`. Pops 2N+1 elements; `argc = 2*N + 1`. A target
19616/// may be a Value::Array of glob matches (spliced into one member
19617/// per match, c:Src/glob.c:2195-2203); an op may be DUP_WRITE for a
19618/// numeric `>&N` member (c:Src/exec.c:3895-3917).
19619///
19620/// Runtime (MULTIOS set):
19621///   1. Seed the member list with `dup(1)` when this command's
19622///      stdout is the pipeline output (c:Src/exec.c:3722-3724).
19623///   2. Open/dup all targets per their op_byte in redirect order
19624///      (WRITE truncate + noclobber gate / APPEND / DUP_WRITE live
19625///      dup); the first member replaces the fd (c:2448-2450).
19626///   3. Save `dup(fd)` onto the active redirect_scope_stack so
19627///      `host_redirect_scope_end` restores the original fd.
19628///   4. Create a pipe; spawn a thread that reads from the pipe
19629///      read-end and writes every chunk to every opened target.
19630///   5. dup2 the pipe write-end onto `fd` so the command's writes
19631///      go through the splitter.
19632///   6. Track `(pipe_write_fd, JoinHandle)` so scope-end can close
19633///      the pipe (draining the thread) and join before restoring.
19634///
19635/// MULTIOS unset (c:2418 `unset(MULTIOS)` replace arm): each entry
19636/// is applied as a plain sequential replace via host_apply_redirect
19637/// — every file still opened/truncated, last one wins.
19638pub const BUILTIN_MULTIOS_REDIRECT: u16 = 617;
19639
19640/// MULTIOS input-side concatenation for `cmd < a < b` shapes
19641/// (Bug #36 input arm). C zsh's `Src/exec.c:2418` mfds dispatch
19642/// also covers the read direction — when multiple `<` redirects
19643/// target the same fd, mfds[fd] grows and addfd splices a
19644/// concatenating cat into the pipe.
19645///
19646/// Stack layout (mirrors the write side): `[source_1, op_1,
19647/// source_2, op_2, …, source_N, op_N, fd]`. Pops 2N + 1 elements
19648/// (argc = 2N + 1). op is READ for file sources, DUP_READ for
19649/// numeric `<&N` members; a source may be a Value::Array of glob
19650/// matches (spliced, c:Src/glob.c:2195-2203).
19651///
19652/// Runtime (MULTIOS set):
19653///   1. Open/dup every source in redirect order; first member
19654///      replaces the fd (c:Src/exec.c:2448-2450).
19655///   2. Save `dup(fd)` onto the redirect_scope_stack.
19656///   3. Create a pipe; spawn a thread that reads each source in
19657///      order and writes every chunk to the pipe write-end. Close
19658///      write-end when done so the consumer sees EOF.
19659///   4. dup2 the pipe read-end onto `fd`.
19660///   5. Track the JoinHandle so scope-end joins (no fd-close needed
19661///      here — the producer thread closes its own pipe write-end
19662///      on exit).
19663///
19664/// MULTIOS unset: sequential replace via host_apply_redirect — last
19665/// source wins (c:2418).
19666pub const BUILTIN_MULTIOS_READ: u16 = 618;
19667
19668/// BUILTIN_MULTIOS_READ member op for a here-string body: the runtime writes
19669/// the body plus a trailing newline to a temp file (c:Src/exec.c:4671-4672,
19670/// `if (!(fn->flags & REDIRF_FROM_HEREDOC)) t[len++] = '\n';`). fusevm's
19671/// `redirect_op` values stop at 8.
19672pub const MULTIOS_OP_HERESTR_BODY: u8 = 9;
19673/// BUILTIN_MULTIOS_READ member op for a here-document body: written verbatim
19674/// (REDIRF_FROM_HEREDOC, c:Src/parse.c:2970-2971).
19675pub const MULTIOS_OP_HEREDOC_BODY: u8 = 10;
19676
19677/// c:Src/exec.c:4673-4679 getherestr — `gettempfile`, `write_loop`, `close`,
19678/// reopen read-only, `unlink`: a read descriptor on the body, as
19679/// BUILTIN_EXEC_HERESTR_FD builds for a single here-string.
19680fn multios_body_fd(body: &str, append_newline: bool) -> std::io::Result<i32> {
19681    let text = if append_newline { format!("{}\n", body) } else { body.to_string() };
19682    let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
19683    let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
19684    if write_fd < 0 {
19685        return Err(std::io::Error::last_os_error());
19686    }
19687    let bytes = text.as_bytes();
19688    let mut off = 0;
19689    while off < bytes.len() {
19690        let n = unsafe {
19691            libc::write(write_fd, bytes[off..].as_ptr() as *const libc::c_void, bytes.len() - off)
19692        };
19693        if n <= 0 {
19694            let err = std::io::Error::last_os_error();
19695            unsafe {
19696                libc::close(write_fd);
19697                libc::unlink(tmpl.as_ptr() as *const libc::c_char);
19698            }
19699            return Err(err);
19700        }
19701        off += n as usize;
19702    }
19703    unsafe { libc::close(write_fd) }; // c:4676
19704    let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
19705    let err = std::io::Error::last_os_error();
19706    unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) }; // c:4678
19707    if read_fd < 0 {
19708        Err(err)
19709    } else {
19710        Ok(read_fd)
19711    }
19712}
19713
19714/// c:Src/exec.c:3775-3777 + c:2447-2480 addfd — when `fd` is 0 and the
19715/// top redirect scope is the pipeline stage command's own list, take the
19716/// pipeline input as the input multio's first member: returns a private
19717/// dup of the pipe (above the script's fd range). One-shot per scope. With
19718/// MULTIOS unset C's addfd replaces the pipe instead (c:2418), so the seed is
19719/// dropped.
19720fn take_pipe_input_seed(exec: &ShellExecutor, fd: i32) -> Option<i32> {
19721    if fd != 0
19722        || PIPE_INPUT_SCOPE.with(|c| c.get()) != Some(exec.redirect_scope_stack.len())
19723    {
19724        return None;
19725    }
19726    PIPE_INPUT_SCOPE.with(|c| c.set(None));
19727    if !opt_state_get("multios").unwrap_or(true) {
19728        return None;
19729    }
19730    let dup = unsafe { libc::dup(0) };
19731    (dup >= 0).then(|| crate::ported::utils::movefd(dup)) // c:2458 `fdN = movefd(fd1)`
19732}
19733
19734/// The concatenating half of an input multio (c:Src/exec.c:2447-2480): a
19735/// pipe onto `fd` fed by a thread that reads every member in order. The
19736/// members are closed by the thread; the join is tracked on the top multios
19737/// scope. Returns false when the pipe cannot be created (members closed).
19738fn multios_read_concat(exec: &mut ShellExecutor, fd: i32, source_fds: Vec<i32>) -> bool {
19739    let (read_end, write_end) = match os_pipe::pipe() {
19740        Ok(p) => p,
19741        Err(_) => {
19742            for f in &source_fds {
19743                unsafe {
19744                    libc::close(*f);
19745                }
19746            }
19747            return false;
19748        }
19749    };
19750    // dup the pipe read-end onto fd before spawning the
19751    // producer; close the original read_end so the consumer
19752    // (reading via fd) is the sole reference until scope-end.
19753    let read_dup = unsafe { libc::dup(AsRawFd::as_raw_fd(&read_end)) };
19754    drop(read_end);
19755    if read_dup < 0 {
19756        for f in &source_fds {
19757            unsafe {
19758                libc::close(*f);
19759            }
19760        }
19761        return false;
19762    }
19763    unsafe {
19764        libc::dup2(read_dup, fd);
19765        libc::close(read_dup);
19766    }
19767    // macOS raises a write's SIGPIPE on the process, not the writing
19768    // thread, so the thread mask below does not cover it there; mark the
19769    // write end itself instead.
19770    #[cfg(target_os = "macos")]
19771    unsafe {
19772        // <sys/fcntl.h>: `#define F_SETNOSIGPIPE 73` (absent from the libc crate).
19773        const F_SETNOSIGPIPE: libc::c_int = 73;
19774        libc::fcntl(AsRawFd::as_raw_fd(&write_end), F_SETNOSIGPIPE, 1);
19775    }
19776    // Spawn the producer.
19777    let handle = crate::signal_thread::spawn(move || {
19778        // !!! WARNING: RUST-ONLY !!! C's multio concatenator is a forked
19779        // child (c:Src/exec.c:2472 `zfork`), so a reader that stops early
19780        // (`read x`) SIGPIPEs only that child. This producer is a thread of
19781        // the shell: block SIGPIPE here so the write fails with EPIPE
19782        // instead of killing the shell. A write-raised SIGPIPE is directed at
19783        // the writing thread, so the mask covers it.
19784        unsafe {
19785            let mut set: libc::sigset_t = std::mem::zeroed();
19786            libc::sigemptyset(&mut set);
19787            libc::sigaddset(&mut set, libc::SIGPIPE);
19788            libc::pthread_sigmask(libc::SIG_BLOCK, &set, std::ptr::null_mut());
19789        }
19790        let mut w = write_end;
19791        let mut buf = [0u8; 8192];
19792        for sfd in source_fds {
19793            loop {
19794                let n = unsafe { libc::read(sfd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
19795                if n <= 0 {
19796                    break;
19797                }
19798                let n = n as usize;
19799                if std::io::Write::write_all(&mut w, &buf[..n]).is_err() {
19800                    break;
19801                }
19802            }
19803            // zclose, not close: movefd marked the member FDT_INTERNAL
19804            // (c:Src/utils.c:2007-2010), and zclose clears that entry.
19805            let _ = crate::ported::utils::zclose(sfd);
19806        }
19807        // Closing w (the write_end) at scope drop signals EOF
19808        // to the consumer.
19809    });
19810    // Track using a closed-write sentinel — the producer
19811    // owns write_end so we just need to join. Use -1 fd
19812    // marker meaning "no fd to close".
19813    if let Some(top) = exec.multios_scope_stack.last_mut() {
19814        top.push((-1, handle));
19815    } else {
19816        let _ = handle.join();
19817    }
19818    true
19819}
19820
19821/// One member of an input multio applied as a plain replacement (the
19822/// NO_MULTIOS and single-member paths): a here-document / here-string body
19823/// goes through `multios_body_fd` and addfd's save + dup2 (c:2421-2443);
19824/// every other op keeps `host_apply_redirect`.
19825fn multios_apply_member(exec: &mut ShellExecutor, fd: i32, op_byte: u8, source: &str) {
19826    if op_byte != MULTIOS_OP_HERESTR_BODY && op_byte != MULTIOS_OP_HEREDOC_BODY {
19827        exec.host_apply_redirect(fd as u8, op_byte, source);
19828        return;
19829    }
19830    exec.save_fd_for_scope(fd);
19831    match multios_body_fd(source, op_byte == MULTIOS_OP_HERESTR_BODY) {
19832        Ok(read_fd) => {
19833            if read_fd != fd {
19834                unsafe {
19835                    libc::dup2(read_fd, fd);
19836                    libc::close(read_fd);
19837                }
19838            }
19839        }
19840        Err(e) => {
19841            // c:Src/exec.c:3828 — `zwarn("can't create temp file for here document: %e", errno);`
19842            crate::ported::utils::zwarn(&format!(
19843                "can't create temp file for here document: {}",
19844                crate::ported::utils::zsh_errno_msg(e.raw_os_error().unwrap_or(0))
19845            ));
19846            exec.redirect_failed = true;
19847        }
19848    }
19849}
19850
19851/// Toggle `ShellExecutor::exec_redirs_permanent`. Emitted by
19852/// compile_zsh's bare-`exec`-with-redirects arm tightly around each
19853/// `Op::Redirect`: `LoadInt(1); CallBuiltin; …Redirect…; LoadInt(0);
19854/// CallBuiltin`. While set, `host_apply_redirect` skips pushing the
19855/// saved fd into the enclosing redirect scope, making the fd change
19856/// permanent.
19857///
19858/// c:Src/exec.c:3978-3986 — nullexec==1 (`exec` carrying only
19859/// redirections): "If nullexec is 1 we specifically *don't* restore
19860/// the original fd's before returning" — the per-execcmd `save[]`
19861/// dups are closed unrestored. An ENCLOSING group's own saves are a
19862/// different execcmd's `save[]` and still restore (verified:
19863/// `{ exec 2>/dev/null; } 2>&1; ls /nope` prints the ls error in zsh).
19864pub const BUILTIN_EXEC_PERM_REDIRS: u16 = 619;
19865
19866/// Set `ShellExecutor::pipe_output_pending`. Emitted by compile_pipe
19867/// at the head of a NON-LAST pipeline-stage sub-chunk when that
19868/// stage's top-level command carries redirects (`Simple` with redirs
19869/// or `Redirected` compound). The forked stage child runs the chunk
19870/// with stdout already dup2'd onto the pipe; the first
19871/// `host_redirect_scope_begin` (the stage command's own redirect
19872/// list) consumes the flag into `pipe_output_scope`, enabling the
19873/// MULTIOS stream-split for fd-1 write redirects in that list.
19874///
19875/// c:Src/exec.c:3722-3724 — `addfd(forked, save, mfds, 1, output, 1,
19876/// NULL)` registers the pipe in mfds[1] in the SAME execcmd that
19877/// walks the stage command's redirect list; mfds is per-execcmd, so
19878/// nested body commands (`{ echo a > f; } | cat`) never see it.
19879pub const BUILTIN_PIPE_OUTPUT_MARK: u16 = 620;
19880
19881/// Install the pipeline stage's parked fds onto 0/1.
19882///
19883/// c:Src/exec.c:3720-3724 — `addfd(forked, save, mfds, 0, input, 0,
19884/// NULL)` / `addfd(..., 1, output, 1, NULL)`. Runs after prefork
19885/// (c:3304) and globlist (c:3702) have expanded the stage's argument
19886/// words, which is why a `$(...)` in those words reads the shell's
19887/// original stdin rather than the pipe. Emitted by
19888/// `compile_zsh.rs::emit_stage_fds_install`; the fds themselves are
19889/// parked by `BUILTIN_RUN_PIPELINE`.
19890pub const BUILTIN_PIPE_FDS_INSTALL: u16 = 642;
19891
19892/// Magic-equals prefork for a single arg word of a
19893/// `BINF_MAGICEQUALS` builtin head (`alias`). Direct port of
19894/// c:Src/exec.c:3298-3304 — `esprefork = PREFORK_TYPESET;
19895/// prefork(args, esprefork, NULL)` runs on the argv BEFORE the addfd
19896/// redirect loop at c:3720, so an expansion zerr (`alias bad===` →
19897/// equalsubstr "= not found" at Src/subst.c:726) prints to the
19898/// command's UN-redirected stderr. argc=1: pops the just-pushed
19899/// word value, runs shtokenize → prefork(PREFORK_TYPESET) →
19900/// untokenize on it (each element for Array splices), pushes the
19901/// result back. Emitted by compile_simple per arg word when the
19902/// dispatch head is `alias`; BUILTIN_ALIAS itself no longer
19903/// preforks (it would double-fire the diagnostic).
19904pub const BUILTIN_MAGIC_EQUALS_PREFORK: u16 = 621;
19905
19906/// Bare (unbraced) `$name[idx]` subscript — same dispatch as
19907/// `BUILTIN_ARRAY_INDEX` while KSHARRAYS is unset, but under
19908/// KSHARRAYS the unbraced form does NOT subscript (c:Src/subst.c:
19909/// 2800-2802 + 2867): `$name` expands bare and `[idx]` stays literal
19910/// trailing text that undergoes filename generation. Operands:
19911/// [name, idx, suffix, quoted].
19912pub const BUILTIN_ARRAY_INDEX_UNBRACED: u16 = 622;
19913
19914/// Assignment-only simple-command exit status. Direct port of
19915/// `lv = (errflag ? errflag : cmdoutval)` (c:Src/exec.c:1322,
19916/// execsimple's WC_ASSIGN arm) / `if (errflag) lastval = 1; else
19917/// lastval = cmdoutval;` (c:Src/exec.c:3393-3396, execcmd_exec's
19918/// no-command-word varspc path; redir variant at c:3977). Pops
19919/// [had_cmd_subst]; cmdoutval is the live vm.last_status when a
19920/// `$()` ran in any RHS of the chain, 0 otherwise. Writes the
19921/// canonical LASTVAL (C's single `lastval` global) so the
19922/// non-interactive errflag abort exits with this value per
19923/// Src/init.c:234. Caller pairs with SetStatus.
19924pub const BUILTIN_ASSIGN_ONLY_STATUS: u16 = 623;
19925
19926/// c:Src/exec.c addvars — `if (!pm) { lastval = 1; if (!cmdoutval)
19927/// cmdoutval = 1; }`. Set by BUILTIN_SET_VAR on assignsparam
19928/// failure, consumed by BUILTIN_ASSIGN_ONLY_STATUS so the
19929/// assignment-only command reports status 1. Process-global like
19930/// C's `cmdoutval` (function bodies may run on a different thread
19931/// than the opcode that reads the status back).
19932pub static ASSIGN_FAILED_FLAG: std::sync::atomic::AtomicBool =
19933    std::sync::atomic::AtomicBool::new(false);
19934
19935/// Precommand walk ahead of the redirections, for a `builtin`/`command`/
19936/// `exec` head with redirections (c:Src/exec.c:3104-3402 run before c:3720).
19937/// Operands: [prefix, words…]; returns the words minus the prefix as one
19938/// array. Pairs with BUILTIN_PRECMD_CHECK_OK.
19939pub const BUILTIN_PRECMD_CHECK: u16 = 742;
19940
19941/// Whether the last BUILTIN_PRECMD_CHECK let the command run. argc=0;
19942/// pushes Bool. Resets the flag to true.
19943pub const BUILTIN_PRECMD_CHECK_OK: u16 = 743;
19944
19945thread_local! {
19946    /// BUILTIN_PRECMD_CHECK's verdict, read once by BUILTIN_PRECMD_CHECK_OK
19947    /// (the two ops run back to back on the same VM thread).
19948    static PRECMD_CHECK_PASSED: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
19949}
19950
19951/// The error exits of C's precommand walk (c:Src/exec.c:3104-3402) for
19952/// `full` = [prefix, words…]; `false` when the walk ended the command.
19953///
19954/// * A shell function named like the prefix shadows it (c:3483-3487): the
19955///   redirections belong to the function call.
19956/// * c:3250-3290 — `exec`'s option errors: the walk `zerr`s and sets
19957///   errflag itself.
19958/// * c:3395-3402 — the walk consumed every word and redirections remain:
19959///   `zerr("redirection with no command")`.
19960///
19961/// `builtin NAME` naming no builtin (c:3490) stays with BUILTIN_BUILTIN: its
19962/// notion of "a builtin" also covers the zshrs extension and native-command
19963/// registries, which only that handler consults.
19964fn precmd_walk_ok(full: &[String]) -> bool {
19965    use std::sync::atomic::Ordering::Relaxed;
19966    let Some(prefix) = full.first() else {
19967        return true;
19968    };
19969    let has_fn = with_executor(|exec| {
19970        exec.functions_compiled.contains_key(prefix.as_str()) || exec.function_exists(prefix)
19971    });
19972    if has_fn {
19973        return true;
19974    }
19975    let before = crate::ported::utils::errflag.load(Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR;
19976    let walk = crate::ported::exec::execcmd_compile_head(full, crate::ported::zsh_h::WC_SIMPLE);
19977    if before == 0
19978        && (crate::ported::utils::errflag.load(Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR) != 0
19979    {
19980        return false; // c:3258/c:3276/c:3285 `zerr(...); … goto done`
19981    }
19982    if walk.is_empty_command && !walk.has_command_vv {
19983        crate::ported::utils::zerr("redirection with no command"); // c:3397
19984        return false;
19985    }
19986    // c:3488-3499 — a word reached through `builtin` is looked up in
19987    // builtintab here, before c:3786 applies the redirections, so
19988    // `builtin nosuch 2>/dev/null` still reports on the original stderr and
19989    // opens nothing.
19990    if walk.precmd_skip > 0
19991        && crate::lex::untokenize(&walk.preargs[walk.precmd_skip - 1]) == "builtin"
19992    {
19993        let cmdarg = crate::lex::untokenize(&walk.preargs[walk.precmd_skip]);
19994        if !builtin_prefix_finds(&cmdarg) {
19995            // c:3491 — `zwarn("no such builtin: %s", cmdarg);`
19996            crate::ported::utils::zwarn(&format!("no such builtin: {}", cmdarg));
19997            return false; // c:3492 `lastval = 1;` … `return;`
19998        }
19999    }
20000    true
20001}
20002
20003/// `redirection with no command` parse-time error for bare
20004/// `builtin 2>&1` / `command < file` / `exec >&-` precmd-keyword
20005/// shapes with a redirect but no following command. Direct port
20006/// of `Src/exec.c:3342 zerr("redirection with no command")`.
20007/// argc=0; pushes Value::Status(1).
20008pub const BUILTIN_REDIR_NO_CMD: u16 = 616;
20009
20010/// GLOB_SUBST guard for `[[ x == $pat ]]` pattern RHS coming from
20011/// parameter / command substitution. C-zsh's `[[ == ]]` semantics
20012/// (Src/options.c GLOB_SUBST default OFF + Src/cond.c:552
20013/// `cond_match` + Src/pattern.c patcompile tokenization-based
20014/// meta detection) treat chars from substitution as LITERAL
20015/// unless GLOB_SUBST is on. The Rust patcompile accepts both
20016/// tokenized and raw-ASCII meta chars, losing the distinction,
20017/// so `pat="h*"; [[ hello == $pat ]]` matched in zshrs but not
20018/// in zsh. Bug #116 in docs/BUGS.md.
20019///
20020/// Compile-time signal: emitted by `compile_cond_expr` ONLY when
20021/// the RHS contains `$` or backtick. Runtime checks the live
20022/// option state. If GLOB_SUBST is OFF, the popped string has
20023/// its glob metachars escaped with `\` so the downstream StrMatch
20024/// → patcompile treats them as literals. If GLOB_SUBST is ON,
20025/// the value passes through unchanged so `setopt glob_subst`
20026/// restores zsh's pattern-on-expansion behavior.
20027///
20028/// Stack: pops one string, pushes the (possibly escaped) result.
20029/// argc = 1.
20030pub const BUILTIN_GLOB_SUBST_GUARD: u16 = 528;
20031
20032/// `${~spec}` / `$~spec` pattern-data guard — the `strcatsub`
20033/// `if (glbsub) shtokenize(dest)` step (c:Src/subst.c:822/830) applied
20034/// to a value that is about to become a `[[ … == pat ]]` RHS or a
20035/// `case` arm pattern.
20036///
20037/// `BUILTIN_GLOB_SUBST_GUARD` covers the option-driven leg (GLOB_SUBST
20038/// off → escape the value's metas). It cannot cover the FLAG leg,
20039/// because `${~spec}` forces the substitution's metachars ACTIVE and
20040/// the compiler therefore emits no guard at all — the value reached
20041/// `patcompile` as raw bytes, which made a DATA backslash
20042/// indistinguishable from a SOURCE-level quote. c:Src/glob.c:3651
20043/// leaves a backslash before a non-`ztokens` char in the string as
20044/// ordinary data (`p='a\ b'; [[ 'a b' == ${~p} ]]` must NOT match,
20045/// `[[ 'a\ b' == ${~p} ]]` must), so the value has to be respelled in
20046/// the normalizer's literal-backslash form before it is compiled.
20047/// docs/BUGS.md #1090, globsubst leg.
20048///
20049/// Stack: pops one string, pushes the respelled result. argc = 1.
20050pub const BUILTIN_PAT_DATA_BACKSLASH: u16 = 668;
20051/// Stage an ALREADY-EXPANDED here-document body as pending stdin,
20052/// verbatim — no trailing newline appended.
20053///
20054/// c:Src/exec.c:4671-4672 — `getherestr` appends the newline only when
20055/// the here-string did NOT come from a here-document:
20056///     if (!(fn->flags & REDIRF_FROM_HEREDOC))
20057///         t[len++] = '\n';
20058/// The quoted form already had a non-appending sink (`Op::HereDoc`),
20059/// but the UNQUOTED form has to run `BUILTIN_EXPAND_TEXT` first, and
20060/// the only stack-consuming sink available afterwards was
20061/// `Op::HereString` — which appends unconditionally, because `<<<`
20062/// genuinely must. The lowering compensated with
20063/// `trim_end_matches('\n')`, and strip-all-then-append-one is lossy in
20064/// both directions: it ADDED a newline to a body that ended without one
20065/// (`cat <<EOF` + `hello` with no final newline printed `hello\n` where
20066/// zsh prints `hello`), and COLLAPSED N trailing newlines to one
20067/// (`hello\n\n\n` printed as `hello\n`). argc = 1.
20068pub const BUILTIN_HEREDOC_BODY_SINK: u16 = 669;
20069
20070/// `BUILTIN_WORD_ELIDE_EMPTY` — prefork's empty-word removal
20071/// (c:Src/subst.c:180-187) for a word that is EXACTLY one unquoted
20072/// `$NAME`. Emitted only by that compile fast path, because C's test is
20073/// post-assembly and only the word SHAPE makes it decidable early.
20074/// argc = 1.
20075pub const BUILTIN_WORD_ELIDE_EMPTY: u16 = 670;
20076
20077
20078/// Coerce a string parameter value to a math number (Int or Float)
20079/// for arithmetic-context reads, mirroring C-zsh's `getmathparam`
20080/// (Src/math.c:337). When the variable holds a string like "hello"
20081/// that isn't numeric, C falls back to recursively evaluating the
20082/// raw string as an arith expression; if that fails too, returns 0.
20083///
20084/// Used by the ArithCompiler pre-load path so `(( y = x ))` with
20085/// `x="hello"` reads `x` as integer 0, then assigns y as integer 0
20086/// — matching zsh's behaviour. The previous Rust port used
20087/// BUILTIN_GET_VAR which returned the raw string "hello"; the
20088/// ArithCompiler stored it verbatim in y's slot, and the post-sync
20089/// BUILTIN_SET_VAR wrote y="hello" as scalar instead of y=0 as
20090/// integer. Bug #118 in docs/BUGS.md.
20091///
20092/// Stack: pops `name` (string), pushes coerced numeric Value.
20093/// argc = 1.
20094pub const BUILTIN_GET_MATH_VAR: u16 = 529;
20095
20096/// GLOB_SUBST runtime gate for words containing parameter / command
20097/// substitution. C-zsh's `prefork` (Src/subst.c) runs `shtokenize`
20098/// on the substituted value when `GLOB_SUBST` is set, making the
20099/// substituted chars eligible for filename generation. With the
20100/// option off, substituted chars stay literal.
20101///
20102/// The Rust port's compile_zsh emits `compile_word_str` for words
20103/// like `/tmp/X/$pat`, which returns the post-expansion string but
20104/// never runs glob expansion (no path here triggers
20105/// BUILTIN_GLOB_EXPAND). Bug #119 in docs/BUGS.md: with `setopt
20106/// glob_subst`, `for f in /tmp/X/$pat` (pat="*.txt") never matched
20107/// `*.txt` files.
20108///
20109/// This opcode wraps the substitution result and dispatches at
20110/// runtime: when GLOB_SUBST is OFF, return unchanged; when ON,
20111/// pass the value through `expand_glob` so glob metas become
20112/// active. Emitted by `compile_for_words` (and similar sites)
20113/// after WORD_SPLIT for words with unquoted expansion.
20114///
20115/// Stack: pops a Value (Str or Array of Str), pushes the glob-
20116/// expanded result (still Str or Array depending on input shape).
20117/// argc = 1.
20118pub const BUILTIN_GLOB_SUBST_EXPAND: u16 = 530;
20119/// `BUILTIN_ASSOC_HAS_KEY` constant — `${(k)assoc[name]}` key-existence
20120/// query. Returns the key text on hit, empty string on miss. Bug #145.
20121pub const BUILTIN_ASSOC_HAS_KEY: u16 = 531;
20122/// `BUILTIN_ARRAY_DROP_EMPTY` constant — filter empty elements from
20123/// an Array on the stack. Used by `for x in $@` / `for x in $*`
20124/// unquoted forms. Bug #166.
20125pub const BUILTIN_ARRAY_DROP_EMPTY: u16 = 532;
20126/// `BUILTIN_QUOTED_STAR_ONE_WORD` — normalize the result of a QUOTED
20127/// `"$*"` / `"${*}"` expansion to EXACTLY ONE word.
20128///
20129/// c:Src/subst.c:3032 — the quoted (`qt`) branch of paramsubst ends in
20130/// `val = sepjoin(aval, sep, 1)`, a plain string join. Joining the
20131/// EMPTY positional list yields `""`, so `"$*"` with no positionals is
20132/// one empty word, exactly like `"$empty"` — which is why
20133/// `set --; printf '%d|%s|%d\n' $# "$*" 7` prints `0||7` in zsh, bash,
20134/// dash and ksh alike.
20135///
20136/// zshrs routes `"$*"` through `BUILTIN_EXPAND_TEXT` mode 1, whose
20137/// `multsub` returns a ZERO-node list for the empty case (correct for
20138/// `"$@"`, which really does vanish) and the bridge turns that into
20139/// `Value::Array(vec![])` — so the word was elided and printf saw one
20140/// argument fewer. This op restores the join's single-word shape at the
20141/// one call site that knows the splat was `*` and not `@`.
20142///
20143/// Stack: pops the expansion result, pushes `Value::str("")` when it
20144/// was an empty Array, and the value unchanged otherwise. argc = 1.
20145pub const BUILTIN_QUOTED_STAR_ONE_WORD: u16 = 663;
20146/// `BUILTIN_KSH_FUNSUB` — zsh NOFORK command substitution
20147/// (`Src/subst.c:1904-2100`), which also covers the ksh93 funsub
20148/// `${ list; }` and mksh valsub `${| list; }`: a command substitution that
20149/// runs in the CURRENT shell environment rather than a subshell.
20150///
20151/// Three forms, selected by the character right after `${`
20152/// (c:Src/subst.c:1924/1930):
20153///   * blank → `${ cmd }`, value is the body's STDOUT. c:2026-2029 scopes
20154///     it under `.zsh.cmdsubst`; c:2035-2044 captures via a temp-file
20155///     redirect so the body still runs in the current shell.
20156///   * `|` → `${| cmd }`, value is `$REPLY`, which is LOCAL to the body
20157///     (c:2018-2024 `createparam("REPLY", PM_LOCAL|PM_UNSET|PM_HIDE)`).
20158///   * `{VAR}` → `${{VAR} cmd }`, value is `$VAR`, NOT localised, and an
20159///     array stays an array (c:2082-2083 re-enters the parameter path).
20160///
20161/// ksh(1), Command Substitution: "${ command;} … the command is executed
20162/// in the current shell environment", and the value is the standard output
20163/// with trailing newlines removed (zsh strips ONE newline unquoted and
20164/// none quoted — c:1908 `trim = (!EMULATION(EMULATE_ZSH)) ? 2 : !qt`).
20165/// mksh(1)'s valsub matches zsh's `${| … }` exactly.
20166///
20167/// Stack (bottom→top): body, rplyvar, kind, qt — kind 0 = stdout capture,
20168/// 1 = REPLY form, 2 = named-variable form; qt = 1 when the word was
20169/// double-quoted. argc = 4. Pushes the resulting string, or an Array when
20170/// the named variable holds one / when the unquoted result is word-split.
20171pub const BUILTIN_KSH_FUNSUB: u16 = 664;
20172/// `BUILTIN_QUOTEDZPUTS` constant — run top-of-stack value through
20173/// `crate::ported::utils::quotedzputs` and push the quoted result.
20174/// Used by the cond xtrace path so non-printable bytes (e.g.
20175/// `$'\C-[OP'` expanded ESC+OP) get re-wrapped in `$'…'` form for
20176/// the trace prefix line, matching zsh's `Src/exec.c` cond trace
20177/// which calls `quotedzputs(operand, xtrerr)` on each side. Bug
20178/// surfaced when `[[ -n $'\C-[OP' ]]` traced as `[[ -n OP ]]`
20179/// (raw bytes leaked through the terminal) vs zsh's
20180/// `[[ -n $'\C-[OP' ]]` source-form preservation.
20181pub const BUILTIN_QUOTEDZPUTS: u16 = 533;
20182/// `BUILTIN_QUOTE_TOKENIZED_OUTPUT` — port of
20183/// `crate::ported::exec::quote_tokenized_output` (Src/exec.c:2114)
20184/// applied to top-of-stack scalar. Used by cond xtrace for the RHS
20185/// of pattern-context comparisons (`=` / `==` / `!=`) where C zsh
20186/// emits the SOURCE form: untokenize lexer tokens (Star → `*`,
20187/// Inpar → `(`, …) and backslash-escape special chars, but
20188/// preserve literal ASCII unchanged. Distinct from quotedzputs
20189/// which wraps the whole string in `'…'` / `$'…'` based on
20190/// non-printability — that's wrong for `[[ x = a* ]]` which must
20191/// render as `[[ x = a* ]]`, not `'a*'`.
20192pub const BUILTIN_QUOTE_TOKENIZED_OUTPUT: u16 = 534;
20193
20194/// Bridge into subst_port::substitute_brace_array for nested forms
20195/// that need to PRESERVE array shape across the expand_string
20196/// boundary. Stack: `[content_string]`. Returns Value::Array of the
20197/// per-element words. Used by the compile path for
20198/// `${(@)<nested>...##pat}` shapes — the standard substitute_brace
20199/// returns String which collapses array→scalar; this builtin
20200/// preserves the multi-word output via paramsubst's third return
20201/// (`nodes` vec, the C source's `aval` thread).
20202pub const BUILTIN_BRIDGE_BRACE_ARRAY: u16 = 347;
20203
20204/// Word-segment concat with FIRST/LAST sticking. Stack: [lhs, rhs].
20205/// Used for default unquoted splice forms (`${arr[@]}`, `$@`, `$*`)
20206/// where prefix sticks to first element only and suffix to last only.
20207///
20208/// Distribution table:
20209/// - both scalar: `Value::str(a + b)` (fast path)
20210/// - lhs scalar, rhs Array(b₀..bₙ): `Value::Array([lhs+b₀, b₁, …, bₙ])`
20211/// - lhs Array(a₀..aₙ), rhs scalar: `Value::Array([a₀, …, aₙ₋₁, aₙ+rhs])`
20212/// - both Array: `Value::Array([a₀, …, aₙ₋₁, aₙ+b₀, b₁, …, bₙ])`
20213///   (last of lhs merges with first of rhs; the rest stay separate)
20214///
20215/// This is the default zsh semantics for `print -l X${arr[@]}Y` →
20216/// "Xa", "b", "cY" — three distinct args, surrounding text only on ends.
20217pub const BUILTIN_CONCAT_SPLICE: u16 = 319;
20218
20219/// `${(flags)name}` — zsh parameter expansion flags. Stack: [name, flags].
20220/// Flags applied left-to-right. Supported subset (high-value, used by zpwr):
20221///
20222///   `L` — lowercase the value (scalar; or each element if array)
20223///   `U` — uppercase
20224///   `j:sep:` — join array with `sep` (delim is the char after `j`)
20225///   `s:sep:` — split scalar on `sep` (returns Value::Array)
20226///   `f` — split on newlines (shorthand for `s.\n.`)
20227///   `o` — sort array ascending
20228///   `O` — sort array descending
20229///   `P` — indirect: read name's value as another var name, return that's value
20230///   `@` — keep as array (returns Value::Array — useful before `j` etc.)
20231///   `k` — keys of assoc array
20232///   `v` — values of assoc array
20233///   `#` — word count (array length as scalar)
20234///
20235/// Flags can stack: `(jL)` joins then lowercases; `(s.,.U)` splits on `,`
20236/// then uppercases each element. The long-tail flags (`q`, `qq`, `qqq` for
20237/// quoting, `A` for assoc, `%` for prompt expansion, `e`/`g` for re-eval,
20238/// `n`/`p` for numeric, `t` for type, etc.) are deferred — they hit the
20239/// runtime fallback via the catch-all expansion path.
20240pub const BUILTIN_PARAM_FLAG: u16 = 297;
20241
20242/// `ShellHost` implementation that delegates to the current `ShellExecutor`
20243/// via the `with_executor` thread-local.
20244///
20245/// Construct fresh on each VM run (it carries no state itself). The VM
20246/// dispatches host method calls during `vm.run()`, and `with_executor`
20247/// resolves to the executor pointer set by `ExecutorContext::enter`.
20248/// fusevm-host implementation tying bytecode ops to the
20249/// shell executor.
20250/// zshrs-original — no C counterpart. C zsh has no bytecode VM
20251/// to host; everything runs through `execlist()`/`execpline()`
20252/// directly (Src/exec.c lines 1349/1668).
20253pub struct ZshrsHost;
20254
20255/// Short label for a sub-chunk, used as a provenance origin. A `Chunk`
20256/// keeps no original source text (only ops, constants and a source
20257/// *file* name), so the readable handle is reconstructed from the
20258/// leading string constants — for `$(date +%s)` that is `date +%s`.
20259fn prov_chunk_label(sub: &fusevm::Chunk) -> String {
20260    sub.constants
20261        .iter()
20262        .filter_map(|c| match c {
20263            Value::Str(s) if !s.is_empty() => Some(s.as_str()),
20264            _ => None,
20265        })
20266        .take(3)
20267        .collect::<Vec<_>>()
20268        .join(" ")
20269}
20270
20271impl fusevm::ShellHost for ZshrsHost {
20272    fn glob(&mut self, pattern: &str, _recursive: bool) -> Vec<String> {
20273        let matches = with_executor(|exec| exec.expand_glob(pattern));
20274        if crate::provenance::active() {
20275            crate::provenance::on_glob(pattern, &matches);
20276        }
20277        matches
20278    }
20279
20280    fn tilde_expand(&mut self, s: &str) -> String {
20281        with_executor(|exec| s.to_string())
20282    }
20283
20284    fn brace_expand(&mut self, s: &str) -> Vec<String> {
20285        // Direct call to the canonical brace expander
20286        // (Src/glob.c::xpandbraces port at glob.rs:1678). Was
20287        // routing through singsub which uses PREFORK_SINGLE — that
20288        // flag explicitly suppresses brace expansion in subst.c:166,
20289        // so `print X{1,2,3}Y` returned the literal string.
20290        //
20291        // brace_ccl: respect the BRACE_CCL option which the bracket-
20292        // class form `{a-z}` requires. Pull from executor options.
20293        let brace_ccl = with_executor(|exec| opt_state_get("braceccl").unwrap_or(false));
20294        crate::ported::glob::xpandbraces(s, brace_ccl)
20295    }
20296
20297    fn str_match(&mut self, s: &str, pattern: &str) -> bool {
20298        let pattern: &str = &pattern_filesub(pattern);
20299        if let Some(m) = crate::pattern_data_escape::ksh93_flagged_match(s, pattern) {
20300            return m;
20301        }
20302        // bash `shopt -s nocasematch` — bash(1): "bash matches patterns in a
20303        // case-insensitive fashion when performing matching while executing
20304        // CASE or [[ conditional commands". This host method is the `case`
20305        // arm dispatch, the half BUILTIN_COND_STRMATCH (which already does
20306        // this, ~4.4k lines up) does not cover, so `shopt -s nocasematch;
20307        // case ABC in abc)` wrongly took the `*)` arm. Same treatment: fold
20308        // BOTH sides for the match decision — glob metacharacters are not
20309        // letters, so `*` / `?` / `[…]` structure survives `to_lowercase`.
20310        // No-op unless the bash shopt is active; --zsh unaffected.
20311        let (folded_s, folded_pat);
20312        let (s, pattern): (&str, &str) = if crate::dash_mode::nocasematch() {
20313            folded_s = s.to_lowercase();
20314            folded_pat = pattern.to_lowercase();
20315            (&folded_s, &folded_pat)
20316        } else {
20317            (s, pattern)
20318        };
20319        // Shell glob match — `*`, `?`, `[...]`, alternation. After the
20320        // cond path moved to BUILTIN_COND_STRMATCH, the consumer here
20321        // is the `case` arm dispatch, whose bad-pattern semantics are
20322        // Src/loop.c:663-667: `if (!(pprog = patcompile(pat, ...)))
20323        // zerr("bad pattern: %s", pat);` — errflag set, the arm
20324        // doesn't match, and the script aborts at the next command
20325        // boundary (matching `zsh -fc 'case x in [a-) ...'` printing
20326        // the diagnostic with exit 0 = untouched lastval).
20327        // Same drop-in split as the cond path: a bare POSIX-family drop-in
20328        // reads a source-level `(` by its own rules, not by zsh's parse-time
20329        // tokenization — see `dropin_source_pattern_parens_literal`.
20330        let pat_eff = if crate::pattern_data_escape::dropin_source_pattern_parens_literal() {
20331            crate::pattern_data_escape::escape_shglob_parens(
20332                pattern,
20333                crate::pattern_data_escape::dropin_keeps_ksh_groups(),
20334            )
20335        } else {
20336            pattern.to_string()
20337        };
20338        let mut pat_tok = pat_eff.clone();
20339        crate::ported::glob::tokenize(&mut pat_tok);
20340        if crate::ported::pattern::patcompile(
20341            &pat_tok,
20342            crate::ported::zsh_h::PAT_STATIC as i32,
20343            None,
20344        )
20345        .is_none()
20346        {
20347            crate::ported::utils::zerr(&format!("bad pattern: {}", pattern)); // c:667
20348            return false;
20349        }
20350        glob_match_static(s, &pat_eff)
20351    }
20352
20353    fn expand_param(&mut self, name: &str, _modifier: u8, _args: &[Value]) -> Value {
20354        // Sole funnel: route through `getsparam` matching C zsh's
20355        // `getsparam(name)` → `getvalue` → `getstrvalue` →
20356        // `Param.gsu->getfn` dispatch (Src/params.c:3076 / 2335).
20357        //
20358        // The lookup chain (GSU dispatch + variables + env + array-
20359        // join) lives in `params::getsparam`; subst.rs and this
20360        // bridge both call into it so the logic is in exactly one
20361        // place — mirroring C's "every read goes through getsparam"
20362        // architecture. fuseVM bytecode triggers this bridge when
20363        // the VM hits a PARAM opcode, equivalent to C's wordcode VM
20364        // resolving a parameter read during `exec.c` execution.
20365        //
20366        // Modifier handling: the `_modifier` / `_args` parameters
20367        // are populated by the bytecode compiler but applied by
20368        // separate VM opcodes (LENGTH/STRIP/SUBST/etc.) downstream
20369        // of this fetch — matching C's split between getsparam
20370        // (value fetch) and paramsubst's modifier-walk loop. This
20371        // bridge is the value-fetch step only.
20372        let val_str = crate::ported::params::getsparam(name).unwrap_or_default();
20373        let value = Value::str(val_str);
20374        // Provenance: a read of a tracked parameter hands the
20375        // parameter's chain to the produced value, by `Arc` identity
20376        // for the rest of this chunk and by content for the host
20377        // boundaries downstream that only see `String`.
20378        if crate::provenance::active() {
20379            crate::provenance::on_param_read(name, &value);
20380        }
20381        value
20382    }
20383
20384    fn process_sub_in(&mut self, sub: &fusevm::Chunk) -> String {
20385        // c:Src/exec.c:4999 (`getoutputfile`: `child_block();`) and, for
20386        // `<(cmd)`, the enclosing execpline's c:1748 block that C's
20387        // `getproc` forks inside. The VM opens no execpline span around a
20388        // simple command, so take it here. See ChildBlockSpan.
20389        #[cfg(unix)]
20390        let _child_block = ChildBlockSpan::enter();
20391        // c:Src/exec.c:4906 getoutputfile — `=(cmd)` (marked "equalsubst" by the
20392        // compiler) is the TEMP-FILE flavor: create a real regular file, fork a
20393        // writer whose stdout is the file, WAIT for it (so the file is complete
20394        // and seekable before the consumer runs), and return the file path. It
20395        // is unlinked at job end. This differs from `<(cmd)` below, which is a
20396        // /dev/fd pipe that is never waited on.
20397        if sub.source == "equalsubst" {
20398            let nam = crate::ported::utils::gettempname(None, true)
20399                .unwrap_or_else(|| format!("/tmp/zshrs_eqsub_{}", std::process::id()));
20400            let cpath = match std::ffi::CString::new(nam.as_str()) {
20401                Ok(c) => c,
20402                Err(_) => return String::from("/dev/null"),
20403            };
20404            // c:4945 — O_WRONLY|O_CREAT|O_EXCL|O_NOCTTY, 0600.
20405            let fd = unsafe {
20406                libc::open(
20407                    cpath.as_ptr(),
20408                    libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOCTTY,
20409                    0o600,
20410                )
20411            };
20412            if fd < 0 {
20413                return String::from("/dev/null");
20414            }
20415            let sub_for_child = sub.clone();
20416            match unsafe { libc::fork() } {
20417                -1 => {
20418                    unsafe { libc::close(fd) };
20419                    let _ = fs::remove_file(&nam);
20420                    return String::from("/dev/null");
20421                }
20422                0 => {
20423                    forked_child_subsh_levels(); // c:Src/exec.c:1221
20424                    // c:Src/exec.c:5037 entersubsh → c:1200 `zsh_subshell++`.
20425                    // The child _exits, so the guard is never dropped.
20426                    std::mem::forget(CmdSubstSubshellBump::enter());
20427                    // c:4985 — child: stdout → the temp file, run the body, exit.
20428                    // Clear the inherited pending-file list so this child never
20429                    // unlinks the PARENT's =() temp files when its own commands
20430                    // dispatch (fork copies the list; unlink hits the shared fs).
20431                    // c:Src/exec.c:5044 — `execode(prog, 0, 1, "equalsubst")`.
20432                    std::mem::forget(crate::ported::exec::EvalContextFrame::push("equalsubst"));
20433                    PSUB_PENDING_FILES.with(|v| v.borrow_mut().clear());
20434                    unsafe {
20435                        libc::dup2(fd, libc::STDOUT_FILENO);
20436                        libc::close(fd);
20437                    }
20438                    let mut vm = fusevm::VM::new(sub_for_child);
20439                    register_builtins(&mut vm);
20440                    vm.set_shell_host(Box::new(ZshrsHost));
20441                    let _ = vm.run();
20442                    let _ = std::io::stdout().flush();
20443                    unsafe { libc::_exit(0) };
20444                }
20445                child_pid => {
20446                    // c:4976-4980 — parent: close the write fd and WAIT so the
20447                    // file is fully written before the consumer opens it.
20448                    unsafe {
20449                        libc::close(fd);
20450                        let mut status: libc::c_int = 0;
20451                        libc::waitpid(child_pid, &mut status, 0);
20452                    }
20453                    psub_pending_file_add(&nam); // c:4960
20454                    return nam;
20455                }
20456            }
20457        }
20458        // c:Src/exec.c::getproc — `<(cmd)` uses pipe + fork + the
20459        // `/dev/fd/N` filesystem entry (where N is the read end of
20460        // the pipe held open in the parent). Consumer opens
20461        // `/dev/fd/N`, reads the cmd's stdout through the pipe.
20462        // Both macOS and Linux expose `/dev/fd` for held-open file
20463        // descriptors. Previous Rust port captured stdout into
20464        // `/tmp/zshrs_psub_*` tempfiles synchronously — works for
20465        // `diff <(a) <(b)` style readers that scan once but diverges
20466        // from zsh's observable path string and breaks any consumer
20467        // that introspects the path or expects a non-seekable pipe.
20468        let mut fds: [libc::c_int; 2] = [-1, -1];
20469        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
20470            // Pipe creation failed — fall back to tempfile so we at
20471            // least return SOMETHING.
20472            let fifo_path = format!(
20473                "/tmp/zshrs_psub_fallback_{}_{}",
20474                std::process::id(),
20475                with_executor(|e| {
20476                    let n = e.process_sub_counter;
20477                    e.process_sub_counter += 1;
20478                    n
20479                })
20480            );
20481            let _ = fs::remove_file(&fifo_path);
20482            return fifo_path;
20483        }
20484        let (read_end, write_end) = (fds[0], fds[1]);
20485        let sub_for_child = sub.clone();
20486        match unsafe { libc::fork() } {
20487            -1 => {
20488                unsafe {
20489                    libc::close(read_end);
20490                    libc::close(write_end);
20491                }
20492                return String::from("/dev/null");
20493            }
20494            0 => {
20495                forked_child_subsh_levels(); // c:Src/exec.c:1221
20496                // c:Src/exec.c:5095 entersubsh → c:1200 `zsh_subshell++`.
20497                std::mem::forget(CmdSubstSubshellBump::enter());
20498                // Child: close read end, dup write end to stdout,
20499                // run the sub-chunk, exit. The exit closes the
20500                // write end automatically, so the parent's reader
20501                // gets EOF when the cmd finishes.
20502                PSUB_PENDING_FILES.with(|v| v.borrow_mut().clear());
20503                unsafe {
20504                    libc::close(read_end);
20505                    libc::dup2(write_end, libc::STDOUT_FILENO);
20506                    libc::close(write_end);
20507                }
20508                // c:Src/exec.c:5101/5150 — `execode(prog, 0, 1, out ?
20509                // "outsubst" : "insubst");`. execode (c:1245-1266) APPENDS its
20510                // context for the duration of the body, so `<(cmd)` — whose child WRITES (out=1) — runs as `…:outsubst`.
20511                // zshrs's ported getproc carries these citations but is NOT the
20512                // live path (established in #1062) — the VM forks here instead,
20513                // so the push belongs in this child. No pop needed: this runs
20514                // INSIDE the forked child and dies with it. Bug #1069 (procsub
20515                // legs).
20516                if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
20517                    ctx.push("outsubst".to_string());
20518                    let joined = ctx.join(":");
20519                    if let Ok(mut tab) = crate::ported::params::paramtab().write() {
20520                        if let Some(pm) = tab.get_mut("zsh_eval_context") {
20521                            pm.u_arr = Some(ctx.clone());
20522                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20523                        }
20524                        if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
20525                            pm.u_str = Some(joined);
20526                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20527                        }
20528                    }
20529                }
20530                crate::fusevm_disasm::maybe_print_stdout("process_subst_in", &sub_for_child);
20531                let mut vm = fusevm::VM::new(sub_for_child);
20532                register_builtins(&mut vm);
20533                vm.set_shell_host(Box::new(ZshrsHost));
20534                let _ = vm.run();
20535                let _ = std::io::stdout().flush();
20536                unsafe { libc::_exit(0) };
20537            }
20538            child_pid => {
20539                // c:Src/exec.c:5092 `procsubstpid = pid;` — record the
20540                // forked child's PID so `${sysparams[procsubstpid]}`
20541                // returns it (was reading the never-updated atomic, so it
20542                // always came back 0). p10k's gitstatus daemon reads
20543                // `sysparams[procsubstpid]` right after `sysopen <(cmd)`
20544                // to track its worker PID; with 0 the daemon's self-check
20545                // failed and gitstatus fell back to re-downloading
20546                // gitstatusd — surfacing as "no prebuilt gitstatusd".
20547                crate::ported::exec::procsubstpid
20548                    .store(child_pid, std::sync::atomic::Ordering::Relaxed);
20549                // Parent: close write end, keep read end open under
20550                // the same fd value so `/dev/fd/N` resolves to the
20551                // pipe's read side. NOTE: FD_CLOEXEC must STAY clear
20552                // — consumers like `cat <(cmd)` and `diff <(a) <(b)`
20553                // discover the fd via exec inheritance, so closing
20554                // on exec defeats the whole point. C zsh's getproc
20555                // (Src/exec.c:5045+) leaves the fd open across exec.
20556                unsafe {
20557                    libc::close(write_end);
20558                }
20559                // Park read_end for close-after-consuming-command,
20560                // exactly like process_sub_out does for its write_end
20561                // (c:Src/exec.c addfilelist(NULL, fd) → deletefilelist).
20562                // WITHOUT this the parent's read_end stayed open for
20563                // the whole shell lifetime: p10k's async worker /
20564                // realtime clock do `exec {fd}< <(cmd)` on every prompt,
20565                // so each keystroke/redraw leaked a pipe fd until the
20566                // ~256-fd limit was hit and the shell locked up
20567                // (107 leaked pipes + 107 unreaped children observed).
20568                let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
20569                PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, read_end)));
20570                // Reap the forked child so it doesn't linger as a
20571                // zombie. `<(cmd)` children are fire-and-forget (their
20572                // output flows through the pipe); C reaps them via the
20573                // job machinery. A non-blocking reap here is scheduled;
20574                // do a best-effort WNOHANG now and the rest drain on
20575                // subsequent proc-subs / prompt cycles.
20576                crate::fusevm_bridge::note_psub_child(child_pid);
20577            }
20578        }
20579        let path = format!("{}/{}", crate::ported::config_h::PATH_DEV_FD, read_end); // c:Src/exec.c:5144
20580        if crate::provenance::active() {
20581            crate::provenance::on_process_subst(&prov_chunk_label(sub), &path);
20582        }
20583        path
20584    }
20585
20586    fn process_sub_out(&mut self, sub: &fusevm::Chunk) -> String {
20587        // c:Src/exec.c:1748 — `getproc` forks inside the enclosing
20588        // execpline's `child_block()` span; the VM has none around a simple
20589        // command, so take it here. See ChildBlockSpan.
20590        #[cfg(unix)]
20591        let _child_block = ChildBlockSpan::enter();
20592        // c:Src/exec.c:5025 getproc, PATH_DEV_FD branch — `>(cmd)`
20593        // (out == 0): `mpipe(pipes)`, fork; the CHILD `redup(pipes[0],
20594        // 0)` (pipe read end onto stdin) and `closem` drops the write
20595        // end; the PARENT closes pipes[0] and hands the consumer
20596        // `/dev/fd/<pipes[1]>` (the write end). The previous Rust port
20597        // used mkfifo + a child that BLOCKED in open(FIFO, O_RDONLY)
20598        // before running cmd — with no writer the child never started,
20599        // never exited, and kept its inherited stdout (e.g. a `$()`
20600        // capture pipe) open forever: `a=$(print -r -- >(true))` hung.
20601        // With the pipe shape the child runs immediately and exits,
20602        // releasing inherited fds exactly like zsh (verified: zsh
20603        // blocks ~2s on `a=$(print -r -- >(sleep 2))`, then EOFs).
20604        let mut fds: [libc::c_int; 2] = [-1, -1];
20605        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
20606            // Pipe creation failed — fall back to a plain temp file so
20607            // the consumer at least has a writable path.
20608            let fallback = format!(
20609                "/tmp/zshrs_psub_out_{}_{}",
20610                std::process::id(),
20611                with_executor(|e| {
20612                    let n = e.process_sub_counter;
20613                    e.process_sub_counter += 1;
20614                    n
20615                })
20616            );
20617            let _ = fs::write(&fallback, "");
20618            return fallback;
20619        }
20620        let (read_end, write_end) = (fds[0], fds[1]);
20621        let sub_for_child = sub.clone();
20622        match unsafe { libc::fork() } {
20623            -1 => {
20624                unsafe {
20625                    libc::close(read_end);
20626                    libc::close(write_end);
20627                }
20628                String::from("/dev/null")
20629            }
20630            0 => {
20631                forked_child_subsh_levels(); // c:Src/exec.c:1221
20632                // c:Src/exec.c:5150 entersubsh → c:1200 `zsh_subshell++`.
20633                std::mem::forget(CmdSubstSubshellBump::enter());
20634                // Child: close the write end (c: closem after redup),
20635                // dup the read end onto stdin (c: redup(pipes[0], 0)),
20636                // run the sub-chunk, exit. Other std fds stay
20637                // inherited — zsh's child keeps the surrounding
20638                // command's stdout/stderr.
20639                unsafe {
20640                    libc::close(write_end);
20641                    libc::dup2(read_end, libc::STDIN_FILENO);
20642                    libc::close(read_end);
20643                }
20644                // c:Src/exec.c:5101/5150 — `execode(prog, 0, 1, out ?
20645                // "outsubst" : "insubst");`. execode (c:1245-1266) APPENDS its
20646                // context for the duration of the body, so `>(cmd)` — whose child READS (out=0) — runs as `…:insubst`.
20647                // zshrs's ported getproc carries these citations but is NOT the
20648                // live path (established in #1062) — the VM forks here instead,
20649                // so the push belongs in this child. No pop needed: this runs
20650                // INSIDE the forked child and dies with it. Bug #1069 (procsub
20651                // legs).
20652                if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
20653                    ctx.push("insubst".to_string());
20654                    let joined = ctx.join(":");
20655                    if let Ok(mut tab) = crate::ported::params::paramtab().write() {
20656                        if let Some(pm) = tab.get_mut("zsh_eval_context") {
20657                            pm.u_arr = Some(ctx.clone());
20658                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20659                        }
20660                        if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
20661                            pm.u_str = Some(joined);
20662                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20663                        }
20664                    }
20665                }
20666                crate::fusevm_disasm::maybe_print_stdout("process_subst_out:child", &sub_for_child);
20667                let mut vm = fusevm::VM::new(sub_for_child);
20668                register_builtins(&mut vm);
20669                vm.set_shell_host(Box::new(ZshrsHost));
20670                let _ = vm.run();
20671                unsafe { libc::_exit(0) };
20672            }
20673            child_pid => {
20674                // c:Src/exec.c:5143 `procsubstpid = pid;` — same fix as
20675                // the `<(cmd)` in-path above: record the forked child's
20676                // PID for `${sysparams[procsubstpid]}` (was always 0).
20677                crate::ported::exec::procsubstpid
20678                    .store(child_pid, std::sync::atomic::Ordering::Relaxed);
20679                // Parent: close the read end, keep the write end open
20680                // under its fd value so `/dev/fd/N` resolves to the
20681                // pipe's write side. FD_CLOEXEC must STAY clear —
20682                // consumers (`tee >(cmd)`) discover the fd via exec
20683                // inheritance, matching process_sub_in above and C's
20684                // fdtable[fd] = FDT_PROC_SUBST bookkeeping. Park the
20685                // fd for close-after-consuming-command (c: addfilelist
20686                // (NULL, fd) → deletefilelist) so the child's reader
20687                // EOFs — without this `tee >(wc -c) </dev/null` left
20688                // wc blocked until shell exit.
20689                unsafe {
20690                    libc::close(read_end);
20691                }
20692                let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
20693                PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, write_end)));
20694                let path = format!("{}/{}", crate::ported::config_h::PATH_DEV_FD, write_end); // c:Src/exec.c:5144
20695                if crate::provenance::active() {
20696                    crate::provenance::on_process_subst(&prov_chunk_label(sub), &path);
20697                }
20698                path
20699            }
20700        }
20701    }
20702
20703    fn subshell_begin(&mut self) {
20704        with_executor(|exec| {
20705            // Special parameters whose value lives in a process GLOBAL behind a
20706            // GSU (`Src/params.c`'s `ifs`, `wordchars`, `home`, `histsiz`, …)
20707            // rather than in the param table. Mirrors the getfn dispatch list at
20708            // params.rs:12548. C isolates these for free by forking `(...)`;
20709            // zshrs's in-process subshell has to snapshot them by hand, or a
20710            // subshell-local `IFS=,` rewrites the parent's word-splitting.
20711            const SUBSHELL_SPECIAL_GLOBALS: &[&str] = &[
20712                "IFS",
20713                "HOME",
20714                "TERM",
20715                "USERNAME",
20716                "WORDCHARS",
20717                "TERMINFO",
20718                "TERMINFO_DIRS",
20719                "KEYBOARD_HACK",
20720                "histchars",
20721                "HISTSIZE",
20722                "SAVEHIST",
20723            ];
20724            // An UNSET special yields None and is skipped: the paramtab snapshot
20725            // restores its PM_UNSET flag, and the getfn dispatch refuses to read
20726            // the (stale) global while PM_UNSET is set (params.rs:12552).
20727            let special_globals_snap: Vec<(String, String)> = SUBSHELL_SPECIAL_GLOBALS
20728                .iter()
20729                .filter_map(|n| crate::ported::params::getsparam(n).map(|v| ((*n).to_string(), v)))
20730                .collect();
20731            // Snapshot paramtab + hashed-storage too (step 1 of the
20732            // store unification mirrors writes there; restoring only
20733            // the HashMaps leaks subshell-scoped writes to the parent
20734            // via paramtab readers like `paramsubst → vars_get`).
20735            let paramtab_snap = crate::ported::params::paramtab()
20736                .read()
20737                .ok()
20738                .map(|t| t.clone())
20739                // c:Src/params.c:854 — a fresh table is 151 buckets, not
20740                // the 17-bucket `Default`.
20741                .unwrap_or_else(|| crate::ported::hashtable::hashtable_nodes::newhashtable(151));
20742            let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
20743                .lock()
20744                .ok()
20745                .map(|m| m.clone())
20746                .unwrap_or_default();
20747            let loop_flags_snap = {
20748                use std::sync::atomic::Ordering::SeqCst;
20749                (
20750                    crate::ported::builtin::LOOPS.load(SeqCst),
20751                    crate::ported::builtin::BREAKS.load(SeqCst),
20752                    crate::ported::builtin::CONTFLAG.load(SeqCst),
20753                    crate::ported::builtin::ANCESTOR_LOOPS.load(SeqCst),
20754                )
20755            };
20756            // c:Src/exec.c:1263-1264 (55061) — entersubsh's `ancestor_loops
20757            // += loops; loops = 0;`: a `break` in `( … )` has no loop of its
20758            // own to leave.
20759            {
20760                use std::sync::atomic::Ordering::SeqCst;
20761                crate::ported::builtin::ANCESTOR_LOOPS
20762                    .fetch_add(crate::ported::builtin::LOOPS.swap(0, SeqCst), SeqCst);
20763            }
20764            exec.subshell_snapshots.push(SubshellSnapshot {
20765                // c:Src/utils.c:2111 `addlockfd` — the fds carrying
20766                // `zsystem flock` locks. Recorded so subshell_end can close
20767                // the ones the subshell itself opened (C's fork does it for
20768                // free). See SubshellSnapshot::flock_fds.
20769                flock_fds: current_flock_fds(),
20770                loop_flags: loop_flags_snap,
20771                cmdoutval: crate::ported::exec::cmdoutval.load(std::sync::atomic::Ordering::Relaxed),
20772                stopmsg: crate::ported::builtin::STOPMSG.load(std::sync::atomic::Ordering::Relaxed),
20773                paramtab: paramtab_snap,
20774                paramtab_hashed_storage: paramtab_hashed_snap,
20775                special_globals: special_globals_snap,
20776                positional_params: exec.pparams(),
20777                // Snapshot canonical `traps_table` — bin_trap writes
20778                // there (`Src/builtin.c`).
20779                traps: crate::ported::builtin::traps_table()
20780                    .lock()
20781                    .map(|t| t.clone())
20782                    .unwrap_or_default(),
20783                // Snapshot option store so `(set -e)` /
20784                // `(setopt extendedglob)` don't leak to parent.
20785                opts: crate::ported::options::opt_state_snapshot(),
20786                // c:Src/exec.c:2880 — fork() copies the alias tables to
20787                // the subshell. `(alias x=y)` inside the subshell dies
20788                // with the child; the parent doesn't see x. Bug #209.
20789                tables: crate::ported::exec::SubshForkCopy::save(),
20790                // c:Src/exec.c::entersubsh — same fork-copy
20791                //   semantics for shfunctab. `(f() { ... })` defined
20792                //   inside the subshell dies with the child; parent's
20793                //   `type f` reports "not found". Bug #208 in
20794                //   docs/BUGS.md.
20795                shfuncs: crate::ported::hashtable::shfunctab_lock()
20796                    .read()
20797                    .ok()
20798                    .map(|t| t.snapshot())
20799                    .unwrap_or_default(),
20800                functions_compiled: exec.functions_compiled.clone(),
20801                function_source: exec.function_source.clone(),
20802                // c:Src/exec.c::entersubsh — fork-copy semantics for
20803                // THINGYTAB (ZLE widget registry). A subshell `zle -N`
20804                // / `zle -D` mutation dies with the child in C zsh;
20805                // mirror via in-process snapshot. Bug #453.
20806                thingytab: crate::ported::zle::zle_thingy::thingytab()
20807                    .lock()
20808                    .ok()
20809                    .map(|t| t.clone())
20810                    .unwrap_or_default(),
20811                // c:Src/exec.c::entersubsh — same fork-copy for the
20812                // KEYMAPNAMTAB (named keymap registry). `bindkey -N km`
20813                // / `bindkey -D km` inside a subshell dies with the
20814                // child. Bug #454.
20815                keymapnamtab: crate::ported::zle::zle_keymap::keymapnamtab()
20816                    .lock()
20817                    .ok()
20818                    .map(|t| t.clone())
20819                    .unwrap_or_default(),
20820                // c:Src/exec.c::entersubsh fork semantics — `$!`
20821                // (clone::lastpid) set by a `&` INSIDE the subshell
20822                // dies with the child: `( : & ); echo $!` -> 0.
20823                lastpid: crate::ported::modules::clone::lastpid
20824                    .load(std::sync::atomic::Ordering::Relaxed),
20825                // c:Src/exec.c::entersubsh fork semantics — the
20826                // subshell gets a COPY of the job table; its disown/
20827                // wait/`&` mutations die with it. Bug #462.
20828                jobtab: crate::ported::jobs::JOBTAB
20829                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
20830                    .lock()
20831                    .map(|t| t.clone())
20832                    .unwrap_or_default(),
20833                curjob: *crate::ported::jobs::CURJOB
20834                    .get_or_init(|| std::sync::Mutex::new(-1))
20835                    .lock()
20836                    .unwrap(),
20837                prevjob: *crate::ported::jobs::PREVJOB
20838                    .get_or_init(|| std::sync::Mutex::new(-1))
20839                    .lock()
20840                    .unwrap(),
20841                maxjob: *crate::ported::jobs::MAXJOB
20842                    .get_or_init(|| std::sync::Mutex::new(0))
20843                    .lock()
20844                    .unwrap(),
20845                thisjob: *crate::ported::jobs::THISJOB
20846                    .get_or_init(|| std::sync::Mutex::new(-1))
20847                    .lock()
20848                    .unwrap(),
20849                oldjobtab: crate::ported::jobs::OLDJOBTAB
20850                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
20851                    .lock()
20852                    .map(|t| t.clone())
20853                    .unwrap_or_default(),
20854                oldmaxjob: *crate::ported::jobs::OLDMAXJOB
20855                    .get_or_init(|| std::sync::Mutex::new(0))
20856                    .lock()
20857                    .unwrap(),
20858                // c:Src/exec.c:2880 — fork copies the fd table; the
20859                // child's `exec >file` / `exec N<&-` die with it.
20860                fd_frame: crate::ported::exec::SubshFdFrame::enter(),
20861                redir_depth: exec.redirect_scope_stack.len(),
20862                slots_mark: crate::exec_jobs::execpline_slots_mark(),
20863                // c:Src/signals.c:39 `sigtrapped` — saved so End restores the
20864                // parent's per-signal trap flags (see the field docs).
20865                sigtrapped: crate::ported::signals::sigtrapped
20866                    .lock()
20867                    .map(|g| g.clone())
20868                    .unwrap_or_default(),
20869                // c:Src/exec.c:160 `int subsh;` — saved so End can put the
20870                // parent's value back (subshells nest).
20871                subsh: crate::ported::exec::subsh.load(std::sync::atomic::Ordering::Relaxed),
20872                // The parent's libc locale — see SubshellSnapshot::locale.
20873                locale: crate::vm_helper::subsh_locale_save(),
20874            });
20875            // c:Src/exec.c:1192-1193 — `if (!(flags & ESUB_FAKE)) subsh = 1;`
20876            // A `( … )` is a real subshell, so the body runs with subsh set.
20877            // The forked child carries it in C; the in-process body needs it
20878            // set explicitly or per-command checks that read it — notably
20879            // PRINT_EXIT_VALUE (c:4309 `&& !subsh`) — behave as if the
20880            // command ran in the parent.
20881            crate::ported::exec::subsh.store(1, std::sync::atomic::Ordering::Relaxed);
20882            // C forks for `(...)` — count the fork-equivalent so
20883            // `time (builtin)` reports like zsh (see FORK_EVENTS).
20884            crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
20885            // c:Src/exec.c:1088-1092 — entersubsh resets traps in the child:
20886            //     if (!(flags & ESUB_KEEPTRAP))
20887            //         for (sig = 0; sig <= SIGCOUNT; sig++)
20888            //             if (!(sigtrapped[sig] & ZSIG_FUNC) &&
20889            //                 !(isset(POSIXTRAPS) && (sigtrapped[sig] & ZSIG_IGNORED)))
20890            //                 unsettrap(sig);
20891            //
20892            // A subshell does NOT inherit string-form traps. Two exemptions:
20893            // FUNCTION-form traps (`TRAPUSR1() { … }`, ZSIG_FUNC) survive, and
20894            // under POSIX_TRAPS an IGNORED trap (`trap '' SIG`) survives.
20895            //
20896            // The ZSIG_FUNC exemption is structural here rather than a flag
20897            // test: zshrs keeps string-form bodies in traps_table and
20898            // function-form ones in shfunctab as TRAPxxx, so filtering only
20899            // traps_table leaves the function form untouched by construction.
20900            // ZSIG_IGNORED is `trap '' SIG`, which stores an empty body.
20901            //
20902            // The LOOP BOUND is also part of the spec, not an implementation
20903            // detail: `sig <= SIGCOUNT` never reaches the PSEUDO-signals,
20904            // which zsh numbers above the real ones —
20905            //     #define SIGZERR   (SIGCOUNT+1)
20906            //     #define SIGDEBUG  (SIGCOUNT+2)      (c:Src/signals.h:34-35)
20907            // so ERR/ZERR and DEBUG traps SURVIVE a subshell, while SIGEXIT
20908            // (sig 0) is inside the loop and is cleared. Verified against the
20909            // oracle: `trap 'print e' ERR; (trap)` lists the ERR trap;
20910            // `trap 'print u' USR1; (trap)` lists nothing.
20911            //
20912            // Without this a subshell kept the parent's traps: `(trap)` listed
20913            // them where zsh lists nothing, and — the part that isn't
20914            // cosmetic — an inherited trap FIRED inside the child, so
20915            // `trap 'print p' USR1; (kill -USR1 $$; print after)` printed
20916            // p before after instead of after…p (the signal is meant to reach
20917            // the parent, whose trap runs there).
20918            //
20919            // The snapshot pushed above restores the parent's table at
20920            // subshell_end, which is what makes clearing safe for zshrs's
20921            // in-process subshell.
20922            {
20923                // Record the PARENT's `sigtrapped[]` BEFORE the reset below
20924                // wipes it. C gets it from the fork: the child mutates a
20925                // private copy (`Src/signals.c:39`) and the parent's stays
20926                // put. (`limits[]` travels in SubshForkCopy.)
20927                subshell_signal_enter();
20928                entersubsh_reset_traps();
20929            }
20930            // c:Src/exec.c:2862 — subshell fork flags carry ESUB_PGRP,
20931            // so entersubsh runs `clearjobtab(monitor)` (c:1219): the
20932            // child gets an EMPTY job table plus the procless control
20933            // job grabbed at Src/jobs.c:1828 (`thisjob = initjob()`).
20934            // That's why zsh's `(jobs)` prints nothing and `(kill %1)`
20935            // hits the empty control job instead of the parent's job 1.
20936            // The snapshot pushed above restores the parent's table at
20937            // subshell_end. Bug #462.
20938            let monitor = crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
20939            crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
20940            // c:Src/exec.c:1133 + c:1245-1246 — `job_control_ok = monitor &&
20941            // (flags & ESUB_JOB_CONTROL) && isset(POSIXJOBS)`, and a
20942            // non-async `( … )` carries ESUB_JOB_CONTROL (c:2919-2920);
20943            // `if (!job_control_ok) opts[MONITOR] = 0;`. With MONITOR off
20944            // `jobs` lists the parent's oldjobtab and fg/bg refuse with
20945            // "no job control in this shell". The option snapshot above
20946            // restores it at subshell_end.
20947            if !(monitor != 0 && crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXJOBS)) {
20948                crate::ported::options::dosetopt(crate::ported::zsh_h::MONITOR, 0, 0);
20949            }
20950            // clearjobtab left THISJOB on the control job (Src/jobs.c:
20951            // 1828). In C the very next pipeline's execpline reassigns
20952            // thisjob (Src/exec.c:1700 `thisjob = newjob = initjob()`),
20953            // so by the time any builtin runs, thisjob never aliases
20954            // the control job. zshrs has no per-pipeline job slot —
20955            // model the between-pipelines state (-1) so getjob's
20956            // `jobnum != thisjob` (c:jobs.c:2107) doesn't reject %1 and
20957            // setcurjob doesn't demote an inherited curjob that
20958            // collides with the control slot.
20959            *crate::ported::jobs::THISJOB
20960                .get_or_init(|| std::sync::Mutex::new(-1))
20961                .lock()
20962                .unwrap() = -1;
20963            // Subshell starts with EXIT trap cleared so the parent's
20964            // EXIT handler doesn't fire when the subshell ends. zsh:
20965            // each subshell has its own trap context. Other signals
20966            // are inherited (well, parent's are still in place — but
20967            // a trap set INSIDE the subshell shouldn't leak out).
20968            if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
20969                t.remove("EXIT");
20970            }
20971            let level = exec
20972                .scalar("ZSH_SUBSHELL")
20973                .and_then(|s| s.parse::<i32>().ok())
20974                .unwrap_or(0);
20975            // c:Src/exec.c — ZSH_SUBSHELL carries PM_READONLY (declared
20976            // in params.rs special_params); setsparam would be rejected
20977            // by assignstrvalue's PM_READONLY guard. Write u_val
20978            // directly — same bypass pattern as BUILTIN_SET_LINENO at
20979            // line 2784. C zsh's PM_SPECIAL GSU vtable handles this
20980            // implicitly via the setfn callback.
20981            let new_level = (level + 1) as i64;
20982            if let Ok(mut tab) = crate::ported::params::paramtab().write() {
20983                if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
20984                    pm.u_val = new_level;
20985                    pm.u_str = Some(new_level.to_string());
20986                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20987                }
20988            }
20989        });
20990        // Bump SUBSHELL_DEPTH so zexit defers process::exit (see
20991        // SUBSHELL_DEPTH declaration in src/ported/builtin.rs for
20992        // rationale).
20993        crate::ported::builtin::SUBSHELL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
20994        // c:Src/exec.c::entersubsh — C zsh's subshell is a forked
20995        // child process: signals sent to the parent (via `kill $$`
20996        // inside the subshell, where `$$` is the parent's pid)
20997        // never reach the child's signal handlers. zshrs's
20998        // in-process subshell shares the process pid with the
20999        // parent, so without queueing the subshell's trap handler
21000        // fires for signals that zsh would deliver only to the
21001        // parent. Queue signals across the subshell body so the
21002        // parent's restored trap table sees them after
21003        // subshell_end's unqueue drain. Bug #450.
21004        crate::ported::signals_h::queue_signals();
21005    }
21006
21007    fn subshell_end(&mut self) -> Option<i32> {
21008        // Fire subshell's EXIT trap BEFORE restoring parent state so
21009        // the trap body sees the subshell's vars and exit status. zsh
21010        // forks for `(...)` so the trap runs in the child process,
21011        // before exit. We mirror by running it here, just before the
21012        // pop+restore. REMOVE the trap before firing so the inner
21013        // execute_script doesn't fire it again at its own end.
21014        let exit_trap_body = crate::ported::builtin::traps_table()
21015            .lock()
21016            .ok()
21017            .and_then(|mut t| t.remove("EXIT"));
21018        if let Some(body) = exit_trap_body {
21019            // Execute the trap body. Errors during trap execution
21020            // don't bubble — zsh ignores trap-body errors.
21021            with_executor(|exec| {
21022                let _ = exec.execute_string_without_exit_hooks(&body);
21023            });
21024        }
21025        with_executor(|exec| {
21026            // c:Src/exec.c::entersubsh — the subshell is a forked child, so a
21027            // NOMATCH failure raised inside it (c:Src/glob.c:1877) lives and
21028            // dies with that process. The per-command glob-failed cell is the
21029            // in-process stand-in for that failure, and a body like `( y* )`
21030            // ends before any command boundary consumes it: the parent's NEXT
21031            // command then read it and returned 1 without running
21032            // (`( y* ); print a` lost `print a`, `( y* ) || print x` lost
21033            // the or-branch).
21034            exec.current_command_glob_failed.set(false);
21035            if let Some(snap) = exec.subshell_snapshots.pop() {
21036                // c:Src/exec.c:1123 entersubsh — the forked child owns its fds, so
21037                // redirections a body error left open (`WithRedirectsEnd` skipped)
21038                // go with it: `(print ${a[]} 2>&1); print -u2 err` keeps fd 2.
21039                exec.unwind_redirect_scopes_to(snap.redir_depth);
21040                crate::exec_jobs::execpline_slots_forget_to(snap.slots_mark);
21041                // c:Src/exec.c::entersubsh fork semantics — `loops` /
21042                // `breaks` / `contflag` are process globals the child
21043                // owns a private copy of, so `(break)` inside a loop
21044                // cannot end the PARENT's loop. See
21045                // SubshellSnapshot::loop_flags.
21046                {
21047                    use std::sync::atomic::Ordering::SeqCst;
21048                    let (loops, breaks, contflag, ancestor_loops) = snap.loop_flags;
21049                    crate::ported::builtin::ANCESTOR_LOOPS.store(ancestor_loops, SeqCst);
21050                    crate::ported::builtin::LOOPS.store(loops, SeqCst);
21051                    crate::ported::builtin::BREAKS.store(breaks, SeqCst);
21052                    crate::ported::builtin::CONTFLAG.store(contflag, SeqCst);
21053                }
21054                // c:Src/exec.c:225 `cmdoutval` — the child's copy dies with
21055                // it. See SubshellSnapshot::cmdoutval.
21056                crate::ported::exec::cmdoutval.store(snap.cmdoutval, std::sync::atomic::Ordering::Relaxed);
21057                // See SubshellSnapshot::stopmsg.
21058                crate::ported::builtin::STOPMSG.store(snap.stopmsg, std::sync::atomic::Ordering::Relaxed);
21059                // c:Src/utils.c:2155-2164 `zcloselockfd` — release the
21060                // `zsystem flock` locks the subshell itself took. Under C
21061                // the forked child's fds close on exit; here we close the
21062                // fds that appeared while the subshell was running.
21063                // See SubshellSnapshot::flock_fds.
21064                for fd in current_flock_fds() {
21065                    if !snap.flock_fds.contains(&fd) {
21066                        crate::ported::utils::zcloselockfd(fd);
21067                    }
21068                }
21069                // c:Src/exec.c:160 / :1192-1193 — the child's `subsh = 1`
21070                // dies with the fork in C; restore the parent's value here.
21071                crate::ported::exec::subsh.store(snap.subsh, std::sync::atomic::Ordering::Relaxed);
21072                // c:Src/signals.c:39 — same fork-copy reasoning for the
21073                // per-signal trap flags cleared at subshell entry.
21074                //
21075                // The `sigaction` DISPOSITIONS the body installed leak
21076                // out too, and the flag restore alone does not undo
21077                // them: `( trap 'echo IN' USR1 )` runs `settrap` →
21078                // `install_handler` (c:Src/signals.c:730) on the shared
21079                // process, so afterwards a `kill -USR1 $$` in the parent
21080                // hit zshrs's handler, found no trap, and was swallowed
21081                // where zsh — whose parent still had SIG_DFL, the child
21082                // having installed the handler on its own copy — dies.
21083                // Roll the dispositions back for exactly the signals
21084                // whose flags differ.
21085                let child_sigtrapped = match crate::ported::signals::sigtrapped.lock() {
21086                    Ok(mut st) => {
21087                        let prev = st.clone();
21088                        *st = snap.sigtrapped.clone();
21089                        prev
21090                    }
21091                    Err(_) => Vec::new(),
21092                };
21093                subshell_restore_signal_dispositions(&snap.sigtrapped, &child_sigtrapped);
21094                // c:Src/exec.c:2880 — alias, hash-table, `environ`, cwd,
21095                // umask, rlimit, zstyle, module, sched … writes in a
21096                // subshell die with the forked child: the parent never sees
21097                // `(alias x=y)` (Bug #209 in docs/BUGS.md), `(export y=sub)`
21098                // or `(zmodload zsh/X)` (Bug #210). See SubshForkCopy.
21099                // BEFORE the paramtab restore below: rolling a module back
21100                // looks its parameters up in the LIVE paramtab.
21101                snap.tables.restore();
21102                // Restore paramtab + hashed storage so subshell-scoped
21103                // writes via setsparam/setaparam/sethparam don't leak
21104                // to the parent via paramtab readers.
21105                if let Some(tab) = crate::ported::params::paramtab()
21106                    .write()
21107                    .ok()
21108                    .as_deref_mut()
21109                {
21110                    *tab = snap.paramtab;
21111                }
21112                // Restore the global-backed specials (see
21113                // SubshellSnapshot::special_globals). MUST run after the
21114                // paramtab restore above: setsparam writes through the GSU setfn
21115                // to BOTH the process global and the param node, so the paramtab
21116                // overwrite would otherwise clobber the node half of it.
21117                // Restore parent's option store so `(set -e)` /
21118                // `(setopt extendedglob)` don't leak. zsh forks
21119                // subshells so child option changes die with the
21120                // child; we run in-process and must restore. This runs
21121                // BEFORE the special-global replay below: that replay is
21122                // assignsparam, which honours options the body set.
21123                crate::ported::options::opt_state_restore(snap.opts);
21124                for (name, val) in &snap.special_globals {
21125                    crate::ported::params::setsparam(name, val);
21126                }
21127                // The body's `setlocale` (c:Src/params.c:4811 setlang,
21128                // c:4877 lcsetfn) changed only the forked child in C.
21129                crate::vm_helper::subsh_locale_restore(snap.locale.as_deref());
21130                // c:Src/exec.c::entersubsh fork semantics — restore
21131                // the parent's `$!`; a background job inside `(...)`
21132                // dies with the child in C zsh.
21133                crate::ported::modules::clone::lastpid
21134                    .store(snap.lastpid, std::sync::atomic::Ordering::Relaxed);
21135                // c:Src/exec.c::entersubsh fork semantics — restore the
21136                // parent's job table + curjob/prevjob/maxjob/thisjob.
21137                // The subshell mutated only its own copy. Bug #462.
21138                if let Ok(mut t) = crate::ported::jobs::JOBTAB
21139                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
21140                    .lock()
21141                {
21142                    *t = snap.jobtab;
21143                }
21144                *crate::ported::jobs::CURJOB
21145                    .get_or_init(|| std::sync::Mutex::new(-1))
21146                    .lock()
21147                    .unwrap() = snap.curjob;
21148                *crate::ported::jobs::PREVJOB
21149                    .get_or_init(|| std::sync::Mutex::new(-1))
21150                    .lock()
21151                    .unwrap() = snap.prevjob;
21152                *crate::ported::jobs::MAXJOB
21153                    .get_or_init(|| std::sync::Mutex::new(0))
21154                    .lock()
21155                    .unwrap() = snap.maxjob;
21156                *crate::ported::jobs::THISJOB
21157                    .get_or_init(|| std::sync::Mutex::new(-1))
21158                    .lock()
21159                    .unwrap() = snap.thisjob;
21160                // c:Src/jobs.c:101/104 — the subshell's oldjobtab died with
21161                // the child in C; put the parent's back.
21162                *crate::ported::jobs::OLDJOBTAB
21163                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
21164                    .lock()
21165                    .unwrap() = snap.oldjobtab;
21166                *crate::ported::jobs::OLDMAXJOB
21167                    .get_or_init(|| std::sync::Mutex::new(0))
21168                    .lock()
21169                    .unwrap() = snap.oldmaxjob;
21170                if let Some(m) = crate::ported::params::paramtab_hashed_storage()
21171                    .lock()
21172                    .ok()
21173                    .as_deref_mut()
21174                {
21175                    *m = snap.paramtab_hashed_storage;
21176                }
21177                exec.set_pparams(snap.positional_params);
21178                // Restore parent's traps (the subshell's own traps die
21179                // with it). zsh: `(trap "X" USR1)` doesn't leak the
21180                // USR1 trap out of the subshell. Write back to the
21181                // canonical `traps_table` (bin_trap writes there).
21182                if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
21183                    *t = snap.traps;
21184                }
21185                // (The parent's option store was put back above, before
21186                // the special-global replay.)
21187                // c:Src/exec.c::entersubsh — same fork-copy
21188                //   semantics for shfunctab. Restore parent's function
21189                //   table from snapshot so `(f() { ... })` definitions
21190                //   inside the subshell don't leak to the parent.
21191                //   Bug #208 in docs/BUGS.md.
21192                if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
21193                    tab.restore(snap.shfuncs);
21194                }
21195                // Restore the runtime dispatch tables (compiled chunks
21196                // + source). Without these, a subshell-defined
21197                // override leaves its bytecode in place even after
21198                // shfunctab is restored — `g` after the subshell would
21199                // still run the override.
21200                exec.functions_compiled = snap.functions_compiled;
21201                exec.function_source = snap.function_source;
21202                // c:Src/exec.c::entersubsh — restore parent's THINGYTAB
21203                // so a subshell's `zle -N w f` / `zle -D w` doesn't
21204                // affect the parent's widget registry. Bug #453.
21205                if let Ok(mut t) = crate::ported::zle::zle_thingy::thingytab().lock() {
21206                    *t = snap.thingytab;
21207                }
21208                // Same for KEYMAPNAMTAB. Bug #454.
21209                if let Ok(mut t) = crate::ported::zle::zle_keymap::keymapnamtab().lock() {
21210                    *t = snap.keymapnamtab;
21211                }
21212                // c:Src/exec.c:2880 — a bare `exec >file` / `exec N>&-`
21213                // inside `(...)` died with the C child; put the parent's
21214                // descriptors back (see SubshFdFrame).
21215                drop(snap.fd_frame);
21216            }
21217        });
21218        // Decrement SUBSHELL_DEPTH. If a deferred subshell exit
21219        // landed inside (EXIT_PENDING set with depth > 0), promote
21220        // the deferred status into the subshell's exit status now
21221        // that we're at the boundary, then clear so the parent
21222        // continues. Matches C zsh's "subshell-exit-via-fork"
21223        // boundary where the child's process::exit(N) becomes
21224        // $WAITSTATUS / $? in the parent.
21225        crate::ported::builtin::SUBSHELL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
21226        // c:Src/exec.c — drain the signal queue against the now-
21227        // restored parent trap table. Pairs with the
21228        // queue_signals() call at the end of subshell_begin.
21229        // Any `kill $$` from inside the subshell is processed
21230        // here against OUTER's trap, matching C zsh's
21231        // signal-delivery-to-parent semantics. Bug #450.
21232        crate::ported::signals_h::unqueue_signals();
21233        // Replay the signals that arrived while the body ran and that
21234        // the PARENT traps, now that the parent's `sigtrapped[]` and
21235        // `traps_table` are back. See `subshell_defer_signal`.
21236        subshell_signal_leave();
21237        // c:Src/exec.c — a `( … )` subshell is a FORK in C: an errflag
21238        // abort inside the child ends the child with its lastval as
21239        // the exit status, and the flag dies with the child process.
21240        // The parent's $? picks up the status and the parent's lists
21241        // keep running. zsh 5.9: `(readonly r=1; r=2); echo "after
21242        // $?"` prints `after 1`. zshrs runs the subshell in-process,
21243        // so mirror the fork isolation by clearing ERRFLAG_ERROR at
21244        // the subshell boundary — exec.last_status() already carries
21245        // the child's lastval (synced by ERREXIT_CHECK trigger 4).
21246        //
21247        // ERRFLAG_HARD must die at this boundary too: `${u:?msg}` sets
21248        // errflag |= ERRFLAG_HARD (c:Src/subst.c:3344) and then, in a
21249        // C forked subshell, `_exit(1)` (c:3353) — the parent never
21250        // sees ANY errflag bit. A leaked HARD bit here made every
21251        // subsequent zerr() take the silent arm (c:Src/utils.c:175-177
21252        // `if (errflag || noerrs) { errflag |= ERRFLAG_ERROR; return; }`),
21253        // so the next eval/source's parse silently "failed" and the
21254        // D04 harness shell wedged after chunk 10's
21255        // `(print ${unset1:?exiting1})`.
21256        //
21257        // !!! DASH-FAMILY GATE — see dash_mode::fatal_error_status !!!
21258        // dash's `sh_error()` unwinds via `exraise(EXERROR)`, which sets
21259        // `exitstatus = 2` before `exitshell()`; the `( … )` boundary IS
21260        // one of the two places that unwind lands, so the subshell reports
21261        // 2 rather than zsh's `lastval == ERRFLAG_ERROR == 1`. Read the
21262        // flag BEFORE the clear below; the status is published at the
21263        // deferred-`exit` arm uses (run_chunk otherwise restores
21264        // `vm.last_status` over any write made here).
21265        let dash_fatal_status = {
21266            let ef = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
21267            let fatal = crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD;
21268            if ef & fatal != 0 {
21269                crate::extensions::dash_mode::fatal_error_status()
21270            } else {
21271                None
21272            }
21273        };
21274        crate::ported::utils::errflag.fetch_and(
21275            !(crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
21276            std::sync::atomic::Ordering::Relaxed,
21277        );
21278        // c:Src/builtin.c:5834 / Src/exec.c:1443 — `retflag` dies at the
21279        // fork boundary for the same reason `errflag` above does. In C a
21280        // `return` inside `( … )` sets retflag in the CHILD; the child's
21281        // execlist unwinds, the child _exit()s, and the PARENT's retflag
21282        // was never touched. zshrs runs subshells in-process, so the flag
21283        // survived and returned from the enclosing FUNCTION:
21284        //   f() { ( return 1 ); print IN }; f
21285        // printed nothing where zsh prints `IN`. Storing 0 is exactly a
21286        // restore-to-entry: a non-zero retflag unwinds its list
21287        // immediately (c:1443's `!retflag` gate), so the parent can never
21288        // be sitting at a subshell with the flag already set. Twin of the
21289        // save/restore `$( … )` already does in vm_helper.rs (the
21290        // `saved_retflag` pair around the cmd-subst sub-VM), and of the
21291        // loops/breaks/contflag restore in SubshellSnapshot::loop_flags.
21292        crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
21293        let exit_pending =
21294            crate::ported::builtin::EXIT_PENDING.load(std::sync::atomic::Ordering::Relaxed);
21295        if exit_pending != 0 {
21296            // c:Src/builtin.c — `exit N` masks N to 8 bits because
21297            // POSIX _exit takes the low byte as status. `(exit 256)`
21298            // and `(exit 0)` are indistinguishable to the parent;
21299            // `(exit 257)` exits with 1. Without the mask zshrs's
21300            // in-process subshell propagated the full i32 (256) into
21301            // the parent's $?, diverging from zsh.
21302            let raw = crate::ported::builtin::EXIT_VAL.load(std::sync::atomic::Ordering::Relaxed);
21303            // dash's `exraise(EXERROR)` assigns `exitstatus = 2` at the
21304            // RAISE, so a fatal error wins over whatever deferred-exit
21305            // value the unwind happened to carry. Only an errflag-driven
21306            // unwind reaches this — a real `(exit 5)` never sets the flag,
21307            // so `(exit 5)` still reports 5.
21308            let val = dash_fatal_status.unwrap_or(raw & 0xFF);
21309            with_executor(|exec| exec.set_last_status(val));
21310            crate::ported::builtin::EXIT_PENDING.store(0, std::sync::atomic::Ordering::Relaxed);
21311            crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
21312            crate::ported::builtin::BREAKS.store(0, std::sync::atomic::Ordering::Relaxed);
21313            // Return the deferred-exit status so the VM updates its
21314            // own `last_status`. Otherwise run_chunk's post-script
21315            // `set_last_status(vm.last_status)` would clobber LASTVAL
21316            // back to the stale pre-subshell value.
21317            return Some(val);
21318        }
21319        // Same publication path for a fatal error that did NOT arm a
21320        // deferred exit (e.g. the failed-assignment unwind).
21321        if let Some(st) = dash_fatal_status {
21322            with_executor(|exec| exec.set_last_status(st));
21323            return Some(st);
21324        }
21325        None
21326    }
21327
21328    fn redirect(&mut self, fd: u8, op: u8, target: &str) {
21329        // Apply a redirection at the OS level for the next command/builtin.
21330        // The host tracks saved fds in a per-executor stack so a future
21331        // `with_redirects_end` can restore. For now, this is a thin wrapper
21332        // that performs the dup2; pairing with explicit save/restore is
21333        // delivered by `with_redirects_begin/end`.
21334        with_executor(|exec| exec.host_apply_redirect(fd, op, target));
21335    }
21336
21337    fn with_redirects_begin(&mut self, count: u8) {
21338        with_executor(|exec| exec.host_redirect_scope_begin(count));
21339    }
21340
21341    fn regex_match(&mut self, s: &str, regex: &str) -> bool {
21342        // c:Src/Modules/regex.c:54 `zcond_regex_match` — POSIX ERE
21343        // matching + populate `$MATCH` / `$MBEGIN` / `$MEND` /
21344        // `$match[]` / `$mbegin[]` / `$mend[]` (or `$BASH_REMATCH`
21345        // under BASHREMATCH). Direct delegation to the canonical
21346        // port at src/ported/modules/regex.rs:58.
21347        //
21348        // The bridge passthru path delivers TOKEN-form bytes here
21349        // (Inbrack \u{e191}, Outbrack \u{e192}, Star \u{e187}, Quest
21350        // \u{e186}, etc.) since the lexer tokenizes regex meta chars
21351        // inside `[[ ]]`. The host regex engine expects ASCII, so
21352        // untokenize the pattern (and subject, for safety) once at
21353        // this boundary. zsh C reaches its POSIX-ERE engine through
21354        // the same untokenize path inside zcond_regex_match.
21355        let s_clean = crate::lex::untokenize(s);
21356        let regex_clean = crate::lex::untokenize(regex);
21357        // c:Src/cond.c:113-119 — WHICH engine `=~` uses is an option:
21358        //
21359        //   char *modname = isset(REMATCHPCRE) ? "zsh/pcre" : "zsh/regex";
21360        //
21361        // and the two speak different languages (POSIX ERE vs PCRE), so the
21362        // option decides whether `\d` is a digit class or a literal `d`, and
21363        // whether `(?<name>…)` compiles at all. This dispatch was missing:
21364        // `=~` always used the regex module, so `setopt rematchpcre` silently
21365        // did nothing.
21366        if crate::ported::zsh_h::isset(crate::ported::zsh_h::REMATCHPCRE) {
21367            // c:115 — "zsh/pcre" → the `-pcre-match` cond.
21368            crate::ported::modules::pcre::cond_pcre_match(
21369                &[s_clean, regex_clean],
21370                crate::ported::modules::pcre::CPCRE_PLAIN,
21371            ) != 0
21372        } else {
21373            // c:115 — "zsh/regex" → the `-regex-match` cond.
21374            crate::ported::modules::regex::zcond_regex_match(
21375                &[s_clean.as_str(), regex_clean.as_str()],
21376                crate::ported::modules::regex::ZREGEX_EXTENDED,
21377            ) != 0
21378        }
21379    }
21380
21381    fn with_redirects_end(&mut self) {
21382        with_executor(|exec| exec.host_redirect_scope_end());
21383        // c:Src/exec.c:5172 — if any redirect in this scope failed
21384        // (noclobber-blocked, ENOENT for read, etc.), the command's
21385        // exit status is forced to 1 regardless of what the (still-
21386        // executed) command's own exit was. C zsh prevents the
21387        // command from running at all when a redirect fails; the
21388        // Rust port still runs it (sinking output to /dev/null in
21389        // the noclobber arm at host_apply_redirect:5481) and then
21390        // overrides $? here. Same observable effect for the common
21391        // pattern `echo x > existing-file` under noclobber.
21392        let failed = with_executor(|exec| {
21393            let f = exec.redirect_failed;
21394            exec.redirect_failed = false;
21395            f
21396        });
21397        if failed {
21398            // !!! DASH-FAMILY GATE (dash_mode::fatal_error_status) !!! dash's
21399            // redirection failure is `sh_error` too, so it reports 2, not 1.
21400            let st = crate::extensions::dash_mode::fatal_error_status().unwrap_or(1);
21401            with_executor(|exec| exec.set_last_status(st));
21402        }
21403    }
21404
21405    fn heredoc(&mut self, content: &str) {
21406        // C `Src/exec.c:4641` — `parsestr(&buf)` runs parameter +
21407        // command substitution on the heredoc body. The lexer's
21408        // quoted-delimiter detection (`<<'EOF'`) routes through the
21409        // `Op::HereDoc` path in `compile_zsh.rs` which short-circuits
21410        // before reaching here; unquoted forms route through the
21411        // BUILTIN_EXPAND_TEXT mode-4 emit path that calls singsub.
21412        // This handler covers the verbatim/quoted case.
21413        if crate::provenance::active() {
21414            crate::provenance::on_heredoc("heredoc", content);
21415        }
21416        with_executor(|exec| exec.host_set_pending_stdin(content.to_string()));
21417    }
21418
21419    fn herestring(&mut self, content: &str) {
21420        // Shell semantics: herestring appends a newline. `<<<` body
21421        // substitution (`Src/exec.c:4655 getherestr` calls
21422        // `quotesubst` + `untokenize`) lands here verbatim; the
21423        // upstream compiler routes through `Op::HereString` after
21424        // BUILTIN_EXPAND_TEXT for the substitution pass, so callers
21425        // of `host.herestring` see the already-expanded form.
21426        let mut s = content.to_string();
21427        s.push('\n');
21428        if crate::provenance::active() {
21429            crate::provenance::on_heredoc("herestring", &s);
21430        }
21431        with_executor(|exec| exec.host_set_pending_stdin(s));
21432    }
21433
21434    fn exec(&mut self, args: Vec<String>) -> i32 {
21435        // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close
21436        // any `>(cmd)` write ends owned by this command once it
21437        // finishes (drops on every return path below).
21438        let _psub_fds = PsubFdGuard;
21439        // c:Src/subst.c paramsubst — when `${var:?msg}` or `${var?msg}`
21440        // triggered the "parameter null or not set" error, errflag
21441        // is raised and zsh aborts the simple command without
21442        // attempting exec. The expansion may have produced empty
21443        // argv[0] which falls into the c:?/permission-denied path
21444        // below, masking the real diagnostic with a spurious
21445        // "permission denied:" line and rc=126 instead of rc=1.
21446        // Honour errflag here so the script ends with the
21447        // paramsubst error as the sole diagnostic. Bug #86.
21448        //
21449        // c:Src/exec.c — C's execlist loop clears ERRFLAG_ERROR
21450        // between sublists when the error came from a NOMATCH-style
21451        // command failure (glob no-match, etc.) so subsequent
21452        // sublists run. zshrs's vm dispatch handles this at the
21453        // post-command-boundary HERE: if THIS command has its
21454        // `current_command_glob_failed` cell set (meaning the glob
21455        // NOMATCH happened during this command's argv prep), surface
21456        // status 1 and clear BOTH the cell AND ERRFLAG_ERROR so the
21457        // NEXT exec call sees a clean state. The errflag from
21458        // genuine script-fatal errors (parse, redirect, paramsubst
21459        // `${:?msg}`) does NOT come paired with glob_failed, so
21460        // those still short-circuit + propagate.
21461        if let Some(status) = external_glob_failure_status() {
21462            return status;
21463        }
21464        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
21465            & crate::ported::zsh_h::ERRFLAG_ERROR)
21466            != 0
21467        {
21468            // c:Src/exec.c:3636-3637 — `if (errflag) { lastval = 1; … return; }`
21469            // after prefork. The abort that follows exits with LASTVAL, so
21470            // store it: `/bin/echo $((1/0))` exited with the previous status.
21471            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
21472            return 1;
21473        }
21474        // c:Src/exec.c — two distinct empty-command cases:
21475        //
21476        // 1. args=[""]  — an explicit empty-string command word
21477        //    (`""`, `"\$unset"`, `\$'\$x'`). zsh attempts exec(2)
21478        //    on the empty path → EACCES → "permission denied", \$?
21479        //    = 126.
21480        //
21481        // 2. args=[]    — the WORD LIST is empty (unquoted \$(\$cmd)
21482        //    that produced empty, or an unquoted unset \$var that
21483        //    elided). zsh: no exec is attempted; \$? becomes the
21484        //    last cmd-subst's exit status (the inner sub-VM
21485        //    already set last_status), and the line completes
21486        //    silently. Critically NOT 126.
21487        if args.is_empty() {
21488            // c:Src/exec.c — empty word list passes through to a
21489            // no-op; preserve whatever the inner cmd-subst's exit
21490            // is. Return last_status so the caller's SetStatus
21491            // round-trips correctly.
21492            return with_executor(|exec| exec.last_status());
21493        }
21494        if args[0].is_empty() {
21495            let script_name =
21496                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
21497            let lineno: u64 = with_executor(|exec| {
21498                exec.scalar("LINENO")
21499                    .and_then(|s| s.parse::<u64>().ok())
21500                    .unwrap_or(1)
21501            });
21502            eprintln!("{}:{}: permission denied: ", script_name, lineno);
21503            return 126;
21504        }
21505        // c:Src/exec.c — when any redirect in the current scope
21506        // failed (e.g. noclobber blocked a `>` overwrite), zsh
21507        // refuses to execute the command and exits with status 1.
21508        // The Rust port still applied the command (writing to the
21509        // /dev/null sink installed by host_apply_redirect's
21510        // noclobber arm), but the success status overwrote the
21511        // intended `1`. Short-circuit here so the exec returns 1
21512        // without running the body.
21513        let redir_failed = with_executor(|exec| {
21514            let f = exec.redirect_failed;
21515            exec.redirect_failed = false;
21516            f
21517        });
21518        if redir_failed {
21519            return 1;
21520        }
21521        // c:Src/exec.c:3545-3547 — `setunderscore(lastnode(args))` for the
21522        // command about to run. Write the canonical `zunderscore` global,
21523        // NOT the paramtab node: `_`'s setfn is `nullstrsetfn`
21524        // (c:Src/params.c:252-253), so a table write has no counterpart in
21525        // C and clobbers the PM_UNSET bit that `unset _` relies on.
21526        if let Some(last) = args.last() {
21527            crate::ported::params::set_zunderscore(std::slice::from_ref(last)); // c:3546
21528        }
21529        // Provenance: record which argv slot each tracked value landed
21530        // in, before the command consumes it.
21531        if crate::provenance::active() {
21532            crate::provenance::on_exec("exec", &args);
21533        }
21534        // Route external command spawning through `executor.execute_external`
21535        // so intercepts (AOP before/after/around), command_hash lookups,
21536        // pre/postexec hooks, and zsh-specific fork-then-exec all apply.
21537        // Without this override, fusevm's default `host.exec` calls
21538        // `Command::new` directly, bypassing zshrs's dispatch logic.
21539        let status = with_executor(|exec| exec.host_exec_external(&args));
21540        // c:Src/jobs.c:1748 waitonejob (no-procs else-branch). zshrs's
21541        // exec model routes external commands through host_exec_external
21542        // (which already waitpid'd in-line); the canonical waitonejob
21543        // expects a Job to derive lastval, but here we already know
21544        // it. A job index of -1 (no job-table entry) takes waitonejob's
21545        // no-procs branch, which fires the `pipestats[0]=lastval;
21546        // numpipestats=1;` update via the canonical port.
21547        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
21548        crate::ported::jobs::waitonejob(-1);
21549        status
21550    }
21551
21552    fn cmd_subst(&mut self, sub: &fusevm::Chunk) -> String {
21553        // Run the sub-chunk on a nested VM with the same host wired up,
21554        // capturing stdout. The current executor remains active via the
21555        // thread-local — the nested VM uses CallBuiltin to dispatch shell
21556        // ops back through `with_executor`.
21557        // c:Src/exec.c:4800 getoutput — `mpipe(pipes)`: both ends are moved
21558        // out of the script's fd range (c:Src/utils.c:2210 mpipe → movefd),
21559        // so a closed stdout cannot hand the pipe fd 1 itself.
21560        let (read_end, write_end) = match os_pipe::pipe() {
21561            Ok(p) => p,
21562            Err(_) => return String::new(),
21563        };
21564        let read_end = unsafe {
21565            <os_pipe::PipeReader as std::os::fd::FromRawFd>::from_raw_fd(crate::lowfd::movefd_past_script(
21566                std::os::fd::IntoRawFd::into_raw_fd(read_end),
21567            ))
21568        };
21569        // A closed fd 1 is saved as -1 and restored by closing it again
21570        // (c:Src/utils.c:2047-2048 redup: `if (x < 0) zclose(y)`). Bailing out
21571        // here skipped the body entirely: `{ x=$(print b >&2) } >&-` lost `b`.
21572        // The saves exist only because the body runs in process (C's forked
21573        // child saves nothing): keep them past the script's `{var}` range.
21574        let saved_stdout = unsafe {
21575            libc::fcntl(libc::STDOUT_FILENO, libc::F_DUPFD_CLOEXEC, crate::lowfd::EXTENSION_FD_FLOOR)
21576        };
21577        let saved_stderr = unsafe {
21578            libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD_CLOEXEC, crate::lowfd::EXTENSION_FD_FLOOR)
21579        };
21580        // A job the body forks must not keep them: they belong to the
21581        // parent C never forked the body from (see mark_saved_fd).
21582        mark_saved_fd(saved_stdout);
21583        mark_saved_fd(saved_stderr);
21584        let write_fd = AsRawFd::as_raw_fd(&write_end);
21585        unsafe {
21586            libc::dup2(write_fd, libc::STDOUT_FILENO); // c:4840 redup(pipes[1], 1)
21587        }
21588        drop(write_end);
21589
21590        // c:Bug #56 — publish the saved outer fds so a trap firing
21591        // during the nested VM run can route its body output to the
21592        // PARENT's stdout instead of the cmdsub's pipe-bound fd 1.
21593        // zsh's forked cmdsub gets this for free (trap runs in the
21594        // parent process whose fd 1 is untouched). zshrs's
21595        // in-process cmdsub needs this thread-local stack so the
21596        // trap dispatcher can find the right destination fd.
21597        CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().push((saved_stdout, saved_stderr)));
21598
21599        // Nested scope for `>(cmd)` fd ownership — commands inside
21600        // the cmdsub must not drain the enclosing command's pending
21601        // psub fds (see PSUB_SCOPE_DEPTH).
21602        let _psub_scope = PsubScope::enter();
21603
21604        // c:Src/exec.c:1161 — forked cmdsub child runs entersubsh()
21605        // which does `zsh_subshell++`; in-process equivalent.
21606        let _subshell_bump = CmdSubstSubshellBump::enter();
21607        let _bash_errexit = crate::dash_mode::BashCmdsubstErrexit::enter();
21608
21609        crate::fusevm_disasm::maybe_print_stdout("host.cmd_subst", sub);
21610        let mut vm = fusevm::VM::new(sub.clone());
21611        register_builtins(&mut vm);
21612        vm.set_shell_host(Box::new(ZshrsHost));
21613        let _ = vm.run();
21614        let cmd_status = vm.last_status;
21615
21616        CMDSUBST_OUTER_FDS.with(|s| {
21617            s.borrow_mut().pop();
21618        });
21619
21620        unsafe {
21621            libc::dup2(saved_stdout, libc::STDOUT_FILENO);
21622        }
21623        crate::ported::utils::zclose(saved_stdout);
21624        if saved_stderr >= 0 {
21625            crate::ported::utils::zclose(saved_stderr);
21626        }
21627
21628        // Inner cmd's status not propagated for the same reason as
21629        // run_command_substitution — see GAPS.md.
21630        let _ = cmd_status;
21631
21632        let mut buf = String::new();
21633        let mut reader = read_end;
21634        let _ = reader.read_to_string(&mut buf);
21635        // Strip trailing newlines (POSIX command substitution semantics)
21636        while buf.ends_with('\n') {
21637            buf.pop();
21638        }
21639        // Provenance: a command substitution is a lineage ORIGIN — the
21640        // bytes did not exist in the shell before this ran.
21641        if crate::provenance::active() {
21642            crate::provenance::on_cmd_subst(&prov_chunk_label(sub), &buf);
21643        }
21644        buf
21645    }
21646
21647    fn call_function(&mut self, name: &str, args: Vec<String>) -> Option<i32> {
21648        // An alias is gone from the text by now; the compiler left its name behind.
21649        if let Some(status) = intercept_alias_origin(name, &args) {
21650            return Some(status);
21651        }
21652        // AOP intercepts (zshrs extension, no C counterpart). An external is
21653        // intercepted where it spawns; every other command word lands here, so
21654        // a shell function, a ported builtin or a host-registered native command
21655        // (the fat binary's `git`) gets its advice from this one gate.
21656        if let Some(status) = intercept_in_process_command(name, &args) {
21657            return Some(status);
21658        }
21659        // c:Src/exec.c — when the command word is empty (e.g. `""`
21660        // or `"$nonexistent"`), zsh attempts the exec(2) which
21661        // returns EACCES ("permission denied") and exits 126. The
21662        // Rust port silently treated empty as a no-op (status 0).
21663        // Match zsh by emitting the diagnostic and returning 126.
21664        if name.is_empty() {
21665            let script_name =
21666                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
21667            let lineno: u64 = with_executor(|exec| {
21668                exec.scalar("LINENO")
21669                    .and_then(|s| s.parse::<u64>().ok())
21670                    .unwrap_or(1)
21671            });
21672            eprintln!("{}:{}: permission denied: ", script_name, lineno);
21673            with_executor(|exec| exec.set_last_status(126));
21674            return Some(126);
21675        }
21676        // c:Src/exec.c — redirect failure in this scope means the
21677        // command should NOT run. The Host::exec path already has
21678        // this gate (at fn exec above); call_function takes external
21679        // commands like `cat <&3` through a different code path, so
21680        // gate here too. Without this, bad-fd redirects produced
21681        // the diagnostic but the external command still ran, so $?
21682        // came out from the command's natural exit instead of the
21683        // forced 1.
21684        let redir_failed = with_executor(|exec| {
21685            let f = exec.redirect_failed;
21686            exec.redirect_failed = false;
21687            f
21688        });
21689        // c:Src/exec.c:4147-4154 — a failed prefix assignment skips the
21690        // command with `lastval = 1` in the same way. For an external the
21691        // assignments run in the forked child (c:4343-4350 `addvars(…); if
21692        // (errflag) _exit(1);`), so the shell again only sees status 1:
21693        //   x=${bad?err} /bin/echo ran; print rc=$?     zsh: rc=1
21694        // while a shell function runs them in the shell, whose errflag ends
21695        // the list.
21696        let prefix_failed = PREFIX_ASSIGN_FAILED.with(|c| c.replace(false));
21697        if redir_failed || prefix_failed {
21698            // c:Src/exec.c:3719 forks an external command (execcmd_fork)
21699            // BEFORE the redirection loop at c:3785, so a redirection that
21700            // `zerr`s (`<&""` → "file number expected", c:Src/glob.c:2192)
21701            // raises errflag in the CHILD, which `_exit(1)`s; the parent only
21702            // sees status 1 and runs the next command:
21703            //   /bin/cat <&""; print after $?     zsh: after 1
21704            // zshrs applies redirections in the shell before choosing what
21705            // runs, so the errflag landed in the shell and ended the script.
21706            // For a name that resolves to neither a function nor a zsh
21707            // builtin, take the error back out of the shell's errflag. A
21708            // zshrs extension builtin counts as external here: zsh has no
21709            // such builtin, so the same name reaches execcmd_fork there
21710            // (an in-process stand-in for `env`, `cat`, … must fail like the
21711            // command it stands in for: `env <&""; print after` continues).
21712            let is_external = !with_executor(|exec| exec.function_exists(name))
21713                && !crate::ported::builtin::createbuiltintable().contains_key(name);
21714            if is_external {
21715                // A `${name?msg}` in the prefix assignments also sets
21716                // ERRFLAG_HARD; the forked child took both bits with it.
21717                let bits = if prefix_failed {
21718                    crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD
21719                } else {
21720                    crate::ported::zsh_h::ERRFLAG_ERROR
21721                };
21722                crate::ported::utils::errflag.fetch_and(!bits, std::sync::atomic::Ordering::Relaxed);
21723                // A NOMATCH / NULL_GLOB failure in the redirect target's
21724                // filename generation belongs to the same child: take its
21725                // carrier too, or the command-boundary gate still ends the list.
21726                let _ = external_glob_failure_status();
21727            }
21728            with_executor(|exec| exec.set_last_status(1));
21729            return Some(1);
21730        }
21731        // c:Src/exec.c:3755-3763 — a shell function is not forked, so a
21732        // failed argument glob is `lastval = 1; goto err;` in the shell: the
21733        // body never runs, and errflag (left set) ends the list with status
21734        // 1 (`f() { :; }; f *nomatch` exits 1).
21735        if with_executor(|exec| exec.function_exists(name))
21736            && with_executor(|exec| exec.current_command_glob_failed.replace(false))
21737        {
21738            consume_tilde_globsubst_carrier();
21739            with_executor(|exec| exec.set_last_status(1));
21740            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
21741            return Some(1);
21742        }
21743        // Provenance: same argv record as `exec`, but ONLY when the name
21744        // really resolves to a shell function — an external command
21745        // reaches `exec` further down and would otherwise be recorded
21746        // twice for the same call site.
21747        if crate::provenance::active() && with_executor(|exec| exec.function_exists(name)) {
21748            let mut argv = Vec::with_capacity(args.len() + 1);
21749            argv.push(name.to_string());
21750            argv.extend(args.iter().cloned());
21751            crate::provenance::on_exec("call", &argv);
21752        }
21753        // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
21754        // functions, NOT builtins. zshrs ships fast native impls, but they
21755        // must behave like the zsh functions — command-not-found until
21756        // `autoload -Uz <name>` creates a function entry. When autoloaded we
21757        // run the native impl here (short-circuiting the fpath source, which
21758        // can hang zshrs's parser on zsh-specific syntax); when NOT autoloaded
21759        // we fall through (return None → resolution ends in command-not-found),
21760        // matching `zsh -f; zmv` → "command not found: zmv".
21761        // A DEFINED zmv/zcp/zln/zcalc body (user-written, or a stub already
21762        // loaded by `autoload +X`) is an ordinary shell function and runs as
21763        // one through the function dispatch below. The native port stands in
21764        // only for the fpath load of a still-PM_UNDEFINED stub
21765        // (c:Src/exec.c:5691-5695 execautofn → loadautofn, which clears
21766        // PM_UNDEFINED at c:5790/c:5815).
21767        let native_autoload =
21768            matches!(name, "zmv" | "zcp" | "zln" | "zcalc") && autoload_stub_pending(name);
21769        if matches!(name, "zmv" | "zcp" | "zln" | "zcalc")
21770            && !with_executor(|exec| exec.function_exists(name))
21771        {
21772            return None;
21773        }
21774        // c:Src/exec.c:3485-3489 — `shfunctab->getnode` is consulted before
21775        // `builtintab`: a shell function shadows every builtin of the same
21776        // name. The zshrs-only builtins dispatched by name below (znative,
21777        // ztest_*, the daemon z* family) are builtins too, so they sit behind
21778        // the same function check as the builtintab arm further down.
21779        let has_user_fn = user_fn_shadows_builtin(name);
21780        match name {
21781            "zmv" if native_autoload => {
21782                return Some(crate::extensions::ext_builtins::zmv(&args, "mv"));
21783            }
21784            "zcp" if native_autoload => {
21785                return Some(crate::extensions::ext_builtins::zmv(&args, "cp"));
21786            }
21787            "zln" if native_autoload => {
21788                return Some(crate::extensions::ext_builtins::zmv(&args, "ln"));
21789            }
21790            "zcalc" if native_autoload => {
21791                return Some(crate::extensions::ext_builtins::zcalc(&args));
21792            }
21793            // znative — the plugin package manager (src/extensions/pkg/). Installs
21794            // + loads zsh script and native (Rust cdylib) plugins from a global
21795            // content-addressed store. `znative add owner/repo`, `znative load`, ...
21796            "znative" if !has_user_fn => {
21797                return Some(crate::extensions::pkg::builtin::znative(&args));
21798            }
21799            // ztest framework (src/extensions/ztest.rs — port of
21800            // ../strykelang's unit-test framework). All zassert_*/
21801            // ztest_* names route through the single try_dispatch
21802            // helper so adding/removing assertions only touches
21803            // ztest.rs.
21804            n if !has_user_fn && crate::extensions::ztest::try_dispatch_known(n) => {
21805                let status = with_executor(|exec| {
21806                    crate::extensions::ztest::try_dispatch(exec, n, &args).unwrap_or(1)
21807                });
21808                return Some(status);
21809            }
21810            // Daemon-managed z* builtins — thin IPC wrappers. Short-circuit BEFORE
21811            // the function-lookup path so a missing daemon doesn't fall through to
21812            // "command not found". The name list is owned by the daemon crate
21813            // (zshrs_daemon::builtins::ZSHRS_BUILTIN_NAMES); routing through
21814            // try_dispatch keeps this site zero-touch as new z* builtins land.
21815            n if !has_user_fn && crate::daemon::builtins::is_zshrs_builtin(n) => {
21816                return run_daemon_builtin(n, &args);
21817            }
21818            _ => {}
21819        }
21820
21821        // c:Src/exec.c:3050-3068 — module-provided builtins (registered
21822        // via each module's `bintab` and folded into the canonical
21823        // `builtintab` by `createbuiltintable`) must dispatch BEFORE
21824        // PATH lookup. fusevm's `shell_builtins::builtin_id` doesn't
21825        // know about per-module entries like `log`
21826        // (Src/Modules/watch.c:693) — they reach call_function as
21827        // CallFunction ops. Consult the merged builtintab here so
21828        // `log` runs the canonical `bin_log` instead of falling
21829        // through to `/usr/bin/log` on macOS. Bug #72 in docs/BUGS.md.
21830        //
21831        // User-defined functions still take precedence over builtins
21832        // (zsh's `alias → function → builtin → external` resolution
21833        // order, c:Src/exec.c:3038-3068). Check `functions_compiled`
21834        // first so a user `log() { ... }` shadows the module bin_log.
21835        // c:Src/exec.c — shfunctab->getnode (the DISABLED-filtering
21836        // accessor) returns NULL for entries flipped to DISABLED via
21837        // `disable -f NAME`. functions_compiled holds the body
21838        // independently of the DISABLED flag, so check shfunctab first
21839        // and mask the lookup when the entry is disabled (see
21840        // `user_fn_shadows_builtin`; `has_user_fn` computed above). Bug #221
21841        // in docs/BUGS.md.
21842        if !has_user_fn {
21843            // c:Src/exec.c:3056 — `builtintab->getnode(builtintab,
21844            // cmdarg)` returns NULL for DISABLED entries, falling
21845            // execcmd through to PATH lookup. Mirror by gating the
21846            // bn_in_tab match on the BUILTINS_DISABLED set. Bug #106
21847            // in docs/BUGS.md.
21848            let disabled = crate::ported::builtin::BUILTINS_DISABLED
21849                .lock()
21850                .map(|s| s.contains(name))
21851                .unwrap_or(false);
21852            let bn_in_tab =
21853                !disabled && crate::ported::builtin::createbuiltintable().contains_key(name);
21854            if bn_in_tab {
21855                // c:Src/exec.c:4287 — `lastval = execbuiltin(args, assigns,
21856                // (Builtin) hn);`. The store happens BEFORE any errflag
21857                // handling, so a builtin that BOTH raises errflag (zerr) and
21858                // returns non-zero still publishes its status. zshrs relied on
21859                // the VM's trailing `SetStatus` op to publish it, and that op
21860                // is skipped once the builtin set ERRFLAG_ERROR — so
21861                // `() { private SECONDS }` (makeprivate's zerrnam + return 1)
21862                // reported 0 where zsh reports 1 (V10private.ztst:22).
21863                let __ret = dispatch_builtin_raw(name, args);
21864                // c:4289-4293 — `if (!(errflag & ERRFLAG_INT)) lastval = ret;`
21865                // (see `dispatch_builtin`).
21866                let __st = if (crate::ported::utils::errflag
21867                    .load(std::sync::atomic::Ordering::Relaxed)
21868                    & crate::ported::zsh_h::ERRFLAG_INT)
21869                    != 0
21870                {
21871                    crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed)
21872                } else {
21873                    __ret
21874                };
21875                crate::ported::builtin::LASTVAL.store(__st, std::sync::atomic::Ordering::Relaxed); // c:4287
21876                with_executor(|exec| exec.set_last_status(__st)); // c:4287
21877                return Some(__st);
21878            }
21879            // zshrs-original opcode builtins (async, doctor, peach, …) are not
21880            // in builtintab, so a run-time-resolved name (`$var`) never reaches
21881            // them. Dispatch by name here — after ported builtins, before
21882            // external — matching the shell's function -> builtin -> external
21883            // order (`has_user_fn` was checked above, so functions still win).
21884            if let Some(status) = try_run_registered_builtin(name, &args) {
21885                return Some(status);
21886            }
21887        }
21888
21889        // c:Src/lex.c:1928 — an alias is replaced by its text while the line is
21890        // LEXED; nothing consults the alias table once the command runs. The
21891        // dispatch-time rewrite that used to sit here (gated on INTERACTIVE)
21892        // expanded the already-expanded command name a second time:
21893        // `alias ls='ls -G'; ls x` ran `ls -G -G x`, and `\ls` picked it up.
21894
21895        // $_ pre-body bump and pending-underscore tracking are
21896        // ZshrsHost-only concerns (prompt rendering). Apply BEFORE
21897        // delegating to dispatch_function_call so the body sees the
21898        // bumped value.
21899        //
21900        // c:Src/exec.c:3491 — `setunderscore((args && nonempty(args))
21901        // ? ((char *) getdata(lastnode(args))) : "")`. C sets $_ to
21902        // the LAST node of the WHOLE args list (which includes argv[0]
21903        // == the function name). So for a no-arg `f`, $_ becomes "f"
21904        // inside the function body. The Rust port at the CallFunction
21905        // op-handler receives `args` WITHOUT the command name
21906        // (compile_zsh.rs:1571 only pushes simple.words[1..]). The
21907        // last() fallback `|| fn_name.clone()` already covers the
21908        // no-arg case, but `exec.set_scalar("_", ...)` writes paramtab
21909        // — the canonical `$_` read goes through `underscoregetfn`
21910        // (params.rs:7836) which reads the `zunderscore` Mutex.
21911        // setsparam("_") doesn't update that mutex, so the body's
21912        // `${_}` returned empty. Bug #279 in docs/BUGS.md. Mirror the
21913        // C `setunderscore` by writing via `set_zunderscore` directly.
21914        let fn_name = name.to_string();
21915        // c:Src/exec.c:5487-5488 — an ANONYMOUS function (execfuncdef, no names)
21916        // sets `$_` from its own arguments only, `""` when it has none; the
21917        // name pushed in front (c:5493) is not one of them.
21918        let anon_fn = fn_name.starts_with("_zshrs_anon_");
21919        let no_arg_underscore = if anon_fn { String::new() } else { fn_name.clone() };
21920        {
21921            let dollar_underscore = args.last().cloned().unwrap_or_else(|| no_arg_underscore.clone());
21922            // c:3546 — zunderscore is the only store; the paramtab write
21923            // that used to accompany this cleared PM_UNSET (see pop_args).
21924            crate::ported::params::set_zunderscore(std::slice::from_ref(&dollar_underscore));
21925        }
21926
21927        // Delegate the actual function dispatch to the canonical
21928        // `dispatch_function_call` (which itself wraps the canonical
21929        // `doshfunc` port from `Src/exec.c:5823`). Single doshfunc
21930        // call-site keeps scope-mgmt invariants in one place.
21931        // c:Src/exec.c:772-776 — a function runs unchanged under `-`; only a
21932        // name that is not a function falls through to the external spawn,
21933        // which still needs the carrier.
21934        let exec_dash = take_exec_carrier();
21935        let job_text = JOB_TEXT.with(|t| t.borrow_mut().take());
21936        PREFORK_CUT.with(|c| c.set(false)); // the words are complete
21937        // c:Src/exec.c:3674-3680 — a shell function runs in the current shell.
21938        if with_executor(|exec| exec.function_exists(&fn_name)) {
21939            crate::exec_jobs::mark_thisjob_cursh(false);
21940        }
21941        let status = with_executor(|exec| exec.dispatch_function_call(&fn_name, &args));
21942        if status.is_none() {
21943            EXEC_DASH.with(|c| c.set(exec_dash));
21944            JOB_TEXT.with(|t| *t.borrow_mut() = job_text);
21945        } else {
21946            // c:Src/exec.c:5643-5644 execshfunc — `if (!list_pipe)
21947            // deletefilelist(last_file_list, 0);` once doshfunc returns: the
21948            // `=(cmd)` temp files and `>(cmd)` fds made for the call's
21949            // arguments go with it, also when the body ran `exit` (that only
21950            // sets exit_pending, the function still returns here first).
21951            close_pending_psub_fds();
21952        }
21953
21954        // Anonymous functions (`() { … } args`, compiled by
21955        // parse_anon_funcdef as `_zshrs_anon_N` / `_zshrs_anon_kw_N`)
21956        // execute exactly ONCE and must not persist. zsh runs the body and
21957        // frees the function, so `${functions}` / `typeset -f` never show
21958        // it. Remove every trace right after the single invocation —
21959        // AFTER `status` is captured, so the body's exit code is preserved
21960        // ($? — calling `unfunction` here would reset it to 0 instead).
21961        // Without this, real plugins that use `() { … }` (fzf-tab, zinit,
21962        // p10k, …) leaked dozens of `_zshrs_anon_N` into `$functions`,
21963        // diverging from zsh's function table on every such config.
21964        if fn_name.starts_with("_zshrs_anon_") {
21965            // `${functions}` / `typeset -f` enumerate the canonical
21966            // `shfunctab` (via scanpmfunctions); the bytecode call path
21967            // also keeps the body in the executor's compiled-fn maps. Clear
21968            // BOTH so no trace of the one-shot anon survives.
21969            crate::ported::hashtable::removeshfuncnode(&fn_name);
21970            with_executor(|exec| {
21971                exec.functions_compiled.remove(&fn_name);
21972                exec.function_source.remove(&fn_name);
21973                exec.function_line_base.remove(&fn_name);
21974                exec.function_def_file.remove(&fn_name);
21975            });
21976        }
21977
21978        // c:Src/exec.c:6207-6265 — doshfunc saves `ou = zunderscore`
21979        // around the body and runs `setunderscore(ou)` (c:6257) on the way
21980        // out, so a function call leaves `$_` at the CALL's last argument
21981        // rather than at whatever the body's last command set. The value
21982        // saved there is the one execcmd_exec installed just before the
21983        // call (c:3546), i.e. exactly `args.last()`.
21984        {
21985            let last_call_arg = args.last().cloned().unwrap_or(no_arg_underscore);
21986            crate::ported::params::set_zunderscore(std::slice::from_ref(&last_call_arg));
21987            // c:6257
21988        }
21989
21990        status
21991    }
21992}
21993
21994// ───────────────────────────────────────────────────────────────────────────
21995/// Render a failed-redirect open error the way C's `zerrmsg` `%e` format
21996/// code does (Src/utils.c): `strerror(errno)` with the first character
21997/// lowercased, except `EIO` (kept capitalized) and `EINTR` (→ "interrupt").
21998/// C's redirect open failures call `zwarn("%e: %s", errno, fname)`
21999/// (Src/exec.c:3741); zshrs's `zwarning` takes a pre-built string, so the
22000/// `%e` part is built here. Replaces the prior hardcoded `ErrorKind` match
22001/// that fell back to a generic "redirect failed" for `EROFS`/`EACCES`/etc.
22002/// Port of `IS_WRITE_FILE` (zsh.h:403, `REDIR_WRITE <= X <= REDIR_READWRITE`)
22003/// over the fusevm redirect op bytes compile_zsh lowers those types to:
22004/// `>`/`>|` (WRITE/CLOBBER), `>>`/`>>|` (APPEND), `>&`/`&>` and their append
22005/// forms (WRITE_BOTH/APPEND_BOTH, compile_zsh.rs REDIR_ERRWRITE..), `<>`.
22006fn redir_is_write_file(op_byte: u8) -> bool {
22007    matches!(
22008        op_byte,
22009        r::WRITE | r::APPEND | r::CLOBBER | r::READ_WRITE | r::WRITE_BOTH | r::APPEND_BOTH
22010    )
22011}
22012
22013fn redir_errno_msg(err: &std::io::Error) -> String {
22014    let errno = match err.raw_os_error() {
22015        Some(n) if n != 0 => n,
22016        _ => return "redirect failed".to_string(),
22017    };
22018    if errno == libc::EINTR {
22019        return "interrupt".to_string(); // c:zerrmsg %e — EINTR special-case
22020    }
22021    let cptr = unsafe { libc::strerror(errno) };
22022    if cptr.is_null() {
22023        return "redirect failed".to_string();
22024    }
22025    let msg = unsafe { std::ffi::CStr::from_ptr(cptr) }.to_string_lossy();
22026    if errno == libc::EIO {
22027        return msg.into_owned(); // c:zerrmsg %e — EIO keeps capitalization
22028    }
22029    // c:zerrmsg %e — `fputc(tulower(errmsg[0])); fputs(errmsg + 1)`.
22030    let mut chars = msg.chars();
22031    match chars.next() {
22032        Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
22033        None => "redirect failed".to_string(),
22034    }
22035}
22036
22037// Host-routed shell ops: ShellExecutor methods invoked by ZshrsHost from the
22038// fusevm VM. Not a port of Src/exec.c (see file-level docs above) — they're
22039// the bridge between fusevm opcodes and ShellExecutor state.
22040// ───────────────────────────────────────────────────────────────────────────
22041impl ShellExecutor {
22042    // ─── Host-routed shell ops (called by ZshrsHost from fusevm) ────────────
22043
22044    /// Apply a single redirection. The current scope's saved-fd vec gets a
22045    /// dup of the original fd so it can be restored by `host_redirect_scope_end`.
22046    /// `op_byte` matches `fusevm::op::redirect_op::*`.
22047    /// Park the CURRENT contents of `fd` in the enclosing redirect
22048    /// scope so `host_redirect_scope_end` can restore them.
22049    ///
22050    /// c:Src/exec.c:2421-2443 — `addfd`'s "starting a new multio" arm:
22051    ///
22052    /// ```c
22053    /// if (!forked && save[fd1] == -2) {
22054    ///     if (fd1 == fd2) save[fd1] = -1;
22055    ///     else {
22056    ///         int fdN = movefd(fd1);
22057    ///         /* fd1 may already be closed here, so
22058    ///          * ignore bad file descriptor error */
22059    ///         if (fdN < 0) { if (errno != EBADF) { … } }
22060    ///         …
22061    ///         save[fd1] = fdN;
22062    ///     }
22063    /// }
22064    /// ```
22065    ///
22066    /// A `save[]` slot of -1 therefore means "fd1 was CLOSED before
22067    /// this redirection", and `fixfds` (c:4522-4532) feeds it to
22068    /// `redup(-1, i)`, whose first arm is `zclose(y)` (c:Src/utils.c:
22069    /// 2047-2048). So zsh CLOSES the fd again at teardown rather than
22070    /// leaving the redirection behind — `print x 3<file; cat <&3`
22071    /// reports "3: bad file descriptor" in zsh.
22072    ///
22073    /// c:Src/exec.c:3978-3986 — a bare `exec` (nullexec==1)
22074    /// "specifically *doesn't* restore the original fd's", so nothing
22075    /// is parked for it.
22076    pub fn save_fd_for_scope(&mut self, fd: i32) {
22077        if self.exec_redirs_permanent {
22078            // Permanent — except inside an in-process subshell, whose
22079            // fd table C forks away (see SubshFdFrame).
22080            crate::ported::exec::SubshFdFrame::touch(fd);
22081            return;
22082        }
22083        // c:2478 — `if (!forked && save[fd1] == -2)`.
22084        if redir_scope_forked(self.redirect_scope_stack.len()) {
22085            return;
22086        }
22087        // c:2425 `movefd(fd1)` — zshrs keeps the original fd open and
22088        // dups it aside instead (the caller's dup2 overwrites it), so
22089        // F_DUPFD stands in for movefd's dup-then-close.
22090        let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
22091        // c:2422-2423 / c:2430-2436 — a closed fd1 parks -1, not a dup.
22092        let slot = if saved >= 0 { saved } else { -1 };
22093        if let Some(top) = self.redirect_scope_stack.last_mut() {
22094            top.push((fd, slot));
22095            mark_saved_fd(slot);
22096        } else if saved >= 0 {
22097            // No scope — leave saved fd open and let the next scope
22098            // reclaim it. (Caller without a scope leaks the dup; this
22099            // matches `WithRedirects` parser construction always wrapping.)
22100            unsafe { libc::close(saved) };
22101        }
22102    }
22103
22104    /// Apply a file-open result to a redirect fd; on error, emit
22105    /// zsh-format diagnostic, set redirect_failed, sink fd to /dev/null.
22106    /// Shared between WRITE/APPEND/READ/CLOBBER arms in
22107    /// host_apply_redirect to keep the error-handling identical.
22108    fn redir_open_or_fail(
22109        fd: i32,
22110        result: std::io::Result<fs::File>,
22111        target: &str,
22112        redirect_failed: &mut bool,
22113    ) -> bool {
22114        match result {
22115            Ok(file) => {
22116                let new_fd = file.into_raw_fd();
22117                unsafe {
22118                    // When the target fd was already closed (e.g. `exec 0<&-;
22119                    // cmd < file`), open() returns the lowest free fd, which is
22120                    // `fd` itself. Then `dup2(fd, fd)` is a no-op: closing new_fd
22121                    // would CLOSE the fd we just opened, AND — since Rust's
22122                    // File::open sets O_CLOEXEC and a no-op dup2 does NOT clear
22123                    // it — an exec'd child would lose the descriptor. So in the
22124                    // reuse case, keep the fd and clear its close-on-exec flag;
22125                    // otherwise dup2 (which clears cloexec on the copy) + close.
22126                    if new_fd != fd {
22127                        libc::dup2(new_fd, fd);
22128                        libc::close(new_fd);
22129                    } else {
22130                        libc::fcntl(fd, libc::F_SETFD, 0);
22131                    }
22132                }
22133                true
22134            }
22135            Err(e) => {
22136                // c:Src/exec.c:3741 — zwarn("%e: %s", errno, fname) with the
22137                // real lineno prefix; redir_errno_msg builds the `%e` errno
22138                // message for all errnos (not just the few hardcoded before).
22139                let msg = redir_errno_msg(&e);
22140                crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
22141                *redirect_failed = true;
22142                // The /dev/null sink keeps a failed scoped redirect
22143                // from leaking the aborted command's output to the
22144                // wrong fd until scope-end restores it. For a bare
22145                // `exec` redirect (permanent, no scope restore) C
22146                // leaves the fd UNTOUCHED — execerr() aborts the
22147                // statement and the original fd 1 keeps flowing
22148                // (A04redirect: `exec >./nonexistent/x` then `echo
22149                // output` still prints). c:Src/exec.c:3735-3742.
22150                let permanent = with_executor(|exec| exec.exec_redirs_permanent);
22151                if !permanent {
22152                    if let Ok(devnull) = fs::OpenOptions::new()
22153                        .read(true)
22154                        .write(true)
22155                        .open("/dev/null")
22156                    {
22157                        let new_fd = devnull.into_raw_fd();
22158                        unsafe {
22159                            if new_fd != fd {
22160                                libc::dup2(new_fd, fd);
22161                                libc::close(new_fd);
22162                            } else {
22163                                libc::fcntl(fd, libc::F_SETFD, 0);
22164                            }
22165                        }
22166                    }
22167                }
22168                false
22169            }
22170        }
22171    }
22172    /// REDIR_ERRWRITE / REDIR_ERRAPP (`>&word`, `>>&word`, `&>`, `&>>`):
22173    /// one file onto BOTH fd 1 and fd 2. c:Src/exec.c:3985-3999 — the open
22174    /// failing is `zwarn("%e: %s", errno, fn->name); execerr();`, i.e. the
22175    /// diagnostic, status 1 and the command abandoned; an empty name fails
22176    /// with ENOENT (`>&''` → `no such file or directory: `).
22177    fn redir_errwrite(&mut self, result: std::io::Result<fs::File>, target: &str) {
22178        match result {
22179            Ok(file) => {
22180                let new_fd = file.into_raw_fd();
22181                unsafe {
22182                    libc::dup2(new_fd, 1);
22183                    libc::dup2(new_fd, 2);
22184                    libc::close(new_fd);
22185                }
22186            }
22187            Err(e) => {
22188                crate::ported::utils::zwarn(&format!("{}: {}", redir_errno_msg(&e), target));
22189                self.set_last_status(1);
22190                self.redirect_failed = true;
22191            }
22192        }
22193    }
22194    /// `host_apply_redirect` — see implementation.
22195    pub fn host_apply_redirect(&mut self, fd: u8, op_byte: u8, target: &str) {
22196        if redir_target_expansion_failed(self) {
22197            return;
22198        }
22199        // c:Src/exec.c:3775-3777 — in a pipeline stage whose input is the
22200        // pipe, mfds[0] already holds it, so `<file` / `<&N` becomes the
22201        // multio's second member (c:2447-2480) instead of replacing it.
22202        if fd == 0
22203            && matches!(op_byte, r::READ | r::DUP_READ)
22204            && target.trim_start_matches('&') != "-"
22205        {
22206            if let Some(seed) = take_pipe_input_seed(self, 0) {
22207                self.host_apply_redirect(0, op_byte, target);
22208                let member = if self.redirect_failed {
22209                    -1
22210                } else {
22211                    unsafe { libc::dup(0) }
22212                };
22213                if member < 0 {
22214                    let _ = crate::ported::utils::zclose(seed);
22215                    return;
22216                }
22217                let member = crate::ported::utils::movefd(member); // c:2465
22218                multios_read_concat(self, 0, vec![seed, member]);
22219                return;
22220            }
22221        }
22222        // `&>` / `&>>` always target both fd 1 and fd 2 regardless of the
22223        // fd byte the parser supplied (the lexer's tokfd clamp makes the
22224        // raw value unreliable for these forms).
22225        let fd: i32 = if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
22226            1
22227        } else {
22228            fd as i32
22229        };
22230        // c:Src/exec.c — for DUP_READ / DUP_WRITE forms (<&N / >&N),
22231        // validate the source fd is open BEFORE the save-and-dup
22232        // dance below. The save's `dup(fd)` reclaims the lowest free
22233        // fd, which on closed-fd reuse would let dup2(src=N, …)
22234        // succeed against the freshly-claimed slot — masking the
22235        // user's "bad file descriptor" error. Check src_fd first.
22236        if matches!(op_byte, r::DUP_READ | r::DUP_WRITE) {
22237            let n_check = target.trim_start_matches('&');
22238            if n_check != "-" {
22239                if let Ok(src_fd) = n_check.parse::<i32>() {
22240                    // c:Src/exec.c:3884-3897 — a descriptor above 9 that
22241                    // the shell knows about is NOT the script's to
22242                    // duplicate:
22243                    //
22244                    //   else if (fn->fd2 > 9 &&
22245                    //            (fn->fd2 <= max_zsh_fd &&
22246                    //             ((fdtable[fn->fd2] != FDT_UNUSED &&
22247                    //               fdtable[fn->fd2] != FDT_EXTERNAL) ||
22248                    //              fn->fd2 == coprocin ||
22249                    //              fn->fd2 == coprocout))) {
22250                    //       fil = -1;
22251                    //       errno = EBADF;
22252                    //
22253                    // `FDT_EXTERNAL` is exempt because that is a
22254                    // descriptor the script itself asked for (`{v}>file`,
22255                    // c:2409) and 0/1/2 (c:Src/init.c:1900). Anything
22256                    // past `max_zsh_fd` is left alone on purpose —
22257                    // c:3886-3891: "the shell doesn't know about it. Just
22258                    // assume the user knows what they're doing."
22259                    //
22260                    // Only the open-ness of the descriptor was checked
22261                    // here, so `>&11` happily duplicated the shell's own
22262                    // history database and `>&10` its log. The `exec
22263                    // N>&-` half of this pair was already ported
22264                    // (fusevm_bridge.rs:7047, c:3830-3835); this half was
22265                    // not, and the ported copy in `ported/exec.rs:11466`
22266                    // is not on the VM's redirection path.
22267                    let shell_owned = src_fd > 9 && {
22268                        let max_fd =
22269                            crate::ported::utils::MAX_ZSH_FD.load(std::sync::atomic::Ordering::Relaxed);
22270                        let cin = crate::ported::modules::clone::coprocin
22271                            .load(std::sync::atomic::Ordering::Relaxed);
22272                        let cout = crate::ported::modules::clone::coprocout
22273                            .load(std::sync::atomic::Ordering::Relaxed);
22274                        src_fd <= max_fd && {
22275                            let kind = crate::ported::utils::fdtable_get(src_fd)
22276                                & crate::ported::zsh_h::FDT_TYPE_MASK;
22277                            (kind != crate::ported::zsh_h::FDT_UNUSED
22278                                && kind != crate::ported::zsh_h::FDT_EXTERNAL)
22279                                // bash hands the coproc fds to the script (`>&${COPROC[1]}`).
22280                                || (!crate::dash_mode::bash_mode() && (src_fd == cin || src_fd == cout))
22281                        }
22282                    };
22283                    if unsafe { libc::fcntl(src_fd, libc::F_GETFD) } == -1 || shell_owned {
22284                        // c:Src/exec.c — zwarn with real lineno prefix.
22285                        crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src_fd));
22286                        self.set_last_status(1);
22287                        self.redirect_failed = true;
22288                        return;
22289                    }
22290                }
22291            }
22292        }
22293        // c:Src/exec.c:3978-3986 — bare `exec` redirects (nullexec==1)
22294        // skip the save entirely: "we specifically *don't* restore the
22295        // original fd's". C's save[] is per-execcmd, so exec's redirs
22296        // never enter an enclosing group's save list either; pushing
22297        // into `redirect_scope_stack.last_mut()` here (the enclosing
22298        // group's scope) made `{ exec 1>&-; … } 2>/dev/null` restore
22299        // stdout at group end — diverging from zsh, which keeps fd 1
22300        // closed for the rest of the script. `save_fd_for_scope` makes
22301        // that call (it parks nothing for a bare `exec` outside an
22302        // in-process subshell).
22303        self.save_fd_for_scope(fd);
22304        // For `&>` / `&>>` also save fd 2 so the scope restores it after
22305        // the body. Otherwise stderr stays redirected past the command.
22306        if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
22307            self.save_fd_for_scope(2);
22308        }
22309        // c:Src/exec.c:3722-3724 + 2447-2480 — MULTIOS split when this
22310        // command's stdout IS the pipeline output. C registers the pipe
22311        // in mfds[1] (`addfd(forked, save, mfds, 1, output, 1, NULL)`)
22312        // BEFORE walking the explicit redirect list, so a write-side
22313        // redirect of fd 1 finds mfds[1] occupied and, with MULTIOS
22314        // set, "split[s] the stream": fd 1 becomes the write end of an
22315        // internal pipe whose reader tees every chunk to BOTH the
22316        // pipeline pipe and the new target. That is why
22317        // `{ echo a; echo b >&2; } 3>&1 1>&2 2>&3 3>&- | cat` sends
22318        // `a` to the pipe (via the tee) AND to stderr — plain dup2
22319        // replacement loses the pipe stream. The scope-depth gate
22320        // mirrors mfds being per-execcmd: only the redirect list
22321        // attached to the stage's own command joins the pipe; nested
22322        // commands inside the body (`{ echo a > f; } | cat`) get a
22323        // fresh "mfds" and replace as usual.
22324        if fd == 1
22325            && self
22326                .pipe_output_scope
22327                .is_some_and(|d| d + 1 == self.redirect_scope_stack.len())
22328            && crate::ported::options::opt_state_get("multios").unwrap_or(true)
22329        {
22330            // Resolve the new write target exactly as the plain arms
22331            // below would, but as a raw fd for the tee.
22332            let new_target_fd: i32 = match op_byte {
22333                r::DUP_WRITE => {
22334                    // Numeric `>&N` only; `-` (close) and `p` (coproc)
22335                    // fall through to the plain arms.
22336                    target
22337                        .trim_start_matches('&')
22338                        .parse::<i32>()
22339                        .map(|src| unsafe { libc::fcntl(src, libc::F_DUPFD_CLOEXEC, 10) })
22340                        .unwrap_or(-1)
22341                }
22342                r::WRITE | r::CLOBBER => fs::File::create(target)
22343                    .map(|f| f.into_raw_fd())
22344                    .unwrap_or(-1),
22345                r::APPEND => fs::OpenOptions::new()
22346                    .create(true)
22347                    .append(true)
22348                    .open(target)
22349                    .map(|f| f.into_raw_fd())
22350                    .unwrap_or(-1),
22351                _ => -1,
22352            };
22353            if new_target_fd >= 0 {
22354                let pipe_dup = unsafe { libc::fcntl(1, libc::F_DUPFD_CLOEXEC, 10) };
22355                match (pipe_dup >= 0).then(os_pipe::pipe) {
22356                    Some(Ok((read_end, write_end))) => {
22357                        // c:Src/exec.c:5222 / Src/utils.c:1990-2012 —
22358                        // `mpipe()` runs both pipe ends through
22359                        // `movefd()`, which lifts any fd below 10 out
22360                        // of the user-visible `>&N` range. Same abort
22361                        // hazard as the BUILTIN_MULTIOS_REDIRECT arm.
22362                        let read_end = unsafe {
22363                            <os_pipe::PipeReader as std::os::unix::io::FromRawFd>::from_raw_fd(
22364                                crate::extensions::fds::movefd(read_end.into_raw_fd()),
22365                            )
22366                        };
22367                        // Splitter: same read-loop shape as
22368                        // BUILTIN_MULTIOS_REDIRECT, with one ordering
22369                        // refinement. C's tee is a forked process
22370                        // (closemn → teeproc) whose wakeup latency lets
22371                        // the stage's DIRECT pipe writes land first —
22372                        // observed zsh output for `{ echo a; echo b >&2; }
22373                        // 3>&1 1>&2 2>&3 3>&- | cat` is `b` then `a`,
22374                        // 15/15 runs. A Rust thread wakes faster than
22375                        // the debug-build VM dispatches the next echo,
22376                        // inverting the order. Emulate the C timing
22377                        // observably: stream to the NEW target (file /
22378                        // stderr dup) immediately, but defer the
22379                        // pipe-bound copy until EOF (or a 64KB cap so a
22380                        // long-running stream still flows instead of
22381                        // growing memory unboundedly).
22382                        let write_now = |tfd: i32, data: &[u8]| {
22383                            let mut off = 0;
22384                            while off < data.len() {
22385                                let w = unsafe {
22386                                    libc::write(
22387                                        tfd,
22388                                        data[off..].as_ptr() as *const libc::c_void,
22389                                        data.len() - off,
22390                                    )
22391                                };
22392                                if w <= 0 {
22393                                    break;
22394                                }
22395                                off += w as usize;
22396                            }
22397                        };
22398                        let handle = crate::signal_thread::spawn(move || {
22399                            let mut rd = read_end;
22400                            let mut buf = [0u8; 8192];
22401                            let mut pipe_pending: Vec<u8> = Vec::new();
22402                            loop {
22403                                match std::io::Read::read(&mut rd, &mut buf) {
22404                                    Ok(0) | Err(_) => break,
22405                                    Ok(n) => {
22406                                        write_now(new_target_fd, &buf[..n]);
22407                                        pipe_pending.extend_from_slice(&buf[..n]);
22408                                        if pipe_pending.len() >= 65536 {
22409                                            write_now(pipe_dup, &pipe_pending);
22410                                            pipe_pending.clear();
22411                                        }
22412                                    }
22413                                }
22414                            }
22415                            write_now(pipe_dup, &pipe_pending);
22416                            unsafe {
22417                                libc::close(pipe_dup);
22418                                libc::close(new_target_fd);
22419                            }
22420                        });
22421                        let write_raw = AsRawFd::as_raw_fd(&write_end);
22422                        unsafe { libc::dup2(write_raw, 1) };
22423                        drop(write_end);
22424                        // A forked command's scope parked no copy of fd 1
22425                        // (save_fd_for_scope), so nothing would release this
22426                        // write end at scope end and the join below would
22427                        // wait forever; a -1 slot closes it there, as the
22428                        // child's exit closes it in C.
22429                        if redir_scope_forked(self.redirect_scope_stack.len()) {
22430                            if let Some(top) = self.redirect_scope_stack.last_mut() {
22431                                top.push((1, -1));
22432                            }
22433                        }
22434                        // Scope-end closes this dup (the last writer once
22435                        // the saved fd 1 is restored) → EOF → join.
22436                        let close_on_end = unsafe { libc::fcntl(1, libc::F_DUPFD_CLOEXEC, 10) };
22437                        if let Some(top) = self.multios_scope_stack.last_mut() {
22438                            top.push((close_on_end, handle));
22439                        } else {
22440                            unsafe { libc::close(close_on_end) };
22441                            let _ = handle.join();
22442                        }
22443                        return;
22444                    }
22445                    _ => unsafe {
22446                        // pipe()/dup failure — fall through to plain replace.
22447                        if pipe_dup >= 0 {
22448                            libc::close(pipe_dup);
22449                        }
22450                        libc::close(new_target_fd);
22451                    },
22452                }
22453            }
22454        }
22455        match op_byte {
22456            r::WRITE => {
22457                // Honor `setopt noclobber`: refuse to overwrite an
22458                // existing regular file unless `>!` / `>|` (CLOBBER).
22459                // zsh internally stores the inverted-name `clobber`
22460                // (default ON); `setopt noclobber` writes
22461                // `clobber=false`. Honor both keys.
22462                //
22463                // c:Src/exec.c:2241-2245 clobber_open recover path:
22464                // after O_EXCL fails, reopen and `if (!S_ISREG(...))
22465                // return fd;` — non-regular targets (char/block-
22466                // special, FIFO, socket) bypass the noclobber check.
22467                // Bug #30 in docs/BUGS.md: this bridge-side check did
22468                // a bare `Path::exists()` and treated `/dev/null` as
22469                // a protected file, breaking `setopt no_clobber; echo
22470                // hi > /dev/null` and every `2> /dev/null` idiom.
22471                // Add a regular-file stat gate that matches the C
22472                // semantic. The canonical clobber_open at
22473                // src/ported/exec.rs:2123 already handles this; the
22474                // bridge duplicates a stripped-down version here and
22475                // must mirror the same check.
22476                let noclobber = opt_state_get("noclobber").unwrap_or(false)
22477                    || !opt_state_get("clobber").unwrap_or(true);
22478                let target_meta = std::fs::metadata(target).ok();
22479                let target_is_regular_file = target_meta
22480                    .as_ref()
22481                    .map(|m| m.file_type().is_file())
22482                    .unwrap_or(false);
22483                // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY permits
22484                // re-using an EMPTY regular file under noclobber: `setopt
22485                // noclobber clobberempty; : >f; echo hi >f` overwrites f.
22486                // The inline bridge check ignored this and errored.
22487                let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
22488                    && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
22489                if noclobber && target_is_regular_file && !clobber_empty_ok {
22490                    eprintln!(
22491                        "{}:{}: file exists: {}",
22492                        shname(),
22493                        crate::ported::lex::lineno(),
22494                        target
22495                    );
22496                    self.set_last_status(1);
22497                    // c:Src/exec.c — set redirect_failed so the scope-end
22498                    // hook (`with_redirects_end` in this file) forces
22499                    // $? to 1 regardless of the still-running command's
22500                    // own exit. Without this the next command (e.g.
22501                    // `echo x` writing to /dev/null below) succeeds
22502                    // and overwrites the redirect-failure status,
22503                    // making noclobber unobservable from $?.
22504                    self.redirect_failed = true;
22505                    // Sink the upcoming command's stdout to /dev/null
22506                    // so we don't leak its output to the terminal.
22507                    // zsh skips the command entirely; we approximate by
22508                    // discarding the output (the redirect target was
22509                    // the user's chosen sink, but with noclobber the
22510                    // file is protected — discarding matches the
22511                    // user's intent better than printing to terminal).
22512                    if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
22513                        let new_fd = file.into_raw_fd();
22514                        unsafe {
22515                            libc::dup2(new_fd, fd);
22516                            libc::close(new_fd);
22517                        }
22518                    }
22519                    return;
22520                }
22521                if !Self::redir_open_or_fail(
22522                    fd,
22523                    fs::File::create(target),
22524                    target,
22525                    &mut self.redirect_failed,
22526                ) {
22527                    self.set_last_status(1);
22528                }
22529            }
22530            r::CLOBBER => {
22531                if !Self::redir_open_or_fail(
22532                    fd,
22533                    fs::File::create(target),
22534                    target,
22535                    &mut self.redirect_failed,
22536                ) {
22537                    self.set_last_status(1);
22538                }
22539            }
22540            r::APPEND => {
22541                // c:Src/exec.c:3924-3927 — `>>` honors NO_CLOBBER+!APPENDCREATE
22542                // by opening O_APPEND|O_WRONLY WITHOUT O_CREAT, so missing
22543                // files yield ENOENT. zsh source:
22544                //   if (!isset(CLOBBER) && !isset(APPENDCREATE) &&
22545                //       !IS_CLOBBER_REDIR(fn->type))
22546                //       mode = O_WRONLY|O_APPEND|O_NOCTTY;
22547                //   else mode = O_WRONLY|O_APPEND|O_CREAT|O_NOCTTY;
22548                // (IS_CLOBBER_REDIR — `>>!`/`>>|` — is currently flattened
22549                // to plain APPEND at compile time in
22550                // src/extensions/compile_zsh.rs:1654-1655, so the bang/pipe
22551                // forms can't be distinguished here yet.)
22552                let noclobber = opt_state_get("noclobber").unwrap_or(false)
22553                    || !opt_state_get("clobber").unwrap_or(true);
22554                let append_create = opt_state_get("appendcreate").unwrap_or(false)
22555                    || opt_state_get("append_create").unwrap_or(false);
22556                let open_result = if noclobber && !append_create {
22557                    fs::OpenOptions::new().append(true).open(target) // no create
22558                } else {
22559                    fs::OpenOptions::new()
22560                        .create(true)
22561                        .append(true)
22562                        .open(target)
22563                };
22564                if !Self::redir_open_or_fail(fd, open_result, target, &mut self.redirect_failed) {
22565                    self.set_last_status(1);
22566                }
22567            }
22568            r::READ => {
22569                if !Self::redir_open_or_fail(
22570                    fd,
22571                    fs::File::open(target),
22572                    target,
22573                    &mut self.redirect_failed,
22574                ) {
22575                    self.set_last_status(1);
22576                }
22577            }
22578            r::READ_WRITE => {
22579                if let Ok(file) = fs::OpenOptions::new()
22580                    .create(true)
22581                    .truncate(false) // <> opens existing-or-new without truncating
22582                    .read(true)
22583                    .write(true)
22584                    .open(target)
22585                {
22586                    let new_fd = file.into_raw_fd();
22587                    unsafe {
22588                        // See redir_open_or_fail: when the opened fd IS the
22589                        // destination (target fd was closed), keep it and clear
22590                        // O_CLOEXEC; else dup2 + close.
22591                        if new_fd != fd {
22592                            libc::dup2(new_fd, fd);
22593                            libc::close(new_fd);
22594                        } else {
22595                            libc::fcntl(fd, libc::F_SETFD, 0);
22596                        }
22597                    }
22598                }
22599            }
22600            r::DUP_READ | r::DUP_WRITE => {
22601                // Target is a numeric fd reference like `&3`. The parser
22602                // strips the `&` prefix before we get here in some paths,
22603                // others retain it — accept both. Also support `-` for
22604                // close-fd (`<&-` / `>&-`) per POSIX. The src_fd
22605                // validity check ran above before the save-and-dup.
22606                let n = target.trim_start_matches('&');
22607                if n == "-" {
22608                    unsafe { libc::close(fd) };
22609                } else if n == "p" {
22610                    // c:Src/exec.c — `<&p` / `>&p` route through the
22611                    // coprocin / coprocout globals. zsh's `coproc CMD`
22612                    // launch publishes those fds; the canonical
22613                    // bin_print / bin_read `-p` arms already consume
22614                    // them. The DUP redirect form is the third
22615                    // consumer: it must dup the coproc fd onto the
22616                    // target slot so the next command's stdin/stdout
22617                    // is wired to the running coprocess. Bug #388.
22618                    let coproc_fd = if op_byte == r::DUP_READ {
22619                        crate::ported::modules::clone::coprocin
22620                            .load(std::sync::atomic::Ordering::Relaxed)
22621                    } else {
22622                        crate::ported::modules::clone::coprocout
22623                            .load(std::sync::atomic::Ordering::Relaxed)
22624                    };
22625                    if coproc_fd < 0 {
22626                        eprintln!("{}:1: no coprocess", shname());
22627                        self.set_last_status(1);
22628                        self.redirect_failed = true;
22629                    } else {
22630                        unsafe {
22631                            libc::dup2(coproc_fd, fd);
22632                        }
22633                    }
22634                } else if let Ok(src_fd) = n.parse::<i32>() {
22635                    unsafe { libc::dup2(src_fd, fd) };
22636                } else if op_byte == r::DUP_WRITE && crate::extensions::dash_mode::dash_faithful() {
22637                    // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash has no
22638                    // `>&file`; a non-number word is "Bad fd number" (status 2).
22639                    crate::ported::utils::zerr(&format!("Bad fd number: {n}"));
22640                    self.set_last_status(crate::extensions::dash_mode::fatal_error_status().unwrap_or(2));
22641                    self.redirect_failed = true;
22642                } else if op_byte == r::DUP_WRITE {
22643                    // c:Src/glob.c:2184-2187 xpandredir — a MERGEOUT
22644                    // word that expands to a non-number becomes
22645                    // REDIR_ERRWRITE: `cmd >& word` opens `word` and
22646                    // routes BOTH fd 1 and fd 2 there. Reached only
22647                    // for dynamic words (`>&$var`); static filenames
22648                    // were converted at compile time.
22649                    self.redir_errwrite(fs::File::create(target), target);
22650                } else {
22651                    // c:Src/glob.c:2185 — MERGEIN non-number:
22652                    // `zerr("file number expected")`.
22653                    crate::ported::utils::zerr("file number expected");
22654                    self.set_last_status(1);
22655                    self.redirect_failed = true;
22656                }
22657            }
22658            r::WRITE_BOTH => {
22659                self.redir_errwrite(fs::File::create(target), target);
22660            }
22661            r::APPEND_BOTH => {
22662                self.redir_errwrite(
22663                    fs::OpenOptions::new().create(true).append(true).open(target),
22664                    target,
22665                );
22666            }
22667            _ => {}
22668        }
22669    }
22670
22671    /// Push a fresh redirect scope. `_count` is informational — the actual
22672    /// saved fds are appended by host_apply_redirect into the top scope.
22673    pub fn host_redirect_scope_begin(&mut self, _count: u8) {
22674        // c:Src/exec.c:3722-3724 — the pipeline child set
22675        // `pipe_output_pending` right after dup2'ing its stdout onto
22676        // the pipe; the FIRST redirect scope opened in that child is
22677        // the stage command's own redirect list (same execcmd as the
22678        // pipe's addfd into mfds[1]). Capture the depth so only THAT
22679        // list's fd-1 write redirects MULTIOS-join the pipe.
22680        if self.pipe_output_pending {
22681            self.pipe_output_pending = false;
22682            self.pipe_output_scope = Some(self.redirect_scope_stack.len());
22683        }
22684        let clean = (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
22685            & crate::ported::zsh_h::ERRFLAG_ERROR)
22686            == 0;
22687        let depth = self.redirect_scope_stack.len();
22688        REDIR_SCOPE_ERRFLAG_CLEAN.with(|v| {
22689            let mut v = v.borrow_mut();
22690            v.truncate(depth);
22691            v.push(clean);
22692        });
22693        self.redirect_scope_stack.push(Vec::new());
22694        self.multios_scope_stack.push(Vec::new());
22695        let serial = SUBLIST_SERIAL.with(|c| c.get());
22696        REDIR_SCOPE_OPENED.with(|c| c.set((serial, self.redirect_scope_stack.len())));
22697        // c:Src/exec.c:3775-3777 — only the stage command's own redirect
22698        // list (same sublist as the pipe install) sees the seeded mfds[0].
22699        if PIPE_INPUT_INSTALLED.with(|c| c.take()) == Some(serial) {
22700            PIPE_INPUT_SCOPE.with(|c| c.set(Some(self.redirect_scope_stack.len())));
22701        }
22702    }
22703
22704    /// Restore every redirect scope opened above `depth`.
22705    ///
22706    /// c:Src/exec.c:4364 — `fixfds(save)` runs on EVERY exit path out of
22707    /// `execcmd_exec`, the one an early `return` takes out of a compound
22708    /// command carrying redirections included (`while … done < f`,
22709    /// `{ …; return } < f`, `if …; then return; fi < f`). zshrs compiles
22710    /// `return` to a Jump past the body's `WithRedirectsEnd`, so that
22711    /// scope stayed on the stack and its saved fds never came back — the
22712    /// caller inherited the callee's redirected fd.
22713    ///
22714    /// gitstatus hit this: `gitstatus.plugin.zsh`'s daemon sources
22715    /// `gitstatus/install`, whose `_gitstatus_install_main` returns out of
22716    /// `while … done <"$gitstatus_dir"/install.info`. fd 0 stayed on
22717    /// install.info instead of reverting to the request FIFO, so
22718    /// `gitstatusd` read EOF on startup and exited ("EOF. Exiting."),
22719    /// `gitstatus_start` failed, `VCS_STATUS_REMOTE_URL` came back empty
22720    /// and powerlevel10k rendered the generic git icon in place of the
22721    /// per-forge one.
22722    pub fn unwind_redirect_scopes_to(&mut self, depth: usize) {
22723        while self.redirect_scope_stack.len() > depth {
22724            self.host_redirect_scope_end();
22725        }
22726    }
22727
22728    /// Pop the top redirect scope, restoring saved fds.
22729    pub fn host_redirect_scope_end(&mut self) {
22730        // c:Src/exec.c — restore saved fds FIRST so the multios
22731        // pipe-write end is released from `fd`, then close our
22732        // tracked close_on_end (the last surviving writer dup), then
22733        // join the splitter thread. If we closed close_on_end before
22734        // restoring saved, `fd` would still hold a pipe writer and
22735        // the thread would block forever waiting for EOF.
22736        if redir_scope_forked(self.redirect_scope_stack.len()) {
22737            FORKED_REDIR_SCOPE.with(|c| c.set(0));
22738        }
22739        if let Some(saved) = self.redirect_scope_stack.pop() {
22740            for (fd, saved_fd) in saved.into_iter().rev() {
22741                // c:Src/exec.c:4530 `redup(save[i], i)` →
22742                // c:Src/utils.c:2047-2048 `if (x < 0) zclose(y);`.
22743                // A -1 slot means the fd was CLOSED before the
22744                // redirection (c:2422-2423, c:2430-2436), so the
22745                // restore is a close, not a dup2 — otherwise the
22746                // redirection outlives the command it was written on
22747                // (`print x 3<file; cat <&3` printed the file instead
22748                // of "3: bad file descriptor").
22749                if saved_fd < 0 {
22750                    unsafe { libc::close(fd) };
22751                    continue;
22752                }
22753                unsafe {
22754                    libc::dup2(saved_fd, fd);
22755                }
22756                // c:Src/utils.c:2049 — redup's `zclose(x)`, which also
22757                // clears the fdtable mark mark_saved_fd set.
22758                crate::ported::utils::zclose(saved_fd);
22759            }
22760        }
22761        if let Some(scope) = self.multios_scope_stack.pop() {
22762            // Close ALL tracked writer dups BEFORE joining any
22763            // thread. When one splitter holds a dup of another's
22764            // pipe write-end (two multios in one scope where a later
22765            // one duped fd 1 while an earlier splitter owned it),
22766            // joining in push order deadlocks: splitter A's EOF
22767            // waits on splitter B's writer dup, which only closes
22768            // after B's thread exits — blocked behind A's join.
22769            let mut handles = Vec::with_capacity(scope.len());
22770            for (write_fd, handle) in scope {
22771                if write_fd >= 0 {
22772                    unsafe {
22773                        libc::close(write_fd);
22774                    }
22775                }
22776                handles.push(handle);
22777            }
22778            for handle in handles {
22779                let _ = handle.join();
22780            }
22781        }
22782        // The scope that captured the pipeline-output marker is gone;
22783        // deeper-nested future scopes must not re-match its depth.
22784        if self.pipe_output_scope == Some(self.redirect_scope_stack.len()) {
22785            self.pipe_output_scope = None;
22786        }
22787        if PIPE_INPUT_SCOPE.with(|c| c.get()).is_some_and(|d| d > self.redirect_scope_stack.len()) {
22788            PIPE_INPUT_SCOPE.with(|c| c.set(None));
22789        }
22790        // c:Src/exec.c:4445-4452 — the command is done: drop its xtrace copy.
22791        let depth = self.redirect_scope_stack.len();
22792        while let Some(fd) = XTRERR_COPIES.with(|v| {
22793            let mut v = v.borrow_mut();
22794            if v.last().is_some_and(|&(d, _)| d > depth) { v.pop().map(|(_, fd)| fd) } else { None }
22795        }) {
22796            xtrerr_flush(); // c:4448 fclose(newxtrerr)
22797            crate::ported::utils::xtrerr.store(libc::STDERR_FILENO, std::sync::atomic::Ordering::Relaxed); // c:4449
22798            let _ = crate::ported::utils::zclose(fd); // c:4451
22799        }
22800    }
22801
22802    /// Set up `content` as stdin (fd 0) for the next command.
22803    /// Used by `Op::HereDoc(idx)` and `Op::HereString`.
22804    ///
22805    /// c:Src/exec.c:4655 getherestr — C writes the body to a TEMP
22806    /// FILE (gettempfile → write_loop → close → reopen O_RDONLY →
22807    /// unlink), NOT a pipe. The previous pipe+writer-thread shape
22808    /// SIGPIPE'd the whole shell when the consumer never read the
22809    /// body (`: <<< ${(F)x/y}` — D04parameter chunk 211, flaky
22810    /// rc=141): the redirect-scope teardown closed the read end
22811    /// while the detached thread was still in write_all, and the
22812    /// shell's SIGPIPE disposition is SIG_DFL. A temp file has no
22813    /// reader/writer coupling — matching C exactly, including
22814    /// lseek-ability of fd 0, which pipes don't give.
22815    pub fn host_set_pending_stdin(&mut self, content: String) {
22816        // c:Src/exec.c:3775-3777 — a here-document or here-string on a
22817        // pipeline stage fed by a pipe joins the pipe as mfds[0]'s second
22818        // member (c:2447-2480): `cat o1 | cat <<<hs` reads o1 then hs.
22819        if let Some(seed) = take_pipe_input_seed(self, 0) {
22820            self.host_set_pending_stdin(content);
22821            let member = unsafe { libc::dup(0) };
22822            if member < 0 {
22823                let _ = crate::ported::utils::zclose(seed);
22824                return;
22825            }
22826            let member = crate::ported::utils::movefd(member); // c:2465
22827            multios_read_concat(self, 0, vec![seed, member]);
22828            return;
22829        }
22830        // c:4673 — `gettempfile(NULL, 1, &s)`.
22831        let mut tmp = std::env::temp_dir();
22832        tmp.push(format!(
22833            "zshrs-herestr-{}-{:x}",
22834            std::process::id(),
22835            std::time::SystemTime::now()
22836                .duration_since(std::time::UNIX_EPOCH)
22837                .map(|d| d.as_nanos())
22838                .unwrap_or(0)
22839        ));
22840        // c:Src/exec.c:4719 — `unmetafy(t, &len);` runs BEFORE
22841        // `write_loop(fd, t, len)`: what lands in the temp file is the RAW
22842        // byte stream, never zshrs's metafied `Meta` + `byte ^ 32` pairs.
22843        // Writing `content.as_bytes()` leaked the metafication onto fd 0,
22844        // so `read -d $'\xa0' <<<$'first\xa0second'` saw `c2 83 c2 80`
22845        // where every other writer (`print`, a pipe) emits the single `a0`.
22846        let raw = crate::ported::utils::unmetafy_str(&content); // c:4719
22847                                                                // c:4675 — `write_loop(fd, t, len); close(fd);`
22848        if std::fs::write(&tmp, &raw).is_err() {
22849            return; // c:4674 — tempfile failure → no redirect
22850        }
22851        // c:Src/utils.c gettempfile → mkstemp creates the temp file mode
22852        // 0600 IGNORING the umask, so the O_RDONLY reopen below always
22853        // succeeds. `std::fs::write` honors the umask, so under `umask
22854        // 0777` the file landed mode 0000 and the reopen failed with
22855        // EACCES — `cat <<<x` then read empty stdin. Force 0600 to match
22856        // mkstemp's umask-independent permissions.
22857        let _ = std::fs::set_permissions(
22858            &tmp,
22859            <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
22860        );
22861        // c:4677 — `fd = open(s, O_RDONLY | O_NOCTTY);`
22862        let file = match std::fs::File::open(&tmp) {
22863            Ok(f) => f,
22864            Err(_) => {
22865                let _ = std::fs::remove_file(&tmp);
22866                return;
22867            }
22868        };
22869        // c:4678 — `unlink(s);` — fd stays valid, name disappears.
22870        let _ = std::fs::remove_file(&tmp);
22871        // c:Src/exec.c:2478 — `if (!forked && save[fd1] == -2)`.
22872        let saved = if redir_scope_forked(self.redirect_scope_stack.len()) {
22873            -1
22874        } else {
22875            unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD_CLOEXEC, 10) }
22876        };
22877        if saved >= 0 {
22878            if let Some(top) = self.redirect_scope_stack.last_mut() {
22879                top.push((libc::STDIN_FILENO, saved));
22880                mark_saved_fd(saved);
22881            } else {
22882                unsafe { libc::close(saved) };
22883            }
22884        }
22885        // c:Src/utils.c:redup — `if (x != y) { dup2(x, y); zclose(x); }`.
22886        // When fd 0 was already CLOSED before this heredoc runs,
22887        // `File::open` returns the lowest free descriptor, which is 0
22888        // itself — so `read_fd == STDIN_FILENO`. C's redup skips both the
22889        // dup2 (a no-op for equal fds) AND the close in that case, leaving
22890        // the just-opened temp file installed at fd 0. Unconditionally
22891        // dropping the File here closed that fd back to nothing, so an
22892        // external NULLCMD (`cat`) inherited a closed fd 0 and failed with
22893        // EBADF (`cat <<EOF` inside `$(...)` when exec 0<&- closed stdin).
22894        let read_fd = AsRawFd::as_raw_fd(&file);
22895        if read_fd != libc::STDIN_FILENO {
22896            // dup2 installs a fresh fd 0 with FD_CLOEXEC clear (dup2 never
22897            // copies the flag), then we close the CLOEXEC-tagged source.
22898            unsafe { libc::dup2(read_fd, libc::STDIN_FILENO) };
22899            drop(file); // c:redup zclose(x)
22900        } else {
22901            // File::open reused fd 0. Rust opens with O_CLOEXEC, so fd 0
22902            // now carries FD_CLOEXEC and would be auto-closed when an
22903            // external NULLCMD (`cat`) exec's — the child then reads a
22904            // closed fd 0 and fails with EBADF. zsh opens the heredoc temp
22905            // via `open(s, O_RDONLY|O_NOCTTY)` (no CLOEXEC), so its child
22906            // inherits the fd. Clear the flag to match, then keep fd 0 open
22907            // (redup's x==y arm: no dup2, no close).
22908            unsafe {
22909                let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFD);
22910                if flags >= 0 {
22911                    libc::fcntl(libc::STDIN_FILENO, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
22912                }
22913            }
22914            std::mem::forget(file);
22915        }
22916    }
22917
22918    /// Spawn an external command using zshrs's full dispatch logic
22919    /// (intercepts, command_hash, redirect handling). Used by
22920    /// `ZshrsHost::exec` so the bytecode VM's `Op::Exec` and
22921    /// `Op::CallFunction` external fallback get the same semantics as
22922    /// the tree-walker's `execute_external` rather than a plain
22923    /// `Command::new` shortcut. Returns the exit status.
22924    pub fn host_exec_external(&mut self, args: &[String]) -> i32 {
22925        // c:Src/exec.c:772-776 — BINF_DASH reaches `execute()` only; every
22926        // other route below runs the command without it.
22927        let exec_dash = take_exec_carrier();
22928        PREFORK_CUT.with(|c| c.set(false)); // the words are complete
22929        // Native p10k API: the `p10k(){ zshrs-p10k-api "$@" }` stub's
22930        // body lands here (the name is neither function nor builtin).
22931        // Route into the engine instead of a PATH miss.
22932        if let Some(name) = args.first() {
22933            if let Some(status) = crate::p10k::maybe_intercept_command(name, &args[1..]) {
22934                self.set_last_status(status);
22935                return status;
22936            }
22937        }
22938        // If a glob expansion in this command's argv triggered the
22939        // nomatch error path, suppress the actual exec and return
22940        // status 1 — mirrors zsh's command-aborted-on-glob-error
22941        // behaviour. The flag is reset BEFORE returning so the next
22942        // command starts clean.
22943        //
22944        // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH sets
22945        // ERRFLAG_ERROR but C's execlist clears the bit per-sublist
22946        // so subsequent commands run. Symmetric with the builtin
22947        // dispatcher's clear at fusevm_bridge.rs:299 — clear it here
22948        // too at the external-command post-command-boundary.
22949        consume_tilde_globsubst_carrier();
22950        if self.current_command_glob_failed.get() {
22951            self.current_command_glob_failed.set(false);
22952            if take_cmd_name_glob_failed() {
22953                self.set_last_status(1);
22954                return 1;
22955            }
22956            crate::ported::utils::errflag.fetch_and(
22957                !crate::ported::zsh_h::ERRFLAG_ERROR,
22958                std::sync::atomic::Ordering::Relaxed,
22959            );
22960            self.set_last_status(1);
22961            return 1;
22962        }
22963        // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the
22964        // NOMATCH gate above, same external-path semantics (skip
22965        // command, `no match`, clear ERRFLAG so the next sublist
22966        // runs).
22967        if consume_badcshglob() {
22968            crate::ported::utils::errflag.fetch_and(
22969                !crate::ported::zsh_h::ERRFLAG_ERROR,
22970                std::sync::atomic::Ordering::Relaxed,
22971            );
22972            self.set_last_status(1);
22973            return 1;
22974        }
22975        let Some((cmd, rest)) = args.split_first() else {
22976            return 0;
22977        };
22978        // Empty command name (e.g. result of an empty `$(false)`
22979        // command-sub being the only word) — zsh: no command runs,
22980        // exit status preserved from prior step. Was hitting the
22981        // "command not found: " path with empty name.
22982        if cmd.is_empty() && rest.is_empty() {
22983            return self.last_status();
22984        }
22985        let rest_vec: Vec<String> = rest.to_vec();
22986        // Update `$_` with the just-arriving argv so the next command
22987        // reads `_=<last_arg>`. Mirrors C zsh's writeback in
22988        // `execcmd_exec` (Src/exec.c). Per `args.last()` semantics,
22989        // when invoked as `cmd a b c`, `$_` becomes "c" — for a bare
22990        // command with no args, `$_` becomes the command name itself.
22991        crate::ported::params::set_zunderscore(args);
22992
22993        // Builtins not in fusevm's name→id table fall through to
22994        // host.exec. Catch them here before the OS-level exec attempts
22995        // to spawn a non-existent binary.
22996        match cmd.as_str() {
22997            "sched" => return dispatch_builtin("sched", rest_vec.clone()),
22998            "echotc" => return dispatch_builtin("echotc", rest_vec.clone()),
22999            "echoti" => return dispatch_builtin("echoti", rest_vec.clone()),
23000            "zpty" => return dispatch_builtin("zpty", rest_vec.clone()),
23001            "ztcp" => return dispatch_builtin("ztcp", rest_vec.clone()),
23002            "zsocket" => {
23003                // c:Src/Modules/socket.c:276 BUILTIN spec — BUILTINS["zsocket"]
23004                // optstr "ad:ltv" parsed by execbuiltin.
23005                return dispatch_builtin("zsocket", rest_vec.clone());
23006            }
23007            "private" => {
23008                // c:Src/Modules/param_private.c:217 — bin_private via
23009                // BUILTINS["private"]. The autoload require_module
23010                // (exec.c:2700-2717) fires inside
23011                // dispatch_builtin_raw, the chokepoint for all routes.
23012                return dispatch_builtin("private", rest_vec.clone());
23013            }
23014            "zformat" => return dispatch_builtin("zformat", rest_vec.clone()),
23015            "zregexparse" => return dispatch_builtin("zregexparse", rest_vec.clone()),
23016            // `unalias`/`unhash`/`unfunction` share `bin_unhash` but
23017            // each carries its own funcid (BIN_UNALIAS / BIN_UNHASH /
23018            // BIN_UNFUNCTION) — dispatch_builtin handles the BUILTINS
23019            // lookup + funcid propagation via execbuiltin.
23020            "unalias" | "unhash" | "unfunction" => {
23021                return dispatch_builtin(cmd.as_str(), rest_vec.clone());
23022            }
23023            // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
23024            // functions — implemented natively in Rust so `autoload -Uz zmv`
23025            // works without shipping the function source (and without the
23026            // fpath source hanging the parser). The stub guard keeps them command-not-found until autoloaded, exactly like zsh;
23027            // an un-guarded arm ran them for bare `zmv`, diverging from
23028            // `zsh -f; zmv` → "command not found: zmv".
23029            // Only a still-undefined autoload stub takes the native port; a
23030            // defined body runs as the shell function (see
23031            // `autoload_stub_pending`).
23032            "zmv" if autoload_stub_pending("zmv") => {
23033                return crate::extensions::ext_builtins::zmv(&rest_vec, "mv")
23034            }
23035            "zcp" if autoload_stub_pending("zcp") => {
23036                return crate::extensions::ext_builtins::zmv(&rest_vec, "cp")
23037            }
23038            "zln" if autoload_stub_pending("zln") => {
23039                return crate::extensions::ext_builtins::zmv(&rest_vec, "ln")
23040            }
23041            "zcalc" if autoload_stub_pending("zcalc") => {
23042                return crate::extensions::ext_builtins::zcalc(&rest_vec)
23043            }
23044            "zselect" => {
23045                // Route through canonical dispatch_builtin which goes
23046                // via execbuiltin → BUILTINS["zselect"] (zselect.c:272).
23047                return dispatch_builtin("zselect", rest_vec.clone());
23048            }
23049            "cap" => return dispatch_builtin("cap", rest_vec.clone()),
23050            "getcap" => return dispatch_builtin("getcap", rest_vec.clone()),
23051            "setcap" => return dispatch_builtin("setcap", rest_vec.clone()),
23052            "yes" => return self.builtin_yes(&rest_vec),
23053            "nl" => return self.builtin_nl(&rest_vec),
23054            "env" => return self.builtin_env(&rest_vec),
23055            "printenv" => return self.builtin_printenv(&rest_vec),
23056            "tty" => return self.builtin_tty(&rest_vec),
23057            // c:Src/Modules/files.c:806 — BUILTINS["chgrp"] with
23058            // BIN_CHGRP funcid + "hRs" optstr.
23059            "chgrp" => return dispatch_builtin("chgrp", rest_vec.clone()),
23060            "nproc" => return self.builtin_nproc(&rest_vec),
23061            "expr" => return self.builtin_expr(&rest_vec),
23062            "sha256sum" => return self.builtin_sha256sum(&rest_vec),
23063            "base64" => return self.builtin_base64(&rest_vec),
23064            "tac" => return self.builtin_tac(&rest_vec),
23065            "expand" => return self.builtin_expand(&rest_vec),
23066            "unexpand" => return self.builtin_unexpand(&rest_vec),
23067            "paste" => return self.builtin_paste(&rest_vec),
23068            "fold" => return self.builtin_fold(&rest_vec),
23069            "shuf" => return self.builtin_shuf(&rest_vec),
23070            "comm" => return self.builtin_comm(&rest_vec),
23071            "cksum" => return self.builtin_cksum(&rest_vec),
23072            "factor" => return self.builtin_factor(&rest_vec),
23073            "tsort" => return self.builtin_tsort(&rest_vec),
23074            "sum" => return self.builtin_sum(&rest_vec),
23075            "mkfifo" => return self.builtin_mkfifo(&rest_vec),
23076            "link" => return self.builtin_link(&rest_vec),
23077            "unlink" => return self.builtin_unlink(&rest_vec),
23078            "dircolors" => return self.builtin_dircolors(&rest_vec),
23079            "groups" => return self.builtin_groups(&rest_vec),
23080            "arch" => return self.builtin_arch(&rest_vec),
23081            "nice" => return self.builtin_nice(&rest_vec),
23082            "logname" => return self.builtin_logname(&rest_vec),
23083            "tput" => return self.builtin_tput(&rest_vec),
23084            "users" => return self.builtin_users(&rest_vec),
23085            // "sync" => return self.bin_sync(&rest_vec),
23086            "zbuild" => return self.builtin_zbuild(&rest_vec),
23087            // `zf_*` aliases from `zsh/files` (Src/Modules/files.c
23088            // BUILTIN table at line 816-824). The C source binds
23089            // both unprefixed (`chmod`) and prefixed (`zf_chmod`)
23090            // names to the SAME `bin_chmod` etc. handlers — the
23091            // prefixed forms exist so a script can portably reach
23092            // the builtin even when a function or alias has shadowed
23093            // the bare name. Each arm routes through the canonical
23094            // zf_* aliases route through canonical BUILTINS entries
23095            // (files.c:816-824) — execbuiltin parses each fn's optstr
23096            // automatically.
23097            "mkdir" | "zf_mkdir" | "zf_rm" | "zf_rmdir" | "zf_chmod" | "zf_chown" | "zf_chgrp"
23098            | "zf_ln" | "zf_mv" | "zf_sync"
23099                // `--zsh` parity gate: zsh -fc has zsh/files UNLOADED
23100                // — bare `mkdir` is /bin/mkdir (so `command mkdir -p`
23101                // honors the system flag set; zconvey.plugin.zsh:44
23102                // got "File exists" from the in-process bin_mkdir
23103                // that this arm intercepted) and `zf_*` names are
23104                // command-not-found 127 until `zmodload zsh/files`.
23105                // Fall through to the external/exec path in --zsh
23106                // mode; default zshrs mode keeps the anti-fork
23107                // intercept.
23108                if !crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) =>
23109            {
23110                return dispatch_builtin(cmd.as_str(), rest_vec.clone());
23111            }
23112            // `zstat` — port of zsh/stat module (Src/Modules/stat.c
23113            // BUILTIN("zstat", …)). Returns file metadata as
23114            // `field value` pairs / an assoc / a plus-separated
23115            // list depending on flags. zsh ALSO registers `stat`
23116            // bound to the same handler, but that name conflicts
23117            // with the system `stat(1)` binary (every script that
23118            // calls `stat -f '%Lp' …` would break). zsh resolves
23119            // this through opt-in `zmodload`; zshrs's modules are
23120            // statically linked so we keep `stat` routing to the
23121            // external command and only intercept the unambiguous
23122            // `zstat` name.
23123            "zstat" => {
23124                // Canonical bin_stat per stat.c:638 via BUILTINS["zstat"].
23125                return dispatch_builtin("zstat", rest_vec.clone());
23126            }
23127            _ => {}
23128        }
23129
23130        // AOP intercepts: when an `intercept :before/:around/:after foo` block
23131        // is registered, dynamic-command-name dispatch must consult it before
23132        // spawning. Without this, `cmd=ls; $cmd` bypasses every intercept that
23133        // a literal `ls` would trigger. The full_cmd string mirrors what the
23134        // tree-walker era passed (cmd + args joined by space) so existing
23135        // pattern matchers continue to work.
23136        if !self.intercepts.is_empty() {
23137            let full_cmd = if rest_vec.is_empty() {
23138                cmd.clone()
23139            } else {
23140                format!("{} {}", cmd, rest_vec.join(" "))
23141            };
23142            if let Some(intercept_result) = self.run_intercepts(cmd, &full_cmd, &rest_vec) {
23143                return intercept_result.unwrap_or(127);
23144            }
23145        }
23146
23147        // User-defined function lookup before OS-level exec. zsh's
23148        // dynamic-command-name dispatch (`cmd=hook1; $cmd`) checks
23149        // the function table FIRST — without this, `$f` for a
23150        // function-name `f` was always falling through to
23151        // `execute_external` and erroring "command not found".
23152        // Plugin code uses this pattern constantly:
23153        //   for f in "${precmd_functions[@]}"; do "$f"; done
23154        if self.function_exists(cmd) {
23155            if let Some(status) = self.dispatch_function_call(cmd, &rest_vec) {
23156                return status;
23157            }
23158        }
23159
23160        EXEC_DASH.with(|c| c.set(exec_dash));
23161        self.execute_external(cmd, &rest_vec, &[]).unwrap_or(127)
23162    }
23163}
23164
23165#[cfg(test)]
23166mod word_assemble_tests {
23167    use super::{word_assemble_plan9, Value};
23168
23169    fn arr(xs: &[&str]) -> Value {
23170        Value::array(xs.iter().map(|s| Value::str(*s)).collect())
23171    }
23172    fn out(v: Value) -> Vec<String> {
23173        match v {
23174            Value::Array(items) => items.iter().map(|i| i.to_str()).collect(),
23175            other => vec![other.to_str()],
23176        }
23177    }
23178
23179    // The edge-tracking fold (c:Src/subst.c:4316-4437). A naive per-segment
23180    // operator gets s,p,p and p,s,p wrong because it forgets which trailing
23181    // elements are still the "growing edge". These pin the exact zsh output
23182    // (verified against zsh 5.9) for every plan9/splice permutation.
23183    #[test]
23184    fn plan9_then_splice() {
23185        // "${(@)^a}${(@)b}" a=(1 2) b=(A B) -> 1A 2A B
23186        let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[true, false]);
23187        assert_eq!(out(r), vec!["1A", "2A", "B"]);
23188    }
23189    #[test]
23190    fn splice_then_plan9() {
23191        // "${(@)a}${(@)^b}" -> 1 2A 2B
23192        let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[false, true]);
23193        assert_eq!(out(r), vec!["1", "2A", "2B"]);
23194    }
23195    #[test]
23196    fn plan9_then_plan9_is_full_cross() {
23197        let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[true, true]);
23198        assert_eq!(out(r), vec!["1A", "1B", "2A", "2B"]);
23199    }
23200    #[test]
23201    fn splice_then_splice() {
23202        let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[false, false]);
23203        assert_eq!(out(r), vec!["1", "2A", "B"]);
23204    }
23205    #[test]
23206    fn plan9_splice_plan9_growing_edge() {
23207        // "${(@)^a}${(@)b}${(@)^c}" -> 1A 2A Bp Bq  (only B, the edge, distributes)
23208        let r = word_assemble_plan9(
23209            &[arr(&["1", "2"]), arr(&["A", "B"]), arr(&["p", "q"])],
23210            &[true, false, true],
23211        );
23212        assert_eq!(out(r), vec!["1A", "2A", "Bp", "Bq"]);
23213    }
23214    #[test]
23215    fn splice_plan9_plan9_keeps_frozen_prefix() {
23216        // "${(@)a}${(@)^b}${(@)^c}" -> 1 2Ap 2Aq 2Bp 2Bq  (1 stays frozen)
23217        let r = word_assemble_plan9(
23218            &[arr(&["1", "2"]), arr(&["A", "B"]), arr(&["p", "q"])],
23219            &[false, true, true],
23220        );
23221        assert_eq!(out(r), vec!["1", "2Ap", "2Aq", "2Bp", "2Bq"]);
23222    }
23223    #[test]
23224    fn empty_plan9_array_deletes_word() {
23225        // "${(@)^a}${(@)b}" with a=() -> word deleted
23226        let r = word_assemble_plan9(&[Value::array(vec![]), arr(&["A", "B"])], &[true, false]);
23227        assert!(out(r).is_empty(), "plan9 empty array deletes the word");
23228    }
23229    #[test]
23230    fn leading_literal_then_mixed() {
23231        // "X${(@)^a}${(@)b}" -> X1A X2A B
23232        let r = word_assemble_plan9(
23233            &[Value::str("X"), arr(&["1", "2"]), arr(&["A", "B"])],
23234            &[false, true, false],
23235        );
23236        assert_eq!(out(r), vec!["X1A", "X2A", "B"]);
23237    }
23238
23239    // c:Src/subst.c:4261 — a NON-plan9 empty expansion collapses to the empty
23240    // string and the word SURVIVES; only plan9 (c:4362 `uremnode`) deletes it.
23241    // A leading empty segment used to leave `words` empty, so every following
23242    // segment cross-multiplied against nothing and the word vanished.
23243    // Verified against zsh 5.9:
23244    //     n=""; a=(x y z); print -rl -- $n${^a}      -> x / y / z
23245    #[test]
23246    fn leading_empty_splice_keeps_word_and_crosses() {
23247        let r = word_assemble_plan9(
23248            &[Value::array(vec![]), arr(&["x", "y", "z"])],
23249            &[false, true],
23250        );
23251        assert_eq!(out(r), vec!["x", "y", "z"]);
23252    }
23253
23254    //     n=""; a=(x y z); print -rl -- $n"pre"${^a} -> prex / prey / prez
23255    #[test]
23256    fn leading_empty_then_literal_then_plan9() {
23257        let r = word_assemble_plan9(
23258            &[
23259                Value::array(vec![]),
23260                Value::str("pre"),
23261                arr(&["x", "y", "z"]),
23262            ],
23263            &[false, false, true],
23264        );
23265        assert_eq!(out(r), vec!["prex", "prey", "prez"]);
23266    }
23267
23268    //     n=""; a=(x y z); print -rl -- $n$a${^a} -> x / y / zx / zy / zz
23269    // The leading empty must not consume the splice's first element.
23270    #[test]
23271    fn leading_empty_does_not_eat_first_splice_element() {
23272        let r = word_assemble_plan9(
23273            &[
23274                Value::array(vec![]),
23275                arr(&["x", "y", "z"]),
23276                arr(&["x", "y", "z"]),
23277            ],
23278            &[false, false, true],
23279        );
23280        assert_eq!(out(r), vec!["x", "y", "zx", "zy", "zz"]);
23281    }
23282
23283    //     n=""; e=(); print -rl -- $n${^e} -> nothing (plan9 empty still wins)
23284    #[test]
23285    fn leading_empty_then_empty_plan9_still_deletes_word() {
23286        let r = word_assemble_plan9(
23287            &[Value::array(vec![]), Value::array(vec![])],
23288            &[false, true],
23289        );
23290        assert!(
23291            out(r).is_empty(),
23292            "plan9 empty array still deletes the word"
23293        );
23294    }
23295}
23296
23297
23298// ═══════════════════════════════════════════════════════════════════════
23299// Compiled-arithmetic value plumbing.
23300//
23301// !!! WARNING: RUST-ONLY HELPERS !!!
23302//
23303// C has no equivalent of these: `Src/math.c` keeps every operand in an
23304// `mnumber` on its own value stack, so an operator arm reads `a.u.l` /
23305// `b.u.d` directly and never converts anything. zshrs's compiled path
23306// keeps operands as `fusevm::Value` on the VM stack instead, so the two
23307// representations have to meet at each builtin boundary. `arith_div` /
23308// `arith_mod` / `arith_pow` below ARE line-by-line ports of their C
23309// arms; only the `Value` ↔ `mnumber` conversion is Rust-only.
23310// ═══════════════════════════════════════════════════════════════════════
23311
23312/// Pop one VM value as an `mnumber`.
23313///
23314/// `Bool` appears because the comparison ops (`Op::NumLt` and friends)
23315/// push one; c:Src/math.c:1200 gives those operators integer 1/0, so
23316/// that is what a `Bool` becomes here. A `Str` reaching this point is a
23317/// value the compiler could not prove numeric; c:Src/math.c:337
23318/// `getmathparam` coerces such a string through the math evaluator,
23319/// which `matheval_str_operand` mirrors.
23320pub(crate) fn arith_pop_mnumber(vm: &mut fusevm::VM) -> crate::ported::zsh_h::mnumber {
23321    use crate::ported::zsh_h::{mnumber, MN_FLOAT, MN_INTEGER};
23322    match vm.pop() {
23323        fusevm::Value::Int(n) => mnumber { l: n, d: 0.0, type_: MN_INTEGER },
23324        fusevm::Value::Float(f) => mnumber { l: 0, d: f, type_: MN_FLOAT },
23325        fusevm::Value::Bool(b) => mnumber { l: i64::from(b), d: 0.0, type_: MN_INTEGER },
23326        other => {
23327            let s = other.to_str();
23328            if s.is_empty() {
23329                // c:Src/math.c:345 — an unset/empty parameter is 0.
23330                mnumber { l: 0, d: 0.0, type_: MN_INTEGER }
23331            } else {
23332                // c:Src/params.c:2646-2647 `matheval(getstrvalue(v))` —
23333                // the default arm of getnumvalue, which re-lexes a
23334                // scalar's string form. A math error reads as 0, as C's
23335                // does after `zerr`.
23336                crate::ported::math::matheval(&s).unwrap_or(mnumber {
23337                    l: 0,
23338                    d: 0.0,
23339                    type_: MN_INTEGER,
23340                })
23341            }
23342        }
23343    }
23344}
23345
23346/// The inverse: an `mnumber` back onto the VM stack.
23347pub(crate) fn arith_push(n: crate::ported::zsh_h::mnumber) -> fusevm::Value {
23348    if n.type_ == crate::ported::zsh_h::MN_FLOAT {
23349        fusevm::Value::Float(n.d)
23350    } else {
23351        fusevm::Value::Int(n.l)
23352    }
23353}
23354
23355/// c:Src/math.c:1144 `notzero` — an INTEGER zero is a math error; a
23356/// float zero is not (it divides to an infinity).
23357pub(crate) fn mn_notzero(a: crate::ported::zsh_h::mnumber) -> bool {
23358    if a.type_ & crate::ported::zsh_h::MN_INTEGER != 0 && a.l == 0 {
23359        crate::ported::utils::zerr("division by zero"); // c:1147
23360        return false; // c:1148
23361    }
23362    true // c:1150
23363}
23364
23365/// Truthiness of an `mnumber` — c:Src/math.c:1200's `BOOL` reading,
23366/// where the operand is compared against zero in its own type.
23367pub(crate) fn mn_is_true(a: crate::ported::zsh_h::mnumber) -> bool {
23368    if a.type_ == crate::ported::zsh_h::MN_FLOAT {
23369        a.d != 0.0
23370    } else {
23371        a.l != 0
23372    }
23373}
23374
23375/// c:Src/math.c:1178-1183 — the two operands of an `OP_A2` operator are
23376/// promoted to float if EITHER is float; the result carries that type.
23377pub(crate) fn mn_promote(
23378    a: crate::ported::zsh_h::mnumber,
23379    b: crate::ported::zsh_h::mnumber,
23380) -> (crate::ported::zsh_h::mnumber, crate::ported::zsh_h::mnumber, u32) {
23381    use crate::ported::zsh_h::{mnumber, MN_FLOAT};
23382    if a.type_ == MN_FLOAT || b.type_ == MN_FLOAT {
23383        let af = if a.type_ == MN_FLOAT { a.d } else { a.l as f64 };
23384        let bf = if b.type_ == MN_FLOAT { b.d } else { b.l as f64 };
23385        (
23386            mnumber { l: 0, d: af, type_: MN_FLOAT },
23387            mnumber { l: 0, d: bf, type_: MN_FLOAT },
23388            MN_FLOAT,
23389        )
23390    } else {
23391        (a, b, a.type_)
23392    }
23393}
23394
23395/// Port of the `DIV` / `DIVEQ` arm, c:Src/math.c:1237-1257.
23396pub(crate) fn arith_div(
23397    a: crate::ported::zsh_h::mnumber,
23398    b: crate::ported::zsh_h::mnumber,
23399) -> crate::ported::zsh_h::mnumber {
23400    use crate::ported::zsh_h::{mnumber, MN_FLOAT};
23401    let (a, b, ctype) = mn_promote(a, b);
23402    let mut c = mnumber { l: 0, d: 0.0, type_: ctype };
23403    if !mn_notzero(b) {
23404        return c; // c:1240
23405    }
23406    if ctype == MN_FLOAT {
23407        c.d = a.d / b.d; // c:1242
23408    } else if b.l == -1 {
23409        // c:1244-1251 — avoid the INT_MIN / -1 trap by treating it as
23410        // multiplication, exactly as C does.
23411        c.l = a.l.wrapping_neg(); // c:1253
23412    } else {
23413        c.l = a.l / b.l; // c:1255
23414    }
23415    c
23416}
23417
23418/// Port of the `MOD` / `MODEQ` arm, c:Src/math.c:1258-1272.
23419pub(crate) fn arith_mod(
23420    a: crate::ported::zsh_h::mnumber,
23421    b: crate::ported::zsh_h::mnumber,
23422) -> crate::ported::zsh_h::mnumber {
23423    use crate::ported::zsh_h::{mnumber, MN_FLOAT};
23424    let (a, b, ctype) = mn_promote(a, b);
23425    let mut c = mnumber { l: 0, d: 0.0, type_: ctype };
23426    if !mn_notzero(b) {
23427        return c; // c:1261
23428    }
23429    if ctype == MN_FLOAT {
23430        c.d = a.d % b.d; // c:1268 fmod
23431    } else if b.l == -1 {
23432        c.l = 0; // c:1270
23433    } else {
23434        c.l = a.l % b.l; // c:1272
23435    }
23436    c
23437}
23438
23439/// Port of the `POWER` / `POWEREQ` arm, c:Src/math.c:1335-1358.
23440pub(crate) fn arith_pow(
23441    a: crate::ported::zsh_h::mnumber,
23442    b: crate::ported::zsh_h::mnumber,
23443) -> crate::ported::zsh_h::mnumber {
23444    use crate::ported::zsh_h::{mnumber, MN_FLOAT, MN_INTEGER};
23445    let (mut a, mut b, mut ctype) = mn_promote(a, b);
23446    if ctype == MN_INTEGER && b.l < 0 {
23447        // c:1337-1342 — a negative integer exponent produces a real
23448        // result, so both operands are cast to real first.
23449        a = mnumber { l: 0, d: a.l as f64, type_: MN_FLOAT };
23450        b = mnumber { l: 0, d: b.l as f64, type_: MN_FLOAT };
23451        ctype = MN_FLOAT;
23452    }
23453    let mut c = mnumber { l: 0, d: 0.0, type_: ctype };
23454    if ctype == MN_INTEGER {
23455        // c:1344 `for (c.u.l = 1; b.u.l--; c.u.l *= a.u.l);`
23456        c.l = 1;
23457        let mut n = b.l;
23458        while n > 0 {
23459            c.l = c.l.wrapping_mul(a.l);
23460            n -= 1;
23461        }
23462    } else {
23463        if b.d <= 0.0 && !mn_notzero(a) {
23464            return c; // c:1346-1347
23465        }
23466        if a.d < 0.0 {
23467            // c:1348-1355 — a negative base to a non-integer power is
23468            // imaginary.
23469            let tst = b.d as i64 as f64;
23470            if tst != b.d {
23471                crate::ported::utils::zerr("bad math expression: imaginary power"); // c:1352
23472                return c; // c:1353
23473            }
23474        }
23475        c.d = a.d.powf(b.d); // c:1356
23476    }
23477    c
23478}
23479
23480/// Body of `BUILTIN_SET_LINENO`, factored out so
23481/// `BUILTIN_STMT_PROLOGUE_FAST` can perform the same write without a
23482/// second indirect call. c:Src/exec.c:1355/1451/2056 `lineno = code - 1`.
23483pub(crate) fn set_lineno_impl(n: i64) -> fusevm::Value {
23484    // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash numbers a function body
23485    // from the line of its definition as 1; zsh (c:Src/exec.c:1355 `lineno =
23486    // code - 1`, function-relative) leaves that line at 0.
23487    let n = if crate::dash_mode::dash_faithful()
23488        && crate::ported::modules::parameter::FUNCSTACK
23489            .lock()
23490            .unwrap_or_else(|e| e.into_inner())
23491            .last()
23492            .is_some_and(|f| f.tp == crate::ported::zsh_h::FS_FUNC)
23493    {
23494        n + 1
23495    } else {
23496        n
23497    };
23498        // c:Src/exec.c:1355 — `/* In evaluated traps, don't modify the
23499        // line number. */  if (!IN_EVAL_TRAP() && !ineval && code)
23500        // lineno = code - 1;` (same gate at c:1451 and c:2056).
23501        // `ineval` is set by `eval()` to `!isset(EVALLINENO)`
23502        // (Src/builtin.c:6155), so under NO_EVAL_LINENO the eval body
23503        // must NOT renumber $LINENO — the caller's line stands.
23504        //
23505        // c:1354 — `/* In evaluated traps, don't modify the line number. */`
23506        // The `IN_EVAL_TRAP()` half of the same gate was missing, so an
23507        // eval-form trap body (`trap 'print $LINENO' DEBUG`) renumbered
23508        // $LINENO to its own line 1 and — because nothing restores
23509        // `lineno` on the way out (C does, via execlist's oldlineno
23510        // save/restore at c:1429/1696) — every later statement in the
23511        // trapped scope reported line 1 as well.
23512        if crate::ported::zsh_h::IN_EVAL_TRAP()
23513            || crate::ported::builtin::ineval.load(std::sync::atomic::Ordering::Relaxed) != 0
23514        {
23515            return Value::Status(0);
23516        }
23517        // Provenance: mirror the line into the lineage ledger's own
23518        // counter. The param-write hooks run inside the parameter
23519        // table's lock, so they cannot read `$LINENO` back out of it.
23520        if crate::provenance::active() {
23521            crate::provenance::note_line(n.max(0) as usize);
23522        }
23523        // c:Src/exec.c:lineno = N — direct write to the param's
23524        // u_val. Cannot go through setsparam because LINENO carries
23525        // PM_READONLY (so `(t)LINENO` reads `integer-readonly-special`
23526        // per zsh); setsparam → assignstrvalue's PM_READONLY guard
23527        // would reject the internal write. C zsh handles this via the
23528        // PM_SPECIAL GSU vtable's setfn callback which bypasses the
23529        // generic readonly check; the Rust port writes the canonical
23530        // field directly instead.
23531        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
23532            if let Some(pm) = tab.get_mut("LINENO") {
23533                // c:Src/utils.c:121 `zlong lineno` — the value lives in the C
23534                // GLOBAL, reached through LINENO's GSU. A `typeset -h +g LINENO`
23535                // local shadow has no PM_SPECIAL and no GSU, so C's `lineno = N`
23536                // never touches it; skip the paramtab mirror for the same reason.
23537                if (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) != 0 {
23538                    pm.u_val = n;
23539                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
23540                }
23541            }
23542        }
23543        // Mirror to the file-static `lineno` (utils.c:121) that
23544        // zerrmsg reads at utils.c:301 for the `:N: msg` prefix.
23545        crate::ported::utils::set_lineno(n as i32);
23546        // Also drive lex::LEX_LINENO — zerrmsg (utils.rs:376) reads
23547        // THAT counter for the `name:N:` prefix. C zsh interleaves
23548        // parse and execute per top-level list, so its single
23549        // `lineno` global serves both; zshrs compiles the whole
23550        // script before running, leaving LEX_LINENO parked at EOF.
23551        // Without this write, every runtime zwarn/zerr reported the
23552        // script's LAST line instead of the failing statement's.
23553        crate::ported::lex::set_lineno(n as u64);
23554        // DAP hook — checks breakpoints / step mode / pause-request
23555        // for the line we just landed on. O(1) no-op when DAP is off
23556        // (single atomic load on a OnceLock). Inside `--dap` mode
23557        // this is the call that blocks the executor on a Condvar
23558        // until the IDE sends `continue`. Mirrors strykelang's
23559        // `debugger.should_stop(line) → debugger.prompt(...)` flow.
23560        crate::extensions::dap::check_line(n as u32);
23561    fusevm::Value::Status(0)
23562}
23563
23564/// !!! WARNING: RUST-ONLY HELPER — NO DIRECT C COUNTERPART !!!
23565/// The `if (lastval) { errreturn / errexit }` half of execlist's
23566/// `sublist_done:` block (c:Src/exec.c:1660-1680), shared by the normal
23567/// check and the retflag-escape check of BUILTIN_ERREXIT_CHECK: C runs it
23568/// at EVERY sublist_done, including the one a `return 1` just ended.
23569/// Returns the builtin's result (1 = unwind this scope).
23570fn errexit_tail(last: i32) -> Value {
23571    use std::sync::atomic::Ordering;
23572    // c:Src/exec.c:1605-1610 — compute errreturn / errexit.
23573    //   errreturn = ERRRETURN && (INTERACTIVE || locallevel || sourcelevel)
23574    //               && !(noerrexit & NOERREXIT_RETURN)
23575    //   errexit   = (ERREXIT || (ERRRETURN && !errreturn))
23576    //               && !(noerrexit & NOERREXIT_EXIT)
23577    let no_err = crate::ported::exec::noerrexit.load(Ordering::Relaxed);
23578    let locallvl = crate::ported::params::locallevel.load(Ordering::Relaxed);
23579    let sourcelvl = crate::ported::init::sourcelevel.load(Ordering::Relaxed);
23580    let errreturn_opt = isset(crate::ported::zsh_h::ERRRETURN);
23581    let in_unwindable_scope =
23582        isset(crate::ported::zsh_h::INTERACTIVE) || locallvl != 0 || sourcelvl != 0;
23583    let errreturn = errreturn_opt
23584        && in_unwindable_scope
23585        && (no_err & crate::ported::zsh_h::NOERREXIT_RETURN) == 0;
23586    let (errexit_on, in_subshell) = with_executor(|exec| {
23587        let on_canonical = isset(ERREXIT) || (errreturn_opt && !errreturn); // c:1608-1609
23588        let on_legacy = opt_state_get("errexit").unwrap_or(false);
23589        (
23590            (on_canonical || on_legacy) && (no_err & crate::ported::zsh_h::NOERREXIT_EXIT) == 0,
23591            !exec.subshell_snapshots.is_empty(),
23592        )
23593    });
23594    if !errexit_on {
23595        // c:1667-1674 — `if (errexit) { … realexit(); }` comes FIRST;
23596        // `if (errreturn) { retflag = 1; breaks = loops; }` only matters
23597        // when the shell did not exit. With both options set inside a
23598        // function, ERR_EXIT wins.
23599        if errreturn {
23600            // c:1675-1678 — unwind to the function boundary without
23601            // exiting the shell.
23602            crate::ported::builtin::RETFLAG.store(1, Ordering::Relaxed);
23603            let loops = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
23604            crate::ported::builtin::BREAKS.store(loops, Ordering::Relaxed);
23605            return Value::Int(1);
23606        }
23607        return Value::Int(0);
23608    }
23609    // c:Src/exec.c:1611-1618 — under ERR_EXIT a failing command exits the
23610    // whole shell via realexit() FROM THE POINT OF FAILURE, before any
23611    // enclosing `always` arm can run. zsh 5.9.2 (the reference) has no
23612    // `this_noerrexit` deferral, so at top-level / function scope the
23613    // faithful behavior is to process-exit here (zexit fires the SIGEXIT
23614    // trap and exits). This bypasses the always arm, fixing
23615    // `setopt errexit; { false } always { print A }` which wrongly ran the
23616    // always body: the deferred EXIT_PENDING routed the unwind through
23617    // always_entry (compile_zsh.rs re-points it there) and
23618    // SET_TRY_BLOCK_ERROR then cleared the pending exit so the body ran.
23619    if crate::ported::builtin::SUBSHELL_DEPTH.load(Ordering::Relaxed) == 0 {
23620        crate::ported::builtin::zexit(last, crate::ported::zsh_h::ZEXIT_NORMAL);
23621        // c:1618 realexit
23622    }
23623    // Subshell: zshrs runs subshells in-process, so it cannot process-exit
23624    // the whole shell here — defer to the subshell-end unwind.
23625    crate::ported::builtin::EXIT_VAL.store(last, Ordering::Relaxed);
23626    crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
23627    let _ = in_subshell;
23628    Value::Int(1)
23629}
23630
23631/// !!! WARNING: RUST-ONLY HELPER — NO DIRECT C COUNTERPART !!!
23632/// `sigtrapped[SIGZERR]` (c:Src/exec.c:1652) read through the port's
23633/// mutex-guarded `sigtrapped` table. C indexes the array inline.
23634fn zerr_sigtrapped() -> bool {
23635    // !!! BASH-MODE (no C counterpart) !!! bash(1) `set -E`: "the ERR trap is
23636    // inherited by shell functions, command substitutions, and commands executed
23637    // in a subshell environment" — without it the trap is silent inside a function.
23638    if crate::extensions::dash_mode::bash_mode()
23639        && crate::ported::params::locallevel.load(std::sync::atomic::Ordering::Relaxed) > 0
23640        && !crate::extensions::dash_mode::bash_set_o_get("errtrace")
23641    {
23642        return false;
23643    }
23644    crate::ported::signals::sigtrapped
23645        .lock()
23646        .ok()
23647        .is_some_and(|st| st.get(crate::ported::signals_h::SIGZERR as usize).is_some_and(|&s| s != 0))
23648}
23649
23650/// Body of `BUILTIN_DONETRAP_RESET`, factored out for the same reason.
23651/// c:Src/exec.c:1455 `donetrap = 0`.
23652pub(crate) fn donetrap_reset_impl() -> fusevm::Value {
23653        // c:Src/jobs.c:651-652 — a CHLD trap update_bg_job owed while the
23654        // shell was not in a wait loop. C runs it from the SIGCHLD handler;
23655        // a trap body run from zshrs's handler can block on a mutex the
23656        // interrupted statement holds, so it runs here, at the next
23657        // statement prologue (c:Src/exec.c:1451-1455).
23658        for _ in 0..crate::ported::jobs::CHLD_TRAP_PENDING.swap(0, std::sync::atomic::Ordering::SeqCst) {
23659            crate::ported::signals::dotrap(libc::SIGCHLD);
23660        }
23661        // c:Src/jobs.c:658-678 — the trapped SIGINT/SIGQUIT pseudo-delivery
23662        // update_job parked in PSEUDO_SIG_TRAP_PENDING (a trap body cannot
23663        // run under the JOBTAB guard update_job holds).
23664        let pseudo = crate::ported::jobs::PSEUDO_SIG_TRAP_PENDING.swap(0, std::sync::atomic::Ordering::SeqCst);
23665        if pseudo != 0 {
23666            crate::ported::signals::dotrap(pseudo); // c:663
23667            if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
23668                crate::ported::builtin::BREAKS.store(
23669                    crate::ported::builtin::LOOPS.load(std::sync::atomic::Ordering::Relaxed),
23670                    std::sync::atomic::Ordering::Relaxed,
23671                ); // c:671-672
23672            }
23673            if let Some(tab) = crate::ported::jobs::JOBTAB.get() {
23674                crate::ported::jobs::check_cursh_sig(&tab.lock().unwrap_or_else(|e| e.into_inner()), pseudo); // c:677
23675            }
23676        }
23677        // c:Src/exec.c:1455 — `donetrap = 0;` at sublist start.
23678        // Reset before each top-level statement so the next
23679        // sublist's ERREXIT_CHECK fires the ZERR trap on its FIRST
23680        // non-zero command. Carries the "already fired" state
23681        // across function-call returns within the SAME outer
23682        // sublist (per C semantics — donetrap is process-global).
23683        // Bug #303 in docs/BUGS.md.
23684        crate::ported::exec::DONETRAP.store(0, std::sync::atomic::Ordering::Relaxed);
23685        // `${~spec}` carrier: C's `globsubst` is a paramsubst-LOCAL
23686        // int (c:Src/subst.c:1671 `int globsubst = isset(GLOBSUBST);`,
23687        // set to 2 by `${~}` at c:2597-2603) whose only effect is the
23688        // `shtokenize()` of THAT substitution's own result
23689        // (c:4419-4420). It can therefore never be observed by a later
23690        // statement. zshrs carries the flag on the global option table
23691        // (subst.rs:5125-5136) so the compile-emitted glob ops in the
23692        // same word pipeline can see it, and restores it at
23693        // command-dispatch boundaries — but a `${~}` sitting in a word
23694        // that dispatches NO command (a `for`/`select` word list, a
23695        // loop/`case` header) had no such boundary before the NEXT
23696        // statement's words were expanded, so GLOB_SUBST leaked into
23697        // them. This op is emitted exactly once per sublist, in
23698        // compile_list's prologue (compile_zsh.rs:557) — i.e. BEFORE
23699        // the sublist's words expand — which is the same "state is
23700        // gone by the next statement" guarantee C gets for free.
23701        // Without it, `_parameters`' `for i in ${…:#${~pfilt}*}` loop
23702        // globbed its `ary+=($i:"$val")` body word and died with
23703        // "bad pattern: HISTCHARS:!^#", killing `-<TAB>` completion.
23704    consume_tilde_globsubst_carrier();
23705    fusevm::Value::Status(0)
23706}
23707
23708#[cfg(test)]
23709mod foreground_wait_guard_tests {
23710    use super::ForegroundWaitGuard;
23711
23712    /// c:Src/jobs.c:1638-1669 — the mask a foreground wait presents.
23713    ///
23714    /// C blocks for a child inside `signal_suspend(SIGCHLD, wait_cmd)`
23715    /// (c:1658 / c:1710), and that call's sigsuspend mask is EMPTY —
23716    /// c:Src/signals.c:216-232 does `sigemptyset(&set)` and adds at most a
23717    /// conditional SIGINT. SIGCHLD is held off by `child_block()` (c:1642,
23718    /// "unblocked in signal_suspend()") rather than by userspace queueing.
23719    /// So while the shell waits: SIGCHLD blocked, SIGWINCH deliverable.
23720    ///
23721    /// This guard used to call `queue_signals()` and nothing else, which
23722    /// left the standing `winch_block()` (c:Src/init.c:1458, re-armed by
23723    /// preprompt at c:Src/utils.c:1541) in force for the whole of any
23724    /// command — so a resize was invisible to a completion however many
23725    /// children it forked, and `trashzle`'s repaint
23726    /// (c:Src/Zle/compresult.c:1922 → c:Src/Zle/zle_main.c:2071-2095) never
23727    /// saw the `resetneeded`/`winchanged` that c:Src/utils.c:1956-1958
23728    /// raises.
23729    #[test]
23730    fn foreground_wait_unblocks_sigwinch_and_blocks_sigchld() {
23731        let _g = crate::test_util::global_state_lock();
23732        let blocked = |sig: libc::c_int| -> bool {
23733            unsafe {
23734                let mut cur: libc::sigset_t = std::mem::zeroed();
23735                libc::sigemptyset(&mut cur);
23736                libc::pthread_sigmask(libc::SIG_BLOCK, std::ptr::null(), &mut cur);
23737                libc::sigismember(&cur, sig) == 1
23738            }
23739        };
23740        let saved_winch = blocked(libc::SIGWINCH);
23741        let saved_chld = blocked(libc::SIGCHLD);
23742
23743        // The standing mask between prompts (c:Src/utils.c:1541).
23744        crate::ported::signals_h::winch_block();
23745        crate::ported::signals_h::child_unblock();
23746        assert!(blocked(libc::SIGWINCH), "precondition: winch_block() holds");
23747
23748        let (winch_in, chld_in) = {
23749            let _w = ForegroundWaitGuard::enter();
23750            (blocked(libc::SIGWINCH), blocked(libc::SIGCHLD))
23751        };
23752        let winch_out = blocked(libc::SIGWINCH);
23753
23754        if !saved_winch {
23755            crate::ported::signals_h::winch_unblock();
23756        }
23757        if saved_chld {
23758            crate::ported::signals_h::child_block();
23759        } else {
23760            crate::ported::signals_h::child_unblock();
23761        }
23762
23763        assert!(
23764            !winch_in,
23765            "a foreground wait is C's one mid-command delivery point for \
23766             SIGWINCH (c:Src/signals.c:216-232 — signal_suspend's mask is empty)"
23767        );
23768        assert!(
23769            chld_in,
23770            "c:Src/jobs.c:1642 — `child_block(); /* unblocked in signal_suspend() */`"
23771        );
23772        assert!(
23773            winch_out,
23774            "c:Src/utils.c:1541 — the standing winch_block() comes back after the wait"
23775        );
23776    }
23777}
23778
23779#[cfg(test)]
23780mod builtin_completer_coverage_tests {
23781    use super::registered_builtin;
23782
23783    /// Every zshrs-original builtin ships a completer in `completions/`
23784    /// (bundled into `~/.zshrs/functions` by build.rs), so `name <TAB>` and
23785    /// `name -<TAB>` offer its arguments instead of nothing. zsh's own
23786    /// completion tree cannot cover these: the commands do not exist there.
23787    ///
23788    /// The set: the in-process builtins [`registered_builtin`] dispatches
23789    /// (taken from `EXT_BUILTIN_NAMES`), the ztest framework, and the
23790    /// daemon-backed `z*` builtins. `pgrep` is left out: it stands in for the
23791    /// external `pgrep`, which zsh's stock `_pgrep` already completes.
23792    #[test]
23793    fn every_zshrs_builtin_has_a_completer() {
23794        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("completions");
23795        let mut claimed = std::collections::HashSet::new();
23796        for entry in std::fs::read_dir(&dir).expect("completions/") {
23797            let path = entry.expect("dir entry").path();
23798            let Ok(text) = std::fs::read_to_string(&path) else { continue };
23799            if let Some(line) = text.lines().next().and_then(|l| l.strip_prefix("#compdef ")) {
23800                claimed.extend(line.split_whitespace().map(str::to_string));
23801            }
23802        }
23803        let names = crate::ext_builtins::EXT_BUILTIN_NAMES
23804            .iter()
23805            .filter(|n| registered_builtin(n).is_some() && **n != "pgrep")
23806            .chain(crate::extensions::ztest::ZTEST_BUILTIN_NAMES)
23807            .chain(crate::daemon::builtins::ZSHRS_BUILTIN_NAMES);
23808        let missing: Vec<&str> = names.copied().filter(|n| !claimed.contains(*n)).collect();
23809        assert!(
23810            missing.is_empty(),
23811            "zshrs builtins with no `#compdef` completer in completions/: {missing:?}"
23812        );
23813    }
23814}
23815
23816#[cfg(test)]
23817mod function_shadows_builtin_tests {
23818    //! c:Src/exec.c:3485-3489 — command lookup consults `shfunctab` before
23819    //! `builtintab`, so a shell function shadows a builtin of the same name.
23820    //! The zshrs-only builtins dispatched by name in `call_function` (daemon
23821    //! z* family, ztest_*, znative) used to be matched before that check.
23822    use crate::vm_helper::ShellExecutor;
23823
23824    fn run(script: &str) -> (ShellExecutor, i32) {
23825        let mut exec = ShellExecutor::new();
23826        let st = exec.execute_script(script).unwrap_or(-1);
23827        (exec, st)
23828    }
23829
23830    #[test]
23831    fn function_shadows_daemon_z_builtin() {
23832        let _g = crate::test_util::global_state_lock();
23833        let (exec, _) = run("hit=; zls() { hit=fn; return 42 }; zls; st=$?");
23834        assert_eq!(exec.scalar("hit").as_deref(), Some("fn"));
23835        assert_eq!(exec.scalar("st").as_deref(), Some("42"));
23836    }
23837
23838    #[test]
23839    fn function_shadows_extension_builtin() {
23840        let _g = crate::test_util::global_state_lock();
23841        let (exec, _) =
23842            run("hit=; basename() { hit=fn; return 43 }; basename a/b; st=$?");
23843        assert_eq!(exec.scalar("hit").as_deref(), Some("fn"));
23844        assert_eq!(exec.scalar("st").as_deref(), Some("43"));
23845    }
23846
23847    #[test]
23848    fn builtin_prefix_and_disable_f_bypass_the_function() {
23849        let _g = crate::test_util::global_state_lock();
23850        // c:Src/exec.c:3483-3487 — after `builtin`, shfunctab is skipped.
23851        let (exec, _) = run(
23852            "hit=; zls() { hit=fn; return 42 }; builtin zls >/dev/null 2>&1; st=$?",
23853        );
23854        assert_eq!(exec.scalar("hit").as_deref(), Some(""));
23855        assert_ne!(exec.scalar("st").as_deref(), Some("42"));
23856        // c:Src/hashtable.c:239 — getnode skips a DISABLED shfunc node.
23857        let (exec, _) = run(
23858            "hit=; zls() { hit=fn; return 42 }; disable -f zls; zls >/dev/null 2>&1; st=$?",
23859        );
23860        assert_eq!(exec.scalar("hit").as_deref(), Some(""));
23861        assert_ne!(exec.scalar("st").as_deref(), Some("42"));
23862    }
23863}
23864
23865#[cfg(test)]
23866mod zmv_native_autoload_tests {
23867    //! zmv/zcp/zln/zcalc are zsh autoload functions with native ports. The
23868    //! port stands in only for loading a PM_UNDEFINED stub (c:Src/exec.c:
23869    //! 5691-5695); a defined body is an ordinary shell function.
23870    use crate::vm_helper::ShellExecutor;
23871
23872    fn run(script: &str) -> ShellExecutor {
23873        let mut exec = ShellExecutor::new();
23874        let _ = exec.execute_script(script);
23875        exec
23876    }
23877
23878    #[test]
23879    fn user_defined_zmv_and_zcalc_bodies_run() {
23880        let _g = crate::test_util::global_state_lock();
23881        let exec = run("hit=; zmv() { hit=fn; return 44 }; zmv a b; st=$?; unfunction zmv");
23882        assert_eq!(exec.scalar("hit").as_deref(), Some("fn"));
23883        assert_eq!(exec.scalar("st").as_deref(), Some("44"));
23884        let exec = run("hit=; zcalc() { hit=fn; return 45 }; zcalc; st=$?; unfunction zcalc");
23885        assert_eq!(exec.scalar("hit").as_deref(), Some("fn"));
23886        assert_eq!(exec.scalar("st").as_deref(), Some("45"));
23887    }
23888
23889    #[test]
23890    fn undefined_autoload_stub_uses_native_port() {
23891        let _g = crate::test_util::global_state_lock();
23892        // The native zmv prints its own usage and returns 1 for a missing
23893        // pattern pair; the stub stays PM_UNDEFINED, which an fpath load
23894        // (stock zmv body) would have cleared.
23895        let exec = run("autoload -Uz zmv; zmv 2>/dev/null; st=$?");
23896        assert_eq!(exec.scalar("st").as_deref(), Some("1"));
23897        assert!(super::autoload_stub_pending("zmv"));
23898        run("unfunction zmv 2>/dev/null");
23899    }
23900
23901    #[test]
23902    fn disabled_or_absent_zmv_is_command_not_found() {
23903        let _g = crate::test_util::global_state_lock();
23904        let exec = run("zmv 2>/dev/null; st=$?");
23905        assert_eq!(exec.scalar("st").as_deref(), Some("127"));
23906        let exec = run("autoload -Uz zmv; disable -f zmv; zmv 2>/dev/null; st=$?");
23907        assert_eq!(exec.scalar("st").as_deref(), Some("127"));
23908        run("unfunction zmv 2>/dev/null");
23909    }
23910}
23911
23912/// !!! BASH-MODE (no C counterpart) !!! Scope guard that holds `KSHGLOB` on
23913/// (bash `extglob`) for one `[[ … == pattern ]]` match. bash turns extglob on
23914/// while it parses `[[ ]]`, so `@(…)` / `+(…)` / `*(…)` / `?(…)` / `!(…)`
23915/// groups work in the pattern with the shopt off. Restores the prior state on
23916/// drop. Inert outside `--bash`, when the option is already on, or when the
23917/// pattern has no extended group opener.
23918struct BashDbracketExtglob {
23919    armed: bool,
23920}
23921
23922impl BashDbracketExtglob {
23923    fn enter(pat: &str) -> Self {
23924        use crate::ported::zsh_h::{isset, KSHGLOB};
23925        let has_group = ["@(", "*(", "+(", "?(", "!("].iter().any(|g| pat.contains(g));
23926        let armed = crate::dash_mode::bash_mode() && has_group && !isset(KSHGLOB);
23927        if armed {
23928            crate::ported::options::dosetopt(KSHGLOB, 1, 1);
23929        }
23930        Self { armed }
23931    }
23932}
23933
23934impl Drop for BashDbracketExtglob {
23935    fn drop(&mut self) {
23936        if self.armed {
23937            crate::ported::options::dosetopt(crate::ported::zsh_h::KSHGLOB, 0, 1);
23938        }
23939    }
23940}