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    /// Counts sublists (bumped by BUILTIN_STMT_PROLOGUE_FAST). Together with
608    /// REDIR_SCOPE_OPENED it tells a builtin whether the redirect scope on
609    /// top of the stack belongs to its own command.
610    ///
611    /// !!! WARNING: RUST-ONLY CARRIER !!! C's `execcmd_exec` holds the
612    /// command's `save[]` array locally and tests `save[1] == -2`
613    /// (c:Src/exec.c:4300); zshrs keeps one shared scope stack for simple
614    /// and compound commands alike.
615    static SUBLIST_SERIAL: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
616    /// `(SUBLIST_SERIAL, stack depth)` recorded when the newest redirect
617    /// scope opened. A simple command opens its scope right before its own
618    /// dispatch, inside its sublist; a compound command's body starts a new
619    /// sublist first.
620    static REDIR_SCOPE_OPENED: std::cell::Cell<(u64, usize)> = const { std::cell::Cell::new((0, 0)) };
621    /// Per open redirect scope (index = depth - 1): whether ERRFLAG_ERROR
622    /// was clear when the scope opened. c:Src/exec.c:3637-3643 aborts on an
623    /// argument-expansion errflag BEFORE the redirect loop, so an errflag
624    /// first seen while a redirection is applied came from that
625    /// redirection's own target (xpandredir). See redir_target_expansion_failed.
626    static REDIR_SCOPE_ERRFLAG_CLEAN: std::cell::RefCell<Vec<bool>> =
627        const { std::cell::RefCell::new(Vec::new()) };
628    /// c:Src/exec.c:3775-3777 — `if (input) addfd(forked, save, mfds, 0,
629    /// input, 0, NULL);` seeds `mfds[0]` with the pipeline input BEFORE the
630    /// stage command's redirect list is walked, so an input redirection
631    /// joins the pipe as a second multio member (`cat o1 | cat <o2` reads
632    /// both). BUILTIN_PIPE_FDS_INSTALL records the sublist serial it
633    /// installed the pipe in; the redirect scope that opens in the same
634    /// sublist (the stage command's own) turns it into PIPE_INPUT_SCOPE.
635    ///
636    /// !!! WARNING: RUST-ONLY CARRIER !!! C does both steps inside one
637    /// execcmd_exec call; zshrs splits them across two VM ops.
638    static PIPE_INPUT_INSTALLED: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
639    /// Redirect-stack depth of the scope whose fd-0 redirection must keep
640    /// the pipeline input as its first multio member; consumed by the first
641    /// such redirection.
642    static PIPE_INPUT_SCOPE: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
643}
644
645/// Register the session executor pointer (called from
646/// `install_session_executor`). See [`with_session_context`].
647pub fn register_session_executor(exec: &mut ShellExecutor) {
648    SESSION_EXECUTOR_PTR.with(|c| c.set(Some(exec as *mut ShellExecutor)));
649}
650
651/// Run `f` with the registered session executor established as
652/// `CURRENT_EXECUTOR`, so code reaching the live executor via
653/// `try_with_executor` works even when no per-command `execode` context
654/// is active yet.
655///
656/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
657/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
658/// first `execode`. Without an active context those sourced bodies
659/// `try_with_executor` → `None` → no-op, so the shell silently ignored
660/// the user's dotfiles. The scope is entered once around the startup
661/// sourcing window and dropped before the loop begins — deliberately NOT
662/// Run `f` with the registered session executor established as
663/// `CURRENT_EXECUTOR`, so code reaching the live executor via
664/// `try_with_executor` works even when no per-command `execode` context
665/// is active yet.
666///
667/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
668/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
669/// first `execode`. Without an active context those sourced bodies
670/// `try_with_executor` → `None` → no-op, so the shell silently ignored
671/// the user's dotfiles. The scope is entered once around the startup
672/// sourcing window and dropped before the loop begins.
673///
674/// `execute_script_zsh_pipeline` now carries the same session-executor
675/// rung of its own (exec.rs), since `execstring` callers that fire
676/// BETWEEN commands — `checksched`, `dotrap` — have no active context
677/// either and were silently dropping their command. That rung is reached
678/// only when `CURRENT_EXECUTOR` is unset, so a nested command
679/// substitution (which always runs inside a chunk, and therefore inside
680/// an `ExecutorContext`) takes the active-executor path as before and
681/// never re-enters the session executor.
682pub fn with_session_context<R>(f: impl FnOnce() -> R) -> R {
683    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
684    match ptr {
685        // SAFETY: set by install_session_executor to an executor that
686        // outlives the single-threaded interactive session.
687        Some(ptr) => {
688            let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
689            f()
690        }
691        None => f(),
692    }
693}
694
695/// Merge finished background-compinit results into shell state, callable
696/// from any site (active VM context first, session executor otherwise —
697/// the ZLE completion path runs OUTSIDE a VM frame, where
698/// `try_with_executor` alone is None and $_comps stayed empty forever).
699pub fn drain_compinit_bg_hook() {
700    if try_with_executor(|exec| exec.drain_compinit_bg()).is_some() {
701        return;
702    }
703    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
704    if let Some(ptr) = ptr {
705        // SAFETY: per with_session_context.
706        let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
707        unsafe { (*ptr).drain_compinit_bg() }
708    }
709}
710
711/// Run the body of a PLAIN sourced file through the per-command loop —
712/// `ShellExecutor::execute_script_per_command`, the port of the
713/// `switch (loop(0, 0))` arm of C's `source()` (c:Src/init.c:1625-1641).
714///
715/// `bin_dot` (`src/ported/builtin.rs`, C's `Src/builtin.c:6118`
716/// `ret = source(enam = buf);`) is the only caller. It lives HERE rather
717/// than beside the other executor accessors in `src/ported/exec.rs`
718/// because everything under `src/ported/` is a line-by-line port and
719/// `build.rs` rejects a `fn` there with no C counterpart — this bridge
720/// shim has none, since C's `source()` reaches its interpreter through
721/// plain globals where Rust needs an explicit executor handle.
722///
723/// Executor ladder, in the order the sibling wrappers in `exec.rs` use:
724/// the innermost ACTIVE executor when one is in scope — inside
725/// `$(source f)` that is the sub-VM that owns the capture, which is why
726/// the substitution still collects the file's output — and the installed
727/// session executor otherwise, for a `source` that runs before the main
728/// loop's first `execode` has entered a context. `Ok(0)` with neither,
729/// matching `exec::execute_script`'s no-executor return.
730pub fn source_file_per_command(src: &str) -> Result<i32, String> {
731    if let Some(r) = try_with_executor(|exec| exec.execute_script_per_command(src)) {
732        return r;
733    }
734    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
735    match ptr {
736        // SAFETY: per with_session_context.
737        Some(ptr) => {
738            let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
739            unsafe { (*ptr).execute_script_per_command(src) }
740        }
741        None => Ok(0),
742    }
743}
744
745/// Run the deparsed text of an ALREADY-COMPILED `.zwc` program on the live
746/// executor — the compiled arm of `source()`, `execode(prog, 1, 0,
747/// "filecode")` (c:Src/init.c:1621).
748///
749/// Same executor resolution as [`source_file_per_command`]: the live
750/// executor when one is in scope, the installed session executor otherwise,
751/// `Ok(0)` with neither. See
752/// [`crate::vm_helper::ShellExecutor::execute_zwc_program`] for why the plain
753/// script entry point is wrong here.
754pub fn execute_zwc_program(src: &str) -> Result<i32, String> {
755    if let Some(r) = try_with_executor(|exec| exec.execute_zwc_program(src)) {
756        return r;
757    }
758    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
759    match ptr {
760        // SAFETY: per with_session_context.
761        Some(ptr) => {
762            let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
763            unsafe { (*ptr).execute_zwc_program(src) }
764        }
765        None => Ok(0),
766    }
767}
768
769/// RAII guard that sets/clears the thread-local executor pointer.
770///
771/// Idempotent: calling `enter` when a context is already active is a no-op
772/// for the entry side, and the guard's drop only clears the thread-local if
773/// *this* call was the one that set it. Nested `execute_command` invocations
774/// (e.g. from inside a builtin handler) reuse the outer pointer instead of
775/// stomping it.
776pub(crate) struct ExecutorContext {
777    we_set_it: bool,
778}
779
780impl ExecutorContext {
781    pub(crate) fn enter(executor: &mut ShellExecutor) -> Self {
782        let we_set_it = CURRENT_EXECUTOR.with(|cell| {
783            let mut slot = cell.borrow_mut();
784            if slot.is_some() {
785                false
786            } else {
787                *slot = Some(executor as *mut ShellExecutor);
788                true
789            }
790        });
791        ExecutorContext { we_set_it }
792    }
793}
794
795impl Drop for ExecutorContext {
796    fn drop(&mut self) {
797        if self.we_set_it {
798            CURRENT_EXECUTOR.with(|cell| {
799                *cell.borrow_mut() = None;
800            });
801        }
802    }
803}
804
805/// Access the current executor from a builtin handler.
806/// # Safety
807/// Only call this from within a VM execution context (after ExecutorContext::enter).
808#[inline]
809pub(crate) fn with_executor<F, R>(f: F) -> R
810where
811    F: FnOnce(&mut ShellExecutor) -> R,
812{
813    CURRENT_EXECUTOR.with(|cell| {
814        let ptr = cell
815            .borrow()
816            .expect("with_executor called outside VM context");
817        // SAFETY: The pointer is valid for the duration of VM execution,
818        // and we're single-threaded within the executor.
819        let executor = unsafe { &mut *ptr };
820        f(executor)
821    })
822}
823
824/// Non-panicking variant of [`with_executor`]: runs `f` against the
825/// current executor and returns `Some(result)`, or `None` when no
826/// executor is in scope (`CURRENT_EXECUTOR` unset — e.g. unit tests /
827/// compsys contexts with no fusevm bridge running).
828///
829/// This is the primitive the `crate::ported::exec` accessor wrappers
830/// (array/assoc/dispatch_function_call/execute_script/...) use to
831/// reach the live executor while preserving the exact "no executor →
832/// fall back to the direct param table / default value" semantics that
833/// the deleted `exec_hooks` OnceLock layer encoded via its
834/// "is-the-hook-installed?" check. `CURRENT_EXECUTOR` being set is the
835/// faithful equivalent of "the bridge installed the hooks".
836#[inline]
837pub(crate) fn try_with_executor<F, R>(f: F) -> Option<R>
838where
839    F: FnOnce(&mut ShellExecutor) -> R,
840{
841    CURRENT_EXECUTOR.with(|cell| {
842        let ptr = (*cell.borrow())?;
843        // SAFETY: same contract as with_executor — the pointer is valid
844        // for the duration of VM execution and access is single-threaded.
845        let executor = unsafe { &mut *ptr };
846        Some(f(executor))
847    })
848}
849
850/// Look up a canonical builtin by name in `BUILTINS` and dispatch
851/// via `execbuiltin` (Src/builtin.c:250). NO shadow check — calls the
852/// builtin even if a user function with the same name exists. Used by
853/// the `builtin foo` prefix opcode (which explicitly bypasses function
854/// lookup per zsh semantics) and by internal call sites where shadowing
855/// is unwanted. For zsh's normal name-resolution order (function shadows
856/// builtin), use `dispatch_builtin` instead.
857/// Shell-identifier prefix for diagnostic lines. Reads the canonical
858/// scriptname (`zsh` in `--zsh` parity mode, `zshrs` otherwise) so a
859/// single helper replaces hardcoded `"zshrs:"` literals across the
860/// file's eprintln paths.
861fn shname() -> String {
862    crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string())
863}
864
865/// c:Src/subst.c:505-507 + Src/exec.c:3378-3380 — per-command
866/// CSH_NULL_GLOB outcome check. During this command's word expansion
867/// `expand_glob` accumulated `badcshglob |= 1` per failed glob and
868/// `|= 2` per successful one (Src/glob.c:1871-1875). Exactly 1 —
869/// failures and no successes — is the csh-style error: `no match`,
870/// command skipped, status 1. Any other value (0 = no globs, 2/3 =
871/// at least one matched) is silent. Always resets the counter for
872/// the next command (C resets at prefork entry, subst.rs:1307).
873/// Returns true when the error fired; callers mirror their
874/// glob_failed handling (builtins leave ERRFLAG_ERROR set so the
875/// script aborts, externals clear it so the next sublist runs —
876/// verified against zsh 5.9.1).
877/// Restore the user's GLOB_SUBST after a `${~spec}` carrier flip
878/// (see subst::TILDE_GLOBSUBST_CARRIER). Runs at the same
879/// command-dispatch boundaries that consume glob_failed /
880/// badcshglob — by then every glob op of the current word pipeline
881/// has read the carrier.
882pub(crate) fn consume_tilde_globsubst_carrier() {
883    // The default-word report belongs to the word pipeline just finished too.
884    crate::ported::subst::DEFAULT_WORD_GLOBSUBST_OFF.with(|c| c.set(false));
885    crate::ported::subst::TILDE_GLOBSUBST_CARRIER.with(|c| {
886        if let Some(saved) = c.take() {
887            crate::ported::options::opt_state_set("globsubst", saved);
888        }
889    });
890}
891
892/// Pop `argc` stack slots for a whole-array assignment: the LAST popped
893/// (deepest pushed) is the param name, the rest are the values in stack
894/// order, with any `Value::Array` flattened to its elements. Mirrors the
895/// Flatten an array-assignment RHS value into scalar strings, descending
896/// through nested `Value::Array`s. zsh arrays are always flat, so recursion
897/// only collapses the wrapper layers the compiler introduces — in particular
898/// the single `Value::Array` built by `Op::MakeArray` for `arr=(...)` literals
899/// (used to dodge `CallBuiltin`'s u8 argc cap), whose own elements may
900/// themselves be arrays from an unquoted `$other_array` expansion. A
901/// one-level flatten would stringify those inner arrays into a single element.
902fn flatten_array_value(v: Value, out: &mut Vec<String>) {
903    match v {
904        Value::Array(items) => {
905            for it in items.iter() {
906                flatten_array_value(it.clone(), out);
907            }
908        }
909        other => out.push(other.to_str()),
910    }
911}
912
913/// c:Src/exec.c:2595-2598, 2605-2608, 2620-2623 (addvars) — `if (errflag)
914/// { state->pc = opc; return; }` after ecgetlist, prefork and globlist. An
915/// array assignment whose element expansion failed (`x=( *(zz) )`, a NOMATCH)
916/// never reaches assignaparam, so the parameter keeps its old type and value.
917/// Shared gate of BUILTIN_SET_ARRAY / BUILTIN_APPEND_ARRAY.
918fn array_assignment_expansion_failed() -> bool {
919    (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
920        & crate::ported::zsh_h::ERRFLAG_ERROR)
921        != 0
922        || with_executor(|exec| exec.current_command_glob_failed.get())
923}
924
925/// pop/flatten prologue of BUILTIN_SET_ARRAY / BUILTIN_APPEND_ARRAY.
926fn pop_array_args_with_name(vm: &mut fusevm::VM, argc: u8) -> (String, Vec<String>) {
927    let n = argc as usize;
928    let mut popped: Vec<Value> = Vec::with_capacity(n);
929    for _ in 0..n {
930        popped.push(vm.pop());
931    }
932    popped.reverse();
933    let name = popped.pop().map(|v| v.to_str()).unwrap_or_default();
934    let mut values: Vec<String> = Vec::new();
935    for v in popped {
936        flatten_array_value(v, &mut values);
937    }
938    (name, values)
939}
940
941/// c:Src/exec.c:3785-3796 — the redirect loop runs `xpandredir(fn, redir)`
942/// on each target and then `if (errflag) { closemnodes(mfds); fixfds(save);
943/// execerr(); }`: an expansion error in a target (`> $((1/0))`,
944/// `> ${u?msg}`, a NULL_GLOB `> nx(N)`) is a failed redirection. zshrs
945/// expands the target before the redirect op runs, so the op sees the
946/// errflag: when it was clear as this command's redirect scope opened, mark
947/// the redirection failed and skip it. The redirect_failed gates then treat
948/// it like any other failure — for an external command, which C forks before
949/// this loop (c:3719), only the child dies (call_function).
950/// !!! RUST-ONLY HELPER: C tests errflag inline in the redirect loop.
951fn redir_target_expansion_failed(exec: &mut ShellExecutor) -> bool {
952    if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
953        & crate::ported::zsh_h::ERRFLAG_ERROR)
954        == 0
955    {
956        return false;
957    }
958    let depth = exec.redirect_scope_stack.len();
959    let clean_at_open = depth > 0
960        && REDIR_SCOPE_ERRFLAG_CLEAN.with(|v| v.borrow().get(depth - 1).copied().unwrap_or(false));
961    if clean_at_open {
962        exec.redirect_failed = true;
963    }
964    clean_at_open
965}
966
967/// Asked by paramsubst's `${name?msg}` arms before they take
968/// c:Src/subst.c:3375's `zexit(1)`: true when the expansion belongs to the
969/// prefix assignments of `X=… cmd`, whose process is not known yet. The exit
970/// is then owed (PREFIX_ASSIGN_EXIT_PENDING) and `settle_prefix_assign_exit`
971/// pays it once the command word resolves.
972pub(crate) fn prefix_assign_defer_exit() -> bool {
973    let assigning = PREFIX_ASSIGN_CMD.with(|c| c.borrow().is_some());
974    if assigning {
975        PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.set(true));
976    }
977    assigning
978}
979
980/// Pays a deferred `${name?msg}` exit once `name` (the expanded command word,
981/// "" for none) is known. c:Src/exec.c:3719-3730 forks an external command
982/// before c:4358 runs its prefix assignments, so the error ends only that
983/// child (c:3377 `_exit(1)`, c:4359 `if (errflag) _exit(1)`): the shell sees
984/// status 1, which the dispatch's PREFIX_ASSIGN_FAILED arm reports. A shell
985/// function, a builtin or no command at all runs the assignments in the
986/// shell (c:4157), which exits there (c:3375).
987fn settle_prefix_assign_exit(name: &str) {
988    if !PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.replace(false)) {
989        return;
990    }
991    let forked = !name.is_empty()
992        && !with_executor(|exec| exec.function_exists(name))
993        && !crate::ported::builtin::createbuiltintable().contains_key(name);
994    if !forked {
995        crate::ported::builtin::STOPMSG.store(1, std::sync::atomic::Ordering::Relaxed); // c:3374
996        // !!! DASH-FAMILY GATE (dash_mode::fatal_error_status): same as the
997        // paramsubst arms. !!!
998        crate::ported::builtin::zexit(
999            crate::extensions::dash_mode::fatal_error_status().unwrap_or(1),
1000            crate::ported::zsh_h::ZEXIT_NORMAL,
1001        ); // c:3375
1002    }
1003}
1004
1005/// c:Src/exec.c:3719 + 3755-3763 — an external command is forked
1006/// (execcmd_fork) BEFORE `globlist(args, 0)`, so a NOMATCH (or the
1007/// CSH_NULL_GLOB `no match`, c:Src/subst.c:505-507) raised while globbing
1008/// its arguments ends the CHILD with status 1; the shell's errflag stays
1009/// clean and the next command runs (`rm *nomatch; print after` prints the
1010/// error, then `after`). zshrs globs in the shell, so every path that runs
1011/// a name zsh would fork for calls this first: `Some(1)` means skip the
1012/// command, with this command's ERRFLAG_ERROR taken back out.
1013fn external_glob_failure_status() -> Option<i32> {
1014    consume_tilde_globsubst_carrier();
1015    let failed = with_executor(|exec| exec.current_command_glob_failed.replace(false))
1016        || consume_badcshglob();
1017    if !failed {
1018        return None;
1019    }
1020    crate::ported::utils::errflag.fetch_and(
1021        !crate::ported::zsh_h::ERRFLAG_ERROR,
1022        std::sync::atomic::Ordering::Relaxed,
1023    );
1024    with_executor(|exec| exec.set_last_status(1));
1025    Some(1)
1026}
1027
1028fn consume_badcshglob() -> bool {
1029    let v = crate::ported::glob::BADCSHGLOB.swap(0, std::sync::atomic::Ordering::Relaxed);
1030    if v == 1 {
1031        crate::ported::utils::zerr("no match"); // c:Src/subst.c:507
1032        true
1033    } else {
1034        false
1035    }
1036}
1037
1038/// Map a builtin name to the zsh module that owns it, IFF zsh does
1039/// not auto-load that builtin on first use. Used by
1040/// `dispatch_builtin_raw` to gate `--zsh` mode dispatch behind
1041/// `zmodload`, mirroring `zsh -fc <name>` returning 127 for these
1042/// names without an explicit module load.
1043///
1044/// Returns `Some(module_name)` if `name` belongs to a non-auto-load
1045/// module per the per-module `Src/Modules/<x>.c` `bintab[]` plus
1046/// the auto-load flag set at module-build time. `None` for core
1047/// builtins and for auto-loaded module builtins (sched, log, echotc,
1048/// echoti, zformat, zparseopts, zregexparse, zstyle, strftime,
1049/// private, vared, zle, bindkey, comp*) which work without zmodload.
1050fn module_bound_builtin_module(name: &str) -> Option<&'static str> {
1051    match name {
1052        "zftp" => Some("zsh/zftp"),
1053        "zsocket" => Some("zsh/net/socket"),
1054        "ztcp" => Some("zsh/net/tcp"),
1055        "zstat" => Some("zsh/stat"),
1056        "zselect" => Some("zsh/zselect"),
1057        "zpty" => Some("zsh/zpty"),
1058        "zprof" => Some("zsh/zprof"),
1059        "zsystem" | "syserror" => Some("zsh/system"),
1060        "clone" => Some("zsh/clone"),
1061        "zcurses" => Some("zsh/curses"),
1062        "ztie" | "zuntie" | "zgdbmpath" => Some("zsh/db/gdbm"),
1063        "pcre_compile" | "pcre_match" | "pcre_study" => Some("zsh/pcre"),
1064        "example" => Some("zsh/example"),
1065        "cap" | "getcap" | "setcap" => Some("zsh/cap"),
1066        "zgetattr" | "zsetattr" | "zdelattr" | "zlistattr" => Some("zsh/attr"),
1067        // c:Src/Modules/datetime.c — `strftime` is registered via
1068        // partab[] when zsh/datetime loads. Verified by
1069        // `zsh -fc 'strftime -s s %Y 0'` → 127 "command not found".
1070        "strftime" => Some("zsh/datetime"),
1071        _ => None,
1072    }
1073}
1074
1075/// Lookup half of [`try_run_registered_builtin`]: the handler for a
1076/// zshrs-original opcode builtin NAME, or `None`. Split from the run half so
1077/// the `builtin` precommand walk can ask whether the name exists before the
1078/// redirections open (c:Src/exec.c:3489 `builtintab->getnode`) without
1079/// running the command.
1080///
1081/// !!! Keep in sync with the matching `vm.register_builtin(...)` closures in
1082/// `register_builtins`: both must route a name to the same executor method.
1083fn registered_builtin(name: &str) -> Option<fn(&[String]) -> i32> {
1084    let f: fn(&[String]) -> i32 = match name {
1085        "ai" => |argv| with_executor(|e| e.builtin_ai(argv)),
1086        "async" => |argv| with_executor(|e| e.builtin_async(argv)),
1087        "await" => |argv| with_executor(|e| e.builtin_await(argv)),
1088        "barrier" => |argv| with_executor(|e| e.builtin_barrier(argv)),
1089        "peach" => |argv| with_executor(|e| e.builtin_peach(argv)),
1090        "pmap" => |argv| with_executor(|e| e.builtin_pmap(argv)),
1091        "pgrep" => |argv| with_executor(|e| e.builtin_pgrep(argv)),
1092        "intercept" => |argv| with_executor(|e| e.builtin_intercept(argv)),
1093        "intercept_proceed" => |argv| with_executor(|e| e.builtin_intercept_proceed(argv)),
1094        "doctor" => |argv| with_executor(|e| e.builtin_doctor(argv)),
1095        "dbview" => |argv| with_executor(|e| e.builtin_dbview(argv)),
1096        "profile" => |argv| with_executor(|e| e.builtin_profile(argv)),
1097        "provenance" => |argv| with_executor(|e| e.builtin_provenance(argv)),
1098        "zbanner" => |argv| with_executor(|e| e.builtin_zbanner(argv)),
1099        "caller" => |argv| with_executor(|e| e.builtin_caller(argv)),
1100        "help" => |argv| with_executor(|e| e.builtin_help(argv)),
1101        "cdreplay" => |argv| with_executor(|e| e.builtin_cdreplay(argv)),
1102        "zsleep" => |argv| crate::extensions::ext_builtins::zsleep(argv),
1103        _ => return None,
1104    };
1105    Some(f)
1106}
1107
1108/// Whether NAME is a zshrs-original opcode builtin or an enabled
1109/// host-registered native command: the names [`try_run_registered_builtin`]
1110/// would run.
1111pub(crate) fn is_registered_builtin(name: &str) -> bool {
1112    registered_builtin(name).is_some() || crate::native_cmds::is_enabled(name)
1113}
1114
1115/// Whether NAME is an autoload stub that has not been loaded yet: an enabled
1116/// shfunctab entry with PM_UNDEFINED set (c:Src/zsh.h:1895). Loading clears
1117/// the flag (c:Src/exec.c:5790, c:5815); a function defined in the shell
1118/// never carries it. Gates the native zmv/zcp/zln/zcalc ports, which stand
1119/// in for the fpath load of the stock autoload functions only.
1120fn autoload_stub_pending(name: &str) -> bool {
1121    crate::ported::hashtable::shfunctab_lock()
1122        .read()
1123        .ok()
1124        .and_then(|t| t.get(name).map(|f| (f.node.flags as u32 & crate::ported::zsh_h::PM_UNDEFINED as u32) != 0))
1125        .unwrap_or(false)
1126}
1127
1128/// Whether a shell function named NAME shadows a builtin of that name at
1129/// command dispatch (c:Src/exec.c:3485-3489: `shfunctab->getnode` before
1130/// `builtintab->getnode`). `getnode` skips DISABLED nodes (c:Src/hashtable.c:239), so a function
1131/// switched off with `disable -f NAME` does not shadow (Bug #221 in
1132/// docs/BUGS.md). `functions_compiled` keeps the body regardless of the
1133/// DISABLED flag, hence the shfunctab probe first.
1134fn user_fn_shadows_builtin(name: &str) -> bool {
1135    let disabled = crate::ported::hashtable::shfunctab_lock()
1136        .read()
1137        .ok()
1138        .and_then(|t| {
1139            let entry = t.get_including_disabled(name)?;
1140            Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
1141        })
1142        .unwrap_or(false);
1143    !disabled && with_executor(|exec| exec.functions_compiled.contains_key(name))
1144}
1145
1146/// The builtin table as the `builtin` precommand modifier consults it
1147/// (c:Src/exec.c:3489 `builtintab->getnode(builtintab, cmdarg)`): the ported
1148/// table minus DISABLED nodes (`getnode` skips them, so `disable typeset;
1149/// builtin typeset` is "no such builtin"), plus the zshrs builtins that live
1150/// outside it: the daemon `z*` family and the opcode builtins above.
1151pub(crate) fn builtin_prefix_finds(name: &str) -> bool {
1152    if crate::daemon::builtins::is_zshrs_builtin(name) {
1153        return true;
1154    }
1155    let disabled = crate::ported::builtin::BUILTINS_DISABLED
1156        .lock()
1157        .map(|s| s.contains(name))
1158        .unwrap_or(false);
1159    (crate::ported::builtin::createbuiltintable().contains_key(name) && !disabled)
1160        || is_registered_builtin(name)
1161}
1162
1163/// Run a daemon `z*` builtin (zcache, zd, zjob, …) by name. They are
1164/// dispatched by name instead of living in builtintab, so each resolver that
1165/// can land on a builtin name — literal head, `$var` head, `builtin NAME` —
1166/// routes them through here. `None` when `name` is not one of them.
1167fn run_daemon_builtin(name: &str, rest: &[String]) -> Option<i32> {
1168    if !crate::daemon::builtins::is_zshrs_builtin(name) {
1169        return None;
1170    }
1171    let argv: Vec<String> = std::iter::once(name.to_string())
1172        .chain(rest.iter().cloned())
1173        .collect();
1174    Some(crate::daemon::builtins::try_dispatch(name, &argv).unwrap_or(1))
1175}
1176
1177/// Dispatch a zshrs-ORIGINAL builtin by NAME, argv-style. These are
1178/// registered as fusevm opcodes in [`register_builtins`] (async, doctor,
1179/// peach, …), so a *literal* name compiles to `CallBuiltin` and runs. But
1180/// they are absent from the static `BUILTINS` port table and the merged
1181/// `builtintab`, so when the command name is resolved only at run time —
1182/// `$var` indirection, `builtin NAME` — the ported command-resolution path
1183/// never finds them and reports "command not found" / "no such builtin",
1184/// even though `whence` (correctly) calls them builtins. The
1185/// `register_builtins` closures use the VM only to pop args and then call an
1186/// executor method, so the identical dispatch works here from any parent-side
1187/// resolver that has an executor — no VM re-entry (which would alias the
1188/// running `&mut VM`). Returns `None` for a name that is not one of them, so
1189/// the caller falls through to external lookup.
1190///
1191/// !!! Keep in sync with the matching `vm.register_builtin(...)` closures in
1192/// `register_builtins`: both must route a name to the same executor method.
1193pub(crate) fn try_run_registered_builtin(name: &str, argv: &[String]) -> Option<i32> {
1194    if let Some(run) = registered_builtin(name) {
1195        return Some(run(argv));
1196    }
1197    // Host-registered native commands (`extensions/native_cmds.rs`): the
1198    // fat binary's sibling runtimes — `git` (zvcs), `arb` (arblang),
1199    // `stryke` (strykelang) in the zshrs-native build. Unknown here in the
1200    // thin shell, where the table is empty and this falls through to
1201    // `None` exactly as before.
1202    //
1203    // Reached from the two places that ask "is this a builtin?": the
1204    // pre-PATH arm of the ZshrsHost dispatch (after functions and after
1205    // builtintab, so a user `git()` still shadows it) and the forced
1206    // `builtin NAME` precommand. `command git` consults neither, so the
1207    // escape hatch to the `git` on PATH is untouched.
1208    //
1209    // The registry's contract is full argv — argv[0] is the command name
1210    // as invoked, which zvcs needs for its `git-<verb>` dashed form and
1211    // for its `zvcs: <command>: <reason>` diagnostics — while every arm
1212    // above takes the operands alone, so the name is spliced back on here.
1213    if !crate::native_cmds::is_enabled(name) {
1214        return None;
1215    }
1216    let full: Vec<String> = std::iter::once(name.to_string())
1217        .chain(argv.iter().cloned())
1218        .collect();
1219    crate::native_cmds::dispatch(name, &full)
1220}
1221
1222/// `command -v` / `command -V`: c:Src/exec.c:3149-3157 — `pushnode(preargs,
1223/// "command"); hn = &commandbn.node; is_builtin = 1;`, i.e. bin_whence with
1224/// the BIN_COMMAND funcid. `post` is the flag words then the names; `dash_p`
1225/// says a `-p` was among the flags.
1226fn command_whence(post: &[String], dash_p: bool) -> i32 {
1227    let mut ops = options {
1228        ind: [0u8; MAX_OPS],
1229        args: Vec::new(),
1230        argscount: 0,
1231        argsalloc: 0,
1232    };
1233    let mut name_pos = 0usize;
1234    let mut flag_byte = b'v';
1235    for (i, a) in post.iter().enumerate() {
1236        if a.starts_with('-') && a.len() >= 2 {
1237            let body = &a.as_bytes()[1..];
1238            if body.contains(&b'V') {
1239                flag_byte = b'V';
1240            }
1241            name_pos = i + 1;
1242        } else {
1243            name_pos = i;
1244            break;
1245        }
1246    }
1247    ops.ind[flag_byte as usize] = 1;
1248    // c:Src/builtin.c:4157 + c:4165-4167 — bin_whence reads `-p` off
1249    // `ops` twice: to prefer a builtin over an external for
1250    // `command -p[vV]`, and as `findcmd`'s `default_path` argument.
1251    if dash_p {
1252        ops.ind[b'p' as usize] = 1;
1253    }
1254    let whence_args: Vec<String> = post[name_pos..].to_vec();
1255    crate::ported::builtin::bin_whence(
1256        "command",
1257        &whence_args,
1258        &ops,
1259        crate::ported::hashtable_h::BIN_COMMAND,
1260    )
1261}
1262
1263/// Whether the redirect scope on top of the stack was opened by the
1264/// command now dispatching — its OWN redirections — rather than by an
1265/// enclosing compound command. C asks the same of the command's local
1266/// `redir` list / `save[]` array (c:Src/exec.c:3651, c:4300).
1267/// !!! RUST-ONLY — no C counterpart.
1268pub(crate) fn own_redirect_scope(exec: &ShellExecutor) -> bool {
1269    let depth = exec.redirect_scope_stack.len();
1270    depth > 0 && REDIR_SCOPE_OPENED.with(|c| c.get()) == (SUBLIST_SERIAL.with(|c| c.get()), depth)
1271}
1272
1273pub(crate) fn dispatch_builtin_raw(name: &str, args: Vec<String>) -> i32 {
1274    // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
1275    // Native p10k engine intercept (src/extensions/p10k): sourcing
1276    // powerlevel10k.zsh-theme activates the Rust segment engine
1277    // instead of executing the ~13k-line zsh theme. The user's
1278    // `.p10k.zsh` CONFIG is NOT intercepted — it sources normally so
1279    // its POWERLEVEL9K_* typesets land in the paramtab, which the
1280    // engine reads live at every render. Placed here (the chokepoint
1281    // every builtin route funnels through) so `source`, `.`, and
1282    // `builtin source` all hit it.
1283    if matches!(name, "source" | ".") {
1284        if let Some(status) = crate::p10k::maybe_intercept_theme_source(&args) {
1285            // Register a `p10k` stub function so `${+functions[p10k]}`
1286            // guards in .zshrc templates stay truthy. The body forwards
1287            // to the bridge-intercepted `zshrs-p10k-api` name so
1288            // `p10k segment` (custom-segment protocol) and the other
1289            // API subcommands reach the native engine (p10k_api).
1290            try_with_executor(|exec| {
1291                let _ = exec.execute_script("function p10k() { zshrs-p10k-api \"$@\" }");
1292            });
1293            return status;
1294        }
1295    }
1296    // Native p10k API dispatch — the `p10k` stub function forwards
1297    // here (see the theme intercept above). Must run before the
1298    // generic builtintab lookup: the name is not a real builtin.
1299    if let Some(status) = crate::p10k::maybe_intercept_command(name, &args) {
1300        return status;
1301    }
1302    // c:Src/exec.c:3674-3682 — a builtin runs in the current shell.
1303    crate::exec_jobs::mark_thisjob_cursh(true);
1304    // c:Src/exec.c:2700-2717 — `private` is an autoloaded builtin in
1305    // zsh (autofeature b:private of zsh/param/private): first use
1306    // runs ensurefeature → require_module → load_module → boot_,
1307    // marking the module MOD_INIT_B (what `zmodload -e` reads) and
1308    // installing the wrap_private FuncWrap (param_private.c:712).
1309    // doshfunc gates the wrapper dispatch on that load state, so
1310    // this require_module is what activates private scoping. The
1311    // raw dispatcher is the chokepoint every builtin route funnels
1312    // through; require_module is idempotent after the first call
1313    // (needs_load checks MOD_INIT_B).
1314    if name == "private" {
1315        if let Ok(mut tab) = crate::ported::module::MODULESTAB.lock() {
1316            let _ = crate::ported::module::require_module(
1317                &mut tab,
1318                "zsh/param/private",
1319                None,
1320                0,
1321                false,
1322            );
1323            // c:2710 ensurefeature
1324        }
1325    }
1326    // c:Src/Modules/param_private.c:682-685 setup_ — loading
1327    // zsh/param/private REPLACES the `local` builtintab node's
1328    // handlerfunc + optstr with bin_private's ("Even more horrible
1329    // hack"), so once the module is loaded `local` IS bin_private: it
1330    // accepts the -P/-Pa private-scope flags, and without -P delegates
1331    // to bin_typeset, which already treats `local` and `private`
1332    // identically (is_locallike, builtin.rs:3666). Replicate the swap by
1333    // routing `local` through the `private` node only after the module
1334    // is loaded — before then, `local -P` still errors "bad option: -P"
1335    // exactly like stock zsh. The `private` node carries the augmented
1336    // optstr (with P) that the `local` node lacks.
1337    if name == "local"
1338        && crate::ported::module::MODULESTAB
1339            .lock()
1340            .map(|t| t.is_bound("zsh/param/private"))
1341            .unwrap_or(false)
1342    {
1343        // c:Src/Modules/param_private.c:683-685 — the swap copies EXACTLY
1344        // two fields:
1345        //     ((Builtin)hn)->handlerfunc = bintab[0].handlerfunc;
1346        //     ((Builtin)hn)->optstr = bintab[0].optstr;
1347        // `defopts` is NOT copied, so the `local` node keeps its own
1348        // (NULL) defaults while `private`'s node keeps `"P"`. That is
1349        // what makes `local x` delegate straight to bin_typeset
1350        // (c:225-229 `if (!OPT_ISSET(ops, 'P'))`) while `private x`
1351        // opens a private scope. Dispatching `local` through the
1352        // `private` NODE inherited defopts="P", so every `local NAME`
1353        // ran the private-promotion path: `() { local h=scalar;
1354        // private -A h }` reported "can't change type of private param"
1355        // where zsh reports "can't change scope of existing param"
1356        // (V10private.ztst:13), and `local` silently made privates.
1357        // zshrs's builtintab maps to `&'static builtin` rows in an
1358        // immutable static, so mirror C's field swap on a private
1359        // static copy of the `local` node instead of mutating the table.
1360        static LOCAL_AS_PRIVATE: std::sync::LazyLock<crate::ported::zsh_h::builtin> =
1361            std::sync::LazyLock::new(|| crate::ported::zsh_h::builtin {
1362                // c:683 `save_local = *(Builtin)hn;` — start from the
1363                // real `local` row so name/flags/minargs/maxargs/funcid/
1364                // defopts all stay `local`'s.
1365                node: crate::ported::zsh_h::hashnode {
1366                    next: None,
1367                    nam: "local".to_string(),
1368                    flags: (crate::ported::zsh_h::BINF_PLUSOPTS
1369                        | crate::ported::zsh_h::BINF_MAGICEQUALS
1370                        | crate::ported::zsh_h::BINF_PSPECIAL
1371                        | crate::ported::zsh_h::BINF_ASSIGN) as i32,
1372                },
1373                // c:684 — handlerfunc from bintab[0] (bin_private).
1374                handlerfunc: Some(
1375                    crate::ported::modules::param_private::bin_private
1376                        as crate::ported::zsh_h::HandlerFunc,
1377                ),
1378                minargs: 0,
1379                maxargs: -1,
1380                funcid: 0,
1381                // c:685 — optstr from bintab[0] (private's, which adds `P`).
1382                // Same string as the `private` row in builtin.rs (5.9.2's `p`).
1383                optstr: Some("AE:%F:%HL:%PR:%TUZ:%ahi:%lmnprtux".to_string()),
1384                // c:683-685 — NOT copied: `local` keeps its own defaults.
1385                defopts: None,
1386            });
1387        let bn_ptr = &*LOCAL_AS_PRIVATE as *const _ as *mut _;
1388        return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
1389    }
1390    // c:Bugs #475/#504/#555 — bash-only builtins (`mapfile`,
1391    // `readarray`, `compopt`) should emit "command not found" in
1392    // `--zsh` mode matching zsh's external-command-lookup miss.
1393    // The per-opcode closures for caller/help/complete/compgen
1394    // already gate via IS_ZSH_MODE at their registration sites;
1395    // names without dedicated opcodes (compopt/mapfile/readarray)
1396    // route through this generic builtintab lookup and need the
1397    // gate here.
1398    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
1399        && matches!(name, "compopt" | "mapfile" | "readarray")
1400    {
1401        eprintln!("zsh:1: command not found: {}", name);
1402        let _ = args;
1403        return 127;
1404    }
1405    // c:Src/exec.c:2700-2724 resolvebuiltin — autoloaded-builtin stub
1406    // (registered by `zmodload -ab MOD NAME`, Src/module.c:426
1407    // add_autobin) fires on first use: ensurefeature loads the owning
1408    // module, then dispatch proceeds against the real builtin. Must
1409    // run BEFORE the module-bound 127 gate below — `zmodload -ab
1410    // zsh/zselect zselect; zselect` previously died there with
1411    // `command not found` because the gate only checked is_loaded,
1412    // never the autoload ledger.
1413    if let Some(rc) = crate::ported::exec::resolvebuiltin(name) {
1414        if rc != 0 {
1415            // Load failed or feature undefined — diagnostics already
1416            // printed (load_module zwarn / resolvebuiltin zerr).
1417            // C's execbuiltin head returns 1 (Src/builtin.c:264-267).
1418            return 1;
1419        }
1420        // Module loaded — fall through; the is_loaded gates below now
1421        // pass and the normal dispatch chain runs the real builtin.
1422    }
1423    // c:Src/Modules/<mod>.c boot_/setup_ chain — module-bound builtins
1424    // (zftp, zsocket, ztcp, zstat, etc.) are only registered into
1425    // `builtintab` when their module is loaded via `zmodload`. In
1426    // zsh `-fc` (the parity test harness's invocation), the modules
1427    // are NOT pre-loaded, so each name reports "command not found"
1428    // with exit 127. zshrs intentionally pre-loads all module bintabs
1429    // in `createbuiltintable` (builtin.rs:131-152) for the default
1430    // mode so users can call these without `zmodload`; that auto-load
1431    // diverges from zsh's gate behavior. Match zsh's stance only when
1432    // the user explicitly asked for parity via `--zsh`.
1433    //
1434    // The list is the union of builtins from modules that zsh does
1435    // NOT auto-load (verified via `zsh -fc <name>` returning 127):
1436    //   zsh/zftp          → zftp
1437    //   zsh/net/socket    → zsocket
1438    //   zsh/net/tcp       → ztcp
1439    //   zsh/stat          → zstat (NOT `stat`; that name resolves to
1440    //                              /bin/stat on PATH per zsh's setup)
1441    //   zsh/zselect       → zselect
1442    //   zsh/zpty          → zpty
1443    //   zsh/zprof         → zprof
1444    //   zsh/system        → zsystem, syserror
1445    //   zsh/clone         → clone
1446    //   zsh/curses        → zcurses
1447    //   zsh/db/gdbm       → ztie, zuntie, zgdbmpath
1448    //   zsh/pcre          → pcre_compile, pcre_match, pcre_study
1449    //   zsh/example       → example
1450    //   zsh/cap           → cap, getcap, setcap
1451    //   zsh/attr          → zgetattr, zsetattr, zdelattr, zlistattr
1452    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
1453        && module_bound_builtin_module(name)
1454            .map(|m| {
1455                !crate::ported::module::MODULESTAB
1456                    .lock()
1457                    .map(|t| t.is_loaded(m))
1458                    .unwrap_or(false)
1459            })
1460            .unwrap_or(false)
1461    {
1462        eprintln!("zsh:1: command not found: {}", name);
1463        let _ = args;
1464        return 127;
1465    }
1466    // c:Src/Modules/files.c:806-824 — zsh/files registers `chmod`,
1467    // `chown`, `chgrp`, `ln`, `mkdir`, `mv`, `rm`, `rmdir`, `sync`
1468    // (plus their `zf_*` aliases) into builtintab on module load.
1469    // Without an explicit `zmodload zsh/files`, zsh resolves the
1470    // names through PATH lookup — `zsh -fc 'chmod +x f'` runs
1471    // `/bin/chmod`, whose argv-parser accepts symbolic modes like
1472    // `+x` that bin_chmod's octal-only parser rejects with
1473    // "invalid mode `+x'". The shadow-aware wrapper at
1474    // `dispatch_builtin` (line 438) already has this gate, but the
1475    // direct `dispatch_builtin_raw` path used by fusevm's
1476    // CallBuiltin opcode bypasses it. Mirror the gate here so the
1477    // low-level dispatch matches C's PATH-fall-through behavior.
1478    // The gate is NOT emulation-mode dependent. C has no `zsh/files`
1479    // builtins in `builtintab` until the module is loaded, in ANY mode, so a
1480    // bare `rm`/`mv`/`chmod` falls through to PATH — `chmod +x FILE` runs
1481    // /bin/chmod and succeeds. Conditioning this on `IS_ZSH_MODE` meant the
1482    // native binary answered `chmod +x` from `bin_chmod`'s octal-only parser
1483    // ("invalid mode `+x'"), and `rm -s` / `mv -s` from the module's argument
1484    // parser instead of the system tool's. `dispatch_builtin` at :1087 already
1485    // gates unconditionally; this low-level `CallBuiltin` path did not.
1486    if files_builtin_runs_from_path(name) {
1487        // PATH lookup uses the LITERAL name: bare `mkdir` finds
1488        // /bin/mkdir; a `zf_*` alias finds nothing and exits 127 —
1489        // matching zsh -fc `zf_mkdir d` → "command not found:
1490        // zf_mkdir" (the aliases exist ONLY in the loaded module's
1491        // builtintab, Src/Modules/files.c:816-824; PATH has no
1492        // /bin/zf_rm). The previous zf_-strip silently ran the
1493        // system binary instead.
1494        if let Some(status) = external_glob_failure_status() {
1495            return status;
1496        }
1497        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1498        return status;
1499    }
1500    // c:Src/Modules/stat.c:637-638 — zsh/stat registers BOTH `stat`
1501    // and `zstat`. `zstat` is in the module_bound 127-gate above (no
1502    // /usr/bin/zstat exists), but the bare `stat` name must FALL
1503    // THROUGH to PATH when zsh/stat isn't loaded — zsh -fc
1504    // 'stat -f %Lp f' runs /usr/bin/stat, while bin_stat's parser
1505    // rejects stat(1) flags ("bad option: -c"). Same fall-through
1506    // shape as the zsh/files gate above.
1507    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
1508        && name == "stat"
1509        && !crate::ported::module::MODULESTAB
1510            .lock()
1511            .map(|t| t.is_loaded("zsh/stat"))
1512            .unwrap_or(false)
1513    {
1514        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1515        return status;
1516    }
1517    // c:Src/exec.c:3050-3068 — builtin lookup hits `builtintab` (the
1518    // merged table containing module-provided builtins). The previous
1519    // port walked only the core `BUILTINS` slice, so per-module
1520    // entries like `log` (Src/Modules/watch.c:693 `BUILTIN("log", …,
1521    // bin_log, …)`) were registered into builtintab via
1522    // createbuiltintable but never reached at dispatch — `log` fell
1523    // through to PATH and ran `/usr/bin/log`. Bug #72 in docs/BUGS.md.
1524    let tab = crate::ported::builtin::createbuiltintable();
1525    if let Some(bn_static) = tab.get(name) {
1526        let bn_ptr = *bn_static as *const _ as *mut _;
1527        return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
1528    }
1529    1
1530}
1531
1532/// Shadow-aware dispatch matching zsh's name-resolution order:
1533/// alias → reserved word → **function (shadows builtin)** → builtin →
1534/// external. All `BUILTIN_X` opcode handlers route through here so a
1535/// user-defined `cd () { … }` (or `r`, `fc`, `which`, … anything in
1536/// fusevm's name→opcode map) takes precedence over the C builtin —
1537/// matching `Src/exec.c:execcmd_exec`'s dispatch at c:3050-3068.
1538/// Without this, compile-time builtin resolution silently ignored
1539/// user wrappers (e.g. ZPWR's `cd () { builtin cd "$@"; … }`).
1540/// True for builtins that are bound by zsh/files's boot_/setup_
1541/// chain (Src/Modules/files.c:806-824). These are the bare-name
1542/// `mkdir`/`rm`/`mv`/`ln`/`chmod`/`chown`/`chgrp`/`sync`/`rmdir`
1543/// AND their `zf_*` aliases at c:816-824. Without explicit
1544/// `zmodload zsh/files`, the names fall through to PATH lookup
1545/// (zsh's `type rm` reports `/bin/rm`). Bug #28.
1546fn module_gated_files_builtin(name: &str) -> bool {
1547    matches!(
1548        name,
1549        "mkdir"
1550            | "rmdir"
1551            | "rm"
1552            | "mv"
1553            | "ln"
1554            | "chmod"
1555            | "chown"
1556            | "chgrp"
1557            | "sync"
1558            | "zf_mkdir"
1559            | "zf_rmdir"
1560            | "zf_rm"
1561            | "zf_mv"
1562            | "zf_ln"
1563            | "zf_chmod"
1564            | "zf_chown"
1565            | "zf_chgrp"
1566            | "zf_sync"
1567    )
1568}
1569
1570/// A zsh/files name while the module is not loaded: C has no such entry in
1571/// `builtintab` then, so the name runs from PATH like any external.
1572fn files_builtin_runs_from_path(name: &str) -> bool {
1573    module_gated_files_builtin(name)
1574        && !crate::ported::module::MODULESTAB
1575            .lock()
1576            .map(|t| t.is_loaded("zsh/files"))
1577            .unwrap_or(false)
1578}
1579
1580thread_local! {
1581    /// Set when a command-word glob (BUILTIN_GLOB_EXPAND / BUILTIN_GLOBLIST)
1582    /// is what raised errflag, so the abort takes C's globlist status
1583    /// (c:Src/exec.c:3760 `lastval = 1`) rather than the prefork one
1584    /// (c:3523 `if (!lastval) lastval = 1`).
1585    ///
1586    /// !!! WARNING: RUST-ONLY CARRIER !!! C knows which of the two checks
1587    /// it is at; zshrs expands and globs each word in one pass and reaches
1588    /// a single gate at dispatch.
1589    static GLOB_WORD_ERRFLAG: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1590}
1591
1592/// Run a command-word glob and note whether it raised errflag.
1593fn glob_word_noting_errflag(raw: Value, skip_glob: bool) -> Value {
1594    use std::sync::atomic::Ordering;
1595    let before = crate::ported::utils::errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR;
1596    let out = glob_expand_word_value(raw, skip_glob);
1597    if before == 0
1598        && crate::ported::utils::errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR != 0
1599    {
1600        GLOB_WORD_ERRFLAG.with(|c| c.set(true));
1601    }
1602    out
1603}
1604
1605/// Glob one BUILTIN_EXPAND_TEXT mode-10 result inside BUILTIN_GLOBLIST.
1606/// EXPAND_TEXT left the glob-eligible words tokenized (their `haswilds`
1607/// test, c:Src/glob.c:1230, ran on the tokenized word) and every other word
1608/// untokenized, so the token test picks the same words out again; noglob was
1609/// already applied there. The glob call and the empty-result shapes are the
1610/// ones mode 0 uses.
1611fn glob_tokenized_word(raw: Value, noglob: bool) -> Value {
1612    use std::sync::atomic::Ordering;
1613    let words: Vec<String> = match &raw {
1614        Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
1615        other => vec![other.to_str()],
1616    };
1617    let mut parts: Vec<String> = Vec::with_capacity(words.len());
1618    for w in words {
1619        // c:Src/glob.c:1872 — NO_GLOB leaves the word literal; a word that came
1620        // back tokenized only for its deferred filesub reaches here too.
1621        // c:Src/glob.c:1230 — `unset(EXECOPT)` is part of the same early return.
1622        if noglob
1623            || !crate::ported::zsh_h::isset(crate::ported::zsh_h::EXECOPT)
1624            || !crate::ported::pattern::haswilds(&w)
1625        {
1626            // c:Src/glob.c:1232 — zglob untokenizes a word it does not glob; a
1627            // deferred `=cmd` / `~user` that filesub left alone (NOMATCH off)
1628            // still carries its token.
1629            parts.push(crate::ported::lex::untokenize(&w).to_string());
1630            continue;
1631        }
1632        let ef = || crate::ported::utils::errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR;
1633        let before = ef();
1634        let globbed = with_executor(|exec| exec.expand_glob(&w));
1635        if before == 0 && ef() != 0 {
1636            GLOB_WORD_ERRFLAG.with(|c| c.set(true)); // c:Src/exec.c:3760-3761
1637        }
1638        parts.extend(globbed);
1639    }
1640    if parts.len() == 1 {
1641        Value::str(parts.into_iter().next().unwrap_or_default())
1642    } else {
1643        Value::array(parts.into_iter().map(Value::str).collect())
1644    }
1645}
1646
1647/// The value of a word whose prefork an `(e)` NULL stopped
1648/// (c:Src/subst.c:142-147): each node left in the list, rendered with its quote
1649/// tokens (c:Src/exec.c:2134 untokenize), one word per node.
1650fn stopped_nodes_value(nodes: Vec<String>) -> Value {
1651    let mut words: Vec<Value> = nodes
1652        .iter()
1653        .map(|n| Value::str(crate::ported::lex::untokenize_ztokens(n)))
1654        .collect();
1655    match words.len() {
1656        0 => Value::str(String::new()),
1657        1 => words.remove(0),
1658        _ => Value::array(words),
1659    }
1660}
1661
1662/// Run the filesub BUILTIN_EXPAND_TEXT mode 10 deferred on one word's
1663/// elements (c:Src/subst.c:178-182, `flags` 0 for a command argument).
1664fn filesub_deferred_word(raw: Value) -> Value {
1665    let expand = |s: String| -> String {
1666        if s.starts_with(crate::ported::zsh_h::Tilde) || s.starts_with(crate::ported::zsh_h::Equals) {
1667            crate::ported::subst::filesub(&s, 0)
1668        } else {
1669            s
1670        }
1671    };
1672    match raw {
1673        Value::Array(items) => {
1674            Value::array(items.iter().map(|v| Value::str(expand(v.to_str()))).collect())
1675        }
1676        other => Value::str(expand(other.to_str())),
1677    }
1678}
1679
1680/// Status for a command whose words raised errflag, stored into LASTVAL:
1681/// c:Src/exec.c:3760-3761 `lastval = 1` after a globlist error, else
1682/// c:3523-3524 `if (!lastval) lastval = 1` after a prefork error.
1683fn words_errflag_status() -> i32 {
1684    use std::sync::atomic::Ordering;
1685    let status = if GLOB_WORD_ERRFLAG.with(|c| c.replace(false)) {
1686        1 // c:3761
1687    } else {
1688        match crate::ported::builtin::LASTVAL.load(Ordering::Relaxed) {
1689            0 => 1, // c:3524
1690            lastval => lastval,
1691        }
1692    };
1693    crate::ported::builtin::LASTVAL.store(status, Ordering::Relaxed);
1694    status
1695}
1696
1697thread_local! {
1698    /// c:Src/subst.c:142-147 / c:326-327 — paramsubst returned NULL (a failed
1699    /// `(e)` re-lex), so prefork stops: the current word ends at the `$`
1700    /// (c:1878) and the later words of the same list stay unexpanded. Read,
1701    /// never cleared, by BUILTIN_PREFORK_CUT_CHECK; cleared at the sublist
1702    /// prologue and when the command dispatches.
1703    ///
1704    /// !!! WARNING: RUST-ONLY CARRIER !!! C's NULL return unwinds prefork's
1705    /// loop in one step; zshrs expands a command's words across many VM ops.
1706    static PREFORK_CUT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1707}
1708
1709/// Take `subst::PARAMSUBST_NULL` right after a direct paramsubst call and
1710/// turn it into the PREFORK_CUT carrier.
1711fn take_paramsubst_null() -> bool {
1712    let null_return = crate::ported::subst::PARAMSUBST_NULL.with(|c| c.replace(false));
1713    if null_return {
1714        PREFORK_CUT.with(|c| c.set(true));
1715    }
1716    null_return
1717}
1718
1719/// Consume the `-` precommand carrier (see BUILTIN_EXEC_DASH).
1720pub(crate) fn take_exec_dash() -> bool {
1721    take_exec_carrier() & EXEC_CARRIER_DASH != 0
1722}
1723
1724/// `EXEC_DASH` bit: the `-` precommand (c:Src/exec.c:772-776).
1725pub(crate) const EXEC_CARRIER_DASH: u8 = 1;
1726/// `EXEC_DASH` bit: exec in the already-forked child (c:Src/exec.c:4369).
1727pub(crate) const EXEC_CARRIER_FORKED: u8 = 2;
1728/// `EXEC_DASH` bit: the exec replaces the shell itself, so SHLVL drops first
1729/// (exec_shlvl_decrement).
1730pub(crate) const EXEC_CARRIER_SHLVL: u8 = 4;
1731
1732/// c:Src/exec.c:3717 — `nsigtrapped || havefiles() || fdtable_flocks`: what
1733/// stops an exiting list's last command from exec'ing in place. nsigtrapped
1734/// counts real traps only (c:Src/signals.c:725 — an ignored signal is not
1735/// counted); zshrs keeps string traps in traps_table as well as sigtrapped.
1736fn fake_exec_blocked() -> bool {
1737    use crate::ported::zsh_h::{ZSIG_FUNC, ZSIG_TRAPPED};
1738    let trapped = crate::ported::signals::sigtrapped
1739        .lock()
1740        .map(|t| t.iter().any(|&st| st & (ZSIG_TRAPPED | ZSIG_FUNC) != 0))
1741        .unwrap_or(true)
1742        || crate::ported::builtin::traps_table()
1743            .lock()
1744            .map(|t| t.values().any(|body| !body.is_empty()))
1745            .unwrap_or(true);
1746    let files = crate::ported::jobs::JOBTAB
1747        .get()
1748        .and_then(|tab| tab.lock().ok().map(|jt| crate::ported::jobs::havefiles(&jt)))
1749        .unwrap_or(false);
1750    trapped
1751        || files
1752        || crate::ported::utils::FDTABLE_FLOCKS.load(std::sync::atomic::Ordering::Relaxed) != 0
1753}
1754
1755/// c:Src/exec.c:4332-4336 —
1756/// ```c
1757/// if (!subsh) {
1758///     /* for either implicit or explicit "exec", decrease $SHLVL
1759///      * as we're now done as a shell */
1760///     if (!forked)
1761///         setiparam("SHLVL", --shlvl);
1762/// ```
1763/// For an exec that replaces the shell itself (explicit `exec`, or the fake
1764/// exec of the `-c` string's last command): the command inherits one level
1765/// LESS than the shell counted for itself — it is taking the shell's place,
1766/// not nesting inside it.
1767///
1768/// C gets the environment update for free: SHLVL is PM_EXPORTED (from
1769/// `addenv` in createparamtable, c:Src/params.c:951), and `setiparam` on an
1770/// exported parameter runs setnumvalue → setstrvalue(NULL) → export_param →
1771/// addenv (c:2872, c:2841, c:2672). zshrs's `setnumvalue`
1772/// (src/ported/params.rs:5588-5595) has no `setstrvalue(v, NULL)` tail, so
1773/// the paramtab write alone would leave the environment holding the
1774/// pre-decrement number and the exec'd command would read one too high.
1775/// Publish it explicitly.
1776pub(crate) fn exec_shlvl_decrement() {
1777    let cur = crate::ported::params::getsparam("SHLVL")
1778        .and_then(|s| s.parse::<i64>().ok())
1779        .unwrap_or(1);
1780    crate::ported::params::setiparam("SHLVL", cur - 1); // c:4336
1781    crate::ported::params::addenv("SHLVL", &(cur - 1).to_string()); // c:2672
1782}
1783
1784/// Take the job text of the command being dispatched (see JOB_TEXT).
1785pub(crate) fn take_job_text() -> Option<String> {
1786    JOB_TEXT.with(|t| t.borrow_mut().take()).map(|v| v.to_str())
1787}
1788
1789/// Consume every external-spawn carrier bit (see EXEC_DASH).
1790pub(crate) fn take_exec_carrier() -> u8 {
1791    EXEC_DASH.with(|c| c.replace(0))
1792}
1793
1794pub(crate) fn dispatch_builtin(name: &str, args: Vec<String>) -> i32 {
1795    // c:Src/exec.c:772-776 — BINF_DASH only changes an external's argv[0].
1796    take_exec_dash();
1797    take_job_text();
1798    PREFORK_CUT.with(|c| c.set(false)); // the words are complete
1799
1800    // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close any
1801    // `>(cmd)` write ends owned by this command once it finishes
1802    // (drops on every return path below).
1803    let _psub_fds = PsubFdGuard;
1804    // c:Src/exec.c — when any redirect in the current scope failed
1805    // (e.g. noclobber blocked a `>` overwrite), zsh refuses to
1806    // execute the command and exits with status 1. The Rust port
1807    // still applied the command (writing to the /dev/null sink
1808    // installed by host_apply_redirect's noclobber arm) so the
1809    // success status overwrote the intended 1. Short-circuit here
1810    // for builtins (the external-exec equivalent lives in
1811    // ZshrsHost::exec).
1812    let redir_failed = with_executor(|exec| {
1813        let f = exec.redirect_failed;
1814        exec.redirect_failed = false;
1815        f
1816    });
1817    if redir_failed {
1818        // c:Src/exec.c:4367-4386 — POSIX special-builtin escalation:
1819        // a failed redirect on a PSPECIAL builtin (set, readonly,
1820        // typeset, ...) under POSIX_BUILTINS is FATAL in a
1821        // non-interactive shell (`exit(1)` at c:4383). The `command`
1822        // prefix resets this (BINF_COMMAND, c:4369) — that path
1823        // dispatches through bin_command, not here.
1824        if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
1825            && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
1826            && builtin_is_pspecial(name)
1827        {
1828            use std::sync::atomic::Ordering;
1829            crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
1830            crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
1831        }
1832        // c:Src/exec.c:252-256 execerr — `redir_err = lastval = 1;`. The
1833        // redirect arm already stored it, but the statement prologue between
1834        // the Redirect op and this call (BUILTIN_XTRACE_ARGS) copies the VM's
1835        // stale status back over LASTVAL, and the errflag abort that follows
1836        // exits with LASTVAL: `print hi <&""` exited 0 where zsh exits 1.
1837        crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
1838        return 1;
1839    }
1840    // c:Src/glob.c:1876-1880 NOMATCH path — when expand_glob() failed
1841    // on a no-match glob, zsh aborts the simple command after zerr()
1842    // printed "no matches found". In C, this works because zerr()
1843    // sets ERRFLAG_ERROR (Src/utils.c) and execcmd_exec()
1844    // (Src/exec.c:3050+) checks errflag before invoking the builtin
1845    // table. Rust's builtin dispatch doesn't sit on the same errflag
1846    // gate, so we explicitly consume the per-command glob-fail cell
1847    // and short-circuit with status 1. Mirrors the external-path
1848    // guard at host_exec_external (line 5167). Without this:
1849    // `echo /never/*` would print empty (silently rolled back to ""
1850    // by the empty glob expansion). Parity bug #13.
1851    //
1852    // A zsh/files name that runs from PATH (the arm below) is an external:
1853    // its glob failure ends the forked child, not the shell.
1854    if files_builtin_runs_from_path(name) {
1855        if let Some(status) = external_glob_failure_status() {
1856            return status;
1857        }
1858    }
1859    consume_tilde_globsubst_carrier();
1860    let glob_failed = with_executor(|exec| {
1861        let f = exec.current_command_glob_failed.get();
1862        exec.current_command_glob_failed.set(false); // c:1879 cleanup
1863        f
1864    });
1865    let postassign_glob_failed = TYPESET_POSTASSIGN_GLOB_FAILED.with(|c| c.replace(false));
1866    if postassign_glob_failed && !glob_failed {
1867        // c:Src/exec.c:4167-4285 — a typeset-family command's postassigns
1868        // (every word from its first `NAME=…` on, c:Src/parse.c:1986-1989 and
1869        // c:2008-2050) are globbed inside the builtin branch, after the
1870        // command words: `globlist(…); if (errflag) { …; break; }`. Then
1871        // c:4287 `if (!errflag) { … lastval = ret; }` skips the builtin
1872        // WITHOUT storing a status, so `lastval` keeps the previous command's
1873        // value (`print hi; local -a a=(zzq*)` exits 0, after `false` 1)
1874        // while ERRFLAG_ERROR still ends the script. A command word globbed
1875        // before them fails at c:3755-3763 instead, with `lastval = 1`.
1876        return crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
1877    }
1878    if glob_failed {
1879        // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH zerr sets
1880        // ERRFLAG_ERROR (via utils.c:184). For a BUILTIN command the
1881        // expansion runs IN the shell process, so errflag stays set
1882        // and the rest of the input aborts (zsh -fc 'echo /nope_*;
1883        // echo after' prints nothing after the error — verified
1884        // against zsh 5.9). The continue-after-nomatch behaviour
1885        // belongs ONLY to externals: C forks BEFORE expansion there,
1886        // so the child's zerr can't touch the parent's errflag (zsh
1887        // -fc 'ls /nope_*; echo after' prints `after`) — that path's
1888        // clear lives in fn exec / execute_external. Leave
1889        // ERRFLAG_ERROR set here; BUILTIN_ERREXIT_CHECK trigger 4
1890        // aborts the remaining script at the next command boundary.
1891        return 1; // c:1880 — command aborted, status 1
1892    }
1893    // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the NOMATCH
1894    // gate above: all of this command's globs failed silently (words
1895    // dropped, badcshglob accumulated 1s and no 2s) → `no match`,
1896    // skip the builtin, status 1. Like the NOMATCH path, ERRFLAG
1897    // from zerr stays set for builtins so the rest of the script
1898    // aborts (zsh -fc 'setopt cshnullglob; print *nope* x; print
1899    // after' prints only the error — verified zsh 5.9.1).
1900    if consume_badcshglob() {
1901        // c:Src/exec.c:3380 — `lastval = 1;` so the shell's final
1902        // exit status reflects the aborted command.
1903        with_executor(|exec| exec.set_last_status(1));
1904        return 1;
1905    }
1906    // c:Src/exec.c:4162-4295 — assignment-builtin (BINF_ASSIGN family:
1907    // typeset / declare / local / export / readonly / integer / float /
1908    // private) whose `name=value` postassign arg raised errflag while
1909    // its RHS was preforked (PREFORK_ASSIGN, c:4239-4245) — the classic
1910    // case is a math error in `typeset -F fv=$((1/0))`. The postassign
1911    // loop `break`s on errflag (c:4243) and then `if (!errflag)
1912    // execbuiltin(...)` (c:4287) SKIPS the builtin entirely, so `lastval`
1913    // is left UNCHANGED from before the command (0 fresh, 1 after
1914    // `false`). This differs from a PLAIN assignment `x=$((1/0))`, which
1915    // goes through execsimple c:1375 `lv = errflag ? errflag : cmdoutval`
1916    // → 1, and from a NON-assign builtin `print $((1/0))`, whose main
1917    // args-prefork errflag lands on c:3760 `lastval = 1`. Only the
1918    // assignment-BUILTIN postassign path preserves the prior status.
1919    // Mirror it here: the fusevm reg_passthru dispatch still calls us
1920    // with errflag set (unlike C's pre-invoke gate), so consume that
1921    // state and return the prior LASTVAL instead of running the builtin.
1922    {
1923        use std::sync::atomic::Ordering;
1924        let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
1925        let ef = live & crate::ported::zsh_h::ERRFLAG_ERROR;
1926        let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
1927        // Only the SOFT recoverable error (math failure like `$((1/0))`,
1928        // ERRFLAG_ERROR without ERRFLAG_HARD) preserves the prior status
1929        // per c:4287. A HARD error (`${var?msg}`, which c:Src/subst.c
1930        // OR's ERRFLAG_HARD onto errflag) is a script-abort that yields
1931        // status 1 regardless of the prior status — leave that to the
1932        // normal dispatch/abort path below (which returns 1 and keeps
1933        // ERRFLAG_HARD set for the downstream errexit gate).
1934        if ef != 0 && hard == 0 && builtin_is_assign_family(name) {
1935            // c:4287 — execbuiltin skipped; lastval unchanged.
1936            return crate::ported::builtin::LASTVAL.load(Ordering::Relaxed);
1937        }
1938        // c:Src/exec.c:3523-3525 — an expansion error in the command words
1939        // (prefork, c:3357-3359) ends the command before anything runs:
1940        //     if (errflag) { if (!lastval) lastval = 1; … return; }
1941        // so the previous command's non-zero status survives
1942        // (`nosuchcmd; print ${.bad}` exits 127). A glob failure took the
1943        // c:3760 `lastval = 1` return above; a HARD error keeps status 1.
1944        if ef != 0 && hard == 0 {
1945            return words_errflag_status();
1946        }
1947    }
1948    if let Some(status) = try_user_fn_override(name, &args) {
1949        // c:Src/jobs.c:1748 waitonejob — canonical single-command
1950        // pipestats update via the no-procs else-branch.
1951        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1952        let mut synth = crate::ported::zsh_h::job::default();
1953        crate::ported::jobs::waitonejob(&mut synth);
1954        return status;
1955    }
1956    // c:Src/builtin.c:587 + Src/exec.c:3056 — a builtin disabled via
1957    // `disable <name>` has its `DISABLED` flag set in `builtintab`;
1958    // `builtintab->getnode` (the DISABLED-filtering accessor) returns
1959    // NULL for it at lookup time, so execcmd_exec falls through to
1960    // PATH lookup and runs the external. The Rust port stores the
1961    // disabled set in `BUILTINS_DISABLED`; the previous dispatcher
1962    // only checked the immutable `createbuiltintable` HashMap which
1963    // never reflects disablement — so `disable echo; echo hi` kept
1964    // running the bin_echo builtin. Bug #106 in docs/BUGS.md.
1965    //
1966    // dispatch_builtin (the high-level wrapper used by the BUILTIN_*
1967    // opcode handlers and reg_passthru! callsites) is the correct
1968    // gate: `dispatch_builtin_raw` is the low-level entry point
1969    // used by `bin_builtin` itself which MUST bypass the disabled
1970    // set (man zshbuiltins: `builtin name` runs the builtin
1971    // regardless of disable state). Place the check here so the
1972    // bypass path stays clean.
1973    // c:Src/exec.c:3088-3096 — a WC_TYPESET command finds the builtin with
1974    // `getnode2`, which ignores DISABLED: `disable typeset` leaves the
1975    // reserved-word form running the builtin.
1976    let typeset_reswd = TYPESET_RESWD_DISPATCH.with(|c| c.replace(false));
1977    let disabled = !typeset_reswd
1978        && crate::ported::builtin::BUILTINS_DISABLED
1979            .lock()
1980            .map(|s| s.contains(name))
1981            .unwrap_or(false);
1982    if disabled {
1983        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1984        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1985        let mut synth = crate::ported::zsh_h::job::default();
1986        crate::ported::jobs::waitonejob(&mut synth);
1987        return status;
1988    }
1989    // c:Src/Modules/files.c:806-814 — `mkdir`, `rm`, `mv`, `ln`, `chmod`,
1990    // `chown`, `chgrp`, `sync`, `rmdir` are bound by the `zsh/files`
1991    // module's boot_/setup_ chain. Without explicit `zmodload zsh/files`,
1992    // these bare names fall through to PATH (`/bin/rm`, `/usr/bin/chmod`,
1993    // etc.) in zsh; `type rm` reports `rm is /bin/rm`. The `zf_*`
1994    // aliases (`zf_rm`, `zf_chmod`, …) are bound by the same module
1995    // and gated the same way. Bug #28 in docs/BUGS.md.
1996    if files_builtin_runs_from_path(name) {
1997        // PATH lookup uses the literal name. In --zsh parity mode
1998        // `zf_rm` must 127 like zsh -fc (no /bin/zf_rm); default
1999        // zshrs mode keeps the convenience zf_-strip so the alias
2000        // still reaches the system binary.
2001        let path_name = if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2002            name
2003        } else {
2004            name.strip_prefix("zf_").unwrap_or(name)
2005        };
2006        let status =
2007            with_executor(|exec| exec.execute_external(path_name, &args, &[])).unwrap_or(127);
2008        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
2009        let mut synth = crate::ported::zsh_h::job::default();
2010        crate::ported::jobs::waitonejob(&mut synth);
2011        return status;
2012    }
2013    // c:Src/exec.c:3997 `int q = queue_signal_level();`
2014    // c:Src/exec.c:4231 `dont_queue_signals();`
2015    // c:Src/exec.c:4243 `restore_queue_signals(q);`
2016    //
2017    // C runs EVERY builtin with signal queueing switched OFF. Two
2018    // consequences the zshrs port was missing:
2019    //
2020    //   1. `dont_queue_signals()` DRAINS the pending queue (it calls
2021    //      run_queued_signals()), so a signal that arrived while an
2022    //      enclosing scope held queue_signals() — doshfunc holds one
2023    //      for the whole call, c:Src/exec.c:5835 — fires its trap at
2024    //      the NEXT command boundary rather than at function exit.
2025    //   2. While the builtin runs, queueing stays off, so a signal the
2026    //      builtin sends to itself (`kill -USR1 $$`) dispatches the
2027    //      trap synchronously inside the builtin — which is why zsh
2028    //      prints pre/trap/post for
2029    //      `f() { print pre; kill -USR1 $$; print post }`.
2030    //
2031    // Without this bracket every trap raised inside a function was
2032    // deferred to the enclosing unqueue_signals() (i.e. script end).
2033    // c:Src/exec.c:3546 — `setunderscore((args && nonempty(args)) ?
2034    // ((char *) getdata(lastnode(args))) : "");`. execcmd_exec sets `$_`
2035    // to the last word of the command it is ABOUT to run — after the
2036    // words were expanded, before the builtin/external executes — so a
2037    // builtin that READS `_` at run time (`typeset -p _`, `${(P)…}`,
2038    // `$parameters[_]`) sees its own last argument, not the previous
2039    // command's. zshrs only did this for a handful of builtins (echo,
2040    // print, true, false, `:`) and for the external/function paths;
2041    // every reg_passthru! builtin was left reading the stale value.
2042    // C's `args` list carries argv[0], so a bare `cat` sets `_=cat` —
2043    // hence the fallback to `name` when there are no arguments.
2044    // c:Src/parse.c:2003-2008 — under the TYPESET reserved word the words
2045    // from the first `NAME=…` on are postassigns, not args: `local b c=2`
2046    // sets `_=b`, `typeset -g a=1` sets `_=-g`.
2047    let n_post = TYPESET_POSTASSIGN_COUNT.with(|c| c.take()).filter(|_| typeset_reswd).unwrap_or(0);
2048    let n_args = args.len().saturating_sub(n_post);
2049    let underscore = args[..n_args].last().cloned().unwrap_or_else(|| name.to_string()); // c:3546
2050    crate::ported::params::set_zunderscore(std::slice::from_ref(&underscore)); // c:3546
2051    let q = crate::ported::signals_h::queue_signal_level(); // c:3997
2052    crate::ported::signals_h::dont_queue_signals(); // c:4231
2053    // c:Src/exec.c:4300 `save[1] == -2` — this command's own redirections
2054    // did not touch fd 1.
2055    let stdout_unredirected = with_executor(|exec| {
2056        !(own_redirect_scope(exec)
2057            && exec
2058                .redirect_scope_stack
2059                .last()
2060                .is_some_and(|top| top.iter().any(|&(fd, _)| fd == 1)))
2061    });
2062    let ret = dispatch_builtin_raw(name, args);
2063    // c:Src/exec.c:4298-4305
2064    //     fflush(stdout);
2065    //     if (save[1] == -2) {
2066    //         if (ferror(stdout)) {
2067    //             zwarn("write error: %e", errno);
2068    //             clearerr(stdout);
2069    //         }
2070    //     } else
2071    //         clearerr(stdout);
2072    let ferror = crate::stdout_ferror::take_stdout_ferror();
2073    if ferror != 0 && stdout_unredirected {
2074        crate::ported::utils::zwarn(&format!(
2075            "write error: {}",
2076            crate::ported::utils::zsh_errno_msg(ferror)
2077        ));
2078    }
2079    // c:Src/exec.c:4289-4293 — "In case of interruption assume builtin
2080    // status is less useful than what interrupt set":
2081    //     if (!(errflag & ERRFLAG_INT)) lastval = ret;
2082    // The SIGINT handler wrote `lastval = 128 + SIGINT` (c:Src/signals.c:463)
2083    // and a forced INT-trap return wrote its own value (c:Src/signals.c:1202),
2084    // both into LASTVAL. Hand that back, so the VM's trailing SetStatus
2085    // publishes it instead of the interrupted builtin's 0:
2086    //   zsh -f -i -c 'kill -INT $$'   → exit 130 (zshrs exited 0)
2087    let status = if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2088        & crate::ported::zsh_h::ERRFLAG_INT)
2089        != 0
2090    {
2091        crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed)
2092    } else {
2093        ret
2094    };
2095    crate::ported::signals_h::restore_queue_signals(q); // c:4243
2096                                                        // c:Src/jobs.c:1748 waitonejob — canonical single-command pipestats update.
2097    crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
2098    let mut synth = crate::ported::zsh_h::job::default();
2099    crate::ported::jobs::waitonejob(&mut synth);
2100    // c:Src/exec.c:4367-4386 — done: tail. A PSPECIAL builtin that
2101    // raised errflag under POSIX_BUILTINS exits the non-interactive
2102    // shell with status 1 ("hard error in POSIX" — e.g. bin_dot's
2103    // zerrnam at Src/builtin.c:6133). Arm the deferred-exit pair so
2104    // the next ERREXIT_CHECK unwinds; EXIT_VAL=1 matches C's
2105    // hardcoded exit(1), NOT the builtin's own status (dot returns
2106    // 127 but POSIX exits 1).
2107    if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
2108        && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
2109        && builtin_is_pspecial(name)
2110        && (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2111            & crate::ported::zsh_h::ERRFLAG_ERROR)
2112            != 0
2113    {
2114        use std::sync::atomic::Ordering;
2115        crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
2116        crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
2117    }
2118    status
2119}
2120
2121/// c:Src/zsh.h:1467 BINF_PSPECIAL — true when `name` is a POSIX
2122/// special builtin per the canonical builtin table flags
2123/// (Src/builtin.c:48-129: `.`, `:`, break, continue, declare, eval,
2124/// exit, export, float, integer, local, readonly, return, set,
2125/// shift, source, times, trap, typeset, unset).
2126fn builtin_is_pspecial(name: &str) -> bool {
2127    crate::ported::builtin::createbuiltintable()
2128        .get(name)
2129        .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_PSPECIAL) != 0)
2130        .unwrap_or(false)
2131}
2132
2133/// c:Src/zsh.h:1486 BINF_ASSIGN — the assignment-builtin family
2134/// (typeset / declare / local / export / readonly / integer / float /
2135/// private). Their `name=value` args are handled as postassigns
2136/// (c:Src/exec.c:4162-4295), whose errflag-abort skips execbuiltin and
2137/// preserves the prior `lastval`. Read the flag straight from the
2138/// builtin table (same pattern as `builtin_is_pspecial`).
2139fn builtin_is_assign_family(name: &str) -> bool {
2140    crate::ported::builtin::createbuiltintable()
2141        .get(name)
2142        .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_ASSIGN) != 0)
2143        .unwrap_or(false)
2144}
2145
2146// The former `install_exec_hooks()` fn-pointer registry is gone. Code
2147// under `src/ported/` now reaches `ShellExecutor` operations
2148// (array/assoc storage, script eval, function dispatch, command
2149// substitution) through the `crate::ported::exec::*` accessor wrappers,
2150// which resolve the live executor via `try_with_executor`
2151// (`CURRENT_EXECUTOR`). The bridge lives in exec.rs — the sanctioned
2152// fusevm-access exception — per `feedback_no_exec_script_from_ported` /
2153// `feedback_no_shellexecutor_in_ported`.
2154
2155/// The outcome of c:`Src/subst.c:3914-3927`, the JOIN half of the
2156/// join-then-split block: either the vector was collapsed onto a scalar
2157/// (`isarr = 0`) or it survived as an array (`isarr` still non-zero).
2158enum JoinC3914 {
2159    /// c:3917 / c:3925 — `val = sepjoin(aval, …, 1); isarr = 0;`
2160    Joined(String),
2161    /// Neither arm fired; `aval` reaches c:3931 with `isarr` non-zero, and
2162    /// that gate (`force_split && !isarr`) then keeps the split off too.
2163    Elements(Vec<String>),
2164}
2165
2166/// Port of the JOIN half of c:`Src/subst.c:3914-3927`, for an ARRAY-shaped
2167/// UNQUOTED expansion whose `spbreak` is non-zero — i.e. c:3912's gate is open
2168/// and c:3913's `force_split` is 1 (the `ssub` callers join at c:4226 instead
2169/// and never reach here).
2170///
2171/// ```c
2172///     if (isarr || quoted_array_with_offset) {
2173///         if (nojoin == 0 || sep) {
2174///             val = sepjoin(aval, sep, 1);
2175///             isarr = 0;
2176///         } else if (force_split &&
2177///                    (spsep || nojoin == 2 || (!ifs && isarr < 0))) {
2178///             val = sepjoin(aval, (nojoin == 1 ? NULL : spsep), 1);
2179///             isarr = 0;
2180///         }
2181///     }
2182/// ```
2183///
2184/// `sep`/`spsep` are the `(j:…:)` / `(s:…:)` separators, which never reach the
2185/// compiler fast paths (a flagged expansion goes to `paramsubst`), so the two
2186/// live discriminators are `nojoin` and whether IFS is UNSET or merely EMPTY:
2187///
2188/// * c:1819 / c:2569 — `nojoin = !(ifs && *ifs)`, i.e. 1 when IFS is unset OR
2189///   set to the empty string, 0 for any non-empty IFS.
2190/// * c:3030-3032 — `if (isarr) { if (nojoin) isarr = -1; }`. This is why a
2191///   `[*]` splice and a `[@]` splice behave IDENTICALLY here: whenever `nojoin`
2192///   is set they are BOTH `isarr < 0`, and when it is clear c:3916 joins them
2193///   both. The `[@]`-vs-`[*]` distinction only survives into the QUOTED arm at
2194///   c:3033, which this block is never reached with.
2195///
2196/// So: a non-empty IFS joins on `IFS[0]`; an UNSET IFS joins on `sepjoin`'s
2197/// default `" "` (c:`Src/utils.c:3936-3945`); an EMPTY IFS joins on nothing at
2198/// all and the element vector survives.
2199fn join_c3914(elems: Vec<String>, ifs: Option<&str>) -> JoinC3914 {
2200    // c:1819 — `!(ifs && *ifs)`.
2201    let nojoin = !matches!(ifs, Some(s) if !s.is_empty());
2202    // c:Src/utils.c:3936-3945 `sepjoin(s, NULL, heap)` — the separator is the
2203    // first metachar of `ifs`, except that a NULL `ifs` (unset) or one whose
2204    // first byte is already a space takes the literal `" "` fast path.
2205    let sep = match ifs {
2206        Some(s) if !s.starts_with(' ') => s.chars().next().map(String::from).unwrap_or_default(),
2207        _ => " ".to_string(),
2208    };
2209    // c:3916 `nojoin == 0`, then c:3919-3925's `(!ifs && isarr < 0)`.
2210    if !nojoin || ifs.is_none() {
2211        JoinC3914::Joined(elems.join(&sep))
2212    } else {
2213        JoinC3914::Elements(elems)
2214    }
2215}
2216
2217/// Port of c:`Src/subst.c:3932` `aval = sepsplit(val, spsep, 0, 1)` with
2218/// `spsep == NULL` (so c:`Src/utils.c:3971-3972` falls through to
2219/// `spacesplit(s, allownull=0, …)`), followed by c:`Src/subst.c:184-187`'s
2220/// empty-word removal.
2221///
2222/// `spacesplit` distinguishes the two kinds of empty field, and that is the
2223/// whole reason this cannot be a plain `split().filter(non-empty)`:
2224///
2225/// * An empty field delimited by IFS-NON-whitespace comes back as `nulstring`
2226///   (c:`Src/subst.c:36` `char nulstring[] = {Nularg, '\0'};`, emitted at
2227///   c:`Src/utils.c:3732` and c:3752). That word is one byte long, so prefork's
2228///   `uremnode` at c:186 keeps it and `remnulargs` at c:169 turns it into `""`.
2229///   `IFS=:; a=(x '' y)` therefore splits `x::y` back to THREE words.
2230/// * A field left by a skipped run of IFS-WHITESPACE is a real `""`
2231///   (c:`Src/utils.c:3734` / c:3757) and c:186 DOES delete it — unless the
2232///   caller is attaching it to neighbouring text, which is what `keep_empties`
2233///   (the `BUILTIN_FORCE_SPLIT` argc contract) says.
2234/// c:Src/glob.c:3663 `remnulargs` for a finished word: remove the c:36
2235/// `nulstring` markers a deferring word kept through its segments (see
2236/// BUILTIN_FORCE_SPLIT and `paramsubst_to_value_pf`), once the end-of-word drop
2237/// has decided which empty nodes survive. A marker-free value is returned as is.
2238/// argc bits the compiler adds to the segment path's end-of-word drop
2239/// (BUILTIN_ARRAY_DROP_EMPTY / BUILTIN_WORD_ELIDE_EMPTY) when the word's first
2240/// or last segment is a quoted empty literal (`""`, `''`). c:Src/subst.c:36 —
2241/// the lexer leaves its Dnull/Snull in the word, so the node that literal is
2242/// glued to is non-empty at c:183 and survives: the first node for a leading
2243/// literal, the last for a trailing one (c:4366-4437).
2244pub const WORD_DROP_KEEPS_FIRST: u8 = 4;
2245/// See [`WORD_DROP_KEEPS_FIRST`].
2246pub const WORD_DROP_KEEPS_LAST: u8 = 8;
2247
2248/// Whether node `i` (of `0..=last`) is anchored by a quoted empty literal.
2249fn word_drop_keeps(argc: u8, i: usize, last: usize) -> bool {
2250    (i == 0 && argc & WORD_DROP_KEEPS_FIRST != 0) || (i == last && argc & WORD_DROP_KEEPS_LAST != 0)
2251}
2252
2253fn strip_nulstring_markers(v: Value) -> Value {
2254    let nul = crate::ported::zsh_h::Nularg;
2255    match v {
2256        Value::Array(items) if items.iter().any(|x| x.as_str_cow().contains(nul)) => Value::array(
2257            items
2258                .iter()
2259                .map(|x| Value::str(x.as_str_cow().replace(nul, "")))
2260                .collect(),
2261        ),
2262        Value::Str(s) if s.contains(nul) => Value::str(s.replace(nul, "")),
2263        other => other,
2264    }
2265}
2266
2267fn sepsplit_c3932(s: &str, keep_empties: bool) -> Vec<String> {
2268    let raw = crate::ported::utils::sepsplit(s, None, false);
2269    let nulstring = crate::ported::zsh_h::Nularg.to_string();
2270    let mut out: Vec<String> = Vec::with_capacity(raw.len());
2271    for w in raw {
2272        if w == nulstring {
2273            out.push(String::new());
2274        } else if w.is_empty() {
2275            if keep_empties {
2276                out.push(String::new());
2277            }
2278        } else {
2279            out.push(w);
2280        }
2281    }
2282    out
2283}
2284
2285/// c:`Src/subst.c:3932-3938` for a bare read: `sepsplit` plus the 0/1/N shape.
2286/// Inside a word that opened with `BUILTIN_WORD_DEFER_EMPTIES`, c:184-187 runs on
2287/// the FINISHED node (after c:4366-4437 glue the affixes on), so the truly empty
2288/// edge fields go out raw for the end-of-word drop to judge:
2289/// `setopt shwordsplit; a=(a b ''); print -l x${a}y` is `xa` `b` `y`.
2290fn split_words_c3932(val: &str) -> Value {
2291    if crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get()) > 0 {
2292        return splice_words_value(crate::ported::utils::sepsplit(val, None, false));
2293    }
2294    splice_words_value(sepsplit_c3932(val, false))
2295}
2296
2297/// c:`Src/subst.c:3933-3938` — how a split (or untouched) word vector reaches
2298/// the caller: nothing at all, a SCALAR for a single field, an array otherwise.
2299///
2300/// ```c
2301///     if (!aval || !aval[0]) val = dupstring("");
2302///     else if (!aval[1])     val = aval[0];
2303///     else                   isarr = nojoin ? 1 : 2;
2304/// ```
2305fn splice_words_value(words: Vec<String>) -> Value {
2306    match words.len() {
2307        0 => Value::array(Vec::new()),
2308        1 => Value::str(words.into_iter().next().unwrap()),
2309        _ => Value::array(words.into_iter().map(Value::str).collect()),
2310    }
2311}
2312
2313/// c:`Src/subst.c:184-187` — prefork's `uremnode` deletes an UNQUOTED word that
2314/// came out empty. Applied to an element vector that c:3912's gate left alone.
2315fn prefork_c184_drop_empty(elems: Vec<String>) -> Vec<String> {
2316    elems.into_iter().filter(|e| !e.is_empty()).collect()
2317}
2318
2319/// Read a parameter as `paramsubst` does at c:`Src/subst.c:2916-2917`, which is
2320/// where both halves of the value come from:
2321///
2322/// ```c
2323///     if ((isarr = (v->scanflags & SCANPM_ISVAR_AT) ? -1
2324///                : v->scanflags ? 1 : 0)) {
2325///         … aval = getarrvalue(v);
2326///     } else {
2327///         … val = getstrvalue(v);
2328/// ```
2329///
2330/// The `bool` is that `isarr != 0` test — whether `fetchvalue` handed back an
2331/// ARRAY (or a hash, whose values `getarrvalue` enumerates) rather than a
2332/// scalar. Every caller needs it, because c:3914's join arm and c:3931's split
2333/// arm are mutually exclusive on exactly that bit.
2334///
2335/// A scalar comes back as a one-element vector so the two shapes share a type;
2336/// the `bool` is what tells them apart (a genuine one-element ARRAY is not the
2337/// same thing, though c:3899-3906 goes on to treat it as a scalar for the
2338/// join/split block — that rule belongs to the caller, not here).
2339fn param_value_elems_c2916(exec: &mut ShellExecutor, name: &str) -> (Vec<String>, bool) {
2340    if let Some(v) = crate::dash_mode::bash_special_array(name) {
2341        // bash `"${PIPESTATUS[*]}"` / `"${FUNCNAME[*]}"` join.
2342        return (v, true);
2343    }
2344    if name == "@" || name == "*" || name == "argv" {
2345        // c:Src/params.c:392,393,430 — the three IPDEF9 rows over `pparams`.
2346        return (exec.pparams(), true);
2347    }
2348    if let Some(assoc_map) = exec.assoc(name) {
2349        // c:Src/params.c — assoc-splat values for `"${h[@]}"` / `"${h[*]}"`.
2350        // Bug #109 in docs/BUGS.md.
2351        return (assoc_map.values().cloned().collect::<Vec<_>>(), true);
2352    }
2353    if let Some(arr) = exec.array(name) {
2354        // bash sparse arrays: `"${a[*]}"` joins only LIVE elements, dropping
2355        // hole slots. No-op in --zsh (no holes tracked).
2356        return (crate::bash_arrays::compact(name, arr), true);
2357    }
2358    if let Some(arr) = crate::ported::subst::arrays_get(name) {
2359        // c:Src/Modules/parameter.c:2239-2291 partab[] — the PM_ARRAY magic
2360        // specials (reswords/patchars/dis_*/…) are getfn-backed, so
2361        // `getaparam`'s `pm->u.arr` read (behind `exec.array`) comes back NULL
2362        // and the join fell through to the scalar fallback, which is empty.
2363        // Same omission the `[@]` splat had.
2364        return (arr, true);
2365    }
2366    if let Some(map) = crate::ported::subst::assoc_get(name) {
2367        // c:Src/Modules/parameter.c:2235-2298 partab[] — the PM_HASHED magic
2368        // assocs (aliases/functions/options/…) live behind a scanfn+getfn pair,
2369        // not in the executor's assoc storage, so `exec.assoc` above misses
2370        // them and `${aliases[*]}` joined an empty scalar where zsh joins the
2371        // alias VALUES.
2372        return (map.values().cloned().collect::<Vec<_>>(), true);
2373    }
2374    (vec![exec.get_variable(name)], false) // c:2927 getstrvalue
2375}
2376
2377/// Register all zsh builtins with the VM.
2378/// c:Src/exec.c:4473-4509 (`save_params`) — the per-name body of the loop,
2379/// run for a prefix assignment while its inline-env frame is still recording.
2380/// Shared by the scalar (BUILTIN_SET_VAR) and array (BUILTIN_SET_ARRAY)
2381/// assignment builtins: C walks every WC_ASSIGN in the chain regardless of
2382/// its type (c:4510-4511 advances over WC_ASSIGN_SCALAR and array words alike).
2383fn save_inline_prefix_param(exec: &mut ShellExecutor, name: &str) {
2384    // c:Src/exec.c:4475-4478 (54941) — `ss = itype_end(s, INAMESPC, 0);
2385    // slen = *ss == '[' || *ss == Inbrack ? ss - s : strlen(s);`: a
2386    // subscripted assignment (`H[2,4]=oo f`) saves its base parameter.
2387    let name = match name.find('[') {
2388        Some(i) => name[..i].to_string(),
2389        None => name.to_string(),
2390    };
2391    let prev_env = env::var(&name).ok();
2392    // c:Src/exec.c:4476 — `pm = paramtab->getnode(paramtab, s)`.
2393    // c:4491-4493 — an existing name is SNAPSHOTTED, not removed:
2394    // `tpm = zshcalloc(...); tpm->node.nam = ztrdup(pm->node.nam);
2395    //  copyparam(tpm, pm, 0);` — the WHOLE Param, type flags and
2396    // value union together. Saving only `getsparam(name)` (a
2397    // scalar string) is what destroyed every non-scalar: an array
2398    // came back as the IFS-joined string retyped to PM_SCALAR, and
2399    // an association came back as None — the scalar getter has
2400    // nothing to read for a PM_HASHED param — so the restore took
2401    // the "did not exist" arm and unset it outright.
2402    let pm_snapshot: Option<crate::ported::zsh_h::param> = {
2403        let live = crate::ported::params::paramtab()
2404            .read()
2405            .ok()
2406            .and_then(|t| t.get(&name).map(|p| (**p).clone()));
2407        // c:4480 / c:4494 — three arms, and the third saves
2408        // nothing: a PM_SPECIAL that is also PM_READONLY leaves
2409        // `tpm` NULL and only joins the remove list (c:4504).
2410        let fakecopy = live.as_ref().map(|p| {
2411            (p.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) != 0
2412        });
2413        let live = match (live, fakecopy) {
2414            (Some(p), Some(true))
2415                if (p.node.flags & crate::ported::zsh_h::PM_READONLY as i32) != 0 =>
2416            {
2417                None
2418            }
2419            (other, _) => other,
2420        };
2421        live.map(|mut tpm| {
2422            // c:Src/params.c:1273-1291 — copyparam reads the value
2423            // back through the type's GSU getfn rather than trusting
2424            // the union slot it already holds, because a special's or
2425            // a tied scalar's value does not live there.
2426            match crate::ported::zsh_h::PM_TYPE(tpm.node.flags as u32) {
2427                crate::ported::zsh_h::PM_INTEGER => {
2428                    // c:1279 — `tpm->u.val = pm->gsu.i->getfn(pm);`
2429                    // A special's getfn (`uidgetfn`, `gidgetfn`, …) does not
2430                    // read `u.val`, which `getiparam` returns for any
2431                    // PM_INTEGER node: `UID=$UID cmd` saved 0 and the restore
2432                    // ran `setuid(0)`. zshrs reaches those getfns by name.
2433                    tpm.u_val = crate::ported::params::lookup_special_var(&name)
2434                        .and_then(|v| v.parse::<i64>().ok())
2435                        .unwrap_or_else(|| crate::ported::params::getiparam(&name));
2436                }
2437                crate::ported::zsh_h::PM_EFLOAT
2438                | crate::ported::zsh_h::PM_FFLOAT => {
2439                    // c:1283 — `tpm->u.dval = pm->gsu.f->getfn(pm);`
2440                    tpm.u_dval = crate::ported::params::getnparam(&name).1;
2441                }
2442                crate::ported::zsh_h::PM_ARRAY => {
2443                    // c:1286 — `tpm->u.arr = zarrdup(pm->gsu.a->getfn(pm));`
2444                    tpm.u_arr = crate::ported::params::getaparam(&name);
2445                }
2446                crate::ported::zsh_h::PM_HASHED => {
2447                    // c:1289 — `tpm->u.hash = copyparamtable(...)`.
2448                    // See `hashed_snapshot` below for where the pairs go.
2449                }
2450                _ => {
2451                    // c:1276 — `tpm->u.str = ztrdup(pm->gsu.s->getfn(pm));`
2452                    tpm.u_str = crate::ported::params::getsparam(&name);
2453                }
2454            }
2455            // c:1269-1272 / c:1301-1302 — the `!fakecopy` postlude:
2456            // the copy is going back into the parameter table as a
2457            // REAL parameter, so it stops being special and gets the
2458            // standard get/set vtable for its type. That is what
2459            // makes a user's `typeset -T PAIR pair` scalar come back
2460            // as a plain scalar holding the joined string instead of
2461            // re-reading the partner array the prefix assignment
2462            // just overwrote.
2463            if fakecopy == Some(false) {
2464                tpm.node.flags &= !(crate::ported::zsh_h::PM_SPECIAL as i32); // c:1271
2465                crate::ported::params::assigngetset(&mut tpm); // c:1302
2466            }
2467            tpm
2468        })
2469    };
2470    // !!! RUST-ONLY FIELD — THE SECOND HALF OF c:1289 !!!
2471    // C keeps an association's pairs in `pm->u.hash`, so
2472    // `copyparam`'s `copyparamtable` copy travels inside the Param
2473    // above and `restore_params`' `tpm->gsu.h->setfn(tpm, pm->u.hash)`
2474    // (c:4561) puts them back. zshrs keeps them in the name-keyed
2475    // `paramtab_hashed_storage` instead, OUTSIDE the Param —
2476    // `arrhashsetfn`, the only writer of whole associations, never
2477    // populates `u_hash` at all (the same structural mismatch
2478    // `stdunsetfn` documents at params.rs:11044). So the pairs have
2479    // to be carried alongside the node and put back alongside it.
2480    let hashed_snapshot = if pm_snapshot.as_ref().is_some_and(|p| {
2481        crate::ported::zsh_h::PM_TYPE(p.node.flags as u32)
2482            == crate::ported::zsh_h::PM_HASHED
2483    }) {
2484        crate::ported::params::paramtab_hashed_storage()
2485            .lock()
2486            .ok()
2487            .and_then(|m| m.get(&name).cloned())
2488    } else {
2489        None
2490    };
2491    exec.inline_env_stack
2492        .last_mut()
2493        .unwrap()
2494        .saved
2495        .push(crate::vm_helper::SavedInlineParam {
2496            name: name.clone(),
2497            pm: pm_snapshot,
2498            hashed: hashed_snapshot,
2499            prev_env,
2500        });
2501}
2502
2503pub(crate) fn register_builtins(vm: &mut fusevm::VM) {
2504    // src/ported/ reaches the live executor (param store, function
2505    // dispatch, nested script/cmdsubst exec) through the
2506    // `crate::ported::exec::*` accessor wrappers, which read
2507    // `CURRENT_EXECUTOR` via `try_with_executor`. No install step is
2508    // needed: the executor is in scope for the duration of any VM run
2509    // (set by `ExecutorContext::enter`), so the wrappers resolve it
2510    // directly. (Replaces the former `exec_hooks` OnceLock fn-ptr
2511    // registry, now deleted.)
2512    // Engage fusevm's tiered JIT (block + tracing) so hot, fully-eligible
2513    // numeric chunks run in native code and — with the `jit-disk-cache`
2514    // feature (on by default) — persist that native code to
2515    // `~/.cache/fusevm-jit`, letting repeated zsh invocations skip Cranelift
2516    // codegen. fusevm gates the JIT on per-chunk eligibility and warms up by
2517    // an invocation threshold, falling back to the interpreter for any chunk
2518    // it cannot compile (e.g. host-builtin/`Extended` command dispatch), so
2519    // enabling it here never changes observable behaviour — it only caches
2520    // the numeric hot path. Idempotent: re-enabling on each VM is a no-op.
2521    vm.enable_tracing_jit();
2522    // Macro for builtins that user functions are allowed to shadow.
2523    // zsh dispatch order is alias → function → builtin; without the
2524    // try_user_fn_override probe a `cat() { ... }; cat` would silently
2525    // run the C builtin and ignore the user function.
2526    macro_rules! reg_overridable {
2527        ($vm:expr, $id:expr, $name:literal, $method:ident) => {
2528            $vm.register_builtin($id, |vm, argc| {
2529                let args = pop_args(vm, argc);
2530                // c:Src/exec.c getproc + Src/jobs.c deletefilelist —
2531                // close `>(cmd)` write ends owned by this command
2532                // once it finishes (shadows bypass dispatch_builtin
2533                // and ZshrsHost::exec, so they need their own guard:
2534                // `tee >(wc -c) </dev/null` left wc blocked).
2535                let _psub_fds = PsubFdGuard;
2536                if let Some(s) = try_user_fn_override($name, &args) {
2537                    return Value::Status(s);
2538                }
2539                // c:Src/exec.c — redirect failure in the current
2540                // scope means the command must NOT run. coreutils
2541                // shadows (cat / head / tail / etc.) take a separate
2542                // dispatch path from dispatch_builtin, so they need
2543                // their own gate. Without this `cat <&3` after a
2544                // closed-fd diagnostic still ran the shadow and
2545                // overwrote $? from the forced 1.
2546                let redir_failed = with_executor(|exec| {
2547                    let f = exec.redirect_failed;
2548                    exec.redirect_failed = false;
2549                    f
2550                });
2551                // c:Src/exec.c:4343-4350 — the prefix assignments of an
2552                // external run in the forked child (`addvars(…); if
2553                // (errflag) _exit(1);`): `UID=0 date` is skipped with
2554                // status 1 and the shell's errflag stays clean.
2555                let prefix_failed = PREFIX_ASSIGN_FAILED.with(|c| c.replace(false));
2556                if redir_failed || prefix_failed {
2557                    // c:Src/exec.c:3719 forks the external command these
2558                    // shadows stand in for BEFORE the redirection loop at
2559                    // c:3785, so a redirection `zerr` (`cat <&""` → "file
2560                    // number expected") lands in the child: the shell only
2561                    // sees status 1 (c:252-256 execerr in the child) and runs
2562                    // the next command. Same rule as call_function's arm.
2563                    let bits = if prefix_failed {
2564                        crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD
2565                    } else {
2566                        crate::ported::zsh_h::ERRFLAG_ERROR
2567                    };
2568                    crate::ported::utils::errflag.fetch_and(
2569                        !bits,
2570                        std::sync::atomic::Ordering::Relaxed,
2571                    );
2572                    // A NOMATCH / NULL_GLOB failure in the redirect target's
2573                    // filename generation belongs to the same child.
2574                    let _ = external_glob_failure_status();
2575                    crate::ported::builtin::LASTVAL
2576                        .store(1, std::sync::atomic::Ordering::Relaxed);
2577                    return Value::Status(1);
2578                }
2579                // A failed argument glob fails in that forked child too.
2580                if let Some(status) = external_glob_failure_status() {
2581                    return Value::Status(status);
2582                }
2583                // `[builtins].coreutils_shadows = off` in
2584                // ~/.zshrs/zshrs.toml (or `ZSHRS_NO_COREUTILS_SHADOWS=1`
2585                // env override) bypasses the in-process shadow and
2586                // fork-execs the real /bin/X. Safety valve for any
2587                // script that hits an edge-case divergence between
2588                // the zshrs shadow and system coreutils. Cached
2589                // after first call, so the hot path is one atomic
2590                // load per shadowed-builtin invocation.
2591                // c:Src/exec.c:3545-3547 — these shadows stand in for
2592                // EXTERNAL commands (`cat`, `head`, …), which in zsh reach
2593                // execcmd_exec and set `$_` to the command's last word
2594                // before running. The in-process shadow below bypasses
2595                // dispatch_builtin AND execute_external_bg, so without this
2596                // `cat f; print $_` reported the PREVIOUS command's last
2597                // argument.
2598                {
2599                    let last = args.last().cloned().unwrap_or_else(|| $name.to_string());
2600                    crate::ported::params::set_zunderscore(std::slice::from_ref(&last));
2601                    // c:3546
2602                }
2603                // With the shadow off (always under `--zsh`) the name is the
2604                // external command it stands in for, so it takes the external
2605                // path: c:Src/exec.c:4369 `execute()` replaces a child already
2606                // forked for it (`sleep 5 &`, a pipeline stage) instead of
2607                // forking again, and a signal sent to the job reaches the
2608                // command itself (`kill -HUP %1` is `hangup`, not `exit 1`).
2609                if !crate::daemon_presence::coreutils_shadows_enabled() {
2610                    return Value::Status(with_executor(|exec| {
2611                        exec.execute_external($name, &args, &[]).unwrap_or(127)
2612                    }));
2613                }
2614                let status = with_executor(|exec| exec.$method(&args));
2615                Value::Status(status)
2616            });
2617        };
2618    }
2619
2620    // Pure-passthru builtin: pops args, routes to canonical
2621    // `dispatch_builtin(name, args)` (which goes via execbuiltin →
2622    // BUILTINS[name] → bin_X). No pre/post bridge work. Used by
2623    // ~25 handlers that were 4-line copy-paste boilerplate.
2624    macro_rules! reg_passthru {
2625        ($vm:expr, $id:expr, $name:literal) => {
2626            $vm.register_builtin($id, |vm, argc| {
2627                let args = pop_args(vm, argc);
2628                // function > builtin: a same-named user function wins over
2629                // the builtin on the normal (CallBuiltin) invocation path.
2630                // The compiler's `user_function_shadow` already routes the
2631                // same-compile-unit case through CallFunction; this probe
2632                // extends that to the cross-unit / interactive case (define
2633                // `zstyle() { … }` on one line, call it on the next). The
2634                // forced `builtin NAME` / `command NAME` paths dispatch
2635                // through their own handlers, not this one, so they still
2636                // reach the builtin as required.
2637                if let Some(s) = try_user_fn_override($name, &args) {
2638                    return Value::Status(s);
2639                }
2640                Value::Status(dispatch_builtin($name, args))
2641            });
2642        };
2643    }
2644
2645    // zshrs-original extension builtins (async / peach / doctor / …) that
2646    // route to an ExecutorContext method. Like `reg_overridable!`, they
2647    // probe `try_user_fn_override` FIRST so a user function of the same
2648    // name wins — zsh's alias → function → builtin dispatch order. Without
2649    // the probe, `doctor() { … }; doctor` silently ran the builtin and
2650    // ignored the function (function > builtin violated for these).
2651    macro_rules! reg_ext_overridable {
2652        ($vm:expr, $id:expr, $name:literal, $method:ident) => {
2653            $vm.register_builtin($id, |vm, argc| {
2654                let args = pop_args(vm, argc);
2655                if let Some(s) = try_user_fn_override($name, &args) {
2656                    return Value::Status(s);
2657                }
2658                Value::Status(with_executor(|exec| exec.$method(&args)))
2659            });
2660        };
2661    }
2662
2663    // Core builtins
2664    vm.register_builtin(BUILTIN_CD, |vm, argc| {
2665        let args = pop_args(vm, argc);
2666        if let Some(s) = try_user_fn_override("cd", &args) {
2667            return Value::Status(s);
2668        }
2669        // c:Src/builtin.c:1258 — the chpwd hook runs inside cd_new_pwd.
2670        Value::Status(dispatch_builtin("cd", args))
2671    });
2672
2673    vm.register_builtin(BUILTIN_PWD, |vm, argc| {
2674        let args = pop_args(vm, argc);
2675        if let Some(s) = try_user_fn_override("pwd", &args) {
2676            return Value::Status(s);
2677        }
2678        // Route through the canonical execbuiltin path so the `rLP`
2679        // optstr at BUILTINS["pwd"] is parsed into `ops`.
2680        let status = dispatch_builtin("pwd", args);
2681        Value::Status(status)
2682    });
2683
2684    vm.register_builtin(BUILTIN_ECHO, |vm, argc| {
2685        let args = pop_args(vm, argc);
2686        if let Some(s) = try_user_fn_override("echo", &args) {
2687            return Value::Status(s);
2688        }
2689        // Update `$_` to the last arg before running. C zsh sets
2690        // zunderscore in execcmd_exec for every simple command,
2691        // including builtins.
2692        crate::ported::params::set_zunderscore(&args);
2693        let status = dispatch_builtin("echo", args);
2694        Value::Status(status)
2695    });
2696
2697    vm.register_builtin(BUILTIN_PRINT, |vm, argc| {
2698        let args = pop_args(vm, argc);
2699        if let Some(s) = try_user_fn_override("print", &args) {
2700            return Value::Status(s);
2701        }
2702        crate::ported::params::set_zunderscore(&args);
2703        let status = dispatch_builtin("print", args);
2704        Value::Status(status)
2705    });
2706
2707    reg_passthru!(vm, BUILTIN_PRINTF, "printf");
2708    reg_passthru!(vm, BUILTIN_EXPORT, "export");
2709    reg_passthru!(vm, BUILTIN_UNSET, "unset");
2710    // `source` (Src/builtin.c c:116) wired to bin_dot via BUILTINS.
2711    reg_passthru!(vm, BUILTIN_SOURCE, "source");
2712    reg_passthru!(vm, BUILTIN_DOT, ".");
2713    reg_passthru!(vm, BUILTIN_LOGOUT, "logout");
2714
2715    vm.register_builtin(BUILTIN_EXIT, |vm, argc| {
2716        let args = pop_args(vm, argc);
2717        let status = dispatch_builtin("exit", args);
2718        Value::Status(status)
2719    });
2720
2721    vm.register_builtin(BUILTIN_RETURN, |vm, argc| {
2722        let args = pop_args(vm, argc);
2723        // zsh: bare `return` (no arg) returns with the status of
2724        // the most recently executed command — `false; return`
2725        // returns 1, not 0. Direct port of zsh's bin_break/RETURN.
2726        // The executor's `last_status` is stale here (synced at
2727        // statement boundaries, not after each VM op), so read
2728        // the live `vm.last_status` instead.
2729        let live_status = vm.last_status;
2730        let status = {
2731            // Sync canonical LASTVAL to the VM's view BEFORE
2732            // bin_break("return") reads it for the no-arg fallback.
2733            with_executor(|exec| exec.set_last_status(live_status));
2734            dispatch_builtin("return", args)
2735        };
2736        Value::Status(status)
2737    });
2738
2739    vm.register_builtin(BUILTIN_TRUE, |vm, argc| {
2740        let args = pop_args(vm, argc);
2741        if let Some(s) = try_user_fn_override("true", &args) {
2742            return Value::Status(s);
2743        }
2744        // c:Src/exec.c:1257 — zsh sets `zunderscore` AT THE END of
2745        // each command (the `if (!noerrs)` block runs `zsfree(prev_argv0); …;
2746        // zunderscore = …`). For no-arg `true`, $_ becomes the
2747        // command name itself. Set DIRECTLY (not via pending_underscore)
2748        // so the NEXT command's argv-expansion of `$_` reads "true",
2749        // not the stale prior value — pending_underscore is consumed
2750        // by pop_args which runs AFTER argv expansion, too late.
2751        // c:Src/exec.c:1257 — `zunderscore = …` at end-of-command.
2752        // With args, $_ = args.last(). Without args, $_ = command name.
2753        // Write DIRECTLY to the canonical zunderscore static (the
2754        // underscoregetfn at params.rs:7003 reads from there); the
2755        // paramtab "_" slot is shadowed by lookup_special_var so
2756        // set_scalar on it has no effect on `$_` reads.
2757        if args.is_empty() {
2758            crate::ported::params::set_zunderscore(&["true".to_string()]);
2759        } else {
2760            crate::ported::params::set_zunderscore(&args);
2761        }
2762        // Route through canonical execbuiltin so PS4 xtrace fires
2763        // via the c:442 printprompt4 path.
2764        Value::Status(dispatch_builtin("true", args))
2765    });
2766    vm.register_builtin(BUILTIN_FALSE, |vm, argc| {
2767        let args = pop_args(vm, argc);
2768        if let Some(s) = try_user_fn_override("false", &args) {
2769            return Value::Status(s);
2770        }
2771        // Direct set; see BUILTIN_TRUE above for rationale.
2772        if args.is_empty() {
2773            crate::ported::params::set_zunderscore(&["false".to_string()]);
2774        } else {
2775            crate::ported::params::set_zunderscore(&args);
2776        }
2777        // Route through canonical execbuiltin — see BUILTIN_TRUE
2778        // above for the same rationale (xtrace + fast-path removal).
2779        let status = dispatch_builtin("false", args);
2780        Value::Status(status)
2781    });
2782    vm.register_builtin(BUILTIN_COLON, |vm, argc| {
2783        let args = pop_args(vm, argc);
2784        // Direct set; see BUILTIN_TRUE above for rationale.
2785        if args.is_empty() {
2786            crate::ported::params::set_zunderscore(&[":".to_string()]);
2787        } else {
2788            crate::ported::params::set_zunderscore(&args);
2789        }
2790        let status = dispatch_builtin(":", args);
2791        Value::Status(status)
2792    });
2793
2794    // `[ … ]` — c:Src/builtin.c:7231 `bin_test` with BIN_BRACKET. See
2795    // BUILTIN_TEST_BRACKET.
2796    vm.register_builtin(BUILTIN_TEST_BRACKET, |vm, argc| {
2797        let args = pop_args(vm, argc);
2798        Value::Status(dispatch_builtin("[", args))
2799    });
2800    vm.register_builtin(BUILTIN_TEST, |vm, argc| {
2801        let args = pop_args(vm, argc);
2802        // c:Src/builtin.c:7231 `bin_test` with funcid BIN_TEST. The `[`
2803        // spelling compiles to BUILTIN_TEST_BRACKET, so this slot is only ever
2804        // `test`. It used to guess the spelling from a trailing `]`, which ran
2805        // `test a ]` as `[ a ]` (status 0) where zsh reports "condition
2806        // expected: a" (status 2), and ran `[ a` as `test a`.
2807        Value::Status(dispatch_builtin("test", args))
2808    });
2809
2810    // Variable declaration. `local` (Src/builtin.c bin_local) handles
2811    // the scope chain (`pm->old = oldpm` at Src/params.c:1137 inside
2812    // createparam, `pm->level = locallevel` at Src/builtin.c:2576).
2813    // `typeset` / `declare` are aliases — fusevm maps both to
2814    // BUILTIN_TYPESET; compile_zsh special-cases `declare` to keep
2815    // the `declare:` error prefix.
2816    reg_passthru!(vm, BUILTIN_LOCAL, "local");
2817    reg_passthru!(vm, BUILTIN_TYPESET, "typeset");
2818
2819    reg_passthru!(vm, BUILTIN_DECLARE, "declare");
2820    reg_passthru!(vm, BUILTIN_READONLY, "readonly");
2821    reg_passthru!(vm, BUILTIN_INTEGER, "integer");
2822    reg_passthru!(vm, BUILTIN_FLOAT, "float");
2823    reg_passthru!(vm, BUILTIN_READ, "read");
2824    // c:Bug #504 — fusevm reserves BUILTIN_MAPFILE for the bash
2825    // mapfile/readarray builtins. Neither exists in zsh; in --zsh
2826    // parity mode the dispatch must emit "command not found" + rc=127
2827    // matching zsh's external-command-lookup miss. The previous wiring
2828    // left BUILTIN_MAPFILE unregistered, so fusevm's VM treated the op
2829    // as a no-op rc=0 — `mapfile` (and `readarray`) silently succeeded
2830    // in --zsh mode. The host gate in `dispatch_builtin_raw` never
2831    // fired because the compile path emitted `Op::CallBuiltin(31, ..)`
2832    // directly. Register the slot so the gate runs (or a future
2833    // non-zsh mode can wire in a real impl).
2834    vm.register_builtin(fusevm::shell_builtins::BUILTIN_MAPFILE, |vm, argc| {
2835        let args = pop_args(vm, argc);
2836        // The fusevm name→id map collapses both `mapfile` and
2837        // `readarray` to the same opcode; pick the right diagnostic
2838        // by sniffing the user's actual invocation. The xtrace ARGS
2839        // push earlier records the cmd-prefix as the bottom of the
2840        // popped argv, but `args` here excludes the prefix — so we
2841        // can't recover the user-typed name from the stack. Default
2842        // to `mapfile` (the more-common spelling); both produce
2843        // identical diagnostics in any case.
2844        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2845            eprintln!("zsh:1: command not found: mapfile");
2846            let _ = args;
2847            return Value::Status(127);
2848        }
2849        // Non-zsh modes (bash drop-in): mapfile / readarray reads lines
2850        // from stdin (or `-u fd`) into an array. Handled by the ported
2851        // bash builtin in ext_builtins.
2852        Value::Status(crate::extensions::ext_builtins::readarray(&args))
2853    });
2854    reg_passthru!(vm, BUILTIN_BREAK, "break");
2855    reg_passthru!(vm, BUILTIN_CONTINUE, "continue");
2856    reg_passthru!(vm, BUILTIN_SHIFT, "shift");
2857
2858    vm.register_builtin(BUILTIN_EVAL, |vm, argc| {
2859        // Direct port of `bin_eval(UNUSED(char *nam), char **argv, UNUSED(Options ops), UNUSED(int func))` body from Src/builtin.c:6151:
2860        //   `if (!*argv) return 0;`
2861        //   `prog = parse_string(zjoin(argv, ' ', 1), 1);`
2862        //   `execode(prog, 1, 0, "eval");`
2863        // The execode invocation lives here (not in the canonical
2864        // free-fn) because it must run through the bytecode VM's
2865        // current executor — the same VM that's mid-dispatch.
2866        let mut args = pop_args(vm, argc);
2867        // c:Src/builtin.c:407-411 — generic `--` end-of-options
2868        // strip applied by `execbuiltin` for builtins that have
2869        // NULL optstr AND no BINF_HANDLES_OPTS. `eval` qualifies
2870        // (Src/builtin.c:65 `BUILTIN("eval", BINF_PSPECIAL, ...,
2871        // NULL, NULL)`). The BUILTIN_EVAL fast-path bypasses
2872        // execbuiltin, so we mirror the strip inline. Bug #319.
2873        if args.first().is_some_and(|s| s == "--") {
2874            args.remove(0);
2875        }
2876        if args.is_empty() {
2877            return Value::Status(0); // c:6160
2878        }
2879        let src = args.join(" "); // c:6166
2880                                  // c:Src/builtin.c:6164-6165 — `if (!ineval) scriptname =
2881                                  // "(eval)";`. Diagnostics emitted while the eval body runs
2882                                  // (command-not-found, parse errors, etc.) use scriptname as
2883                                  // the source-context prefix. Without setting it here the
2884                                  // BUILTIN_EVAL fast-path leaked the outer "zsh" prefix
2885                                  // through, breaking the `(eval):N:` convention zsh uses
2886                                  // for in-eval errors. Bug #420.
2887                                  // c:Src/builtin.c:6209 — `execode(prog, 1, 0, "eval");`. execode
2888                                  // (c:Src/exec.c:1245-1266) APPENDS its context argument to
2889                                  // `zsh_eval_context` for the duration of the body, so code inside
2890                                  // `eval` sees `cmdarg:eval` (and `cmdarg:shfunc:eval` when the eval is
2891                                  // in a function). zshrs pushed "shfunc" and, since #1065, "cmdsubst",
2892                                  // but never "eval". Popped on every return path by the guard, matching
2893                                  // execode's stack discipline. Bug #1065 (eval leg).
2894        let _eval_ctx_guard = crate::ported::exec::EvalContextFrame::push("eval");
2895        // c:Src/builtin.c:6163-6178 — `eval` pushes a funcstack frame named
2896        // "(eval)" (tp = FS_EVAL), gated on `ineval = !isset(EVALLINENO)` /
2897        // `if (!ineval)` — i.e. pushed when EVAL_LINENO is SET, which is the
2898        // zsh default. zshrs already set `scriptname = "(eval)"` (below) but
2899        // never pushed the frame, so `eval '…${#funcstack}'` reported 0 where
2900        // zsh reports 1, and inside a function `${(j:,:)funcstack}` was `f`
2901        // rather than `(eval),f`. Both shells already agreed under
2902        // `unsetopt evallineno` (no frame), so the option gate is load-bearing
2903        // and is mirrored here. Popped on every return path by the guard.
2904        // Bug #1066.
2905        // The push itself is the canonical port (`EvalFuncstackFrame::push`,
2906        // exec.rs, c:6155-6193) — shared with `eval_string`, which the
2907        // compsys `_dispatch` port uses for its `eval "$comp"` sites so both
2908        // eval entry points produce a byte-identical `(eval)` frame. It was
2909        // inline here and set `lineno`/`flineno` to 0 with no `filename`,
2910        // which C computes at c:6161 / c:6174-6188 — so `$functrace` read
2911        // `<caller>:0` and `$funcfiletrace` lost the defining file.
2912        let _eval_fs_guard = crate::ported::exec::EvalFuncstackFrame::push();
2913        let oscriptname = crate::ported::utils::scriptname_get();
2914        crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
2915        // Recursion backstop — c:Src/jobs.c:1878-1884. zsh caps eval recursion
2916        // via its job table (every eval'd pipeline grabs a job slot; the table
2917        // caps at MAX_MAXJOBS → "job table full or recursion limit exceeded").
2918        // The fusevm runtime allocates no job per pipeline, and nested evals
2919        // push no funcstack frame (INEVAL suppression, c:6164), so eval nesting
2920        // is invisible to both the job table AND FUNCNEST/FUNCSTACK — runaway
2921        // `eval`-string recursion overflowed the 256 MB main-thread stack →
2922        // uncatchable SIGBUS. Track eval re-entry depth (the Rust proxy for
2923        // held job slots) and refuse at the same MAX_MAXJOBS ceiling.
2924        let eval_depth = crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| {
2925            let v = d.get() + 1;
2926            d.set(v);
2927            v
2928        });
2929        let mut status = if eval_depth >= crate::ported::jobs::MAX_MAXJOBS {
2930            crate::ported::utils::zerr("job table full or recursion limit exceeded");
2931            1
2932        } else {
2933            with_executor(|exec| {
2934                // c:6175 execode — no end-of-script hooks (c:Src/builtin.c:6213)
2935                exec.execute_string_without_exit_hooks(&src).unwrap_or(1)
2936            })
2937        };
2938        crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
2939        // c:Src/builtin.c:6211-6212 — `if (errflag && !lastval)
2940        //   lastval = errflag;`
2941        // c:Src/builtin.c:6221 — `errflag &= ~ERRFLAG_ERROR;`
2942        // eval is a CONTAINMENT boundary: an error inside the eval
2943        // body (readonly reassign, bad assoc set, ${unset?msg}, …)
2944        // breaks the eval body's lists via errflag, then eval clears
2945        // the flag and returns lastval, and the CALLER's next list
2946        // runs. zsh 5.9: `eval 'assoc=(odd)'; echo "after $?"`
2947        // prints `after 1` in -c, script, and stdin contexts.
2948        {
2949            use std::sync::atomic::Ordering;
2950            let ef = crate::ported::utils::errflag.load(Ordering::Relaxed)
2951                & crate::ported::zsh_h::ERRFLAG_ERROR;
2952            if ef != 0 && status == 0 {
2953                status = ef; // c:6212 lastval = errflag
2954            }
2955            crate::ported::utils::errflag
2956                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
2957            // The NOMATCH cell is the Rust half of that error state (set
2958            // beside `zerr` for c:Src/glob.c:1877). The builtin dispatcher
2959            // reads it BEFORE the next command runs, so one left over from
2960            // `eval 'x=( *nomatch )'` skipped the caller's next command.
2961            with_executor(|exec| exec.current_command_glob_failed.set(false));
2962        }
2963        crate::ported::utils::set_scriptname(oscriptname);
2964        Value::Status(status)
2965    });
2966
2967    // `builtin foo args…`: precmd-modifier that forces builtin dispatch,
2968    // bypassing alias AND function lookup. Without this, `builtin cd /`
2969    // inside a user `cd () { … }` wrapper recurses (real-world ZPWR pattern).
2970    // Handler pops argc args from the stack, treats args[0] as the builtin
2971    // name, and dispatches the rest via `dispatch_builtin` → `execbuiltin`
2972    // → `bin_*` directly. No function/alias lookup happens.
2973    vm.register_builtin(BUILTIN_BUILTIN, |vm, argc| {
2974        let args = pop_args(vm, argc);
2975        // c:Src/exec.c:3483-3487 — the precommand-modifier walk checks
2976        // `shfunctab` for the command word BEFORE `builtintab`, and only
2977        // skips that check once a prefix has already been consumed
2978        // (`cflags & (BINF_BUILTIN|BINF_COMMAND)`). So on the FIRST word a
2979        // shell function literally named `builtin` shadows the builtin —
2980        // `builtin() { ... }; builtin whence -va x` runs the function.
2981        // zshrs resolves `builtin` at bytecode-compile time (no function
2982        // table yet), so the shadow test has to happen here, at dispatch.
2983        if let Some(status) = try_user_fn_override("builtin", &args) {
2984            return Value::Status(status);
2985        }
2986        // c:Src/exec.c:3760-3763 — `if (errflag) { lastval = 1; goto err; }`
2987        // after argument expansion: a bad glob qualifier or pattern in the
2988        // words (`builtin print x(a)` "number expected") skips the builtin
2989        // with status 1 and leaves errflag set, so the script ends. This
2990        // handler is reached without dispatch_builtin's gates, and ran the
2991        // builtin, which reported 0.
2992        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2993            & crate::ported::zsh_h::ERRFLAG_ERROR)
2994            != 0
2995        {
2996            // c:3523-3525 / c:3760-3762 — see words_errflag_status.
2997            return Value::Status(words_errflag_status());
2998        }
2999        // c:Src/exec.c:3104-3146 — the precommand walk strips a `-` word after
3000        // `builtin` too (it is itself a BINF_PREFIX node, c:Src/builtin.c:42);
3001        // when nothing is left, c:3380-3406 is the empty-command return and
3002        // `builtin -` succeeds without running anything.
3003        let mut full = Vec::with_capacity(args.len() + 1);
3004        full.push("builtin".to_string());
3005        full.extend(args.iter().cloned());
3006        if crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE)
3007            .is_empty_command
3008        {
3009            return Value::Status(0);
3010        }
3011        let Some((name, rest)) = args.split_first() else {
3012            // `builtin` with no args → list builtins (zsh emits nothing,
3013            // exit 0). Match that behavior; the BIN_BUILTIN bin_* in C
3014            // does the same default-list-nothing.
3015            return Value::Status(0);
3016        };
3017        // zshrs extension builtins (daemon z* family: zd, zcache, zjob,
3018        // …) are dispatched by name via try_dispatch instead of living
3019        // in builtintab — but they ARE builtins, so the `builtin`
3020        // precommand must reach them (`builtin zd ping` errored
3021        // "no such builtin: zd" while bare `zd ping` worked).
3022        if let Some(status) = run_daemon_builtin(name, rest) {
3023            return Value::Status(status);
3024        }
3025        // c:Src/exec.c:3435-3436 — `builtin NAME` with NAME not in
3026        // builtintab emits `zwarn("no such builtin: %s", cmdarg)`
3027        // and returns 1. zshrs's dispatch_builtin_raw bare-returned 1
3028        // silently. Probe the table here so the diagnostic fires
3029        // before dispatch.
3030        let tab = crate::ported::builtin::createbuiltintable();
3031        // c:Src/exec.c:3489-3499 — `builtintab->getnode` skips DISABLED
3032        // entries, so `disable typeset; builtin typeset x=1` is "no such
3033        // builtin: typeset" with status 1, like a name that was never a builtin.
3034        let disabled = crate::ported::builtin::BUILTINS_DISABLED
3035            .lock()
3036            .map(|s| s.contains(name.as_str()))
3037            .unwrap_or(false);
3038        if !tab.contains_key(name.as_str()) || disabled {
3039            // zshrs-original opcode builtins (async, doctor, peach, …) aren't
3040            // in builtintab; `builtin NAME` must still reach them.
3041            if let Some(status) = try_run_registered_builtin(name, rest) {
3042                return Value::Status(status);
3043            }
3044            // c:Src/exec.c:3436 — `zwarn("no such builtin: %s", cmdarg);`.
3045            // Route through the ported `zwarn` rather than formatting the
3046            // prefix by hand: zwarn emits zsh's `zsh:LINE:` prefix, and the
3047            // hand-rolled `eprintln!` here printed `zshrs:1:` instead. Of the
3048            // twelve error shapes probed this was the ONLY one carrying the
3049            // wrong prefix — the ported twin at exec.rs:9878 already used
3050            // zwarn correctly, so this was a reimplementation shadowing a
3051            // faithful port (same shape as #1027 / #1031 / #1044 / #1050).
3052            // Bug #1063.
3053            crate::ported::utils::zwarn(&format!("no such builtin: {}", name));
3054            return Value::Status(1);
3055        }
3056        // `builtin foo` MUST bypass function shadow — that's the whole
3057        // point of the prefix. Use the _raw helper, not the shadow-aware
3058        // one. Without this, `cd () { builtin cd "$@"; }` recurses.
3059        // c:Src/exec.c:3484 — BINF_BUILTIN suppresses the shfunctab probe.
3060        // `bin_compadd` runs that probe itself, so publish the modifier across
3061        // the call (see `ForcedBuiltinGuard`).
3062        let _forced = crate::ported::zle::complete::ForcedBuiltinGuard::enter();
3063        Value::Status(dispatch_builtin_raw(name, rest.to_vec()))
3064    });
3065
3066    // `command foo args…` — BINF_COMMAND prefix (Src/builtin.c:44). Zsh
3067    // semantic: bypass alias+function lookup, search builtin then $PATH.
3068    // Without this, `cd () { command cd "$@" }` would re-invoke the user
3069    // wrapper (same root cause as the `builtin` bug). Flags `-p`/`-v`/`-V`
3070    // route to bin_whence with BIN_COMMAND funcid; bare `command foo`
3071    // dispatches builtin if present, else external (no fork — direct
3072    // spawn via execute_external since zshrs is non-forking).
3073    // BUILTIN_COMMAND — `command [-p] [-v|-V] cmd args…` BIN_PREFIX
3074    // (Src/builtin.c:45). PURE PASSTHRU: prepend "command" and hand
3075    // to `exec::execcmd_compile_head` (the fusevm-bytecode-time head
3076    // resolver mirroring `Src/exec.c::execcmd_exec` precommand-modifier
3077    // walk at c:3104-3187). That helper already does the -p / -v / -V
3078    // option parsing, surfaces `has_command_vv` for the whence
3079    // redirect, and reports the dispatch shape (is_builtin vs external).
3080    vm.register_builtin(BUILTIN_COMMAND, |vm, argc| {
3081        let args = pop_args(vm, argc);
3082        // c:Src/exec.c:3483-3487 — same shfunctab-before-builtintab rule
3083        // as BUILTIN_BUILTIN above: a shell function named `command`
3084        // shadows the `command` precommand modifier on the first word.
3085        if let Some(status) = try_user_fn_override("command", &args) {
3086            return Value::Status(status);
3087        }
3088        let mut full = Vec::with_capacity(args.len() + 1);
3089        full.push("command".to_string());
3090        full.extend(args.clone());
3091        let dispatch =
3092            crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
3093        let post = &full[dispatch.precmd_skip..];
3094        // c:Src/exec.c:3212-3214 — `} else if (has_p) { /* Use default path */
3095        // use_defpath = 1; … }`. `-p` is a flag carried down to the exec
3096        // (c:4369 `execute(args, cflags, use_defpath)`) and to whence
3097        // (c:Src/builtin.c:4165-4167 `findcmd(*argv, 1, func == BIN_COMMAND &&
3098        // OPT_ISSET(ops,'p'))`), where the compiled-in DEFAULT_PATH
3099        // (configure.ac:1954, `getconf _CS_PATH` at build time) is searched
3100        // directly. C never assigns to `$path` for this.
3101        //
3102        // command's OWN options end at the first non-flag arg —
3103        // everything after the command name belongs to IT. The
3104        // previous `.any()` scan over ALL args stole `-p` from
3105        // `command mkdir -p DIR` (zconvey.plugin.zsh:44), stripping
3106        // the flag before /bin/mkdir ran → "File exists" errors on
3107        // every re-source.
3108        let mut lead = 0usize;
3109        let mut dash_p = false;
3110        let mut kept_flags: Vec<String> = Vec::new();
3111        for a in post.iter() {
3112            let s = a.as_str();
3113            if s == "--" {
3114                lead += 1;
3115                break;
3116            }
3117            if s.starts_with('-')
3118                && s.len() >= 2
3119                && s[1..].chars().all(|c| c == 'p' || c == 'v' || c == 'V')
3120            {
3121                if s.contains('p') {
3122                    dash_p = true;
3123                }
3124                // -v / -V drive the whence-style lookup downstream —
3125                // keep them in post (only the PATH-reset `p` is
3126                // consumed here).
3127                let rest: String = s[1..].chars().filter(|c| *c != 'p').collect();
3128                if !rest.is_empty() {
3129                    kept_flags.push(format!("-{}", rest));
3130                }
3131                lead += 1;
3132                continue;
3133            }
3134            break;
3135        }
3136        let mut post: Vec<String> = {
3137            let mut v = kept_flags;
3138            v.extend(post[lead..].iter().cloned());
3139            v
3140        };
3141        // c:Src/exec.c:3176-3177 — `BINF_COMMAND` arm strips a single
3142        // leading `--` end-of-options marker.
3143        // `execcmd_compile_head` (src/ported/exec.rs:1042) performs
3144        // this removal on its LOCAL `preargs` Vec but doesn't surface
3145        // the modified args; the caller still sees `--` in `full` and
3146        // tried to dispatch it as the command name. Bug #251. Mirror
3147        // the C strip here so `command -- echo hi` and
3148        // `command -p -- echo hi` route correctly.
3149        if let Some(first) = post.first() {
3150            if first == "--" {
3151                post.remove(0);
3152            }
3153        }
3154        let post = post.as_slice();
3155        // The previous implementation spelled `-p` as a temporary `$PATH`
3156        // reassignment around the call, seeded by forking `getconf PATH`.
3157        // Three things were wrong with that and all three are gone now:
3158        // every `$PATH` write empties `cmdnamtab` (c:Src/params.c pathsetfn),
3159        // so the entry `execcmd_exec` had just made for the command was wiped
3160        // on restore and `hash` came back empty where the oracle lists the
3161        // command; the child process inherited the DEFAULT_PATH in its
3162        // environment instead of the caller's `$PATH`; and a shell whose point
3163        // is not forking paid a fork per `command -p` for a value the C build
3164        // bakes in at configure time.
3165        // c:Src/exec.c:3760-3763 — `if (errflag) { lastval = 1; goto err; }`
3166        // after argument expansion. The in-shell forms (`command -v`, and a
3167        // builtin under POSIX_BUILTINS) keep errflag, so the script ends; the
3168        // external form clears it below, as its forked child would.
3169        let expansion_error = (crate::ported::utils::errflag
3170            .load(std::sync::atomic::Ordering::Relaxed)
3171            & crate::ported::zsh_h::ERRFLAG_ERROR)
3172            != 0;
3173        if expansion_error && (dispatch.has_command_vv || dispatch.is_builtin) {
3174            // c:3523-3525 / c:3760-3762 — see words_errflag_status.
3175            return Value::Status(words_errflag_status());
3176        }
3177        if dispatch.has_command_vv {
3178            return Value::Status(command_whence(post, dash_p));
3179        }
3180        if dispatch.is_empty_command {
3181            return Value::Status(0);
3182        }
3183        let Some((name, rest)) = post.split_first() else {
3184            return Value::Status(0);
3185        };
3186        // c:Src/exec.c:3275-3278 — `execcmd_compile_head` cleared
3187        // hn for the BINF_COMMAND + !POSIXBUILTINS case, surfacing
3188        // is_builtin=false. Run as external. Under POSIXBUILTINS
3189        // dispatch.is_builtin would be true; honour it.
3190        let n = name.clone();
3191        let r = rest.to_vec();
3192        if dispatch.is_builtin
3193            && crate::ported::builtin::BUILTINS
3194                .iter()
3195                .any(|b| b.node.nam == n.as_str())
3196        {
3197            // c:Src/exec.c:3484 — BINF_COMMAND suppresses it too.
3198            let _forced = crate::ported::zle::complete::ForcedBuiltinGuard::enter();
3199            return Value::Status(dispatch_builtin_raw(&n, r));
3200        }
3201        // c:Src/exec.c:3755-3763 — `globlist(args, 0); if (errflag) { lastval
3202        // = 1; goto err; }`. Without POSIX_BUILTINS `command NAME` is always
3203        // an external, and C forks before that globlist, so a NOMATCH error
3204        // skips the command in the child while the parent's errflag stays
3205        // clean: `command ls zzq*; print $?` prints the error, then 1. Here
3206        // the words were expanded in the shell, so drop the failed command
3207        // and its ERRFLAG_ERROR the way the external dispatcher does. Any
3208        // other expansion error (`command print x(a)` "number expected")
3209        // takes the same path.
3210        let glob_failed = with_executor(|exec| {
3211            let f = exec.current_command_glob_failed.get();
3212            exec.current_command_glob_failed.set(false);
3213            f
3214        });
3215        // A prefork error (c:3357-3359, a bad `${…}` or math) happens BEFORE
3216        // that fork, in the shell: c:3523-3525 keeps a non-zero lastval and
3217        // errflag ends the list (`command print ${.bad}` after `(exit 5)`
3218        // exits 5). Only a globlist error belongs to the forked child.
3219        if expansion_error && !glob_failed && !GLOB_WORD_ERRFLAG.with(|c| c.get()) {
3220            return Value::Status(words_errflag_status());
3221        }
3222        GLOB_WORD_ERRFLAG.with(|c| c.set(false));
3223        if glob_failed || expansion_error {
3224            crate::ported::utils::errflag.fetch_and(
3225                !crate::ported::zsh_h::ERRFLAG_ERROR,
3226                std::sync::atomic::Ordering::Relaxed,
3227            );
3228            with_executor(|exec| exec.set_last_status(1));
3229            return Value::Status(1);
3230        }
3231        // c:Src/exec.c:3719 forks the external command before the redirection
3232        // loop at c:3785, so a redirection that failed (`command cat <&""` →
3233        // "file number expected") fails in the child: the command never runs
3234        // and the shell continues with status 1. The redirect arm only
3235        // records the failure; without consuming it here `command cat` ran
3236        // the real cat on the untouched stdin and blocked.
3237        let redir_failed = with_executor(|exec| {
3238            let f = exec.redirect_failed;
3239            exec.redirect_failed = false;
3240            f
3241        });
3242        if redir_failed {
3243            crate::ported::utils::errflag.fetch_and(
3244                !crate::ported::zsh_h::ERRFLAG_ERROR,
3245                std::sync::atomic::Ordering::Relaxed,
3246            );
3247            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
3248            return Value::Status(1);
3249        }
3250        // c:Src/exec.c:3275-3278 — `command NAME` asks for the thing on
3251        // `PATH`, not the in-process one. The host-registered native commands
3252        // (`extensions/native_cmds.rs`) are caught inside `execute_external`,
3253        // which is this very call, so they are marked as explicitly forced
3254        // past for its duration — matching what `command cat` already does to
3255        // the coreutils shadow.
3256        let _forced = crate::native_cmds::force_external();
3257        // c:Src/exec.c:4369 — `execute(args, cflags, use_defpath)`. The `-p`
3258        // flag is the third argument, not a mutation of the environment.
3259        Value::Status(
3260            with_executor(|exec| {
3261                if dash_p {
3262                    exec.execute_external_defpath(&n, &r, &[])
3263                } else {
3264                    exec.execute_external(&n, &r, &[])
3265                }
3266            })
3267            .unwrap_or(127),
3268        )
3269    });
3270
3271    // `exec cmd args…` — BINF_EXEC prefix (Src/builtin.c:45). Zsh
3272    // semantic: replace the current shell process with `cmd`. On Unix
3273    // this is `execvp(2)`; the call only returns on error. zshrs is
3274    // non-forking, so the shell process IS the calling process —
3275    // execvp here directly replaces it. Options `-a name` (override
3276    // argv[0]), `-c` (clean env), `-l` (login shell — prepend `-`)
3277    // ported minimally; advanced redirect-only `exec >file` is handled
3278    // upstream by compile_zsh and never reaches this handler.
3279    vm.register_builtin(BUILTIN_EXEC, |vm, argc| {
3280        let args = pop_args(vm, argc);
3281        // c:Src/exec.c:3029-3278 — the precommand walk, `exec` included:
3282        // its options (c:3178-3274, `-a`/`-c`/`-l` and the "exec requires
3283        // a command to execute" / "unknown exec flag" errors), then any
3284        // further BINF_PREFIX word (`command`, `builtin`, `exec`, `-`) whose
3285        // flags join cflags with BINF_EXEC still set (c:3116-3118). So
3286        // `exec command X` runs the external X in the shell's place and
3287        // `exec builtin X` the builtin. Under POSIX_BUILTINS the walk stops
3288        // after `exec`'s options (c:3098-3103): the name is a command.
3289        let mut full = Vec::with_capacity(args.len() + 1);
3290        full.push("exec".to_string());
3291        full.extend(args);
3292        let walk = crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
3293        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3294            & crate::ported::zsh_h::ERRFLAG_ERROR)
3295            != 0
3296        {
3297            vm.last_status = 1; // c:3205 `lastval = 1;`
3298            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
3299            return Value::Status(1); // c:3203-3207 / c:3230-3234 / c:3249-3256 `goto done`
3300        }
3301        if walk.has_command_vv {
3302            // c:3149-3157 — whence, then the exec'd shell is gone.
3303            let post = &walk.preargs[walk.precmd_skip..];
3304            let dash_p = post.iter().take_while(|a| a.starts_with('-')).any(|a| a.contains('p'));
3305            let status = command_whence(post, dash_p);
3306            let _ = std::io::stdout().flush();
3307            std::process::exit(status);
3308        }
3309        if walk.is_empty_command {
3310            // `exec` with no command + no redirects = no-op success
3311            // (c:3365-3406; the redirect-only form never reaches here).
3312            return Value::Status(0);
3313        }
3314        let args: Vec<String> = walk.preargs[walk.precmd_skip..].to_vec();
3315        let cmd = args[0].clone();
3316        if (walk.cflags & crate::ported::zsh_h::BINF_BUILTIN) != 0 && !walk.is_builtin {
3317            // c:3490-3492 — `zwarn("no such builtin: %s", cmdarg); lastval = 1;`
3318            crate::ported::utils::zwarn(&format!("no such builtin: {}", cmd));
3319            return Value::Status(1);
3320        }
3321        let login = (walk.cflags & crate::ported::zsh_h::BINF_DASH) != 0; // c:3246
3322        let clean_env = (walk.cflags & crate::ported::zsh_h::BINF_CLEARENV) != 0; // c:3243
3323        let argv0_override = walk.exec_argv0.clone(); // c:3263-3274
3324        // c:4369 — `execute(args, cflags, use_defpath)`: `exec command -p X`.
3325        let use_defpath = walk.use_defpath;
3326        let prog = if use_defpath && !cmd.contains('/') {
3327            crate::ported::exec::search_defpath(&cmd, libc::PATH_MAX as usize).unwrap_or_else(|| cmd.clone())
3328        } else {
3329            cmd.clone()
3330        };
3331        let rest: Vec<String> = args[1..].to_vec();
3332        let display_argv0 = match argv0_override {
3333            Some(a) => a,
3334            None => {
3335                if login {
3336                    format!("-{}", cmd)
3337                } else {
3338                    cmd.clone()
3339                }
3340            }
3341        };
3342        // c:Src/exec.c:3468/3582 — execcmd bails out before running anything
3343        // once a redirection has failed: the failure calls zerr, which sets
3344        // errflag, and both bail-outs test it. `exec` is not exempt, so
3345        //     exec ls 3>&98; print after
3346        // in zsh reports the bad fd, does NOT run ls, and the SHELL SURVIVES
3347        // to run `print after`. zshrs consumed the flag in
3348        // BUILTIN_EXEC_PERM_REDIRS (returning status 1) but then dispatched
3349        // the command regardless — replacing the shell with it, so anything
3350        // after the exec never ran, and `exec 99>&98` reported a spurious
3351        // `command not found: 99` for the leftover fd word.
3352        if with_executor(|exec| {
3353            let f = exec.redirect_failed;
3354            exec.redirect_failed = false;
3355            f
3356        }) {
3357            vm.last_status = 1;
3358            return Value::Status(1);
3359        }
3360
3361        // c:Src/exec.c::execcmd — `exec funcname` runs the function
3362        // in-process as the shell's last act, then exits with the
3363        // function's status. zsh's dispatcher falls through from the
3364        // BINF_EXEC prefix into the normal Builtin/External/Function
3365        // resolution and only execvp's if the target ISN'T a
3366        // function. Bug #101 in docs/BUGS.md: zshrs's exec went
3367        // straight to execvp and errored `not found` for shell
3368        // functions.
3369        //
3370        // For both subshell and top-level contexts: dispatch through
3371        // the function/builtin lookup first; only fall through to
3372        // execvp/spawn if the name isn't shell-resolvable.
3373        // c:Src/exec.c:3098-3103 — `else if (isset(POSIXBUILTINS) &&
3374        // (cflags & BINF_EXEC)) break;` "POSIX doesn't allow "exec" to
3375        // operate on builtins or shell functions": the walk stops before
3376        // the shfunctab and builtintab lookups, so the name is searched
3377        // as an external command. A walked chain has already resolved the
3378        // head (c:3104-3108 skips functions after `builtin` / `command`).
3379        let has_user_fn = walk.is_shfunc; // c:3104-3108
3380        if has_user_fn {
3381            let status =
3382                with_executor(|exec| exec.dispatch_function_call(&cmd, &rest).unwrap_or(127));
3383            // Top-level `exec funcname` — exit the shell with the
3384            // function's status (mirrors C's "exec replaces shell as
3385            // last act"). Subshell `(exec funcname)` — return through
3386            // the EXIT_PENDING path so the subshell body aborts and
3387            // the parent resumes via subshell_end.
3388            let in_subshell_now = exec_in_process_subshell();
3389            if in_subshell_now {
3390                crate::ported::builtin::EXIT_VAL
3391                    .store(status, std::sync::atomic::Ordering::Relaxed);
3392                crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
3393                return Value::Status(status);
3394            }
3395            std::process::exit(status);
3396        }
3397        // c:Src/exec.c — builtin path: `exec builtin` runs the
3398        // builtin in-process and exits.
3399        let bn_in_tab = walk.is_builtin; // c:3120-3129
3400        if bn_in_tab {
3401            let status = dispatch_builtin_raw(&cmd, rest.clone());
3402            let in_subshell_now = exec_in_process_subshell();
3403            if in_subshell_now {
3404                crate::ported::builtin::EXIT_VAL
3405                    .store(status, std::sync::atomic::Ordering::Relaxed);
3406                crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
3407                return Value::Status(status);
3408            }
3409            std::process::exit(status);
3410        }
3411        // c:Src/exec.c — `exec` inside a subshell (`(exec cmd)`)
3412        // replaces ONLY the subshell child process; the parent shell
3413        // continues. C zsh always forks for `(...)`, so the actual
3414        // execvp lands in the forked child. zshrs runs subshells via
3415        // a snapshot/restore pattern in the SAME process — calling
3416        // execvp here would replace the parent too. Bug #94 in
3417        // docs/BUGS.md.
3418        //
3419        // Detect subshell context via the non-empty
3420        // `subshell_snapshots` stack. When in a subshell: spawn the
3421        // command as a child, wait for it, then signal the subshell
3422        // body to abort (return Status(N) and the caller's
3423        // subshell_end will pop the snapshot and resume the parent).
3424        let in_subshell = exec_in_process_subshell();
3425        if in_subshell && !clean_env {
3426            let status = exec_execute(&args, walk.exec_argv0.clone(), login, use_defpath, false);
3427            crate::ported::builtin::EXIT_VAL.store(status, std::sync::atomic::Ordering::Relaxed);
3428            crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
3429            return Value::Status(status);
3430        }
3431        if in_subshell {
3432            // c:Src/exec.c:4352 `closem(FDT_INTERNAL, 0)` before execve.
3433            crate::lowfd::cloexec_internal_fds();
3434            let mut command = std::process::Command::new(&prog);
3435            command.arg0(&display_argv0);
3436            command.args(&rest);
3437            if clean_env {
3438                command.env_clear();
3439            }
3440            // Queue signals across spawn+wait so the SIGCHLD reaper
3441            // can't reap this child before child.wait() does — see
3442            // ForegroundWaitGuard.
3443            let _wait_guard = ForegroundWaitGuard::enter();
3444            let status = match command.spawn() {
3445                Ok(mut child) => match child.wait() {
3446                    Ok(s) => crate::exec_jobs::wait_status_val(s),
3447                    Err(_) => 127,
3448                },
3449                Err(e) => {
3450                    // c:Src/exec.c:797 — `zerr("%e: %s", lerrno, arg0)`
3451                    //                     when arg0 contains `/`.
3452                    // c:872-876 — when arg0 has no `/` (PATH search
3453                    //              path), C tracks the "good" errno
3454                    //              via `isgooderr`; if all PATH entries
3455                    //              were ENOENT-not-good, eno stays 0
3456                    //              and C emits `command not found: %s`
3457                    //              instead of strerror.
3458                    // %e expands to strerror(errno) with the first
3459                    // letter lowercased (unless errno == EIO; see
3460                    // Src/utils.c:362-368). `zerr` prepends the
3461                    // scriptname:lineno: prefix — matching zsh's
3462                    // canonical `zsh:N: <errmsg>: <cmd>` pattern.
3463                    // Previously emitted `zshrs: exec: {}: not found`
3464                    // (wrong prefix, hardcoded message, missing
3465                    // lineno). Bug #140 in docs/BUGS.md.
3466                    let errno = e.raw_os_error().unwrap_or(libc::ENOENT);
3467                    let has_slash = cmd.contains('/');
3468                    if !has_slash && errno == libc::ENOENT {
3469                        // c:876 — PATH search exhausted with no good
3470                        // errno → `command not found: arg0`.
3471                        crate::ported::utils::zerr(&format!("command not found: {}", crate::ported::utils::nicedupstring(&cmd)));
3472                    } else {
3473                        let mut errmsg = crate::ported::compat::strerror(errno);
3474                        if errno != libc::EIO {
3475                            if let Some(c) = errmsg.chars().next() {
3476                                errmsg = format!(
3477                                    "{}{}",
3478                                    c.to_ascii_lowercase(),
3479                                    &errmsg[c.len_utf8()..]
3480                                );
3481                            }
3482                        }
3483                        crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
3484                    }
3485                    // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
3486                    if errno == libc::EACCES || errno == libc::ENOEXEC {
3487                        126
3488                    } else {
3489                        127
3490                    }
3491                }
3492            };
3493            // Mark the subshell as exec-replaced so subsequent body
3494            // commands skip — mirrors the post-execvp "child process
3495            // is gone" reality in C. EXIT_PENDING + EXIT_VAL drive
3496            // the next ERREXIT_CHECK to unwind to the subshell-end
3497            // patch.
3498            crate::ported::builtin::EXIT_VAL.store(status, std::sync::atomic::Ordering::Relaxed);
3499            crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
3500            return Value::Status(status);
3501        }
3502        // Both guards of exec_shlvl_decrement hold here: the in-subshell
3503        // form returned at the branch above (so `subsh` is false), and this
3504        // call replaces THIS process rather than a child (so `forked` is
3505        // false).
3506        exec_shlvl_decrement();
3507        // c:Src/exec.c:4369 — `execute(args, cflags, use_defpath)` in this
3508        // very process, exec'ing in place (see exec_execute). `exec -c`
3509        // keeps the Command route below: C hands it `blank_env` (c:778-779),
3510        // which carries no `_` either.
3511        if !clean_env {
3512            std::process::exit(exec_execute(&args, walk.exec_argv0.clone(), login, use_defpath, true));
3513        }
3514        let mut command = std::process::Command::new(&prog);
3515        command.arg0(&display_argv0);
3516        command.args(&rest);
3517        if clean_env {
3518            command.env_clear();
3519        }
3520        use std::os::unix::process::CommandExt;
3521        // `exec` returns the OS error iff exec(2) failed; on success
3522        // it never returns. Match zsh: print the error to stderr with
3523        // the `exec` prefix and exit 127 (cmd not found) or 126 (not
3524        // executable).
3525        // c:Src/exec.c:4352 `closem(FDT_INTERNAL, 0)` before execve.
3526        crate::lowfd::cloexec_internal_fds();
3527        let err = command.exec();
3528        // c:Src/exec.c:797 / c:872-876 — same format as in-subshell
3529        // branch. arg0-has-/ → `<strerror>: <cmd>`; arg0-no-/ +
3530        // ENOENT → `command not found: <cmd>`. Lowercase strerror
3531        // first letter unless EIO. Bug #140 in docs/BUGS.md.
3532        let errno = err.raw_os_error().unwrap_or(libc::ENOENT);
3533        let has_slash = cmd.contains('/');
3534        if !has_slash && errno == libc::ENOENT {
3535            crate::ported::utils::zerr(&format!("command not found: {}", crate::ported::utils::nicedupstring(&cmd)));
3536        } else {
3537            let mut errmsg = crate::ported::compat::strerror(errno);
3538            if errno != libc::EIO {
3539                if let Some(c) = errmsg.chars().next() {
3540                    errmsg = format!("{}{}", c.to_ascii_lowercase(), &errmsg[c.len_utf8()..]);
3541                }
3542            }
3543            crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
3544        }
3545        // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
3546        let code = if errno == libc::EACCES || errno == libc::ENOEXEC {
3547            126
3548        } else {
3549            127
3550        };
3551        std::process::exit(code);
3552    });
3553
3554    reg_passthru!(vm, BUILTIN_LET, "let");
3555
3556    // Job control
3557    reg_passthru!(vm, BUILTIN_JOBS, "jobs");
3558    reg_passthru!(vm, BUILTIN_FG, "fg");
3559    reg_passthru!(vm, BUILTIN_BG, "bg");
3560    reg_passthru!(vm, BUILTIN_KILL, "kill");
3561    reg_passthru!(vm, BUILTIN_DISOWN, "disown");
3562    reg_passthru!(vm, BUILTIN_WAIT, "wait");
3563    reg_passthru!(vm, BUILTIN_SUSPEND, "suspend");
3564
3565    // History — `fc` / `history` / `r` all route to `bin_fc` (zsh
3566    // registers them as aliases of the same builtin per Src/builtin.c).
3567    reg_passthru!(vm, BUILTIN_FC, "fc");
3568    reg_passthru!(vm, BUILTIN_HISTORY, "history");
3569    reg_passthru!(vm, BUILTIN_R, "r");
3570
3571    // Aliases — alias is `BINF_MAGICEQUALS` per Src/builtin.c:50.
3572    // c:Src/exec.c:3298-3304 — when a builtin has BINF_MAGICEQUALS,
3573    // execcmd_exec sets esprefork = PREFORK_TYPESET and calls
3574    // `prefork(args, esprefork, NULL)` on the argv. prefork (subst.c:
3575    // 100) drives `filesub` on each word (c:133), which (c:677-686)
3576    // looks for the assignment Equals and runs `filesubstr` on the
3577    // VALUE side. That's how `alias bad===` triggers equalsubstr's
3578    // "= not found" via the inner Equals after the first `=`.
3579    //
3580    // The fusevm dispatch path doesn't go through execcmd_exec, so
3581    // BUILTIN_ALIAS previously passed args straight to bin_alias with
3582    // no expansion — `alias x=~/foo` stored literal `~/foo` (no tilde
3583    // expand), `alias bad===` stored a broken entry without firing
3584    // the "= not found" diagnostic. The prefork(PREFORK_TYPESET) runs
3585    // per arg word via BUILTIN_MAGIC_EQUALS_PREFORK ops that
3586    // compile_simple emits BEFORE the redirect scope opens (matching
3587    // c:3304 prefork-before-addfd order), so the dispatch here is a
3588    // plain passthrough — re-running prefork would double-fire the
3589    // "= not found" diagnostic.
3590    reg_passthru!(vm, BUILTIN_ALIAS, "alias");
3591    // c:Src/exec.c:3298-3304 — per-word magic-equals prefork; see the
3592    // const doc at BUILTIN_MAGIC_EQUALS_PREFORK. prefork's filesub
3593    // trigger (subst.c:678 `strchr(*namptr+1, Equals)`) looks for the
3594    // EQUALS TOKEN, not literal `=`. The fusevm path delivers args
3595    // already-untokenized, so re-tokenize each element via
3596    // `shtokenize` (the same call C's lexer makes implicitly when
3597    // assembling the word) so prefork sees Equals tokens at `=`
3598    // boundaries and Tilde tokens at `~` starts. After prefork
3599    // expands, untokenize for storage.
3600    vm.register_builtin(BUILTIN_MAGIC_EQUALS_PREFORK, |vm, _argc| {
3601        let raw = vm.pop();
3602        let inputs: Vec<String> = match raw {
3603            Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
3604            other => vec![other.to_str()],
3605        };
3606        let mut as_linklist: crate::ported::linklist::LinkList<String> = Default::default();
3607        for s in &inputs {
3608            let mut tokd = s.clone();
3609            crate::ported::glob::shtokenize(&mut tokd);
3610            as_linklist.push_back(tokd);
3611        }
3612        let mut rf = 0i32;
3613        crate::ported::subst::prefork(
3614            &mut as_linklist,
3615            crate::ported::zsh_h::PREFORK_TYPESET,
3616            &mut rf,
3617        );
3618        let mut expanded: Vec<String> = Vec::with_capacity(inputs.len());
3619        while let Some(s) = as_linklist.pop_front() {
3620            expanded.push(crate::ported::lex::untokenize(&s).to_string());
3621        }
3622        if expanded.len() == 1 {
3623            Value::str(expanded.into_iter().next().unwrap())
3624        } else {
3625            Value::array(expanded.into_iter().map(Value::str).collect())
3626        }
3627    });
3628
3629    // Options. `setopt` (BIN_SETOPT=0) / `unsetopt` (BIN_UNSETOPT=1)
3630    // share bin_setopt (options.c:580) — funcid bit discriminates
3631    // the polarity via BUILTINS table entries.
3632    reg_passthru!(vm, BUILTIN_SET, "set");
3633    reg_passthru!(vm, BUILTIN_SETOPT, "setopt");
3634    reg_passthru!(vm, BUILTIN_UNSETOPT, "unsetopt");
3635
3636    vm.register_builtin(BUILTIN_SHOPT, |vm, argc| {
3637        let args = pop_args(vm, argc);
3638        // `shopt` is a BASH builtin; no shell in the zsh family has it
3639        // (c:Src/builtin.c:40-137 `builtins[]` has no `shopt` row), so
3640        // outside bash drop-in mode the name must resolve through PATH
3641        // like any other unknown command — `zsh -fc shopt` →
3642        // `zsh:1: command not found: shopt`, rc 127.
3643        //
3644        // compile_zsh:3109 already forces `builtin_id = None` for the
3645        // literal name so the compile-time `CallBuiltin` fast path is
3646        // skipped, but that guard alone stopped working: the emitted
3647        // `Op::CallFunction` reaches fusevm's run-time
3648        // `VM::run_builtin_by_name` (fusevm-0.23.0/src/vm.rs:3348 →
3649        // :979) once the host's `call_function` answers None, and THAT
3650        // resolves the name straight back to this registered handler.
3651        // mapfile / readarray / compopt survive only because their
3652        // opcodes gate on the mode inside the handler; do the same
3653        // here. Mirrors the BUILTIN_COMPGEN / BUILTIN_COMPLETE gates
3654        // below, including their user-function precedence: a shell
3655        // function named `shopt` (bashcompinit-style shims define one)
3656        // still wins over the fall-through.
3657        if !crate::extensions::dash_mode::bash_mode() {
3658            if crate::ported::utils::getshfunc("shopt").is_some() {
3659                let status = with_executor(|exec| exec.dispatch_function_call("shopt", &args))
3660                    .unwrap_or(127);
3661                return Value::Status(status);
3662            }
3663            // PATH lookup with the literal name, so the diagnostic and
3664            // the status are the shell's own external-miss ones
3665            // (`zsh:1:` under --zsh, `zshrs:1:` natively) rather than a
3666            // second hand-rolled spelling of them.
3667            let status =
3668                with_executor(|exec| exec.execute_external("shopt", &args, &[])).unwrap_or(127);
3669            return Value::Status(status);
3670        }
3671        let status = crate::extensions::ext_builtins::shopt(&args);
3672        Value::Status(status)
3673    });
3674
3675    reg_passthru!(vm, BUILTIN_EMULATE, "emulate");
3676    reg_passthru!(vm, BUILTIN_GETOPTS, "getopts");
3677    reg_passthru!(vm, BUILTIN_AUTOLOAD, "autoload");
3678    reg_passthru!(vm, BUILTIN_FUNCTIONS, "functions");
3679    reg_passthru!(vm, BUILTIN_TRAP, "trap");
3680    reg_passthru!(vm, BUILTIN_DIRS, "dirs");
3681    // pushd / popd dispatch through canonical bin_cd via execbuiltin
3682    // — the BUILTINS table at src/ported/builtin.rs:9298 wires
3683    // `pushd` to bin_cd with funcid=BIN_PUSHD, and `popd` similarly
3684    // with BIN_POPD. Without these reg_passthru lines the fusevm
3685    // BUILTIN_PUSHD/POPD opcodes had no handler installed, so the
3686    // emitted CallBuiltin(110, …) silently returned a no-op and the
3687    // dirstack/$dirstack/pwd all stayed unchanged.
3688    reg_passthru!(vm, BUILTIN_PUSHD, "pushd");
3689    reg_passthru!(vm, BUILTIN_POPD, "popd");
3690    // type / whence / where / which all route through `bin_whence`
3691    // (canonical port at `src/ported/builtin.rs:3734` of
3692    // `Src/builtin.c:3975`). Each gets its own opcode so funcid +
3693    // defopts come from the BUILTINS table entry — execbuiltin
3694    // applies them correctly via the module-level dispatch_builtin.
3695    reg_passthru!(vm, BUILTIN_WHENCE, "whence");
3696    reg_passthru!(vm, BUILTIN_TYPE, "type");
3697    reg_passthru!(vm, BUILTIN_WHICH, "which");
3698    reg_passthru!(vm, BUILTIN_WHERE, "where");
3699    reg_passthru!(vm, BUILTIN_HASH, "hash");
3700    reg_passthru!(vm, BUILTIN_REHASH, "rehash");
3701
3702    // `unhash`/`unalias`/`unfunction` share `bin_unhash` (Src/builtin.c
3703    // c:4350) but each carries its own funcid (BIN_UNHASH /
3704    // BIN_UNALIAS / BIN_UNFUNCTION) in the BUILTINS table.
3705    reg_passthru!(vm, BUILTIN_UNHASH, "unhash");
3706    vm.register_builtin(BUILTIN_UNALIAS, |vm, argc| {
3707        let args = pop_args(vm, argc);
3708        Value::Status(dispatch_builtin("unalias", args))
3709    });
3710    vm.register_builtin(BUILTIN_UNFUNCTION, |vm, argc| {
3711        let args = pop_args(vm, argc);
3712        Value::Status(dispatch_builtin("unfunction", args))
3713    });
3714
3715    // Completion
3716    vm.register_builtin(BUILTIN_COMPGEN, |vm, argc| {
3717        let args = pop_args(vm, argc);
3718        // c:Bug #475/#555 — `compgen` is a bash-only builtin. In
3719        // `--zsh` mode emit "command not found" matching zsh's
3720        // external-command lookup miss — UNLESS a user FUNCTION of
3721        // that name exists: zsh has no such builtin, so bashcompinit's
3722        // `compgen() {...}` definition wins the dispatch there. The
3723        // unconditional 127 shadowed it and broke every
3724        // bashcompinit-style completion file (zsh-more-completions
3725        // _msync/_gocomplete/_qshell/_cw), spraying "command not
3726        // found: complete" at every deferred compinit load.
3727        // c:Src/exec.c:3105-3110 — a shell FUNCTION is consulted before the
3728        // builtin table, UNCONDITIONALLY. This check used to sit INSIDE the
3729        // `IS_ZSH_MODE` arm below, so in NATIVE mode zshrs's own extension
3730        // builtin ran and a user function of the same name was ignored.
3731        //
3732        // Live cost: `bashcompinit` defines shell functions named `complete`
3733        // and `compgen`, so in native mode `complete -F _fn cmd` silently
3734        // registered NOTHING and returned 0:
3735        //     zsh -f          _comps[zzbashc]=[_bash_complete -F _zzfn]
3736        //     zshrs -f        _comps[zzbashc]=[]            <- BUG
3737        //     zshrs --zsh -f  _comps[zzbashc]=[_bash_complete -F _zzfn]
3738        // That breaks every bashcompinit-bridged completer on the host.
3739        //
3740        // `try_user_fn_override` is the shape the correct registrations
3741        // already use — `zsleep` (below) and `compdef` — which is why
3742        // `compdef` alone always worked.
3743        if let Some(status) = try_user_fn_override("compgen", &args) {
3744            return Value::Status(status);
3745        }
3746        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
3747            // No user function, and `compgen` is a bash-only builtin, so in a
3748            // zsh-emulation shell it genuinely does not exist.
3749            eprintln!("zsh:1: command not found: compgen");
3750            let _ = args;
3751            return Value::Status(127);
3752        }
3753        let status = with_executor(|exec| exec.builtin_compgen(&args));
3754        Value::Status(status)
3755    });
3756
3757    vm.register_builtin(BUILTIN_COMPLETE, |vm, argc| {
3758        let args = pop_args(vm, argc);
3759        // c:Bug #475 — `complete` is a bash-only builtin. Same gate +
3760        // user-function precedence as BUILTIN_COMPGEN above.
3761        // Same unconditional function-before-builtin rule as BUILTIN_COMPGEN
3762        // above (c:Src/exec.c:3105-3110); this check was inside the mode gate
3763        // too, so `bashcompinit`'s `complete` function lost to the extension
3764        // builtin in native mode.
3765        if let Some(status) = try_user_fn_override("complete", &args) {
3766            return Value::Status(status);
3767        }
3768        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
3769            eprintln!("zsh:1: command not found: complete");
3770            let _ = args;
3771            return Value::Status(127);
3772        }
3773        let status = with_executor(|exec| exec.builtin_complete(&args));
3774        Value::Status(status)
3775    });
3776
3777    reg_passthru!(vm, BUILTIN_COMPADD, "compadd");
3778    reg_passthru!(vm, BUILTIN_COMPSET, "compset");
3779
3780    // See the const's doc comment for the contract. Stack (bottom→top):
3781    // base, e1, …, eN — argc = N + 1.
3782    // See the consts' doc comments.
3783    vm.register_builtin(BUILTIN_TYPESET_POSTASSIGNS_BEGIN, |vm, argc| {
3784        let n_post = (argc == 1).then(|| vm.pop().to_int().max(0) as usize);
3785        TYPESET_POSTASSIGN_COUNT.with(|c| c.set(n_post));
3786        let word_failed = with_executor(|exec| exec.current_command_glob_failed.replace(false));
3787        TYPESET_WORD_GLOB_FAILED.with(|c| c.set(word_failed));
3788        Value::Int(0)
3789    });
3790    // See BUILTIN_TYPESET_RESWD.
3791    vm.register_builtin(BUILTIN_TYPESET_RESWD, |_vm, _argc| {
3792        TYPESET_RESWD_DISPATCH.with(|c| c.set(true));
3793        Value::Int(0)
3794    });
3795    // See BUILTIN_EXEC_DASH.
3796    vm.register_builtin(BUILTIN_EXEC_DASH, |_vm, _argc| {
3797        EXEC_DASH.with(|c| c.set(c.get() | EXEC_CARRIER_DASH));
3798        Value::Int(0)
3799    });
3800    // See BUILTIN_EXECPLINE_CHILD_BLOCK / _UNBLOCK.
3801    // The depth argument is absent from chunks compiled before the job frame
3802    // was added (the autoload cache); those keep the signal half only.
3803    vm.register_builtin(BUILTIN_EXECPLINE_CHILD_BLOCK, |vm, argc| {
3804        crate::ported::signals_h::child_block(); // c:Src/exec.c:1748
3805        // argc 2: the pipeline is a lone current-shell compound command.
3806        let cursh = argc == 2 && vm.pop().to_int() != 0;
3807        if argc >= 1 {
3808            let depth = vm.pop().to_int() as u32;
3809            let frame = vm as *const fusevm::VM as usize;
3810            crate::exec_jobs::execpline_slot_open(frame, depth); // c:Src/exec.c:1656-1666
3811            if cursh {
3812                crate::exec_jobs::mark_thisjob_cursh(false); // c:Src/exec.c:3674-3680
3813            }
3814        }
3815        Value::Int(0)
3816    });
3817    vm.register_builtin(BUILTIN_EXECPLINE_CHILD_UNBLOCK, |vm, argc| {
3818        if argc == 1 {
3819            let depth = vm.pop().to_int() as u32;
3820            let frame = vm as *const fusevm::VM as usize;
3821            crate::exec_jobs::execpline_slot_close(frame, depth); // c:Src/jobs.c:1754, exec.c:1981
3822        }
3823        crate::ported::signals_h::child_unblock(); // c:Src/exec.c:2017
3824        Value::Int(0)
3825    });
3826    // See BUILTIN_JOB_TEXT.
3827    vm.register_builtin(BUILTIN_JOB_TEXT, |vm, _argc| {
3828        let text = vm.pop();
3829        JOB_TEXT.with(|t| *t.borrow_mut() = Some(text));
3830        Value::Int(0)
3831    });
3832    // See BUILTIN_EXEC_FORKED_SIMPLE.
3833    vm.register_builtin(BUILTIN_EXEC_FORKED_SIMPLE, |vm, argc| {
3834        let mode = if argc >= 1 { vm.pop().to_int() } else { 0 };
3835        let exiting_tail = mode != 0;
3836        let this_vm = vm as *const fusevm::VM as usize;
3837        if mode == 2 {
3838            // The `-c` string's last command (compile_zsh exiting_tail_mode
3839            // 2): the shell exits after it, so it is exec'd in place under
3840            // the same conditions, and not being forked it takes SHLVL down
3841            // (c:Src/exec.c:4334-4336).
3842            if !fake_exec_blocked() {
3843                EXEC_DASH.with(|c| c.set(c.get() | EXEC_CARRIER_FORKED | EXEC_CARRIER_SHLVL));
3844            }
3845            return Value::Int(0);
3846        }
3847        if exiting_tail {
3848            // c:Src/exec.c:3715-3718 — the fake exec of an exiting list's
3849            // last command needs `last1 == 1` (the compiler's mark, in a
3850            // child that was forked) and no traps, no files for the job to
3851            // clean up and no flock()s to keep: `nsigtrapped || havefiles()
3852            // || fdtable_flocks` otherwise forks as usual.
3853            if FORKED_SIMPLE_VM.with(|f| f.get()) != this_vm || fake_exec_blocked() {
3854                return Value::Int(0);
3855            }
3856        }
3857        if FORKED_SIMPLE_VM.with(|f| f.replace(0)) == this_vm {
3858            EXEC_DASH.with(|c| c.set(c.get() | EXEC_CARRIER_FORKED));
3859        }
3860        Value::Int(0)
3861    });
3862    // See BUILTIN_PREFORK_CUT_CHECK.
3863    vm.register_builtin(BUILTIN_PREFORK_CUT_CHECK, |_vm, _argc| {
3864        // c:Src/subst.c:118-121 — prefork's word loop also returns once
3865        // errflag is set, before expanding the next word.
3866        let errflag_set = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3867            & crate::ported::zsh_h::ERRFLAG_ERROR
3868            != 0;
3869        Value::Bool(PREFORK_CUT.with(|c| c.get()) || errflag_set)
3870    });
3871    // See BUILTIN_GLOBLIST. c:Src/subst.c:488-498 globlist:
3872    //     badcshglob = 0;
3873    //     for (node = firstnode(list); !errflag && node; node = next)
3874    //         { next = nextnode(node); zglob(list, node, nocheck); }
3875    // `badcshglob` stays with the per-word glob path (glob_expand_word_value
3876    // feeds consume_badcshglob), so only the loop shape changes here.
3877    vm.register_builtin(BUILTIN_GLOBLIST, |vm, argc| {
3878        let text_mask = vm.pop().to_int() as u64;
3879        let mask = vm.pop().to_int() as u64;
3880        let n = (argc as usize).saturating_sub(2);
3881        let mut words: Vec<Value> = (0..n).map(|_| vm.pop()).collect();
3882        words.reverse();
3883        let noglob =
3884            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
3885        let errflag_set = || {
3886            (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3887                & crate::ported::zsh_h::ERRFLAG_ERROR)
3888                != 0
3889        };
3890        // c:Src/subst.c:165-191 — prefork's second pass: filesub on each word
3891        // in order, returning at the first error, before globlist ever runs.
3892        // Only EXPAND_TEXT mode-10 words deferred theirs (see that arm).
3893        for (i, word) in words.iter_mut().enumerate() {
3894            if errflag_set() {
3895                break; // c:187-190
3896            }
3897            // c:178 — `if (unset(SHFILEEXPANSION))`: under SH_FILE_EXPANSION
3898            // the file expansion already ran in the first pass (c:122-138),
3899            // BEFORE substitution, so a `~` that arrived through a GLOB_SUBST
3900            // expansion stays literal (`emulate sh; v='~/x'; print $v`).
3901            if i < 64
3902                && text_mask & (1u64 << i) != 0
3903                && !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHFILEEXPANSION)
3904            {
3905                *word = filesub_deferred_word(std::mem::replace(word, Value::Int(0)));
3906            }
3907        }
3908        let mut out: Vec<Value> = Vec::with_capacity(n);
3909        for (i, word) in words.into_iter().enumerate() {
3910            let stopped = errflag_set(); // c:494 `!errflag`
3911            let bit = if i < 64 { 1u64 << i } else { 0 };
3912            if stopped || (mask | text_mask) & bit == 0 {
3913                out.push(word);
3914            } else if text_mask & bit != 0 {
3915                out.push(glob_tokenized_word(word, noglob)); // c:495 zglob
3916            } else {
3917                out.push(glob_word_noting_errflag(word, noglob)); // c:495 zglob
3918            }
3919        }
3920        for v in out {
3921            vm.push(v);
3922        }
3923        Value::Int(0)
3924    });
3925    vm.register_builtin(BUILTIN_TYPESET_POSTASSIGNS_END, |_vm, _argc| {
3926        let word_failed = TYPESET_WORD_GLOB_FAILED.with(|c| c.replace(false));
3927        let postassign_failed =
3928            with_executor(|exec| exec.current_command_glob_failed.replace(word_failed));
3929        TYPESET_POSTASSIGN_GLOB_FAILED.with(|c| c.set(postassign_failed));
3930        Value::Int(0)
3931    });
3932
3933    vm.register_builtin(BUILTIN_TYPESET_PAREN_PACK, |vm, argc| {
3934        let mut vals: Vec<Value> = Vec::with_capacity(argc as usize);
3935        for _ in 0..argc {
3936            vals.push(vm.pop());
3937        }
3938        vals.reverse();
3939        let mut it = vals.into_iter();
3940        let mut out = it.next().map(|v| v.to_str()).unwrap_or_default();
3941        for v in it {
3942            match v {
3943                // Array → splice items as separate elements (splat);
3944                // empty array contributes nothing (empty elision).
3945                Value::Array(items) => {
3946                    for item in items.iter() {
3947                        out.push('\u{1f}');
3948                        out.push_str(&item.to_str());
3949                    }
3950                }
3951                other => {
3952                    out.push('\u{1f}');
3953                    out.push_str(&other.to_str());
3954                }
3955            }
3956        }
3957        Value::str(out)
3958    });
3959
3960    vm.register_builtin(BUILTIN_TYPESET_PAREN_CLOSE, |vm, _argc| {
3961        let base = vm.pop().to_str();
3962        Value::str(format!("{}\u{1f})", base))
3963    });
3964
3965    vm.register_builtin(BUILTIN_COMPDEF, |vm, argc| {
3966        let args = pop_args(vm, argc);
3967        // ACTUALLY A ZSH FUNCTION: compdef is defined by `compinit`, it is
3968        // never a builtin. Without the completion system set up it is
3969        // command-not-found (127) in every mode — `zsh -f; compdef` prints
3970        // "command not found: compdef". A user/compsys `compdef` FUNCTION
3971        // (autoload compinit → compinit defines compdef) wins and runs the
3972        // fast native impl; otherwise it's command-not-found. Previously the
3973        // extension builtin ran in native mode (bare `compdef` → "I need
3974        // arguments"), diverging from zsh.
3975        // compinit installs a `compdef` function stub (see
3976        // NATIVE_COMPDEF_MARKER) purely so `${+functions[compdef]}` is
3977        // true; route that exact body to the fast native impl instead of
3978        // dispatching the stub. A genuine user/compsys compdef function
3979        // (any other body) still wins via try_user_fn_override below.
3980        let is_native_stub = crate::ported::hashtable::shfunctab_lock()
3981            .read()
3982            .ok()
3983            .and_then(|t| t.get("compdef").and_then(|shf| shf.body.clone()))
3984            .map(|b| b.trim() == crate::extensions::ext_builtins::NATIVE_COMPDEF_MARKER)
3985            .unwrap_or(false);
3986        if is_native_stub {
3987            return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
3988        }
3989        if let Some(s) = try_user_fn_override("compdef", &args) {
3990            return Value::Status(s);
3991        }
3992        if with_executor(|exec| exec.function_exists("compdef")) {
3993            return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
3994        }
3995        eprintln!("zsh:1: command not found: compdef");
3996        Value::Status(127)
3997    });
3998
3999    vm.register_builtin(BUILTIN_COMPINIT, |vm, argc| {
4000        let args = pop_args(vm, argc);
4001        // ACTUALLY A ZSH FUNCTION: compinit is a contrib FUNCTION (autoloaded
4002        // from $fpath), never a builtin. Without `autoload -Uz compinit` it is
4003        // command-not-found
4004        // (127) in every mode — `zsh -f; compinit` prints
4005        // "command not found: compinit". zshrs previously ran its builtin
4006        // unconditionally, so bare `compinit` succeeded. Gate on a compinit
4007        // function entry existing (which `autoload -Uz compinit` creates);
4008        // once the user has autoloaded/defined it, run zshrs's implementation.
4009        if !with_executor(|exec| exec.function_exists("compinit")) {
4010            eprintln!("zsh:1: command not found: compinit");
4011            let _ = args;
4012            return Value::Status(127);
4013        }
4014        Value::Status(with_executor(|exec| exec.builtin_compinit(&args)))
4015    });
4016
4017    reg_ext_overridable!(vm, BUILTIN_CDREPLAY, "cdreplay", builtin_cdreplay);
4018
4019    // Zsh-specific
4020    reg_passthru!(vm, BUILTIN_ZSTYLE, "zstyle");
4021    reg_passthru!(vm, BUILTIN_ZMODLOAD, "zmodload");
4022    reg_passthru!(vm, BUILTIN_BINDKEY, "bindkey");
4023    reg_passthru!(vm, BUILTIN_ZLE, "zle");
4024    reg_passthru!(vm, BUILTIN_VARED, "vared");
4025    reg_passthru!(vm, BUILTIN_ZCOMPILE, "zcompile");
4026    reg_passthru!(vm, BUILTIN_ZFORMAT, "zformat");
4027    reg_passthru!(vm, BUILTIN_ZPARSEOPTS, "zparseopts");
4028    reg_passthru!(vm, BUILTIN_ZREGEXPARSE, "zregexparse");
4029
4030    // Resource limits
4031    reg_passthru!(vm, BUILTIN_ULIMIT, "ulimit");
4032    reg_passthru!(vm, BUILTIN_LIMIT, "limit");
4033    reg_passthru!(vm, BUILTIN_UNLIMIT, "unlimit");
4034    reg_passthru!(vm, BUILTIN_UMASK, "umask");
4035
4036    // Misc
4037    reg_passthru!(vm, BUILTIN_TIMES, "times");
4038
4039    vm.register_builtin(BUILTIN_CALLER, |vm, argc| {
4040        let args = pop_args(vm, argc);
4041        // c:Bug #475 — `caller` is a bash-only builtin. In `--zsh`
4042        // mode emit the canonical "command not found" diagnostic
4043        // and rc=127 matching zsh's external-command-lookup miss.
4044        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
4045            eprintln!("zsh:1: command not found: caller");
4046            let _ = args;
4047            return Value::Status(127);
4048        }
4049        Value::Status(with_executor(|exec| exec.builtin_caller(&args)))
4050    });
4051
4052    vm.register_builtin(BUILTIN_HELP, |vm, argc| {
4053        let args = pop_args(vm, argc);
4054        // c:Bug #475 — `help` is a bash-only builtin. Same gate as
4055        // BUILTIN_CALLER above.
4056        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
4057            eprintln!("zsh:1: command not found: help");
4058            let _ = args;
4059            return Value::Status(127);
4060        }
4061        Value::Status(with_executor(|exec| exec.builtin_help(&args)))
4062    });
4063
4064    reg_passthru!(vm, BUILTIN_ENABLE, "enable");
4065    reg_passthru!(vm, BUILTIN_DISABLE, "disable");
4066    reg_passthru!(vm, BUILTIN_TTYCTL, "ttyctl");
4067    reg_passthru!(vm, BUILTIN_SYNC, "sync");
4068    reg_passthru!(vm, BUILTIN_MKDIR, "mkdir");
4069    reg_passthru!(vm, BUILTIN_STRFTIME, "strftime");
4070
4071    vm.register_builtin(BUILTIN_ZSLEEP, |vm, argc| {
4072        let args = pop_args(vm, argc);
4073        // function > builtin: a user `zsleep() { … }` wins.
4074        if let Some(s) = try_user_fn_override("zsleep", &args) {
4075            return Value::Status(s);
4076        }
4077        Value::Status(crate::extensions::ext_builtins::zsleep(&args))
4078    });
4079
4080    reg_passthru!(vm, BUILTIN_ZSYSTEM, "zsystem");
4081
4082    // PCRE
4083    reg_passthru!(vm, BUILTIN_PCRE_COMPILE, "pcre_compile");
4084    reg_passthru!(vm, BUILTIN_PCRE_MATCH, "pcre_match");
4085    reg_passthru!(vm, BUILTIN_PCRE_STUDY, "pcre_study");
4086
4087    // Database (GDBM)
4088    reg_passthru!(vm, BUILTIN_ZTIE, "ztie");
4089    reg_passthru!(vm, BUILTIN_ZUNTIE, "zuntie");
4090    reg_passthru!(vm, BUILTIN_ZGDBMPATH, "zgdbmpath");
4091
4092    // Prompt
4093    //
4094    // ACTUALLY ZSH FUNCTIONS: `promptinit` is Functions/Prompts/promptinit,
4095    // and `prompt` is a function that file defines — neither is a builtin,
4096    // but fusevm's name table hands both an opcode. Resolve them the way
4097    // execcmd does a name that is not in builtintab (c:Src/exec.c:3484-3488
4098    // shfunctab probe, else the command search): run the shell function,
4099    // or fall to the external lookup and its "command not found".
4100    //
4101    // A native stand-in used to answer both names: a hard-coded theme list
4102    // (one of them, `minimal`, not shipped by 5.9) in place of promptinit's
4103    // `$^fpath/prompt_*_setup(N)` scan, so a user's own themes were never
4104    // listed, and a `prompt` that shadowed the real function even after
4105    // promptinit had defined it — `prompt -l` printed one theme per line,
4106    // `prompt -h THEME` never showed the theme's help, and `prompt adam1`
4107    // assigned a canned PS1 without running prompt_adam1_setup.
4108    vm.register_builtin(BUILTIN_PROMPTINIT, |vm, argc| {
4109        let args = pop_args(vm, argc);
4110        if let Some(s) = try_user_fn_override("promptinit", &args) {
4111            return Value::Status(s);
4112        }
4113        let argv: Vec<String> = std::iter::once("promptinit".to_string()).chain(args).collect();
4114        Value::Status(with_executor(|exec| exec.host_exec_external(&argv)))
4115    });
4116
4117    vm.register_builtin(BUILTIN_PROMPT, |vm, argc| {
4118        let args = pop_args(vm, argc);
4119        if let Some(s) = try_user_fn_override("prompt", &args) {
4120            return Value::Status(s);
4121        }
4122        let argv: Vec<String> = std::iter::once("prompt".to_string()).chain(args).collect();
4123        Value::Status(with_executor(|exec| exec.host_exec_external(&argv)))
4124    });
4125
4126    // Async / Parallel (zshrs extensions) — all overridable by a
4127    // same-named user function (function > builtin).
4128    reg_ext_overridable!(vm, BUILTIN_ASYNC, "async", builtin_async);
4129    reg_ext_overridable!(vm, BUILTIN_AWAIT, "await", builtin_await);
4130    reg_ext_overridable!(vm, BUILTIN_PMAP, "pmap", builtin_pmap);
4131    reg_ext_overridable!(vm, BUILTIN_PGREP, "pgrep", builtin_pgrep);
4132    reg_ext_overridable!(vm, BUILTIN_PEACH, "peach", builtin_peach);
4133    reg_ext_overridable!(vm, BUILTIN_BARRIER, "barrier", builtin_barrier);
4134
4135    // `ai` — zshrs-original LLM builtin (`src/extensions/ai.rs`).
4136    // fusevm's name registry maps `"ai"` to `BUILTIN_AI` (264), so the
4137    // compiler emits `CallBuiltin(BUILTIN_AI)` for a literal `ai`. The VM
4138    // silently skips an unregistered id: without this line `ai` is a no-op.
4139    reg_ext_overridable!(vm, BUILTIN_AI, "ai", builtin_ai);
4140
4141    // Intercept (AOP)
4142    reg_ext_overridable!(vm, BUILTIN_INTERCEPT, "intercept", builtin_intercept);
4143    reg_ext_overridable!(
4144        vm,
4145        BUILTIN_INTERCEPT_PROCEED,
4146        "intercept_proceed",
4147        builtin_intercept_proceed
4148    );
4149
4150    // Debug / Profile
4151    reg_ext_overridable!(vm, BUILTIN_DOCTOR, "doctor", builtin_doctor);
4152    reg_ext_overridable!(vm, BUILTIN_DBVIEW, "dbview", builtin_dbview);
4153    reg_ext_overridable!(vm, BUILTIN_PROFILE, "profile", builtin_profile);
4154    reg_ext_overridable!(vm, BUILTIN_PROVENANCE, "provenance", builtin_provenance);
4155
4156    reg_passthru!(vm, BUILTIN_ZPROF, "zprof");
4157
4158    // ═══════════════════════════════════════════════════════════════════════
4159    // Coreutils builtins (anti-fork, gated by !posix_mode)
4160    //
4161    // All of these are routinely wrapped by user functions in real
4162    // dotfiles (zpwr, oh-my-zsh, etc.) — `cat() { ... }`, `ls() { ... }`,
4163    // `find() { ... }`. Each handler MUST consult try_user_fn_override
4164    // first (via reg_overridable!) so the user definition wins, matching
4165    // zsh's alias → function → builtin dispatch order.
4166    // ═══════════════════════════════════════════════════════════════════════
4167
4168    reg_overridable!(vm, BUILTIN_CAT, "cat", builtin_cat);
4169    reg_overridable!(vm, BUILTIN_HEAD, "head", builtin_head);
4170    reg_overridable!(vm, BUILTIN_TAIL, "tail", builtin_tail);
4171    reg_overridable!(vm, BUILTIN_WC, "wc", builtin_wc);
4172    reg_overridable!(vm, BUILTIN_BASENAME, "basename", builtin_basename);
4173    reg_overridable!(vm, BUILTIN_DIRNAME, "dirname", builtin_dirname);
4174    reg_overridable!(vm, BUILTIN_TOUCH, "touch", builtin_touch);
4175    reg_overridable!(vm, BUILTIN_REALPATH, "realpath", builtin_realpath);
4176    reg_overridable!(vm, BUILTIN_SORT, "sort", builtin_sort);
4177    reg_overridable!(vm, BUILTIN_FIND, "find", builtin_find);
4178    reg_overridable!(vm, BUILTIN_UNIQ, "uniq", builtin_uniq);
4179    reg_overridable!(vm, BUILTIN_CUT, "cut", builtin_cut);
4180    reg_overridable!(vm, BUILTIN_TR, "tr", builtin_tr);
4181    reg_overridable!(vm, BUILTIN_SEQ, "seq", builtin_seq);
4182    reg_overridable!(vm, BUILTIN_REV, "rev", builtin_rev);
4183    reg_overridable!(vm, BUILTIN_TEE, "tee", builtin_tee);
4184    reg_overridable!(vm, BUILTIN_SLEEP, "sleep", builtin_sleep);
4185    reg_overridable!(vm, BUILTIN_WHOAMI, "whoami", builtin_whoami);
4186    reg_overridable!(vm, BUILTIN_ID, "id", builtin_id);
4187
4188    reg_overridable!(vm, BUILTIN_HOSTNAME, "hostname", builtin_hostname);
4189    reg_overridable!(vm, BUILTIN_UNAME, "uname", builtin_uname);
4190    reg_overridable!(vm, BUILTIN_DATE, "date", builtin_date);
4191    reg_overridable!(vm, BUILTIN_MKTEMP, "mktemp", builtin_mktemp);
4192    // `cp` — zshrs extension (NOT in upstream zsh; upstream's
4193    // zsh/files module ships `ln`/`mv`/`rm`/`chmod`/`chown` but no
4194    // `cp`). In-process implementation in
4195    // `ext_builtins::cp_impl` — recursive copy with -r/-R, -f, -i,
4196    // -n, -p (chown + utimensat), -v. ID 263 is the first slot
4197    // past fusevm's built-in range (260-262) and before BUILTIN_MAX
4198    // (280).
4199    /// `BUILTIN_CP` constant.
4200    pub const BUILTIN_CP: u16 = 263;
4201    reg_overridable!(vm, BUILTIN_CP, "cp", builtin_cp);
4202
4203    // Pipeline execution — bytecode-native fork-per-stage. Pops N sub-chunk
4204    // indices, forks N children with stdin/stdout wired through N-1 pipes,
4205    // each child runs its stage's compiled bytecode and exits. Parent waits
4206    // and returns the last stage's status.
4207    //
4208    // Caveats: post-fork in a multi-threaded program, only async-signal-safe
4209    // ops are POSIX-safe. We violate this (running the bytecode VM after fork
4210    // touches mutexes like REGEX_CACHE). In practice, most pipeline stages
4211    // don't touch shared mutex state — externals fork/exec away, builtins do
4212    // pure I/O. Risks are bounded; if a stage does touch a held mutex, the
4213    // child deadlocks.
4214    vm.register_builtin(BUILTIN_RUN_PIPELINE, |vm, argc| {
4215        let n = argc as usize;
4216        if n == 0 {
4217            return Value::Status(0);
4218        }
4219
4220        // c:Src/exec.c — every pipeline stage forks from the current
4221        // shell state, so each stage observes the pre-pipeline $? until
4222        // it runs its own command. Stage sub-VMs start fresh with
4223        // last_status=0, so seed them with the parent's lastval; without
4224        // this `false; echo $? | cat` prints 0 instead of zsh's 1.
4225        let parent_status = vm.last_status;
4226
4227        // Pop N sub-chunk indices (LIFO → reverse to stage order)
4228        let mut indices: Vec<u16> = Vec::with_capacity(n);
4229        for _ in 0..n {
4230            indices.push(vm.pop().to_int() as u16);
4231        }
4232        indices.reverse();
4233
4234        // Clone each stage's sub-chunk
4235        let stages: Vec<fusevm::Chunk> = indices
4236            .iter()
4237            .filter_map(|&i| vm.chunk.sub_chunks.get(i as usize).cloned())
4238            .collect();
4239        if stages.len() != n {
4240            return Value::Status(1);
4241        }
4242
4243        // Single stage — no pipe, just run inline
4244        if n == 1 {
4245            let stage = stages.into_iter().next().unwrap();
4246            crate::fusevm_disasm::maybe_print_stdout("pipeline:single", &stage);
4247            let mut stage_vm = fusevm::VM::new(stage);
4248            stage_vm.last_status = parent_status;
4249            register_builtins(&mut stage_vm);
4250            let _ = stage_vm.run();
4251            return Value::Status(stage_vm.last_status);
4252        }
4253
4254        // A `time`d pipeline: record each forked stage for dumptime (see
4255        // TimedPipeline). Taken, so a pipeline nested in a stage runs untimed.
4256        let mut timed = TIMED_PIPELINE
4257            .with(|t| t.borrow_mut().take())
4258            .filter(|t| t.texts.len() == n);
4259        let mut child_started: Vec<std::time::Instant> = Vec::with_capacity(n - 1);
4260
4261        // c:Src/exec.c:1748 — `child_block();` before any stage is forked,
4262        // held through the in-shell last stage and the stage waits below,
4263        // released at c:2017. See ChildBlockSpan.
4264        #[cfg(unix)]
4265        let _child_block = ChildBlockSpan::enter();
4266
4267        // Build N-1 pipes
4268        let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(n - 1);
4269        for _ in 0..n - 1 {
4270            let mut fds = [0i32; 2];
4271            if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
4272                // Cleanup any pipes we already created
4273                for (r, w) in &pipes {
4274                    unsafe {
4275                        libc::close(*r);
4276                        libc::close(*w);
4277                    }
4278                }
4279                return Value::Status(1);
4280            }
4281            pipes.push((fds[0], fds[1]));
4282        }
4283
4284        // zsh runs the LAST stage of a pipeline in the CURRENT shell
4285        // (not a forked child) so a trailing `read x` keeps its
4286        // assignment in the parent. Other shells (bash) fork every
4287        // stage. Honor zsh by leaving stage N-1 inline. Forks the
4288        // first N-1 stages with fork(); runs the last in this process
4289        // with stdin dup2'd to the last pipe's read end and stdout
4290        // restored after.
4291        let last_idx = n - 1;
4292        let stages_vec: Vec<fusevm::Chunk> = stages.into_iter().collect();
4293
4294        let mut child_pids: Vec<libc::pid_t> = Vec::with_capacity(n - 1);
4295        for (i, chunk) in stages_vec.iter().take(last_idx).enumerate() {
4296            match unsafe { libc::fork() } {
4297                -1 => {
4298                    // fork failed — kill any children we already started
4299                    for pid in &child_pids {
4300                        unsafe { libc::kill(*pid, libc::SIGTERM) };
4301                    }
4302                    for (r, w) in &pipes {
4303                        unsafe {
4304                            libc::close(*r);
4305                            libc::close(*w);
4306                        }
4307                    }
4308                    return Value::Status(1);
4309                }
4310                0 => {
4311                    forked_child_subsh_levels(); // c:Src/exec.c:1221
4312                    EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
4313                    // Reset SIGPIPE to default so a broken-pipe write
4314                    // kills the child cleanly instead of triggering a
4315                    // Rust println! panic. The parent shell ignores
4316                    // SIGPIPE so it can handle EPIPE itself, but child
4317                    // pipeline stages should die quietly when their
4318                    // downstream stage closes early (e.g. `seq | head -3`).
4319                    unsafe {
4320                        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
4321                    }
4322                    // c:Src/exec.c — pipeline children are forked
4323                    // subshells; their EXIT trap context is reset so
4324                    // the parent's `trap '...' EXIT` doesn't fire when
4325                    // the child exits. Mirror by dropping EXIT from
4326                    // the inherited traps_table inside the child.
4327                    // c:Src/exec.c:2917-2918 — a forked PIPELINE stage enters
4328                    // the subshell with ESUB_KEEPTRAP:
4329                    //     if ((type != WC_SUBSH) && !(how & Z_ASYNC))
4330                    //         flags |= ESUB_KEEPTRAP;
4331                    // so entersubsh's c:1127 reset loop is SKIPPED and the
4332                    // stage keeps the parent's traps. Only the EXIT trap goes,
4333                    // so the parent's `trap '…' EXIT` does not fire when the
4334                    // stage exits. Applying the full reset here instead was
4335                    // wrong: it wiped the inherited-SIGQUIT record and the
4336                    // parent's other trap flags inside every pipeline stage.
4337                    //
4338                    // Drop it from BOTH stores, since a body-less entry lives
4339                    // only in sigtrapped and the `trap` listing now reads it
4340                    // (c:Src/builtin.c:7358-7361); clearing just the body left
4341                    // `trap | grep -c EXIT` reporting the stale flag.
4342                    if let Ok(mut tt) = crate::ported::builtin::traps_table().lock() {
4343                        tt.remove("EXIT");
4344                    }
4345                    if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
4346                        if let Some(slot) = st.get_mut(crate::ported::signals_h::SIGEXIT as usize) {
4347                            *slot = 0;
4348                        }
4349                    }
4350                    // c:Src/exec.c:2862 → 1219 — pipeline children run
4351                    // entersubsh with ESUB_PGRP, which clears the job
4352                    // table (clearjobtab, Src/jobs.c:1780). Without
4353                    // this, `sleep 5 & jobs -p | wc -l` reports 1 in
4354                    // the forked stage where zsh reports 0. The fork
4355                    // already copy-isolates the statics, so mutating
4356                    // them here can't leak to the parent.
4357                    with_executor(|exec| {
4358                        let monitor =
4359                            crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
4360                        crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
4361                    });
4362                    // c:Src/exec.c:1153-1154 — the same entersubsh call sets
4363                    // `subsh = 1` in the forked stage. PRINT_EXIT_VALUE reads
4364                    // it (c:4309 `&& !subsh`), which is why zsh prints nothing
4365                    // for the failing stage of `false | true`.
4366                    crate::ported::exec::subsh.store(1, std::sync::atomic::Ordering::Relaxed);
4367                    *crate::ported::jobs::THISJOB
4368                        .get_or_init(|| std::sync::Mutex::new(-1))
4369                        .lock()
4370                        .unwrap() = -1;
4371                    // c:Src/exec.c:3720-3724 — the stage's own fds go
4372                    // onto 0/1 only AFTER its argument words have been
4373                    // expanded (prefork c:3304 / globlist c:3702), so
4374                    // park them and let the stage chunk's
4375                    // BUILTIN_PIPE_FDS_INSTALL do the dup2 at the
4376                    // C-faithful point. `print -rl -- c a b |
4377                    // print -r -- "[$(cat)]" | cat` therefore prints
4378                    // `[]` — the middle stage's `$(cat)` reads the
4379                    // shell's stdin, not the pipe.
4380                    let in_fd = if i > 0 { pipes[i - 1].0 } else { -1 };
4381                    let out_fd = pipes[i].1;
4382                    // (Pipe-output MULTIOS marking — c:Src/exec.c:3724 —
4383                    // is emitted INTO the stage chunk by compile_pipe
4384                    // via BUILTIN_PIPE_OUTPUT_MARK, gated on the stage's
4385                    // top-level command actually carrying redirects, so
4386                    // a nested `{ echo a > f; } | cat` body redirect
4387                    // does not wrongly join the pipe.)
4388                    // Close every pipe fd this stage doesn't need. The
4389                    // two it does keep are closed by the install op
4390                    // right after their dup2.
4391                    for (r, w) in &pipes {
4392                        unsafe {
4393                            if *r != in_fd && *r != out_fd {
4394                                libc::close(*r);
4395                            }
4396                            if *w != in_fd && *w != out_fd {
4397                                libc::close(*w);
4398                            }
4399                        }
4400                    }
4401                    stage_fds_park(in_fd, out_fd);
4402
4403                    // Run this stage's bytecode on a fresh VM
4404                    crate::fusevm_disasm::maybe_print_stdout(
4405                        &format!("pipeline:child:stage:{i}"),
4406                        chunk,
4407                    );
4408                    let mut stage_vm = fusevm::VM::new(chunk.clone());
4409                    stage_vm.last_status = parent_status;
4410                    register_builtins(&mut stage_vm);
4411                    let _ = stage_vm.run();
4412                    // Flush any buffered output before exiting
4413                    let _ = std::io::stdout().flush();
4414                    let _ = std::io::stderr().flush();
4415                    std::process::exit(stage_vm.last_status);
4416                }
4417                pid => {
4418                    child_pids.push(pid);
4419                    child_started.push(std::time::Instant::now()); // c:Src/jobs.c:1480 bgtime
4420                }
4421            }
4422        }
4423
4424        // Parent runs the LAST stage inline. Save stdin, park the last
4425        // pipe's read end for the chunk's BUILTIN_PIPE_FDS_INSTALL
4426        // (c:Src/exec.c:3722 `addfd(..., 0, input, 0, NULL)` — after
4427        // the stage's args are expanded, so `… | print -r -- "[$(cat)]"`
4428        // has its `$(cat)` read the shell's stdin, not the pipe), run
4429        // the chunk, restore stdin. Close every other pipe fd so the
4430        // producer side gets EOF when the last upstream stage exits.
4431        // Shell-internal save — keep it out of the script's fd range (movefd,
4432        // c:Src/exec.c:2425).
4433        let saved_stdin = unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD_CLOEXEC, 10) };
4434        let last_in_fd = if last_idx > 0 {
4435            pipes[last_idx - 1].0
4436        } else {
4437            -1
4438        };
4439        // Close all pipe fds in the parent except the one the last
4440        // stage still has to install. (Children already have their own
4441        // copies; the install op closes the read end after its dup2.)
4442        for (r, w) in &pipes {
4443            unsafe {
4444                if *r != last_in_fd {
4445                    libc::close(*r);
4446                }
4447                libc::close(*w);
4448            }
4449        }
4450        let outer_stage_fds = stage_fds_park(last_in_fd, -1);
4451
4452        // Run the last stage's bytecode on a sub-VM with the host wired up.
4453        // By default (zsh semantics) the sub-VM runs IN THIS PROCESS so the
4454        // last stage's reads/assignments update the parent's state directly
4455        // (`echo x | read v` sets $v; `cmd | mapfile arr` sets arr).
4456        //
4457        // !!! BASH-MODE GATE !!! bash forks EVERY pipeline stage (unless
4458        // `shopt -s lastpipe`), so the last stage runs in a SUBSHELL and its
4459        // variable/array assignments do NOT persist — `echo x | read v; echo
4460        // $v` prints an empty line, `cmd | mapfile arr` leaves arr unset.
4461        // Fork the last stage under `--bash` to match. The parent's existing
4462        // `stage_fds_take()` below closes its `last_in_fd` copy; the forked
4463        // child inherits the parked pipe fd and installs it onto stdin, and
4464        // the writer stages (already forked) supply its input.
4465        //
4466        // c:Src/exec.c:3042-3043 — zsh itself forks the last stage of a
4467        // multi-stage pipeline under sh emulation:
4468        //     if ((how & Z_ASYNC) || output ||
4469        //         (last1 == 2 && input && EMULATION(EMULATE_SH))) {
4470        //         ... execcmd_fork(...)
4471        // so `emulate sh -c 'echo x | read v; echo $v'` prints an empty
4472        // line. ksh emulation keeps the in-shell last stage.
4473        let mut last_proc: Option<crate::ported::zsh_h::process> = None;
4474        let last_stage_status = if crate::dash_mode::bash_mode()
4475            || crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_SH)
4476        {
4477            let last_chunk = stages_vec.into_iter().last().unwrap();
4478            crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
4479            match unsafe { libc::fork() } {
4480                -1 => 1,
4481                0 => {
4482                    forked_child_subsh_levels(); // c:Src/exec.c:1221
4483                    EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
4484                    // Subshell child: run the last stage, then _exit with its
4485                    // status. Reset SIGPIPE + drop the EXIT trap like the
4486                    // other pipeline children above.
4487                    unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL) };
4488                    if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
4489                        t.remove("EXIT");
4490                    }
4491                    let mut stage_vm = fusevm::VM::new(last_chunk);
4492                    stage_vm.last_status = parent_status;
4493                    register_builtins(&mut stage_vm);
4494                    stage_vm.set_shell_host(Box::new(ZshrsHost));
4495                    let _ = stage_vm.run();
4496                    let st = stage_vm.last_status;
4497                    let _ = std::io::stdout().flush();
4498                    let _ = std::io::stderr().flush();
4499                    unsafe { libc::_exit(st) };
4500                }
4501                pid => {
4502                    let started = std::time::Instant::now();
4503                    // Same EINTR retry as the stage reap loop below.
4504                    let waited = wait4_eintr(pid);
4505                    // c:Src/exec.c:3042-3043 — this last stage forked, so it
4506                    // is a process of a timed job whatever its command is.
4507                    if let (Some(t), Some((status, ru))) = (timed.as_ref(), waited.as_ref()) {
4508                        last_proc = Some(timed_proc(pid, &t.texts[n - 1], *status, ru, started));
4509                    }
4510                    match waited.map(|(status, _)| status) {
4511                        Some(status) if libc::WIFEXITED(status) => libc::WEXITSTATUS(status),
4512                        Some(status) if libc::WIFSIGNALED(status) => 128 + libc::WTERMSIG(status),
4513                        Some(_) => 1,
4514                        None => 0,
4515                    }
4516                }
4517            }
4518        } else {
4519            let last_chunk = stages_vec.into_iter().last().unwrap();
4520            crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
4521            // A timed pipeline's in-shell last stage is a process of the job
4522            // only when it forks (TimedPipeline::last_forks); its times are
4523            // those of the externals it waits for (FG_CHILD_TIMES). Not a
4524            // RUSAGE_CHILDREN delta: the reaper on another thread can collect
4525            // a forked stage inside this window and its usage would land here.
4526            let last_timed = timed.as_ref().filter(|t| t.last_forks).map(|_| {
4527                FG_CHILD_TIMES.with(|c| c.set(Some((0, 0))));
4528                std::time::Instant::now()
4529            });
4530            let mut stage_vm = fusevm::VM::new(last_chunk);
4531            stage_vm.last_status = parent_status;
4532            register_builtins(&mut stage_vm);
4533            stage_vm.set_shell_host(Box::new(ZshrsHost));
4534            // The externals this stage waits for are not the job's process
4535            // group leader (the first stage is), so they skip update_job's
4536            // foreground tail; see exec_jobs::foreground_job_signalled.
4537            crate::exec_jobs::NOT_JOB_LEADER.with(|d| d.set(d.get() + 1));
4538            let _ = stage_vm.run();
4539            crate::exec_jobs::NOT_JOB_LEADER.with(|d| d.set(d.get() - 1));
4540            let _ = std::io::stdout().flush();
4541            let _ = std::io::stderr().flush();
4542            if let (Some(t), Some(started)) = (timed.as_ref(), last_timed) {
4543                let (utime, stime) = FG_CHILD_TIMES.with(|c| c.take()).unwrap_or((0, 0));
4544                let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
4545                ru.ru_utime = usec_timeval(utime);
4546                ru.ru_stime = usec_timeval(stime);
4547                let status = (stage_vm.last_status & 0xff) << 8; // W_EXITCODE
4548                last_proc = Some(timed_proc(0, &t.texts[n - 1], status, &ru, started));
4549            }
4550            stage_vm.last_status
4551        };
4552
4553        // Reclaim the read end if the stage chunk never reached its
4554        // install op (an expansion error aborted it, or the stage was
4555        // a shape that dispatches without one), then restore the outer
4556        // stage's still-pending fds for a nested pipeline.
4557        let (leftover_in, _) = stage_fds_take();
4558        if leftover_in >= 0 {
4559            unsafe { libc::close(leftover_in) };
4560        }
4561        stage_fds_park(outer_stage_fds.0, outer_stage_fds.1);
4562
4563        // Restore stdin
4564        if saved_stdin >= 0 {
4565            unsafe {
4566                libc::dup2(saved_stdin, libc::STDIN_FILENO);
4567                libc::close(saved_stdin);
4568            }
4569        }
4570
4571        // Wait for all forked stages, capture per-stage statuses for PIPESTATUS.
4572        let mut pipestatus: Vec<i32> = Vec::with_capacity(n);
4573        for (i, pid) in child_pids.into_iter().enumerate() {
4574            // EINTR retry: the SIGCHLD handler interrupts this wait and
4575            // leaves `status` untouched, which used to read back as a
4576            // clean exit 0 for every forked stage. See waitpid_eintr.
4577            let waited = match timed.as_mut() {
4578                Some(t) => wait4_eintr(pid).map(|(status, ru)| {
4579                    let started = child_started.get(i).copied().unwrap_or_else(std::time::Instant::now);
4580                    t.procs.push(timed_proc(pid, &t.texts[i], status, &ru, started));
4581                    status
4582                }),
4583                None => waitpid_eintr(pid),
4584            };
4585            if i == 0 {
4586                // c:Src/jobs.c:497-498 — `if (pn->pid == jn->gleader) status =
4587                // pn->status;`: the first stage leads the job's process group,
4588                // and its status is the one update_job's foreground tail reads.
4589                if let Some(status) = waited {
4590                    crate::exec_jobs::foreground_job_signalled(status);
4591                }
4592            }
4593            let s = match waited {
4594                Some(status) if libc::WIFEXITED(status) => libc::WEXITSTATUS(status),
4595                Some(status) if libc::WIFSIGNALED(status) => 128 + libc::WTERMSIG(status),
4596                Some(_) => 1,
4597                // Unreapable (ECHILD — the handler got there first).
4598                // Nothing better to report than success; the stage's
4599                // real status is gone.
4600                None => 0,
4601            };
4602            pipestatus.push(s);
4603        }
4604        // Append the in-parent last-stage status so `pipestatus` ends
4605        // with N entries (one per stage).
4606        pipestatus.push(last_stage_status);
4607        if let Some(mut t) = timed {
4608            t.procs.extend(last_proc);
4609            TIMED_PIPELINE.with(|slot| *slot.borrow_mut() = Some(t));
4610        }
4611        // Pipeline exit status: by default, the LAST stage's status.
4612        // With `setopt pipefail` (or `set -o pipefail`), use the
4613        // first non-zero stage status (so failures earlier in the
4614        // pipeline propagate even if the last stage succeeded).
4615        let pipefail_on = with_executor(|exec| opt_state_get("pipefail").unwrap_or(false));
4616        let last_status = if pipefail_on {
4617            pipestatus
4618                .iter()
4619                .copied()
4620                .rfind(|&s| s != 0)
4621                .or_else(|| pipestatus.last().copied())
4622                .unwrap_or(0)
4623        } else {
4624            *pipestatus.last().unwrap_or(&0)
4625        };
4626
4627        // c:Src/params.c:265,438 — only `pipestatus` (lowercase) is the
4628        // zsh special parameter; bash's `PIPESTATUS` doesn't exist in
4629        // zsh's special-params table. Prior port also populated
4630        // `PIPESTATUS` "for portability" — but that's a real divergence
4631        // from zsh: a script doing `[[ -z $PIPESTATUS ]]` to detect
4632        // zsh-vs-bash would mis-classify. Bug #64 in docs/BUGS.md.
4633        // c:Src/jobs.c:438-441 (storepipestats) — the per-stage statuses go
4634        // into the C globals `pipestats[]` / `numpipestats` whatever the
4635        // emulation. Under sh/ksh `pipestatus` is not a special parameter
4636        // (no PM_SPECIAL, so the paramtab mirror below is skipped), but the
4637        // globals still carry the pipeline — `--mksh`'s `$PIPESTATUS` and
4638        // doshfunc's funcsave snapshot (c:Src/exec.c:5864) read them.
4639        {
4640            use crate::ported::jobs::{NUMPIPESTATS, PIPESTATS};
4641            use std::sync::Mutex;
4642            let count = pipestatus.len().min(crate::ported::zsh_h::MAX_PIPESTATS);
4643            let p = PIPESTATS.get_or_init(|| Mutex::new([0; crate::ported::zsh_h::MAX_PIPESTATS]));
4644            if let Ok(mut pguard) = p.lock() {
4645                pguard[..count].copy_from_slice(&pipestatus[..count]); // c:438 memcpy
4646            }
4647            let n = NUMPIPESTATS.get_or_init(|| Mutex::new(0));
4648            if let Ok(mut nguard) = n.lock() {
4649                *nguard = count; // c:441
4650            }
4651        }
4652        with_executor(|exec| {
4653            // c:Src/jobs.c:83 `int pipestats[MAX_PIPESTATS]` — the values
4654            // live in that C GLOBAL, reached through `pipestatus`'s GSU
4655            // (c:Src/params.c pipestatgetfn). A `typeset -h +g pipestatus`
4656            // local shadow carries no PM_SPECIAL and no GSU, so the C
4657            // writer cannot reach it; skip the paramtab mirror likewise or
4658            // the shadow loses its PM_UNSET (B02typeset.ztst:37,38).
4659            let shadowed = crate::ported::params::paramtab()
4660                .read()
4661                .ok()
4662                .and_then(|t| {
4663                    t.get("pipestatus")
4664                        .map(|pm| (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) == 0)
4665                })
4666                .unwrap_or(false);
4667            if !shadowed {
4668                let strs: Vec<String> = pipestatus.iter().map(|s| s.to_string()).collect();
4669                exec.set_array("pipestatus".to_string(), strs);
4670            }
4671        });
4672
4673        Value::Status(last_status)
4674    });
4675
4676    // Array→String join. Pops one value; if it's an Array (e.g. from Op::Glob),
4677    // joins string-coerced elements with a single space. Pass-through for
4678    // non-arrays so the op is safe to chain after any String-or-Array producer.
4679    // Scalar coercion of an assembled word: pop a Value; if it's an
4680    // Array (produced by a splice segment like `"$@"` / `"${arr[@]}"`),
4681    // IFS[0]-join it to a single scalar; a scalar passes through. This
4682    // is the assignment-context coercion C zsh applies in multsub when
4683    // the expansion is the RHS of a SCALAR assignment (Src/subst.c
4684    // c:3032 sepjoin under ssub) — `v="$@"` joins the positionals with
4685    // ${IFS[1]} rather than leaving an array whose splat would lose all
4686    // but the first element. Joins via sepjoin so a custom / empty IFS
4687    // is honored (not a hardcoded space).
4688    vm.register_builtin(BUILTIN_ARRAY_JOIN, |vm, _argc| {
4689        let val = vm.pop();
4690        match val {
4691            Value::Array(items) => {
4692                let strs: Vec<String> = items.iter().map(|v| v.to_str()).collect();
4693                Value::str(crate::ported::utils::sepjoin(&strs, None))
4694            }
4695            other => other,
4696        }
4697    });
4698
4699    // `cmd &` background execution. Compile_list emits this for any item
4700    // followed by ListOp::Amp: the job text + the cmd's sub-chunk index are
4701    // pushed, then this builtin pops both, looks up the chunk, forks. The
4702    // child detaches via setsid (so SIGINT to the foreground job doesn't kill
4703    // it), runs the bytecode on a fresh VM with builtins re-registered, exits
4704    // with the last status. The parent registers the job in the canonical
4705    // JOBTAB (c:Src/exec.c::execpline Z_ASYNC arm) and returns Status(0).
4706    vm.register_builtin(BUILTIN_RUN_BG, |vm, _argc| {
4707        // `&|` / `&!` set disown → the job is dropped from the table (no
4708        // `[N] pid` announcement, no `[N] done`), matching C exec.c:1752-1758.
4709        let disown = vm.pop().to_int() != 0;
4710        // Stage count. 1 for anything but a multi-stage pipeline; the compiler
4711        // pushes one (text, sub_chunk_index) pair per stage ahead of it.
4712        let nstages = vm.pop().to_int().max(1) as usize;
4713        let mut stages: Vec<(usize, String)> = Vec::with_capacity(nstages);
4714        for _ in 0..nstages {
4715            let idx = vm.pop().to_int() as usize;
4716            let text = vm.pop().to_str();
4717            stages.push((idx, text));
4718        }
4719        stages.reverse(); // popped last-stage-first
4720        let chunks: Vec<fusevm::Chunk> = match stages
4721            .iter()
4722            .map(|(i, _)| vm.chunk.sub_chunks.get(*i).cloned())
4723            .collect::<Option<Vec<_>>>()
4724        {
4725            Some(c) => c,
4726            None => return Value::Status(1),
4727        };
4728
4729        // c:Src/exec.c:1748 — `child_block();`, released by the async arm's
4730        // `child_unblock();` (c:1815) once the job is registered. A child
4731        // that exits at once must not be reaped before `addproc` knows its
4732        // pid. See ChildBlockSpan.
4733        #[cfg(unix)]
4734        let _child_block = ChildBlockSpan::enter();
4735
4736        if nstages > 1 {
4737            // c:Src/exec.c:1795 — the Z_ASYNC arm runs execpline2 in THIS
4738            // shell, so every stage is forked here and becomes its own proc
4739            // entry on the one job. Build the N-1 pipes, fork N children, and
4740            // addproc each; nothing in the parent waits.
4741            let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(nstages - 1);
4742            for _ in 0..nstages - 1 {
4743                let mut fds = [0i32; 2];
4744                if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
4745                    for (r, w) in &pipes {
4746                        unsafe {
4747                            libc::close(*r);
4748                            libc::close(*w);
4749                        }
4750                    }
4751                    return Value::Status(1);
4752                }
4753                pipes.push((fds[0], fds[1]));
4754            }
4755            // c:Src/exec.c:2916 — every async stage enters the subshell with
4756            // ESUB_PGRP, so they all land in ONE new process group led by the
4757            // first stage (c:Src/jobs.c:1582-1583 sets jn->gleader to the first
4758            // proc's pid). Keeping them together is what makes `kill %1` reach
4759            // the whole pipeline. Both parent and child set the pgid so neither
4760            // side can lose the race.
4761            let mut gleader: libc::pid_t = 0;
4762            let mut procs: Vec<(libc::pid_t, String)> = Vec::with_capacity(nstages);
4763            for (i, chunk) in chunks.iter().enumerate() {
4764                match unsafe { libc::fork() } {
4765                    -1 => {
4766                        for (pid, _) in &procs {
4767                            unsafe { libc::kill(*pid, libc::SIGTERM) };
4768                        }
4769                        for (r, w) in &pipes {
4770                            unsafe {
4771                                libc::close(*r);
4772                                libc::close(*w);
4773                            }
4774                        }
4775                        return Value::Status(1);
4776                    }
4777                    0 => {
4778                        forked_child_subsh_levels(); // c:Src/exec.c:1221
4779                        EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
4780                        // c:Src/exec.c:2916 — every async stage runs
4781                        // `entersubsh(ESUB_ASYNC|ESUB_PGRP)`: traps reset, MONITOR
4782                        // off, SIGINT/SIGQUIT ignored without job control, the
4783                        // interactive SIGTERM ignore dropped, job table cleared,
4784                        // subsh set. With job control it joins the job's group,
4785                        // led by the first stage (c:1149-1190); the port reads
4786                        // that leader from jobtab[thisjob], which zshrs fills in
4787                        // only after every fork, so join it here instead.
4788                        let monitor = crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR);
4789                        crate::ported::exec::entersubsh(
4790                            crate::ported::exec::esub::ASYNC | crate::ported::exec::esub::PGRP,
4791                            None,
4792                        );
4793                        if monitor {
4794                            unsafe { libc::setpgid(0, gleader) };
4795                        }
4796                        // Mirrors the RUN_PIPELINE stage child: default SIGPIPE
4797                        // so a broken pipe kills the stage quietly, and drop the
4798                        // parent's EXIT trap (c:Src/exec.c:2917-2918).
4799                        unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL) };
4800                        if let Ok(mut tt) = crate::ported::builtin::traps_table().lock() {
4801                            tt.remove("EXIT");
4802                        }
4803                        if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
4804                            if let Some(slot) =
4805                                st.get_mut(crate::ported::signals_h::SIGEXIT as usize)
4806                            {
4807                                *slot = 0;
4808                            }
4809                        }
4810                        *crate::ported::jobs::THISJOB
4811                            .get_or_init(|| std::sync::Mutex::new(-1))
4812                            .lock()
4813                            .unwrap() = -1;
4814                        let in_fd = if i > 0 { pipes[i - 1].0 } else { -1 };
4815                        // Unlike a foreground pipeline (whose last stage runs
4816                        // inline in the shell), an async pipeline forks its last
4817                        // stage too, so that stage simply has no pipe on stdout.
4818                        let out_fd = if i + 1 < nstages { pipes[i].1 } else { -1 };
4819                        for (r, w) in &pipes {
4820                            unsafe {
4821                                if *r != in_fd && *r != out_fd {
4822                                    libc::close(*r);
4823                                }
4824                                if *w != in_fd && *w != out_fd {
4825                                    libc::close(*w);
4826                                }
4827                            }
4828                        }
4829                        stage_fds_park(in_fd, out_fd);
4830                        crate::fusevm_disasm::maybe_print_stdout(
4831                            &format!("background_job:stage:{i}"),
4832                            chunk,
4833                        );
4834                        let mut stage_vm = fusevm::VM::new(chunk.clone());
4835                        register_builtins(&mut stage_vm);
4836                        let _ = stage_vm.run();
4837                        let _ = std::io::stdout().flush();
4838                        let _ = std::io::stderr().flush();
4839                        std::process::exit(stage_vm.last_status);
4840                    }
4841                    pid => {
4842                        if gleader == 0 {
4843                            gleader = pid;
4844                        }
4845                        if crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) {
4846                            unsafe { libc::setpgid(pid, gleader) };
4847                        }
4848                        procs.push((pid, stages[i].1.clone()));
4849                    }
4850                }
4851            }
4852            for (r, w) in &pipes {
4853                unsafe {
4854                    libc::close(*r);
4855                    libc::close(*w);
4856                }
4857            }
4858            // c:Src/exec.c:2891-2892 — `if (how & Z_ASYNC) lastpid = pid;` runs
4859            // for EVERY stage as it is forked, so `$!` ends up holding the LAST
4860            // stage's pid even though the job's group leader is the first.
4861            if let Some((last, _)) = procs.last() {
4862                crate::ported::modules::clone::lastpid
4863                    .store(*last, std::sync::atomic::Ordering::Relaxed);
4864            }
4865            {
4866                use crate::ported::jobs;
4867                use std::sync::Mutex;
4868                let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
4869                let idx = {
4870                    let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
4871                    let idx = jobs::initjob(&mut tab); // c:exec.c:1756
4872                    for (pid, text) in &procs {
4873                        // c:exec.c:2907 — one addproc per forked stage.
4874                        jobs::addproc(
4875                            &mut tab[idx],
4876                            *pid,
4877                            text,
4878                            false,
4879                            Some(std::time::Instant::now()),
4880                            -1,
4881                            -1,
4882                        );
4883                    }
4884                    tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1803
4885                    idx
4886                };
4887                jobs::clearoldjobtab(); // c:exec.c:1798
4888                if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
4889                    *tj = idx as i32;
4890                }
4891                if disown {
4892                    // c:exec.c:1808-1811
4893                    {
4894                        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
4895                        jobs::pipecleanfilelist(&mut tab[idx], false);
4896                        jobs::deletejob(&mut tab[idx], true);
4897                    }
4898                    if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
4899                        *tj = -1;
4900                    }
4901                } else {
4902                    jobs::spawnjob(); // c:exec.c:1814
4903                }
4904            }
4905            with_executor(|exec| {
4906                for (pid, text) in &procs {
4907                    exec.jobs.add_pid_job(*pid, text.clone(), JobState::Running);
4908                }
4909            });
4910            return Value::Status(0);
4911        }
4912
4913        let chunk = chunks.into_iter().next().unwrap();
4914        let job_text = stages.into_iter().next().unwrap().1;
4915        match unsafe { libc::fork() } {
4916            -1 => Value::Status(1),
4917            0 => {
4918                forked_child_subsh_levels(); // c:Src/exec.c:1221
4919                EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
4920                // c:Src/exec.c:2916 — an async command's child runs
4921                // `entersubsh(ESUB_ASYNC|ESUB_PGRP)`: string traps reset,
4922                // MONITOR off (no ESUB_JOB_CONTROL, c:1245-1246), SIGINT/
4923                // SIGQUIT ignored without job control (c:1137-1140), its own
4924                // process group with it (c:1149-1190), SIGTERM/SIGINT back to
4925                // default in an interactive shell (c:1224-1230), subsh set. The old `setsid()` did none of
4926                // that: the child kept MONITOR and the interactive SIGTERM
4927                // ignore, so it put its own commands in fresh groups and
4928                // `kill %N` reached nothing.
4929                crate::ported::exec::entersubsh(
4930                    crate::ported::exec::esub::ASYNC | crate::ported::exec::esub::PGRP,
4931                    None,
4932                );
4933                // `$ZSH_SUBSHELL` itself is bumped by the chunk's leading
4934                // BUILTIN_EXECCMD_FORKED_LEVEL, which knows whether the job
4935                // is a `( … )` whose own subshell_begin is the one bump.
4936                crate::fusevm_disasm::maybe_print_stdout("background_job", &chunk);
4937                let mut bg_vm = fusevm::VM::new(chunk);
4938                register_builtins(&mut bg_vm);
4939                let _ = bg_vm.run();
4940                let _ = std::io::stdout().flush();
4941                let _ = std::io::stderr().flush();
4942                std::process::exit(bg_vm.last_status);
4943            }
4944            pid => {
4945                // c:Src/exec.c:2887-2890 — C's parent blocks on the synch
4946                // pipe until the child has run entersubsh, so the child's
4947                // own group exists before `kill %N` can target it. Set it
4948                // from this side too (the async-pipeline arm above does the
4949                // same) so neither side can lose the race.
4950                if crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) {
4951                    unsafe { libc::setpgid(pid, pid) };
4952                }
4953                // Parent: record the PID into `$!` (most recent
4954                // backgrounded job's pid). zsh exposes this for any
4955                // script that needs `wait $!`. Also register the
4956                // bare-pid job so a no-args `wait` can synchronize.
4957                // c:Src/jobs.c:73 — `lastpid = pid;` after a
4958                // background fork. zshrs's `$!` getter
4959                // (params.rs::lookup_special_var "!") reads from
4960                // the same atomic, so a single store here is the
4961                // canonical writer.
4962                crate::ported::modules::clone::lastpid
4963                    .store(pid, std::sync::atomic::Ordering::Relaxed);
4964                // c:Src/exec.c:1700 — `thisjob = newjob = initjob()`:
4965                // allocate the canonical jobtab slot. c:Src/exec.c:2950
4966                // zfork path → addproc(pid, text, 0, &bgtime, ...) hangs
4967                // the proc entry (with its display text) off the job.
4968                // c:Src/exec.c:1744-1746 — `clearoldjobtab();
4969                // jobtab[thisjob].stat |= STAT_NOSTTY;` then c:1758
4970                // `spawnjob()` promotes it to curjob (top-level shell
4971                // only), marks STAT_LOCKED and resets thisjob.
4972                {
4973                    use crate::ported::jobs;
4974                    use std::sync::Mutex;
4975                    let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
4976                    let idx = {
4977                        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
4978                        let idx = jobs::initjob(&mut tab); // c:exec.c:1700
4979                        jobs::addproc(
4980                            &mut tab[idx],
4981                            pid,
4982                            &job_text,
4983                            false,
4984                            Some(std::time::Instant::now()),
4985                            -1,
4986                            -1,
4987                        ); // c:exec.c:2950 addproc
4988                        tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
4989                        idx
4990                    };
4991                    jobs::clearoldjobtab(); // c:exec.c:1744
4992                    if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
4993                        *tj = idx as i32;
4994                    }
4995                    if disown {
4996                        // c:exec.c:1752-1755 — `pipecleanfilelist(...);
4997                        // deletejob(jobtab + thisjob, 1); thisjob = -1;` — a
4998                        // disowned job leaves the table entirely, so neither
4999                        // spawnjob's `[N] pid` nor the later `[N] done` prints.
5000                        // This is what keeps zinit-turbo's `… &|` completion
5001                        // jobs silent (they load inside a `zle -F` handler).
5002                        {
5003                            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
5004                            jobs::pipecleanfilelist(&mut tab[idx], false); // c:1753
5005                            jobs::deletejob(&mut tab[idx], true); // c:1754
5006                        }
5007                        if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
5008                            *tj = -1; // c:1755
5009                        }
5010                    } else {
5011                        jobs::spawnjob(); // c:exec.c:1758
5012                    }
5013                }
5014                with_executor(|exec| {
5015                    exec.jobs
5016                        .add_pid_job(pid, job_text.clone(), JobState::Running);
5017                });
5018                Value::Status(0)
5019            }
5020        }
5021    });
5022
5023    // ── Indexed-array storage ─────────────────────────────────────────────
5024    //
5025    // Stack: pushed values then name (LAST). `arr=(a b c)` → 4 args
5026    // (a, b, c, arr). `arr=($(cmd))` → 2 args (FlatArray, arr).
5027    //
5028    // PURE PASSTHRU: pop name + values, dispatch to canonical
5029    // `setaparam` / `sethparam` (C port of `Src/params.c:3595/3602`).
5030    // assignaparam already handles PM_UNIQUE dedupe, type-flag flip,
5031    // PM_NAMEREF rejection, ASSPM_AUGMENT prepend, and createparam
5032    // for fresh names.
5033    vm.register_builtin(BUILTIN_SET_ARRAY, |vm, argc| {
5034        // `${~spec}` carrier: an assignment statement is a word-
5035        // pipeline boundary too — restore the user's GLOB_SUBST
5036        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
5037        // ${options[globsubst]}` must read the user value).
5038        consume_tilde_globsubst_carrier();
5039        let n = argc as usize;
5040        let mut popped: Vec<Value> = Vec::with_capacity(n);
5041        for _ in 0..n {
5042            popped.push(vm.pop());
5043        }
5044        popped.reverse();
5045        if popped.is_empty() {
5046            return Value::Status(1);
5047        }
5048        let name = popped.pop().unwrap().to_str();
5049        let mut values: Vec<String> = Vec::new();
5050        for v in popped {
5051            flatten_array_value(v, &mut values);
5052        }
5053        if array_assignment_expansion_failed() {
5054            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
5055            return Value::Status(1);
5056        }
5057        // Bash sparse: a full `a=(...)` reassign resets the array to dense
5058        // (drops any prior holes from subscript-assign / unset).
5059        if crate::dash_mode::sparse_arrays() {
5060            crate::bash_arrays::clear(&name);
5061        }
5062        let blocked = with_executor(|exec| {
5063            // c:Src/exec.c:4473-4509 save_params — an ARRAY prefix
5064            // assignment (`z=(9 8 7) f`) is a WC_ASSIGN in the same chain
5065            // as a scalar one, so it is snapshotted the same way and put
5066            // back by restore_params when the command returns.
5067            let prefix_restored = exec
5068                .inline_env_stack
5069                .last()
5070                .is_some_and(|frame| frame.recording);
5071            if prefix_restored {
5072                save_inline_prefix_param(exec, &name);
5073            }
5074            // Assoc init `typeset -A m; m=(k v k v ...)` — route to
5075            // canonical sethparam (Src/params.c:3602) which parses the
5076            // flat (k,v) pair list internally.
5077            if exec.assoc(&name).is_some() {
5078                // `[k]=v` / `[k]+=v` elements arrive from the compiler
5079                // as Marker / key / value triples (compile_zsh's port
5080                // of keyvalpairelement, c:Src/subst.c:49-79).
5081                let marker = crate::ported::zsh_h::Marker;
5082                let values = if values.iter().any(|e| e.starts_with(marker)) {
5083                    // c:Src/params.c:3544-3560 — under ASSPM_KEY_VALUE
5084                    // assocs strictly enforce `[key]=value`: every
5085                    // stride-of-3 element must be a Marker. Mixing
5086                    // plain pairs with kv triads is an error.
5087                    let mut i = 0usize;
5088                    while i < values.len() {
5089                        if !values[i].starts_with(marker) {
5090                            crate::ported::utils::zerr(
5091                                "bad [key]=value syntax for associative array",
5092                            );
5093                            crate::ported::utils::errflag.fetch_or(
5094                                crate::ported::zsh_h::ERRFLAG_ERROR,
5095                                std::sync::atomic::Ordering::Relaxed,
5096                            );
5097                            exec.set_last_status(1);
5098                            return true;
5099                        }
5100                        i += 3;
5101                    }
5102                    if values.len() % 3 != 0 {
5103                        // c:Src/params.c:4124-4131 arrhashsetfn — a
5104                        // truncated triad leaves an odd non-Marker
5105                        // count → "bad set of key/value pairs".
5106                        crate::ported::utils::zerr(
5107                            "bad set of key/value pairs for associative array",
5108                        );
5109                        crate::ported::utils::errflag.fetch_or(
5110                            crate::ported::zsh_h::ERRFLAG_ERROR,
5111                            std::sync::atomic::Ordering::Relaxed,
5112                        );
5113                        exec.set_last_status(1);
5114                        return true;
5115                    }
5116                    // c:Src/params.c:4136-4168 arrhashsetfn — whole
5117                    // assignment builds a FRESH table; a `Marker +`
5118                    // triad (`[k]+=v`) appends to the value inserted
5119                    // EARLIER IN THIS SAME LITERAL (assignstrvalue
5120                    // with eltflags=ASSPM_AUGMENT against the new ht),
5121                    // so `h=([k]=a [k]+=b)` yields "ab". Resolve the
5122                    // appends here, then hand flat pairs to sethparam.
5123                    let mut order: Vec<String> = Vec::new();
5124                    let mut map: std::collections::HashMap<String, String> =
5125                        std::collections::HashMap::new();
5126                    for ch in values.chunks(3) {
5127                        let elt_append = ch[0].chars().nth(1) == Some('+');
5128                        let k = ch[1].clone();
5129                        let v = ch[2].clone();
5130                        let nv = if elt_append {
5131                            format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
5132                        } else {
5133                            v
5134                        };
5135                        if !map.contains_key(&k) {
5136                            order.push(k.clone());
5137                        }
5138                        map.insert(k, nv);
5139                    }
5140                    order
5141                        .into_iter()
5142                        .flat_map(|k| {
5143                            let v = map.get(&k).cloned().unwrap_or_default();
5144                            [k, v]
5145                        })
5146                        .collect()
5147                } else {
5148                    values
5149                };
5150                // Odd-count rejection lives in the canonical chain:
5151                // sethparam → setarrvalue (c:3651/c:2920) →
5152                // arrhashsetfn's zerr "bad set of key/value pairs"
5153                // (Src/params.c:4128-4131). zerr sets ERRFLAG_ERROR,
5154                // which aborts the remaining list at the next command
5155                // boundary (BUILTIN_ERREXIT_CHECK trigger 4) —
5156                // matching `zsh -fc 'typeset -A m; m=(odd); print x'`
5157                // printing nothing after the error. Like C's sethparam
5158                // (c:3652-3653 returns v->pm regardless), the Rust
5159                // port returns Some on the odd-count path — the
5160                // failure travels via errflag, so check BOTH.
5161                let pre_err = crate::ported::utils::errflag
5162                    .load(std::sync::atomic::Ordering::Relaxed)
5163                    & crate::ported::zsh_h::ERRFLAG_ERROR;
5164                let res = crate::ported::params::sethparam(&name, values.clone());
5165                let now_err = crate::ported::utils::errflag
5166                    .load(std::sync::atomic::Ordering::Relaxed)
5167                    & crate::ported::zsh_h::ERRFLAG_ERROR;
5168                if res.is_none() || (pre_err == 0 && now_err != 0) {
5169                    // c:Src/exec.c:2632-2633 addvars — `if
5170                    // (!assignaparam(name, arr, myflags)) lastval = 1;`
5171                    // — failed assignment sets lastval so the errflag
5172                    // abort exits 1 (init.c loop() breaks, zsh_main
5173                    // returns lastval).
5174                    exec.set_last_status(1);
5175                    return true;
5176                }
5177                #[cfg(feature = "recorder")]
5178                if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5179                    let ctx = exec.recorder_ctx();
5180                    let attrs = exec.recorder_attrs_for(&name);
5181                    let mut pairs: Vec<(String, String)> = Vec::with_capacity(values.len() / 2);
5182                    let mut iter = values.iter().cloned();
5183                    while let Some(k) = iter.next() {
5184                        if let Some(v) = iter.next() {
5185                            pairs.push((k, v));
5186                        }
5187                    }
5188                    crate::recorder::emit_assoc_assign(&name, pairs, attrs, false, ctx);
5189                }
5190                return false;
5191            }
5192            // Indexed-array: setaparam (Src/params.c:3766) wraps
5193            // assignaparam with ASSPM_WARN — handles PM_UNIQUE dedupe,
5194            // type-flag flip, PM_READONLY rejection.
5195            //
5196            // `[k]=v` elements arrive as Marker / key / value triples
5197            // (compile_zsh's keyvalpairelement port). Mirror
5198            // c:Src/exec.c:2552-2553 — `if (prefork_ret &
5199            // PREFORK_KEY_VALUE) myflags |= ASSPM_KEY_VALUE;` — so
5200            // assignaparam runs its kv-resolution block (sparse fill
5201            // for PM_ARRAY, c:3447-3541; strict-triad enforcement for
5202            // special PM_HASHED targets like `options`, c:3544-3560).
5203            let values = values;
5204            let has_kv = values
5205                .iter()
5206                .any(|e| e.starts_with(crate::ported::zsh_h::Marker));
5207            // The tied-array mirror to a PM_TIED scalar
5208            // (`typeset -T PATH path`) lives canonically in
5209            // setarrvalue's dispatch in C zsh; until that wires
5210            // through assignaparam, mirror here so PATH stays in sync
5211            // after `path=(/x)`.
5212            //
5213            // The mirrored value must be the array AS STORED, which for a
5214            // PM_UNIQUE tie means deduped. c:4066-4076 arrsetfn fixes the
5215            // order:
5216            //     if (pm->node.flags & PM_UNIQUE) uniqarray(x);
5217            //     pm->u.arr = x;
5218            //     if (pm->ename && x) arrfixenv(pm->ename, x);
5219            // — the dedupe happens FIRST, so the scalar publishes the same
5220            // list the array holds and the two halves of a tie always agree.
5221            // Mirroring the raw `values` broke exactly that:
5222            //     typeset -U path; path=(/a /b /a)
5223            //       $path → /a /b        (right)
5224            //       $PATH → /a:/b:/a     (wrong; zsh gives /a:/b)
5225            // i.e. `typeset -U path`, the standard PATH-dedup idiom in
5226            // essentially every .zshrc. assignaparam's own arrfixenv does not
5227            // rescue it: that call is gated on the param having a gsu_a wired,
5228            // and `path` has none.
5229            //
5230            // The dedupe is applied here rather than by reading the array back
5231            // after assignaparam, because this mirror must stay BEFORE it.
5232            // `exec.set_scalar` is heavier than C's arrfixenv — arrfixenv only
5233            // rewrites the environment string, while set_scalar re-derives the
5234            // ARRAY from the scalar. Running it afterwards makes `path=()`
5235            // publish PATH="" and then re-split that back into a one-element
5236            // `path=("")`, where zsh leaves 0 elements.
5237            if let Some((scalar_name, sep)) = exec
5238                .tied_array_to_scalar
5239                .get(&name)
5240                .cloned()
5241            {
5242                let uniq = crate::ported::params::paramtab()
5243                    .read()
5244                    .ok()
5245                    .and_then(|t| t.get(&name).map(|p| p.node.flags))
5246                    .map(|f| (f as u32 & crate::ported::zsh_h::PM_UNIQUE) != 0)
5247                    .unwrap_or(false);
5248                let mirror = if uniq {
5249                    crate::ported::params::simple_arrayuniq(values.clone()) // c:4068
5250                } else {
5251                    values.clone()
5252                };
5253                exec.set_scalar(scalar_name, mirror.join(&sep)); // c:4074-4075
5254            }
5255            // c:Src/exec.c:2632-2633 addvars — `if (!assignaparam(...))
5256            // lastval = 1;` — a failed assignment (bad subscript, bad
5257            // [key]=value syntax, readonly) exits 1 and the errflag
5258            // abort stops the remaining list. Track errflag pre/post
5259            // like the assoc branch above.
5260            let kv_flag = if has_kv {
5261                crate::ported::zsh_h::ASSPM_KEY_VALUE
5262            } else {
5263                0
5264            };
5265            let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
5266                & crate::ported::zsh_h::ERRFLAG_ERROR;
5267            // c:Src/exec.c:2573 — `flags = !(addflags & ADDVAR_RESTORE) ?
5268            // ASSPM_WARN : 0;`: a prefix assignment that is put back after
5269            // the command is implicitly scoped and never warns, the same rule
5270            // the scalar arm applies.
5271            let warn = if prefix_restored { 0 } else { crate::ported::zsh_h::ASSPM_WARN };
5272            let res = crate::ported::params::assignaparam(&name, values.clone(), warn | kv_flag);
5273            let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
5274                & crate::ported::zsh_h::ERRFLAG_ERROR;
5275            if res.is_none() && pre_err == 0 && now_err != 0 {
5276                exec.set_last_status(1);
5277                return true;
5278            }
5279            // Bash sparse: an explicit-index array literal `a=([2]=x [5]=y)`
5280            // leaves the un-indexed slots as HOLES (bash: count 2, indices
5281            // {2,5}), not dense empties. assignaparam has already placed each
5282            // value at its 0-based index; mark every OTHER slot a hole. Gated
5283            // to a PURE indexed literal (every element a `[idx]=val` triple) —
5284            // a mixed positional/indexed literal (`a=(x [3]=y z)`) needs the
5285            // positional-counter replay we don't model, so it stays dense.
5286            if crate::dash_mode::sparse_arrays() && has_kv {
5287                let marker = crate::ported::zsh_h::Marker;
5288                let pure_indexed = !values.is_empty()
5289                    && values.len() % 3 == 0
5290                    && values.chunks(3).all(|ch| ch[0].starts_with(marker));
5291                if pure_indexed {
5292                    let mut explicit: std::collections::BTreeSet<usize> =
5293                        std::collections::BTreeSet::new();
5294                    for ch in values.chunks(3) {
5295                        if let Ok(i) = ch[1].trim().parse::<usize>() {
5296                            explicit.insert(i);
5297                        }
5298                    }
5299                    let len = exec.array(&name).map(|a| a.len()).unwrap_or(0);
5300                    crate::bash_arrays::note_holes_outside(&name, &explicit, len);
5301                }
5302            }
5303            #[cfg(feature = "recorder")]
5304            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5305                let ctx = exec.recorder_ctx();
5306                let attrs = exec.recorder_attrs_for(&name);
5307                emit_path_or_assign(&name, &values, attrs, false, &ctx);
5308            }
5309            false
5310        });
5311        let status = if blocked { 1 } else { 0 };
5312        // c:Src/jobs.c:1748-1757 waitonejob — in C an array-assignment
5313        // simple command goes through execpline → waitjobs; with no
5314        // procs the else-branch stores `pipestats[0] = lastval;
5315        // numpipestats = 1`. Bare SCALAR assignments never create a
5316        // job (no waitjobs), so this clobber is array/assoc-assignment
5317        // specific: `false|true; x=(1 2); echo $pipestatus` → `0` in
5318        // zsh while `x=1` preserves `1 0`. Bug #373.
5319        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
5320        let mut synth = crate::ported::zsh_h::job::default();
5321        crate::ported::jobs::waitonejob(&mut synth);
5322        Value::Status(status)
5323    });
5324    // `arr+=(d e f)` — array append. Same calling conventions as SET_ARRAY.
5325    //
5326    // PURE PASSTHRU shape: pop name + values, dispatch through the
5327    // canonical assoc / array setter. assignaparam's ASSPM_AUGMENT
5328    // flag handles the C-source-equivalent "preserve prior value"
5329    // semantics; for now we read the current array, extend with
5330    // new values, write through set_array (which routes to
5331    // setaparam → assignaparam where PM_UNIQUE dedupe lands).
5332    vm.register_builtin(BUILTIN_APPEND_ARRAY, |vm, argc| {
5333        let n = argc as usize;
5334        let mut popped: Vec<Value> = Vec::with_capacity(n);
5335        for _ in 0..n {
5336            popped.push(vm.pop());
5337        }
5338        popped.reverse();
5339        if popped.is_empty() {
5340            return Value::Status(1);
5341        }
5342        let name = popped.pop().unwrap().to_str();
5343        let mut values: Vec<String> = Vec::new();
5344        for v in popped {
5345            flatten_array_value(v, &mut values);
5346        }
5347        if array_assignment_expansion_failed() {
5348            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
5349            return Value::Status(1);
5350        }
5351        let blocked = with_executor(|exec| -> bool {
5352            // Assoc append `m+=(k1 v1 ...)`: merge the (k,v) pairs into
5353            // the existing map and write back via canonical sethparam
5354            // (Src/params.c:3602). The canonical C path would go
5355            // assignaparam(ASSPM_AUGMENT) → arrhashsetfn(ASSPM_AUGMENT)
5356            // at Src/params.c:3850, but the zshrs port of
5357            // arrhashsetfn doesn't yet implement value-storage
5358            // (pending Param.u_hash backend wireup) — until that
5359            // lands, do the augment + write here so the storage
5360            // actually mutates.
5361            if exec.assoc(&name).is_some() {
5362                // `[k]=v` / `[k]+=v` elements arrive as Marker / key /
5363                // value triples (compile_zsh's port of
5364                // keyvalpairelement, c:Src/subst.c:49-79).
5365                let marker = crate::ported::zsh_h::Marker;
5366                let mut map = exec.assoc(&name).unwrap_or_default();
5367                if values.iter().any(|e| e.starts_with(marker)) {
5368                    // c:Src/params.c:3544-3560 — strict triad rule.
5369                    let mut i = 0usize;
5370                    while i < values.len() {
5371                        if !values[i].starts_with(marker) {
5372                            crate::ported::utils::zerr(
5373                                "bad [key]=value syntax for associative array",
5374                            );
5375                            crate::ported::utils::errflag.fetch_or(
5376                                crate::ported::zsh_h::ERRFLAG_ERROR,
5377                                std::sync::atomic::Ordering::Relaxed,
5378                            );
5379                            exec.set_last_status(1);
5380                            return true;
5381                        }
5382                        i += 3;
5383                    }
5384                    if values.len() % 3 != 0 {
5385                        // c:Src/params.c:4124-4131 — odd pair count.
5386                        crate::ported::utils::zerr(
5387                            "bad set of key/value pairs for associative array",
5388                        );
5389                        crate::ported::utils::errflag.fetch_or(
5390                            crate::ported::zsh_h::ERRFLAG_ERROR,
5391                            std::sync::atomic::Ordering::Relaxed,
5392                        );
5393                        exec.set_last_status(1);
5394                        return true;
5395                    }
5396                    // c:Src/params.c:4133-4168 arrhashsetfn with
5397                    // ASSPM_AUGMENT — ht = the EXISTING table, so
5398                    // `[k]+=v` appends to the current value
5399                    // (assignstrvalue eltflags=ASSPM_AUGMENT,
5400                    // c:4144-4150) and `[k]=v` overwrites.
5401                    for ch in values.chunks(3) {
5402                        let elt_append = ch[0].chars().nth(1) == Some('+');
5403                        let k = ch[1].clone();
5404                        let v = ch[2].clone();
5405                        let nv = if elt_append {
5406                            format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
5407                        } else {
5408                            v
5409                        };
5410                        map.insert(k, nv);
5411                    }
5412                } else {
5413                    // c:Src/params.c:4076-4085 arrhashsetfn — the SAME odd-count
5414                    // gate the non-augment form uses runs before ASSPM_AUGMENT
5415                    // merges anything:
5416                    //     for (aptr = val; *aptr; ++aptr)
5417                    //         if (**aptr != Marker) ++alen;
5418                    //     if (alen % 2) { freearray(val);
5419                    //         zerr("bad set of key/value pairs for associative
5420                    //              array"); return; }
5421                    // c:4086 `if (flags & ASSPM_AUGMENT)` is reached only AFTER
5422                    // it, so `h+=(k2)` with a lone key is refused and the hash is
5423                    // left alone. The walk below just dropped the unpaired key
5424                    // (`if let Some(v) = it.next()`), so the append silently
5425                    // no-opped at status 0 where zsh errors and exits 1.
5426                    if values.len() % 2 != 0 {
5427                        crate::ported::utils::zerr(
5428                            "bad set of key/value pairs for associative array",
5429                        ); // c:4083
5430                        crate::ported::utils::errflag.fetch_or(
5431                            crate::ported::zsh_h::ERRFLAG_ERROR,
5432                            std::sync::atomic::Ordering::Relaxed,
5433                        );
5434                        // c:Src/exec.c:2632-2633 — a failed assignment sets
5435                        // lastval 1, the same tail the triad form above uses.
5436                        exec.set_last_status(1);
5437                        return true;
5438                    }
5439                    let mut it = values.iter().cloned();
5440                    while let Some(k) = it.next() {
5441                        if let Some(v) = it.next() {
5442                            map.insert(k, v);
5443                        }
5444                    }
5445                }
5446                exec.set_assoc(name, map);
5447                return false;
5448            }
5449            // Indexed-array append `arr+=(d e f)` — route directly
5450            // through canonical assignaparam with ASSPM_AUGMENT
5451            // (`Src/params.c:3570-3585` append-on-array branch).
5452            // assignaparam reads the prior array internally and
5453            // appends the new values, so the bridge no longer needs
5454            // to pre-concat manually. Marker triples from `[k]=v`
5455            // elements add ASSPM_KEY_VALUE (c:Src/exec.c:2552-2553)
5456            // so the kv sparse-fill block (c:Src/params.c:3447-3541)
5457            // resolves them against the existing elements.
5458            let kv_flag = if values
5459                .iter()
5460                .any(|e| e.starts_with(crate::ported::zsh_h::Marker))
5461            {
5462                crate::ported::zsh_h::ASSPM_KEY_VALUE
5463            } else {
5464                0
5465            };
5466            let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
5467                & crate::ported::zsh_h::ERRFLAG_ERROR;
5468            let res = crate::ported::params::assignaparam(
5469                &name,
5470                values.clone(),
5471                crate::ported::zsh_h::ASSPM_AUGMENT | kv_flag,
5472            );
5473            let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
5474                & crate::ported::zsh_h::ERRFLAG_ERROR;
5475            if res.is_none() && pre_err == 0 && now_err != 0 {
5476                // c:Src/exec.c:2632-2633 — failed assignment → lastval 1.
5477                exec.set_last_status(1);
5478                return true;
5479            }
5480            #[cfg(feature = "recorder")]
5481            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5482                let ctx = exec.recorder_ctx();
5483                let attrs = exec.recorder_attrs_for(&name);
5484                emit_path_or_assign(&name, &values, attrs, true, &ctx);
5485            }
5486            // Tied-scalar mirror — TODO faithful: should live in
5487            // setarrvalue's gsu dispatch once boot_ paramtab wiring
5488            // lands (Task #16). Re-read the canonical post-augment
5489            // array so the joined scalar matches.
5490            let tied_scalar = exec.tied_array_to_scalar.get(&name).cloned();
5491            if let Some((scalar_name, sep)) = tied_scalar {
5492                let merged = exec.array(&name).unwrap_or_default();
5493                let joined = merged.join(&sep);
5494                exec.set_scalar(scalar_name.clone(), joined.clone());
5495                let _ = crate::ported::params::zputenv(&format!("{}={}", &scalar_name, &joined));
5496                // c:Src/params.c:5354
5497            }
5498            false
5499        });
5500        // c:Src/jobs.c:1748-1757 waitonejob — `arr+=(...)` is an
5501        // array-assignment simple command and clobbers pipestats to
5502        // `[lastval]` exactly like `arr=(...)` above. Bug #373.
5503        let status = if blocked { 1 } else { 0 };
5504        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
5505        let mut synth = crate::ported::zsh_h::job::default();
5506        crate::ported::jobs::waitonejob(&mut synth);
5507        Value::Status(status)
5508    });
5509    // `name[@]=(...)` / `name[*]=(...)` — whole-array SET with the assoc
5510    // guard (c:Src/params.c:3324-3327). Stack: [v0..vn, name].
5511    vm.register_builtin(BUILTIN_SET_ARRAY_AT, |vm, argc| {
5512        let (name, values) = pop_array_args_with_name(vm, argc);
5513        let status = with_executor(|exec| {
5514            // c:Src/exec.c:4475-4478 (54941) — `a[@]=(…) f` saves `a`.
5515            if exec.inline_env_stack.last().is_some_and(|frame| frame.recording) {
5516                save_inline_prefix_param(exec, &name);
5517            }
5518            if exec.assoc(&name).is_some() {
5519                // c:Src/params.c:3324-3327 — `[@]` (any slice) on a
5520                // PM_HASHED target is an error.
5521                crate::ported::utils::zerr(&format!(
5522                    "{}: attempt to set slice of associative array",
5523                    name
5524                ));
5525                crate::ported::utils::errflag.fetch_or(
5526                    crate::ported::zsh_h::ERRFLAG_ERROR,
5527                    std::sync::atomic::Ordering::Relaxed,
5528                );
5529                exec.set_last_status(1);
5530                return 1;
5531            }
5532            exec.set_array(name, values); // whole replace (c:3528 setarrvalue)
5533            0
5534        });
5535        Value::Status(status)
5536    });
5537    // `name[@]+=(...)` / `name[*]+=(...)` — whole-array APPEND (push) with
5538    // the same assoc guard.
5539    vm.register_builtin(BUILTIN_APPEND_ARRAY_AT, |vm, argc| {
5540        let (name, values) = pop_array_args_with_name(vm, argc);
5541        let status = with_executor(|exec| {
5542            // c:Src/exec.c:4475-4478 (54941) — `a[@]+=(…) f` saves `a`.
5543            if exec.inline_env_stack.last().is_some_and(|frame| frame.recording) {
5544                save_inline_prefix_param(exec, &name);
5545            }
5546            if exec.assoc(&name).is_some() {
5547                crate::ported::utils::zerr(&format!(
5548                    "{}: attempt to set slice of associative array",
5549                    name
5550                ));
5551                crate::ported::utils::errflag.fetch_or(
5552                    crate::ported::zsh_h::ERRFLAG_ERROR,
5553                    std::sync::atomic::Ordering::Relaxed,
5554                );
5555                exec.set_last_status(1);
5556                return 1;
5557            }
5558            let mut cur = exec.array(&name).unwrap_or_default();
5559            cur.extend(values);
5560            exec.set_array(name, cur); // c:3511-3528 AUGMENT on array → push
5561            0
5562        });
5563        Value::Status(status)
5564    });
5565    vm.register_builtin(BUILTIN_RUN_SELECT, |vm, argc| {
5566        if argc < 2 {
5567            return Value::Status(1);
5568        }
5569        let n = argc as usize;
5570        let mut popped: Vec<Value> = Vec::with_capacity(n);
5571        for _ in 0..n {
5572            popped.push(vm.pop());
5573        }
5574        // popped: [sub_idx, name, word_N, ..., word_1] (popping from top)
5575        let sub_idx_val = popped.remove(0);
5576        let name_val = popped.remove(0);
5577        // c:Src/loop.c — `select` flattens Array values (from `$@`,
5578        // `${arr[@]}`, etc.) into the menu. Without per-element
5579        // splice, `select x do ... done` (bare, iterating $@)
5580        // collapsed all positionals into one joined entry.
5581        let mut words: Vec<String> = Vec::new();
5582        for v in popped.into_iter().rev() {
5583            match v {
5584                Value::Array(items) => {
5585                    for item in items.iter() {
5586                        words.push(item.to_str());
5587                    }
5588                }
5589                other => words.push(other.to_str()),
5590            }
5591        }
5592
5593        let sub_idx = sub_idx_val.to_int() as usize;
5594        let name = name_val.to_str();
5595
5596        // c:Src/loop.c:250-255 — `execsubst(args); if (errflag) { state->pc =
5597        // end; …; return 1; }`: a word list that failed to expand (a NOMATCH
5598        // glob) ends the `select` with status 1, ahead of the empty-list 0.
5599        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
5600            return Value::Status(1);
5601        }
5602
5603        // c:Src/loop.c:248-252 — `if (!args || empty(args)) {
5604        // state->pc = end; ... return 0; }`. An empty option list
5605        // skips the body entirely; without this gate the prompt loop
5606        // runs indefinitely (or twice on the EOF stdin case before
5607        // exiting). Bug #401.
5608        if words.is_empty() {
5609            return Value::Status(0);
5610        }
5611
5612        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
5613            Some(c) => c,
5614            None => return Value::Status(1),
5615        };
5616
5617        let prompt =
5618            with_executor(|exec| exec.scalar("PROMPT3").unwrap_or_else(|| "?# ".to_string()));
5619
5620        let stdin = std::io::stdin();
5621        let mut reader = stdin.lock();
5622        let mut last_status: i32 = 0;
5623
5624        // c:Src/loop.c:264 — `more = selectlist(args, 0);` renders the menu
5625        // ONCE, BEFORE the selection loop. C reprints it ONLY when the user
5626        // enters an EMPTY line (c:290, inside the inner read loop) — never per
5627        // body iteration. This was a single conflated loop that re-rendered at
5628        // the top of every pass, so `printf "1\n2\n" | select x in a b; do
5629        // print $x; done` redrew the list before each prompt where zsh prints
5630        // it once.
5631        //
5632        // (`selectlist` is also ported at src/ported/loop.rs:127, but that copy
5633        // derives its row budget from adjustlines()/adjustcolumns() — an ioctl
5634        // on fd 1 — and so renders nothing when stdout is not a tty, which is
5635        // exactly this path. Keeping the working inline render here.)
5636        let render_menu = || {
5637            // Direct port of zsh's selectlist from
5638            // src/zsh/Src/loop.c:347-409. Layout is column-major
5639            // ("down columns, then across") — NOT row-major. With
5640            // 6 items in 3 cols zsh produces:
5641            //   1  3  5
5642            //   2  4  6
5643            // The previous Rust impl walked row-major which
5644            // produced 1 2 3 / 4 5 6 (visually similar but wrong
5645            // for prompts that mention ordering and breaks scripts
5646            // that rely on column count == ceil(N/rows)).
5647            //
5648            // C variable mapping:
5649            //   ct      -> word count (n)
5650            //   longest -> max item width + 1, then plus digits-of-ct
5651            //   fct     -> column count
5652            //   fw      -> per-column width
5653            //   colsz   -> row count = ceil(ct / fct)
5654            //   t1      -> row index, walks 0..colsz
5655            //   ap      -> item pointer; advances by colsz to step
5656            //              DOWN a column.
5657            let term_width: usize = env::var("COLUMNS")
5658                .ok()
5659                .and_then(|v| v.parse().ok())
5660                .unwrap_or(80);
5661            let ct = words.len();
5662            // loop.c:354-363 — find longest item width.
5663            let mut longest = 1usize;
5664            for w in &words {
5665                let aplen = w.chars().count();
5666                if aplen > longest {
5667                    longest = aplen;
5668                }
5669            }
5670            // loop.c:365-367 — `longest++` then add digits of `ct`.
5671            longest += 1;
5672            let mut t0 = ct;
5673            while t0 > 0 {
5674                t0 /= 10;
5675                longest += 1;
5676            }
5677            // loop.c:369-373 — fct = (cols - 1) / (longest + 3); if
5678            // 0, fct = 1; else fw = (cols - 1) / fct.
5679            let raw_fct = (term_width.saturating_sub(1)) / (longest + 3);
5680            let (fct, fw) = if raw_fct == 0 {
5681                (1, longest + 3)
5682            } else {
5683                (raw_fct, (term_width.saturating_sub(1)) / raw_fct)
5684            };
5685            // loop.c:374 — colsz = (ct + fct - 1) / fct.
5686            let colsz = ct.div_ceil(fct);
5687            // loop.c:375-395 — for each row t1, walk down columns.
5688            for t1 in 0..colsz {
5689                let mut ap_idx = t1;
5690                while ap_idx < ct {
5691                    let w = &words[ap_idx];
5692                    let n = ap_idx + 1;
5693                    let _ = write!(std::io::stderr(), "{}) {}", n, w);
5694                    let mut t2 = w.chars().count() + 2;
5695                    let mut t3 = n;
5696                    while t3 > 0 {
5697                        t2 += 1;
5698                        t3 /= 10;
5699                    }
5700                    // Pad to fw (loop.c:389-390).
5701                    while t2 < fw {
5702                        let _ = write!(std::io::stderr(), " ");
5703                        t2 += 1;
5704                    }
5705                    ap_idx += colsz;
5706                }
5707                let _ = writeln!(std::io::stderr());
5708            }
5709        };
5710        render_menu(); // c:264 — once, before the loop
5711
5712        'select: loop {
5713            // c:266-290 — inner read loop: prompt and read until a NON-EMPTY
5714            // line arrives; each empty line reprints the menu and re-reads.
5715            let trimmed = loop {
5716                let _ = write!(std::io::stderr(), "{}", prompt);
5717                let _ = std::io::stderr().flush();
5718
5719                let mut line = String::new();
5720                match reader.read_line(&mut line) {
5721                    Ok(0) => {
5722                        // c:277-285 — EOF (user pressed Ctrl+D): REPLY="",
5723                        // a newline to stderr, then leave the construct.
5724                        with_executor(|exec| {
5725                            exec.set_scalar("REPLY".to_string(), String::new());
5726                        });
5727                        let _ = writeln!(std::io::stderr());
5728                        let _ = std::io::stderr().flush();
5729                        break 'select;
5730                    }
5731                    Ok(_) => {}
5732                    Err(_) => break 'select,
5733                }
5734                let t = line.trim_end_matches(['\n', '\r'][..].as_ref()).to_string();
5735                // c:288-289 — `if (*str) break;`
5736                if !t.is_empty() {
5737                    break t;
5738                }
5739                // c:290 — `more = selectlist(args, more);` on an empty line.
5740                render_menu();
5741            };
5742            // c:291 `setsparam("REPLY", ztrdup(str));` — REPLY is set once the
5743            // inner loop yields a non-empty line. An empty line never reaches
5744            // here: c:290 reprints and re-reads instead.
5745            with_executor(|exec| {
5746                exec.set_scalar("REPLY".to_string(), trimmed.clone());
5747            });
5748
5749            // c:293 `i = atoi(str);` — atoi(3) reads a LEADING integer:
5750            // optional blanks, optional sign, then digits, ignoring whatever
5751            // trails, and yields 0 when there are no digits at all.
5752            // `parse::<usize>()` is strict and rejected `1 2` / `1abc` / `+2`
5753            // / ` 1`, so a reply with anything after the number selected
5754            // NOTHING where zsh selects the leading number's item.
5755            let i: i64 = {
5756                let b = trimmed.as_bytes();
5757                let mut p = 0;
5758                while p < b.len() && (b[p] == b' ' || b[p] == b'\t') {
5759                    p += 1;
5760                }
5761                let neg = p < b.len() && b[p] == b'-';
5762                if p < b.len() && (b[p] == b'-' || b[p] == b'+') {
5763                    p += 1;
5764                }
5765                let mut v: i64 = 0;
5766                while p < b.len() && b[p].is_ascii_digit() {
5767                    v = v.saturating_mul(10).saturating_add((b[p] - b'0') as i64);
5768                    p += 1;
5769                }
5770                if neg {
5771                    -v
5772                } else {
5773                    v
5774                }
5775            };
5776            // c:294-301 — `if (!i) str = "";` else walk i-1 nodes and take
5777            // that word; running off the end leaves "". A NEGATIVE i walks
5778            // until the list is exhausted (`n && i`), which also lands on "".
5779            let chosen = if i <= 0 {
5780                String::new()
5781            } else {
5782                words.get((i - 1) as usize).cloned().unwrap_or_default()
5783            };
5784
5785            with_executor(|exec| {
5786                exec.set_scalar(name.clone(), chosen);
5787            });
5788
5789            // Reset canonical BREAKS/CONTFLAG before running the body
5790            // so a stale value from a sibling construct doesn't leak in.
5791            crate::ported::builtin::BREAKS.store(0, SeqCst);
5792            crate::ported::builtin::CONTFLAG.store(0, SeqCst);
5793
5794            // c:Src/loop.c — `select` increments LOOPS for the body so
5795            // `break` / `continue` inside the body see loops > 0 and
5796            // don't emit `not in while, until, select, or repeat loop`.
5797            // Mirrors execwhile/execrepeat's `LOOPS.fetch_add` pattern.
5798            // The decrement happens after the body call so a body that
5799            // explicitly returns / errors still leaves the counter
5800            // balanced for the next iteration.
5801            crate::ported::builtin::LOOPS.fetch_add(1, SeqCst);
5802
5803            crate::fusevm_disasm::maybe_print_stdout("select:body", &chunk);
5804            let mut body_vm = fusevm::VM::new(chunk.clone());
5805            register_builtins(&mut body_vm);
5806            let _ = body_vm.run();
5807            last_status = body_vm.last_status;
5808
5809            crate::ported::builtin::LOOPS.fetch_sub(1, SeqCst);
5810
5811            // Drain the canonical BREAKS/CONTFLAG counters. Mirrors
5812            // loop.c:529-534's `if (breaks) { breaks--; if (breaks ||
5813            // !contflag) break; contflag = 0; }` drain pattern.
5814            // The legacy `BREAK_SELECT=1` env-var sentinel is still
5815            // honored for backward compat.
5816            let break_legacy = with_executor(|exec| {
5817                let v = exec.scalar("BREAK_SELECT");
5818                exec.unset_scalar("BREAK_SELECT");
5819                v.map(|s| s != "0" && !s.is_empty()).unwrap_or(false)
5820            });
5821            use std::sync::atomic::Ordering::SeqCst;
5822            let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
5823            if breaks > 0 {
5824                let cont = crate::ported::builtin::CONTFLAG.load(SeqCst);
5825                crate::ported::builtin::BREAKS.fetch_sub(1, SeqCst);
5826                if breaks - 1 > 0 || cont == 0 {
5827                    break;
5828                }
5829                crate::ported::builtin::CONTFLAG.store(0, SeqCst);
5830                continue;
5831            }
5832            if break_legacy {
5833                break;
5834            }
5835        }
5836
5837        Value::Status(last_status)
5838    });
5839
5840    // Magic special-parameter assoc lookup. Synthesizes values from
5841    // shell state for zsh's shell-introspection assocs:
5842    //   commands, aliases, galiases, saliases, dis_aliases, dis_galiases,
5843    //   dis_saliases, functions, dis_functions, builtins, dis_builtins,
5844    //   reswords, options, parameters, jobtexts, jobdirs, jobstates,
5845    //   nameddirs, userdirs, modules.
5846    // Returns None if `name` isn't a recognized magic name.
5847
5848    // `${arr[idx]}` — pop name, then idx_str. zsh is 1-based for positive
5849    // indices; we honor that. `@`/`*` return the whole array as Value::Array
5850    // so Op::Exec splice produces N argv slots. For `${foo[key]}` where foo
5851    // is an assoc, the idx is a string key — we check assoc_arrays first
5852    // when the idx isn't `@`/`*` and the name has an assoc binding.
5853    // BUILTIN_ARRAY_INDEX — `${name[idx]}` paramsubst dispatch.
5854    // PURE PASSTHRU: pops the idx + name, hands the canonical
5855    // `${name[idx]}` form to `subst::paramsubst` (C port of
5856    // `Src/subst.c::paramsubst`). All subscript-flag dispatch
5857    // ((I)pat / (R)pat / (i)/(r)/(K)/(k), range slices `[N,M]`,
5858    // negative indices, magic-assoc shape lookup, DQ-join collapse)
5859    // lives inside paramsubst → fetchvalue → getarg in params.rs.
5860    //
5861    // Outer-flag dispatch (`(@)` / `(@k)` / `(v)NAME[(I)pat]` / etc.)
5862    // routes through BUILTIN_BRIDGE_BRACE_ARRAY at the compile path
5863    // (canonical paramsubst flag parser owns dispatch at Src/subst.c:2147+),
5864    // so BUILTIN_ARRAY_INDEX receives clean name+key with no sentinel
5865    // prefixes.
5866    //
5867    // argc contract: `3` means the caller is a `PREFORK_SINGLE` (`ssub`)
5868    // context — a scalar assignment RHS (c:Src/exec.c:2603), a `case` WORD
5869    // (c:Src/loop.c:611-612), a `[[ … ]]` operand (c:Src/cond.c:53) or the
5870    // here-string word (c:Src/exec.c:4711). c:Src/subst.c:4226
5871    //   if (isarr && ssub) { val = sepjoin(aval, NULL, 1); isarr = 0; }
5872    // so a RANGE subscript joins on `IFS[0]` and stops. `2` is every other
5873    // caller, unchanged. The stack shape is the same either way (name, key) —
5874    // the VM hands `argc` to the handler untouched and zshrs never sets
5875    // `Chunk::builtin_argc_is_arity`.
5876    vm.register_builtin(BUILTIN_ARRAY_INDEX, |vm, argc| {
5877        let ssub = argc == 3;
5878        let idx = vm.pop().to_str();
5879        let name = vm.pop().to_str();
5880        let v = array_index_lookup(&name, &idx, ssub);
5881        // c:Src/subst.c:3912 — a RANGE subscript is array-shaped, so
5882        // c:2916-2917 leaves `isarr` non-zero and the join-then-split block
5883        // applies to it exactly as it does to a `[@]`/`[*]` splice or a bare
5884        // array read. The single-element, assoc-key and search-flag shapes all
5885        // come back as scalars (`isarr == 0`) and pass straight through.
5886        //
5887        // The gate: `ssub` joins at c:4226 and stops (c:3913 `force_split =
5888        // !ssub && …`); `qt` clears `spbreak` at c:1707 and joins at c:3033;
5889        // `sep` / `spsep` are the `(j:…:)` / `(s:…:)` separators, which take a
5890        // flagged expansion to `paramsubst` rather than this fast path. So the
5891        // only door in from here is SH_WORD_SPLIT, and the handler had no such
5892        // block at all:
5893        //     setopt shwordsplit; a=('p:q' r); IFS=:; print -rl -- ${a[1,2]}
5894        //       zsh: `p` `q` `r`          zshrs: `p:q` `r`
5895        //     setopt shwordsplit; a=(x '' y); IFS=:; print -rl -- ${a[1,2]}
5896        //       zsh: `x` ``               zshrs: `x`
5897        // The second is `sepsplit_c3932`'s `nulstring` rule (c:Src/subst.c:36),
5898        // which c:186 keeps for an IFS-NON-whitespace separator.
5899        if ssub || !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT) {
5900            return v;
5901        }
5902        let (ifs_opt, in_dq) = with_executor(|exec| (exec.scalar("IFS"), exec.in_dq_context > 0));
5903        if in_dq {
5904            return v; // c:1707 `!qt` — `spbreak` is 0, the block never opens
5905        }
5906        let Value::Array(items) = &v else {
5907            // c:2916 `isarr == 0` — the single-element, assoc-key, scalar-slice
5908            // and search-flag shapes. c:3914's join arm does not apply to them,
5909            // but c:3931's `if (force_split && !isarr)` does, and it is the
5910            // SAME `sepsplit`: `setopt shwordsplit; a=('p:q' r); IFS=:;
5911            // print -rl -- ${a[1]}` is `p` `q` in zsh, and
5912            // `typeset -A h=(k 'p:q'); … ${h[k]}` likewise.
5913            return split_words_c3932(&v.to_str());
5914        };
5915        let elems: Vec<String> = items.iter().map(|x| x.to_str()).collect();
5916        match join_c3914(elems, ifs_opt.as_deref()) {
5917            // c:3914-3927 left `isarr` non-zero, so c:3931's `!isarr` keeps the
5918            // split off too and the ORIGINAL elements are the answer.
5919            JoinC3914::Elements(_) => v,
5920            // c:3932 `sepsplit`, then c:3933-3938's 0/1/N result shape.
5921            JoinC3914::Joined(val) => split_words_c3932(&val),
5922        }
5923    });
5924    // BUILTIN_ARRAY_INDEX_UNBRACED — bare `$name[idx]` (no braces).
5925    // Same subscript dispatch as BUILTIN_ARRAY_INDEX when KSHARRAYS
5926    // is unset, but under KSHARRAYS the UNBRACED form does NOT
5927    // subscript at all:
5928    //   c:Src/subst.c:2800-2802 — fetchvalue's bracket-parse arg is
5929    //     `(unset(KSHARRAYS) || inbrace) ? 1 : -1`; -1 inhibits
5930    //     subscript parsing for the bare form under KSHARRAYS.
5931    //   c:Src/subst.c:2867 — the bracket-consuming loop only runs
5932    //     `while (v || ((inbrace || (unset(KSHARRAYS) && vunset)) &&
5933    //     isbrack(*s)))` — bare + KSHARRAYS leaves `[...]` as literal
5934    //     trailing text.
5935    // The bare `$name` expands (first element for identifier-named
5936    // arrays per c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`),
5937    // the literal `[idx]` (+ any literal suffix) joins the last word,
5938    // and the word undergoes filename generation: `[...]` is a glob
5939    // char class, so unquoted it hits the c:Src/glob.c:1873-1886
5940    // nomatch/nullglob dispatch (reused via exec.expand_glob).
5941    // Operands: [name, idx, suffix, quoted] — `quoted` set when the
5942    // word carries DQ markers (no filename generation in DQ; zsh 5.9:
5943    // `setopt ksharrays; a=(x y z); print "$a[0]"` → `x[0]`).
5944    // Verbatim zsh 5.9 ground truth for the unquoted form:
5945    //   `setopt ksharrays; a=(x y z); print -- $a[0]` →
5946    //   stderr `zsh:1: no matches found: x[0]`, rc=1, empty stdout.
5947    vm.register_builtin(BUILTIN_ARRAY_INDEX_UNBRACED, |vm, _argc| {
5948        let quoted = vm.pop().to_str() == "1";
5949        let suffix = vm.pop().to_str();
5950        let idx = vm.pop().to_str();
5951        let name = vm.pop().to_str();
5952        if !crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
5953            // The bare `$name[idx]` word is not itself a `singsub` caller
5954            // shape the compiler tracks — it reaches this builtin from
5955            // `compile_word_str`, which the `PREFORK_SINGLE` callers do not
5956            // use. Pass `false` until a measured divergence says otherwise.
5957            //
5958            // c:Src/subst.c:1625 — paramsubst runs with `qt` set inside `"…"`;
5959            // array_index_lookup reads it from in_dq_context. Without it the
5960            // lookup ran unquoted and elided EMPTY elements before the join
5961            // below: `a=("" b); "$a[1,2]"` gave `b` for ` b`, and a quoted
5962            // SCANPM_ISVAR_AT selection (see isvar_at) lost its empty words
5963            // (`set -- "" b; "$@[1,2]"` is two words).
5964            let isvar_at = name == "@" || idx == "@";
5965            if quoted {
5966                with_executor(|exec| exec.in_dq_context += 1);
5967            }
5968            let v = array_index_lookup(&name, &idx, false);
5969            if quoted {
5970                with_executor(|exec| exec.in_dq_context -= 1);
5971            }
5972            // c:Src/subst.c:3033-3034 — `if (qt && !getlen && isarr > 0)
5973            // { val = sepjoin(aval, sep, 1); isarr = 0; }`. Inside double
5974            // quotes a subscript that selects an array (a range, or nothing
5975            // at all) is joined into ONE word on IFS[0]; an empty selection
5976            // joins to "" and still counts as a word. `array_index_lookup`
5977            // rebuilds a flat `${name[idx]}` that carries no `qt`, so the
5978            // unbraced form returned the raw array: `"$a[1]"` on an empty
5979            // array vanished and `"$n[1,2]"` split into two words.
5980            //
5981            // `nojoin` (isarr = -1, c:3031-3032) spares a SCANPM_ISVAR_AT
5982            // value, which c:Src/params.c:2251/2272 sets when the name is
5983            // `@` and c:2049-2050 when the subscript is `[@]` (c:Src/subst.c:
5984            // 2917 turns it into isarr = -1). So `"$@[2,3]"` stays one word
5985            // per element, as `"${@[2,3]}"` does; `for 2 3 4 in "$@[7,-1]"`
5986            // in Functions/Misc/regexp-replace depends on it.
5987            let v = match v {
5988                Value::Array(items) if quoted && !isvar_at => {
5989                    let strs: Vec<String> = items.iter().map(|x| x.to_str()).collect();
5990                    Value::str(crate::ported::utils::sepjoin(&strs, None)) // c:3034
5991                }
5992                other => other,
5993            };
5994            if suffix.is_empty() {
5995                return v;
5996            }
5997            // Mirrors the previous compile-shape `ARRAY_INDEX +
5998            // Op::Concat` exactly: Concat stringifies via as_str_cow
5999            // (fusevm value.rs:132-146, arrays join with " ").
6000            if let (true, Value::Array(items)) = (isvar_at, &v) {
6001                let mut items: Vec<Value> = items.iter().cloned().collect();
6002                // An unjoined `"$@[…]"` keeps its elements; the suffix
6003                // glues onto the last one (c:Src/subst.c:4434
6004                // `strcatsub(&y, aptr, aptr, x, xlen, fstr, …)`).
6005                if let Some(last) = items.pop() {
6006                    items.push(Value::str(format!("{}{}", last.to_str(), suffix)));
6007                    return Value::array(items);
6008                }
6009            }
6010            return Value::str(format!("{}{}", v.to_str(), suffix));
6011        }
6012        // KSHARRAYS bare form: no subscript. Bare-`$name` words +
6013        // literal `[idx]suffix` glued onto the last word.
6014        let mut words = ksharrays_bare_words(&name);
6015        let last = format!("{}[{}]{}", words.pop().unwrap_or_default(), idx, suffix);
6016        if quoted {
6017            // DQ context — no filename generation, bracket text stays
6018            // literal.
6019            words.push(last);
6020            return Value::str(words.join(" "));
6021        }
6022        // c:Src/glob.c:1873-1886 — expand_glob handles nullglob /
6023        // NOMATCH (zerr "no matches found" + errflag + the
6024        // current_command_glob_failed cell consumed at the command
6025        // dispatch boundary) / literal passthrough for glob-free text.
6026        let matches = with_executor(|exec| exec.expand_glob(&last));
6027        let mut out: Vec<Value> = words.into_iter().map(Value::str).collect();
6028        out.extend(matches.into_iter().map(Value::str));
6029        if out.len() == 1 {
6030            return out.pop().unwrap();
6031        }
6032        Value::array(out)
6033    });
6034    // BUILTIN_ASSOC_HAS_KEY — `${(k)assoc[name]}` key-existence query.
6035    // Pops [assoc_name, key]; returns key (Str) if present in the
6036    // assoc, empty Str otherwise. Mirrors zsh's `${(k)h[name]}`
6037    // documented semantics in zshparam(1) "Parameter Expansion Flags".
6038    // Distinct from BUILTIN_ARRAY_INDEX (which returns the VALUE) and
6039    // from `${+h[name]}` (which returns "0"/"1"). Bug #145.
6040    vm.register_builtin(BUILTIN_ASSOC_HAS_KEY, |vm, _argc| {
6041        let key = vm.pop().to_str();
6042        let name = vm.pop().to_str();
6043        // c:Src/params.c getindex — the subscript text is substituted
6044        // (singsub) before the lookup; `${(k)H[$k]}` must resolve $k.
6045        // The compiler hands this opcode the RAW subscript text, so a
6046        // dynamic key arrived literally ("$k") and matched nothing.
6047        // singsub is identity for plain keys.
6048        let key = if key.contains('$') || key.contains('`') || key.contains('\u{e18c}') {
6049            crate::ported::subst::singsub(&key)
6050        } else {
6051            key
6052        };
6053        // c:Src/params.c:3131 gethkparam covers ordinary PM_HASHED
6054        // paramtab entries only. Special/magic hashes (`parameters`,
6055        // `options`, … — the zsh/parameter module's partab-backed
6056        // params, Src/Modules/parameter.c) aren't in that storage, so
6057        // a None here doesn't mean "no such assoc". Route those
6058        // through paramsubst, whose assoc materialization handles the
6059        // magic hashes and whose getarg port (c:Src/params.c:1591 +
6060        // Src/subst.c:2922) returns the KEY for `(k)` on a plain
6061        // subscript. zsh 5.9: `${(k)parameters[PATH]}` → "PATH".
6062        // c:Src/params.c:1602-1612 — on a hash subscript C dispatches
6063        // `ht->getnode(ht, s)`; it never enumerates. For the magic hashes that
6064        // distinction is observable: `getfunction_source` (c:Src/Modules/
6065        // parameter.c:549-566) answers for names its scan never lists, `mapfile`
6066        // answers for any readable file, and the job trio calls `getjob(name,
6067        // NULL)` whose `job not found` diagnostic (Src/jobs.c:2150-2151) must be
6068        // emitted by the read. `gethkparam` answers Some(<enumerated keys>) for
6069        // these names, which shortcut the dispatch entirely — `${(k)jobstates[x]}`
6070        // stayed silent and `${(k)functions_source[x]}` returned "" where zsh
6071        // returns the key. Send PARTAB names to paramsubst, which owns the
6072        // getnode path (c:Src/subst.c:2923-2925 — key when set, "" when unset).
6073        // Keys carrying `]`/`}` can't survive the flat rebuild (see
6074        // array_index_lookup), so those keep the enumeration answer.
6075        let magic_getnode = !key.contains(']')
6076            && !key.contains('}')
6077            && crate::ported::modules::parameter::PARTAB
6078                .iter()
6079                .any(|e_| e_.name == name);
6080        match crate::ported::params::gethkparam(&name) {
6081            Some(keys) if !magic_getnode => {
6082                if keys.iter().any(|k| k == &key) {
6083                    Value::str(key)
6084                } else {
6085                    Value::str("")
6086                }
6087            }
6088            // c:2048 — `key` is already expanded, so an `@`/`*` here is a key,
6089            // not the splat; see `expanded_subscript_text`.
6090            _ => paramsubst_to_value(&format!(
6091                "${{(k){}[{}]}}",
6092                name,
6093                expanded_subscript_text(&key)
6094            )),
6095        }
6096    });
6097    vm.register_builtin(BUILTIN_BRIDGE_BRACE_ARRAY, |vm, argc| {
6098        // argc 2 = the compiler flagged this expansion as the VALUE of a
6099        // scalar assignment (`x=…` / `local x=…` / `typeset x=…`), which C
6100        // preforks with PREFORK_SINGLE|PREFORK_ASSIGN (c:Src/exec.c:2546).
6101        // PREFORK_SINGLE is paramsubst's `ssub` (c:Src/subst.c:1759) and gates
6102        // off c:3913's `force_split` — without it `r=${(s.c.)v[2,-1]}` split
6103        // and re-joined on IFS (`b def`) where zsh keeps `bcdef`. Same
6104        // operand contract as BUILTIN_PARAM_FLAG's argc-3 ssub. argc 1 =
6105        // ordinary word, no ssub (kept for callers that predate the operand).
6106        let ssub = if argc >= 2 {
6107            vm.pop().to_int() != 0
6108        } else {
6109            false
6110        };
6111        // Inner body of `${(...)...}` (already stripped of `${`/`}` by
6112        // the caller). The compiler optionally prefixes Qstring
6113        // (\u{e18c}) to signal "expanded in DQ context" — strip it
6114        // here and bump in_dq_context for the paramsubst call so the
6115        // SUB_ZIP and other qt-aware paths fire.
6116        let body = vm.pop().to_str();
6117        let (dq, inner) = if let Some(rest) = body.strip_prefix('\u{e18c}') {
6118            (true, rest.to_string())
6119        } else {
6120            (false, body)
6121        };
6122        if dq {
6123            with_executor(|exec| exec.in_dq_context += 1);
6124        }
6125        let pf_flags = if ssub {
6126            crate::ported::zsh_h::PREFORK_SINGLE // c:1759
6127        } else {
6128            0
6129        };
6130        let v = paramsubst_to_value_status(vm, &format!("${{{}}}", inner), pf_flags);
6131        if dq {
6132            with_executor(|exec| exec.in_dq_context -= 1);
6133        }
6134        v
6135    });
6136
6137    // BUILTIN_PARAM_FLAG — `${(flags)name}` paramsubst dispatch.
6138    // PURE PASSTHRU: pops sentinel-tagged flags + name, hands the
6139    // canonical `${(flags)name}` form to `subst::paramsubst` (C port
6140    // of `Src/subst.c::paramsubst`). The bridge does no flag
6141    // walking, no DQ-context branching, no array/scalar shape
6142    // selection — all of that lives inside paramsubst. Compile-time
6143    // context (DQ / scalar-assign-RHS) flows through executor cells
6144    // (in_dq_context, in_scalar_assign) bumped by BUILTIN_EXPAND_TEXT.
6145    vm.register_builtin(BUILTIN_PARAM_FLAG, |vm, argc| {
6146        // argc 3 = the compiler flagged this expansion as the VALUE of a
6147        // scalar assignment (`x=…` / `local x=…`), which C preforks with
6148        // PREFORK_SINGLE|PREFORK_ASSIGN (c:Src/exec.c:2603 / :4239-4241).
6149        // PREFORK_SINGLE is paramsubst's `ssub` (c:Src/subst.c:1761) and
6150        // gates off c:3913's `force_split`, so `(s::)` / `(f)` / `(0)` do
6151        // not split there. argc 2 = ordinary word, no ssub.
6152        let ssub = if argc >= 3 {
6153            vm.pop().to_int() != 0
6154        } else {
6155            false
6156        };
6157        let flags = vm.pop().to_str();
6158        let name = vm.pop().to_str();
6159        let body = format!("${{({}){}}}", flags, name);
6160        let pf_flags = if ssub {
6161            crate::ported::zsh_h::PREFORK_SINGLE
6162        } else {
6163            0
6164        };
6165        paramsubst_to_value_status(vm, &body, pf_flags)
6166    });
6167
6168    // `foo[key]=val` — single-key set on an assoc array. Stack: [name, key, value].
6169    // PURE PASSTHRU: assignsparam with `name[key]` form (C port of
6170    // `Src/params.c::assignsparam` subscript path at c:3210-3231)
6171    // already does the indexed-array vs assoc decision, PM_HASHED
6172    // auto-vivification, numeric-subscript bounds handling, and
6173    // PM_READONLY rejection.
6174    /// Assign `val` to one element of the PM_HASHED parameter `name`.
6175    ///
6176    /// This is the tail of C's `assignsparam` for a subscripted target:
6177    /// c:Src/params.c:3251 `getvalue(&vbuf, &t, 1)` (→ `fetchvalue` →
6178    /// `getindex`) followed by c:3343 `assignstrvalue(v, val, flags)`.
6179    ///
6180    /// C hands `getindex` the FLAT `"name[subscript]"` text, and that is
6181    /// safe there because its subscript is still the SOURCE spelling: the
6182    /// bracket walk at c:2008 `parse_subscript` runs BEFORE the
6183    /// `parsestr`/`singsub` round at c:1585-1592, so a `]` that arrives by
6184    /// expansion can never terminate it. zshrs expands a subscript before
6185    /// this builtin runs, so re-flattening to `name[key]` and re-splitting
6186    /// corrupts any key containing `]` (`k='x]y'; h[$k]=5` stored `x`). The
6187    /// two halves therefore stay separate the whole way down:
6188    ///
6189    ///   * `sub` is the EXPANDED subscript — the key text.
6190    ///   * `sub_src` is the SOURCE subscript, used for ONE decision, the
6191    ///     one C makes at c:1410: `if (v->pm && (*s == '(' || *s == Inpar))`
6192    ///     — is there a flag block? Flags can only be literal (they are read
6193    ///     at c:1409, before any expansion), so `x='(r)v'; h[$x]=Z` has no
6194    ///     flag block and stores the literal key `(r)v`, while `h[(r)$x]=Z`
6195    ///     does have one and is a search.
6196    ///
6197    /// With no flag block there is nothing for `getindex` to resolve beyond
6198    /// the exact-key rebind at c:1596-1616, so the key goes straight to the
6199    /// element store and never meets a parser. With one, `getindex` runs on
6200    /// the EXPANDED text: its flag block is byte-identical to the source's
6201    /// (flags are literal) and its pattern is already substituted, which is
6202    /// what c:1585-1592 would have produced anyway.
6203    fn assign_hash_element(name: &str, sub: &str, sub_src: &str, val: &str) -> i32 {
6204        use crate::ported::zsh_h::{Inpar, PM_HASHED, PM_READONLY, SCANPM_ARRONLY};
6205        let pm = crate::ported::params::paramtab()
6206            .read()
6207            .ok()
6208            .and_then(|t| t.get(name).cloned());
6209        // c:3216-3221 — `if (v->pm->node.flags & PM_READONLY)`.
6210        if pm
6211            .as_ref()
6212            .is_some_and(|p| (p.node.flags as u32 & PM_READONLY) != 0)
6213        {
6214            crate::ported::utils::zerr(&format!("read-only variable: {}", name)); // c:3217
6215            return 1; // c:3221
6216        }
6217        // c:3171 — `v->pm->node.flags &= ~PM_DEFAULTED;` on the parameter
6218        // being subscripted: an element store makes a TYPESET_TO_UNSET
6219        // declaration (`typeset -A h; h[k]=v`) a set parameter, so
6220        // `typeset -p h` then prints its value.
6221        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
6222            if let Some(p) = tab.get_mut(name) {
6223                p.node.flags &= !(crate::ported::zsh_h::PM_DEFAULTED as i32); // c:3171
6224            }
6225        }
6226        // c:1410 — `if (v->pm && (*s == '(' || *s == Inpar))`, read off the
6227        // SOURCE spelling.
6228        let has_flags = sub_src.starts_with('(') || sub_src.starts_with(Inpar);
6229        if has_flags {
6230            let mut v = crate::ported::zsh_h::value {
6231                pm,
6232                arr: Vec::new(),
6233                // c:2274-2280 — fetchvalue promotes a PM_ARRAY/PM_HASHED
6234                // value with no caller flags to SCANPM_ARRONLY; assignsparam
6235                // arrives through `getvalue`, i.e. flags 0.
6236                scanflags: SCANPM_ARRONLY as i32,
6237                valflags: 0,
6238                start: 0,
6239                end: -1, // c:2279
6240            };
6241            let bracketed = format!("[{}]", sub); // c:2281 `*s == '['`
6242            let mut sp: &str = &bracketed;
6243            if crate::ported::params::getindex(&mut sp, &mut v, 0) != 0 {
6244                // c:2020-2022 — `zerr("invalid subscript")` already reported.
6245                return 1;
6246            }
6247            let elem_is_hash = v
6248                .pm
6249                .as_ref()
6250                .is_some_and(|p| crate::ported::zsh_h::PM_TYPE(p.node.flags as u32) == PM_HASHED);
6251            if elem_is_hash {
6252                // A search subscript: the c:1596 exact-key rebind did not
6253                // happen, so the value still refers to the whole association
6254                // and c:3343 `assignstrvalue` reports it — either "attempt to
6255                // set slice of associative array" (c:2701-2706, the scanflags
6256                // survived the c:2179 clear) or "attempt to set associative
6257                // array to scalar" (c:2831-2839, they did not and no member
6258                // was found).
6259                let pre = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
6260                crate::ported::params::assignstrvalue(
6261                    Some(&mut v),
6262                    Some(val.to_string()),
6263                    crate::ported::zsh_h::ASSPM_WARN,
6264                ); // c:3343
6265                let post = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
6266                return if post != pre { 1 } else { 0 };
6267            }
6268            // c:1596-1616 — a non-search flag group (`(e)`, `(w)`, `(n:N:)`,
6269            // `(p)`, or an unrecognised one, c:1498 `flagerr`): `v->pm` is now
6270            // the ELEMENT and its name is the subscript with the group already
6271            // consumed. That name IS the key.
6272            let key =
6273                v.pm.as_ref()
6274                    .map_or(sub, |p| p.node.nam.as_str())
6275                    .to_string();
6276            return store_hash_element(name, &key, val);
6277        }
6278        // c:1596-1616 with no flag group at all — the subscript is the key
6279        // verbatim. Nothing to parse, so an expanded `]` stays intact.
6280        store_hash_element(name, sub, val)
6281    }
6282
6283    /// c:Src/params.c:2841 — `foundparam->gsu.s->setfn(foundparam, val)`,
6284    /// the write to one member of an association. This port keeps assoc
6285    /// members as plain strings in `paramtab_hashed_storage` rather than as
6286    /// Params carrying their own `strsetfn` (the same substitution
6287    /// `arrhashsetfn` makes at c:4113), so the member write is a map insert.
6288    fn store_hash_element(name: &str, key: &str, val: &str) -> i32 {
6289        if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
6290            store
6291                .entry(name.to_string())
6292                .or_default()
6293                .insert(key.to_string(), val.to_string()); // c:2841
6294        }
6295        0
6296    }
6297
6298    vm.register_builtin(BUILTIN_SET_ASSOC, |vm, _argc| {
6299        // `${~spec}` carrier: an assignment statement is a word-
6300        // pipeline boundary too — restore the user's GLOB_SUBST
6301        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
6302        // ${options[globsubst]}` must read the user value).
6303        consume_tilde_globsubst_carrier();
6304        // argc 4 = compile flagged the subscript as DYNAMIC (`H[$k]`):
6305        // an EXPANDED-empty key is then a legal assoc key (C's
6306        // assignsparam isident gate sees the raw `$k` text and the
6307        // empty key stores at getindex time — zinit's
6308        // ZINIT_SICE[$1…$2] relies on it). argc 3 = source-literal
6309        // key; `H[]` stays the "not an identifier" error.
6310        // Stack shapes (compile_zsh::compile_assign):
6311        //   argc 4 = [name, key, value, key_src]            — literal key
6312        //   argc 5 = [name, key, value, key_src, dynamic]   — expanded key
6313        // `key_src` is the SOURCE spelling of the subscript, kept beside
6314        // the expanded one so nothing downstream has to re-flatten
6315        // `name[key]` and re-split it (c:Src/params.c:2008 parses the
6316        // subscript BEFORE expansion; see `assign_hash_element`).
6317        //   argc 6 = [name, key, value, key_src, dynamic, append] — `+=`;
6318        //            value is the pre-concatenated read-modify-write.
6319        let append = if _argc >= 6 {
6320            vm.pop().to_int() != 0
6321        } else {
6322            false
6323        };
6324        let key_is_dynamic = if _argc >= 5 {
6325            vm.pop().to_int() != 0
6326        } else {
6327            false
6328        };
6329        let key_src = if _argc >= 4 {
6330            Some(vm.pop().to_str())
6331        } else {
6332            None
6333        };
6334        let value = vm.pop().to_str();
6335        let key = vm.pop().to_str();
6336        let name = vm.pop().to_str();
6337        // c:Src/exec.c:4475-4478 (54941) — `A[k]=v cmd`: save_params cuts
6338        // the assignment text at the subscript (`itype_end(s, INAMESPC, 0)`
6339        // stopping at `[`) and snapshots the whole base parameter `A`, so
6340        // the element assignment is undone after a builtin or shell
6341        // function exactly as after a forked external (whose `addvars`
6342        // runs in the child and never touches the parent's `A`).
6343        with_executor(|exec| {
6344            if exec
6345                .inline_env_stack
6346                .last()
6347                .is_some_and(|frame| frame.recording)
6348            {
6349                save_inline_prefix_param(exec, &name);
6350            }
6351        });
6352        // c:Src/params.c:3250-3266 — assignsparam ASSPM_AUGMENT on a
6353        // subscripted integer/float:
6354        //     case PM_INTEGER: case PM_EFLOAT: case PM_FFLOAT:
6355        //         zsfree(val); unqueue_signals();
6356        //         zerr("attempt to add to slice of a numeric variable");
6357        //         return NULL;
6358        if append {
6359            let numeric = crate::ported::params::paramtab()
6360                .read()
6361                .ok()
6362                .and_then(|t| t.get(&name).map(|pm| crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)))
6363                .is_some_and(|t| {
6364                    t == crate::ported::zsh_h::PM_INTEGER
6365                        || t == crate::ported::zsh_h::PM_EFLOAT
6366                        || t == crate::ported::zsh_h::PM_FFLOAT
6367                });
6368            if numeric {
6369                crate::ported::utils::zerr("attempt to add to slice of a numeric variable"); // c:3264
6370                return Value::Status(1);
6371            }
6372        }
6373        let key_src = key_src.unwrap_or_else(|| key.clone());
6374        // c:Src/params.c:3203-3207 — `if (!isident(s)) { zerr("not an
6375        // identifier: %s", s); errflag |= ERRFLAG_ERROR; return NULL; }`.
6376        // Every subscripted assignment passes through that gate, and isident
6377        // rejects an empty subscript at c:1334 `if (!(ss =
6378        // parse_subscript(++ss, 1, ']'))) return 0;` — the LHS text is
6379        // untokenized by then, so the `]` IS parse_subscript's literal endchar
6380        // and c:Src/lex.c:1748 returns NULL. `m[]=z` / `A[]=z` / `s[]=z` are
6381        // therefore all `not an identifier: NAME[]`, never a store.
6382        // Only the SOURCE-LITERAL empty subscript is affected: with a dynamic
6383        // key (`H[$k]=v`) C's isident sees the unexpanded `$k` text, passes,
6384        // and getindex stores the expanded — possibly empty — key.
6385        // The gate lives here because the PM_HASHED fast path and the numeric
6386        // pre-resolve below both reach the store without calling assignsparam
6387        // (the empty key resolved to "" for a hash and to math-0 for an
6388        // indexed array, so `A[]=z` reported "assignment to invalid subscript
6389        // range" instead). Route through assignsparam so the diagnostic and
6390        // the errflag are the canonical ones.
6391        if !key_is_dynamic && key.is_empty() {
6392            crate::ported::params::assignsparam(
6393                &format!("{}[]", name), // c:3203 — the LHS spelling zsh reports
6394                &value,
6395                crate::ported::zsh_h::ASSPM_WARN,
6396            );
6397            return Value::Status(1);
6398        }
6399        // Bash sparse-array tracking for `a[i]=v` (scalar single-index). A set
6400        // that pads the dense Vec past its old end leaves old_len..i as holes;
6401        // on an undefined array, `a[5]=q` leaves only index 5 (count 1). Only
6402        // for INDEXED arrays (assoc keys are strings), bash mode, numeric key.
6403        // Captured before the assign; applied after (on the array path).
6404        let sparse_track: Option<(String, usize, usize)> =
6405            if crate::dash_mode::sparse_arrays() && !key.contains(',') {
6406                key.trim().parse::<usize>().ok().and_then(|i| {
6407                    with_executor(|exec| {
6408                        if !exec.has_assoc(&name) {
6409                            let old_len = exec.array(&name).map(|a| a.len()).unwrap_or(0);
6410                            Some((name.clone(), old_len, i))
6411                        } else {
6412                            None
6413                        }
6414                    })
6415                })
6416            } else {
6417                None
6418            };
6419        if key_is_dynamic && key.is_empty() {
6420            with_executor(|exec| {
6421                let _ = exec;
6422            });
6423            // Mirror assignsparam's PM_HASHED tail directly (the
6424            // textual `name[]` reconstruction can't pass isident).
6425            if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
6426                let entry = store.entry(name.clone()).or_default();
6427                let newval = if let Some(old) = entry.get("") {
6428                    // `+=` arrives pre-concatenated by the compile
6429                    // read-modify-write; plain `=` overwrites.
6430                    let _ = old;
6431                    value.clone()
6432                } else {
6433                    value.clone()
6434                };
6435                entry.insert(String::new(), newval);
6436            }
6437            return Value::Status(0);
6438        }
6439        with_executor(|exec| {
6440            #[cfg(feature = "recorder")]
6441            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
6442                let ctx = exec.recorder_ctx();
6443                let attrs = exec.recorder_attrs_for(&name);
6444                crate::recorder::emit_assoc_assign(
6445                    &name,
6446                    vec![(key.clone(), value.clone())],
6447                    attrs,
6448                    true,
6449                    ctx,
6450                );
6451            }
6452            let _ = exec;
6453        });
6454        // Build `name[key]=value` shape for assignsparam's subscript
6455        // dispatch. Arith-evaluate numeric subscripts on an existing
6456        // indexed array (`a[i+1]=v` form) before handing off — the
6457        // canonical port currently only handles literal int / string
6458        // keys, so pre-resolve here.
6459        let resolved_key = with_executor(|exec| {
6460            // Existence probes only — use the non-cloning `has_*`
6461            // checks. `exec.assoc()` / `exec.array()` return owned
6462            // clones of the whole map/vector, so probing `.is_some()`
6463            // here copied the entire associative array on every
6464            // `h[k]=v` (O(n) per store → O(n²) for a fill loop). The
6465            // profiler flagged `ShellExecutor::assoc → IndexMap::clone`
6466            // as the dominant cost.
6467            let is_indexed = exec.has_array(&name);
6468            let is_assoc = exec.has_assoc(&name);
6469            let is_scalar = !is_indexed && !is_assoc && exec.has_scalar(&name);
6470            // c:Src/params.c::getindex — `(i)pat` / `(I)pat` / `(R)pat`
6471            // / `(r)pat` subscript flags on an indexed array LHS resolve
6472            // to a numeric index (first / last match of pat). On a
6473            // SCALAR LHS the same flags resolve to a CHAR position
6474            // (1-based first/last match of pat in the scalar string)
6475            // for the c:2748+ char-splice assignment. zshrs's
6476            // read-form `${a[(i)pat]}` already implements both shapes;
6477            // the LHS assignment path silently stored the literal
6478            // "(i)pat" as an assoc key (for scalar: auto-vivified to
6479            // PM_HASHED via the assignsparam unknown-subscript
6480            // fallback). Bug #293 (array) / scalar sibling.
6481            //
6482            // Detect the `(flags)pat` shape and resolve to a numeric
6483            // index before assignsparam.
6484            if is_indexed || is_scalar {
6485                if let Some(rest) = key.strip_prefix('(') {
6486                    if let Some(close) = rest.find(')') {
6487                        let flags = &rest[..close];
6488                        let pat = &rest[close + 1..];
6489                        if !flags.is_empty()
6490                            && flags
6491                                .chars()
6492                                .all(|c| matches!(c, 'I' | 'R' | 'i' | 'r' | 'n' | 'e'))
6493                        {
6494                            // Resolve via the array's contents.
6495                            if let Some(arr) = exec.array(&name) {
6496                                let return_index = true; // LHS write — index needed
6497                                let down = flags.contains('I') || flags.contains('R');
6498                                let exact = flags.contains('e');
6499                                let iter: Box<dyn Iterator<Item = (usize, &String)>> = if down {
6500                                    Box::new(arr.iter().enumerate().rev())
6501                                } else {
6502                                    Box::new(arr.iter().enumerate())
6503                                };
6504                                let mut found: Option<usize> = None;
6505                                for (idx, elem) in iter {
6506                                    let matched = if exact {
6507                                        elem == pat
6508                                    } else {
6509                                        crate::ported::pattern::patcompile(
6510                                            &{
6511                                                let mut __pat_tok = (pat).to_string();
6512                                                crate::ported::glob::tokenize(&mut __pat_tok);
6513                                                __pat_tok
6514                                            },
6515                                            crate::ported::zsh_h::PAT_HEAPDUP as i32,
6516                                            None,
6517                                        )
6518                                        .map_or(false, |p| crate::ported::pattern::pattry(&p, elem))
6519                                    };
6520                                    if matched {
6521                                        found = Some(idx);
6522                                        break;
6523                                    }
6524                                }
6525                                let _ = return_index;
6526                                // (i)/(r) return 1-based index of match,
6527                                // arr.len()+1 (or 1 for I/R) on miss
6528                                // per zsh docs. We mirror the read-form
6529                                // semantics from subst.rs.
6530                                let idx_1based = match found {
6531                                    Some(i) => (i + 1) as i64,
6532                                    None => (arr.len() + 1) as i64,
6533                                };
6534                                return idx_1based.to_string();
6535                            }
6536                            // Scalar LHS — resolve to a CHAR position
6537                            // (1-based first/last match of pat in the
6538                            // string). c:Src/params.c:1411-1418 — the
6539                            // scalar path returns the char index from
6540                            // sliding-window pattern match against
6541                            // pm.u_str. Same algorithm as the read-form
6542                            // at subst.rs:5283-5306. Bug (scalar
6543                            // sibling of #293): `a=hello; a[(I)l]=X`
6544                            // previously auto-vivified `a` into
6545                            // PM_HASHED with key "(I)l" instead of
6546                            // splicing X at the last 'l' position
6547                            // (yielding "helXo").
6548                            if is_scalar {
6549                                let s = exec.scalar(&name).unwrap_or_default();
6550                                let s_chars: Vec<char> = s.chars().collect();
6551                                let n = s_chars.len();
6552                                let want_last = flags.contains('I') || flags.contains('R');
6553                                let exact = flags.contains('e');
6554                                let mut found: Option<usize> = None;
6555                                'outer: for start in 0..=n {
6556                                    let lengths: Box<dyn Iterator<Item = usize>> = if want_last {
6557                                        Box::new((1..=(n - start)).rev())
6558                                    } else {
6559                                        Box::new(1..=(n - start))
6560                                    };
6561                                    for len in lengths {
6562                                        let cand: String =
6563                                            s_chars[start..start + len].iter().collect();
6564                                        let matched = if exact {
6565                                            cand == pat
6566                                        } else {
6567                                            crate::ported::pattern::patcompile(
6568                                                &{
6569                                                    let mut __pat_tok = (pat).to_string();
6570                                                    crate::ported::glob::tokenize(&mut __pat_tok);
6571                                                    __pat_tok
6572                                                },
6573                                                crate::ported::zsh_h::PAT_HEAPDUP as i32,
6574                                                None,
6575                                            )
6576                                            .map_or(false, |p| {
6577                                                crate::ported::pattern::pattry(&p, &cand)
6578                                            })
6579                                        };
6580                                        if matched {
6581                                            found = Some(start);
6582                                            if !want_last {
6583                                                break 'outer;
6584                                            }
6585                                            break;
6586                                        }
6587                                    }
6588                                }
6589                                // (I)/(R): scan again to find LAST.
6590                                if want_last {
6591                                    let mut last_found: Option<usize> = found;
6592                                    for start in (0..=n).rev() {
6593                                        for len in 1..=(n - start) {
6594                                            let cand: String =
6595                                                s_chars[start..start + len].iter().collect();
6596                                            let matched = if exact {
6597                                                cand == pat
6598                                            } else {
6599                                                crate::ported::pattern::patcompile(
6600                                                    &{
6601                                                        let mut __pat_tok = (pat).to_string();
6602                                                        crate::ported::glob::tokenize(
6603                                                            &mut __pat_tok,
6604                                                        );
6605                                                        __pat_tok
6606                                                    },
6607                                                    crate::ported::zsh_h::PAT_HEAPDUP as i32,
6608                                                    None,
6609                                                )
6610                                                .map_or(false, |p| {
6611                                                    crate::ported::pattern::pattry(&p, &cand)
6612                                                })
6613                                            };
6614                                            if matched {
6615                                                last_found = Some(start);
6616                                                break;
6617                                            }
6618                                        }
6619                                        if last_found.is_some() && last_found.unwrap() >= start {
6620                                            break;
6621                                        }
6622                                    }
6623                                    found = last_found;
6624                                }
6625                                let idx_1based = match found {
6626                                    Some(i) => (i + 1) as i64,
6627                                    // (i) miss → len+1 (one past end).
6628                                    None => (n + 1) as i64,
6629                                };
6630                                return idx_1based.to_string();
6631                            }
6632                        }
6633                    }
6634                }
6635            }
6636            if is_indexed && key.trim().parse::<i64>().is_err() {
6637                crate::ported::math::mathevali(&crate::ported::subst::singsub(&key))
6638                    .map(|n| n.to_string())
6639                    .unwrap_or(key.clone())
6640            } else {
6641                key.clone()
6642            }
6643        });
6644        // c:Src/params.c getindex — C parses the subscript from the
6645        // TOKENIZED source word, so a `]`/`}` that arrived via `$key`
6646        // expansion is plain data and can never terminate the
6647        // subscript. The textual `name[key]` rebuild below re-parses
6648        // the FLAT string, where an expanded `]` splits the key at the
6649        // first bracket (`c[$k]=5` with k='x]y' stored key "x" and
6650        // spilled junk — zpwr expandstats died on the spill in a later
6651        // math expr). For a PM_HASHED target the compile-time split
6652        // already isolated the exact key: store it directly via the
6653        // canonical hashed storage (same mechanism as the
6654        // dynamic-empty-key arm above / assignsparam's PM_HASHED
6655        // tail), with the readonly guard assignsparam would apply.
6656        let target_flags = with_executor(|exec| exec.param_flags(&name));
6657        // PM_SPECIAL exclusion: the zsh/parameter magic assocs
6658        // (functions / aliases / galiases / saliases / options / …)
6659        // have per-key setfns with SIDE EFFECTS — `functions[x]=body`
6660        // must parse the body into shfunctab (Src/Modules/
6661        // parameter.c:296 setfunction), `aliases[x]=v` must write
6662        // aliastab. The direct hashed-storage store below silently
6663        // swallowed those: zinit's tmp-subst wrappers
6664        // (`functions[autoload]=':zinit-tmp-subst-autoload "$@";'`)
6665        // never became real functions, so every
6666        // `.zinit-tmp-subst-off` spammed `unfunction: no such hash
6667        // table element: autoload/compdef/bindkey/…`. Route specials
6668        // through assignsparam's canonical per-name arms instead.
6669        if (target_flags as u32 & crate::ported::zsh_h::PM_HASHED) != 0
6670            && (target_flags as u32 & crate::ported::zsh_h::PM_SPECIAL) == 0
6671        {
6672            // c:3251 + c:3343 — run the real chain (getindex →
6673            // assignstrvalue) instead of storing the subscript verbatim.
6674            // Storing it verbatim is what made `h[(r)v]=Z` invent a key
6675            // named `(r)v` where zsh reports a slice assignment, and it
6676            // also skipped the c:2701 / c:2831 guards entirely.
6677            let _ = &resolved_key;
6678            return Value::Status(assign_hash_element(&name, &key, &key_src, &value));
6679        }
6680        let subscripted = format!("{}[{}]", name, resolved_key);
6681        crate::ported::params::assignsparam(&subscripted, &value, crate::ported::zsh_h::ASSPM_WARN);
6682        if let Some((nm, old_len, i)) = sparse_track {
6683            crate::bash_arrays::note_subscript_set(&nm, old_len, i);
6684        }
6685        Value::Status(0)
6686    });
6687
6688    // Brace expansion. Routes through executor.xpandbraces (already
6689    // implemented for the pre-fusevm executor). Returns Value::Array.
6690    // BUILTIN_ARRAY_DROP_EMPTY — filter out empty Value::Str entries
6691    // from a Value::Array on the stack. Used by `for x in $@` /
6692    // `for x in $*` unquoted forms which drop empty positionals
6693    // (POSIX-like) but do NOT IFS-split each element internally
6694    // (zsh-specific — scalar word splitting is off by default).
6695    // Distinct from BUILTIN_WORD_SPLIT which routes through
6696    // multsub PREFORK_SPLIT (full IFS-split). Bug #166.
6697    vm.register_builtin(BUILTIN_ARRAY_DROP_EMPTY, |vm, argc| {
6698        let v = vm.pop();
6699        strip_nulstring_markers(drop_word_empties(v, argc))
6700    });
6701    // The c:183-186 decision of BUILTIN_ARRAY_DROP_EMPTY, before c:170's
6702    // remnulargs (strip_nulstring_markers) runs on the survivors.
6703    fn drop_word_empties(v: Value, argc: u8) -> Value {
6704        // End of word: whatever `ARRAY_EMPTIES_ELIDABLE` was carrying is
6705        // spent here even though this builtin drops unconditionally, so a
6706        // following word starts from a clean bit.
6707        let _ = take_array_empties_elidable();
6708        // c:Src/subst.c:4387 / :4404 / :4426 — a QUOTED splat of two or more
6709        // elements stored `nulstring` in its empty nodes, which c:183 finds
6710        // NON-empty, so c:186's `uremnode` skips them and `remnulargs` (c:170)
6711        // restores `""`. `a=(x '' y); PRE"${a[@]}"POST` is three words in zsh.
6712        // See `QUOTED_SPLICE_KEEPS_EMPTIES` for the c:4261 element-count
6713        // cut-off that makes this a run-time decision.
6714        if take_quoted_splice_keeps_empties() {
6715            return v;
6716        }
6717        match v {
6718            Value::Array(items) => {
6719                if argc & (WORD_DROP_KEEPS_FIRST | WORD_DROP_KEEPS_LAST) != 0 && plan9_active() {
6720                    // c:4341 — under plan9 the quoted-empty affix is glued to
6721                    // every element, so no node of the word is empty.
6722                    return Value::Array(items);
6723                }
6724                let last = items.len().saturating_sub(1);
6725                let filtered: Vec<Value> = items
6726                    .iter()
6727                    .enumerate()
6728                    .filter(|(i, x)| word_drop_keeps(argc, *i, last) || !x.to_str().is_empty())
6729                    .map(|(_, x)| x.clone())
6730                    .collect();
6731                Value::array(filtered)
6732            }
6733            Value::Str(s) if s.is_empty() => Value::array(Vec::new()),
6734            other => other,
6735        }
6736    }
6737
6738    // Opens (argc 0) / closes (argc 1) a word whose literals the compiler
6739    // attaches itself — see `BUILTIN_WORD_DEFER_EMPTIES`. Pushes nothing
6740    // meaningful; the emitter pops the status.
6741    vm.register_builtin(BUILTIN_WORD_DEFER_EMPTIES, |_vm, argc| {
6742        if argc == 0 {
6743            open_deferred_affix_word();
6744        } else {
6745            close_deferred_affix_word();
6746        }
6747        Value::Int(0)
6748    });
6749
6750    // c:Src/subst.c:180-187 — prefork's final loop deletes a list node whose
6751    // text is EMPTY: `} else if (!(flags & PREFORK_SINGLE) &&
6752    // !(*ret_flags & PREFORK_KEY_VALUE) && !keep) uremnode(list, node);`.
6753    // The compiler emits it once the word's segments have been folded, which
6754    // is the ORDER c:183-186 has: the node text tested there is the finished
6755    // word, prefix and suffix already attached by the array emit block
6756    // (c:4245 onward — plan9 cross-product at c:4316-4365, the "simply join
6757    // the first and last values" splice at c:4366-4437). So an element that
6758    // is empty at read time is not yet an empty word: under plan9 a prefixed
6759    // word cross-products (`setopt rcexpandparam; a=('' x);
6760    // print -rl -- p$a` → `p`, `px`), and under the splice an affix lands on
6761    // the boundary element (`a=(x y ""); print -rl -- ${a}POST` → `x`, `y`,
6762    // `POST`).
6763    //
6764    // Applies only to the empties of a genuine ARRAY reference. A split field
6765    // — SH_WORD_SPLIT (c:3919 `sepsplit`) or command substitution — is
6766    // `nulstring` in C (c:36), non-empty at c:183, so it SURVIVES:
6767    // `setopt shwordsplit; IFS=:; s='a::b'; print -rl -- P${s}S` is `Pa`,
6768    // ``, `bS`. zshrs renders both as "", so the discriminator is the
6769    // `ARRAY_EMPTIES_ELIDABLE` bit `get_var_impl` sets on the array read.
6770    vm.register_builtin(BUILTIN_WORD_ELIDE_EMPTY, |vm, argc| {
6771        let v = vm.pop();
6772        strip_nulstring_markers(elide_word_empties(v, argc))
6773    });
6774    // The c:183-186 decision of BUILTIN_WORD_ELIDE_EMPTY, before c:170's
6775    // remnulargs (strip_nulstring_markers) runs on the survivors.
6776    fn elide_word_empties(v: Value, argc: u8) -> Value {
6777        // `PARAMSUBST_EMPTIES_DEFERRED` is the same bit reported from the
6778        // other direction: `paramsubst` skipped c:186 for a genuine array
6779        // reference because this word opened with
6780        // `BUILTIN_WORD_DEFER_EMPTIES`, so the removal is owed here. It is
6781        // cleared by that marker's closing half, emitted just below this op.
6782        let elidable = take_array_empties_elidable()
6783            | crate::ported::subst::PARAMSUBST_EMPTIES_DEFERRED.with(|c| c.get());
6784        // A quoted splat never reaches this arm (the compile site sends every
6785        // splice-shaped word to `BUILTIN_ARRAY_DROP_EMPTY` instead), but the
6786        // bit is word-scoped, so spend it here too rather than let it reach
6787        // the next word's drop. See `QUOTED_SPLICE_KEEPS_EMPTIES`.
6788        let _ = take_quoted_splice_keeps_empties();
6789        // argc == 2 is the compiler's "this word carries a quoted-empty
6790        // literal segment" bit (see the emit site). c:4341 — under plan9 that
6791        // affix is glued to EVERY element, so no node of the word can be
6792        // empty and c:186 removes nothing: `setopt rcexpandparam;
6793        // a=("" y); print -rl -- $a''` is `` `y`.
6794        if argc & 2 != 0 && plan9_active() {
6795            return v;
6796        }
6797        if !elidable {
6798            return v;
6799        }
6800        match v {
6801            Value::Array(items) => {
6802                // `items` is an `Arc<Vec<Value>>`; borrow rather than move.
6803                let last = items.len().saturating_sub(1);
6804                let kept: Vec<Value> = items
6805                    .iter()
6806                    .enumerate()
6807                    .filter(|(i, x)| word_drop_keeps(argc, *i, last) || !x.to_str().is_empty())
6808                    .map(|(_, x)| x.clone())
6809                    .collect();
6810                // c:4245 — an array reference that ends up empty is still an
6811                // ARRAY for the plan9 word-removal rule, not a scalar.
6812                if kept.is_empty() {
6813                    note_empty_is_scalar(false);
6814                }
6815                Value::array(kept)
6816            }
6817            other => other,
6818        }
6819    }
6820
6821    // zsh nofork command substitution (c:Src/subst.c:1904-2100) — and the
6822    // ksh93 funsub / mksh valsub it subsumes. See the BUILTIN_KSH_FUNSUB
6823    // doc comment.
6824    vm.register_builtin(BUILTIN_KSH_FUNSUB, |vm, _argc| {
6825        // 0 = unquoted, 1 = inside `"…"`, 2 = unquoted PREFORK_SINGLE (a
6826        // scalar assignment RHS): trimmed like unquoted, never split.
6827        let qt_code = vm.pop().to_int();
6828        let single = qt_code == 2;
6829        let mut qt = qt_code == 1;
6830        let kind = vm.pop().to_int();
6831        let rplyvar = vm.pop().to_str();
6832        let body = vm.pop().to_str();
6833        // !!! POSIX-FAMILY GATE !!! bash, dash and sh have no nofork
6834        // command substitution — all three answer `bad substitution` and
6835        // fail, measured on this host:
6836        //   bash -c 'printf "%s\\n" "${ printf inner; }"'  -> rc 1
6837        //   dash -c 'printf "%s\\n" "${ printf inner; }"'  -> rc 2
6838        //   sh   -c 'printf "%s\\n" "${ printf inner; }"'  -> rc 1
6839        // The Korn family DOES have it (funsub/valsub) and so does zsh
6840        // 5.10 (`${ … }` / `${| … }` / `${{VAR} … }`), so the gate is the
6841        // bare bash/sh/dash drop-in only: `posix_faithful()` without the
6842        // Korn leg. `--zsh` and native zshrs keep the substitution.
6843        if crate::dash_mode::posix_faithful() && !crate::dash_mode::korn_mode() {
6844            crate::ported::utils::zerr("bad substitution");
6845            crate::ported::utils::errflag.fetch_or(
6846                crate::ported::zsh_h::ERRFLAG_ERROR,
6847                std::sync::atomic::Ordering::Relaxed,
6848            );
6849            with_executor(|exec| exec.set_last_status(1));
6850            return Value::str("");
6851        }
6852        let live_status = vm.last_status;
6853        // c:Src/subst.c:1625 — paramsubst's `qt` is "am I inside double
6854        // quotes", which is a LEXICAL property of the whole word. The
6855        // compiler can see it only when the substitution IS the entire
6856        // word; `"${ print INNER } $?"` reaches here as a segment whose own
6857        // text carries no quotes, so pick the enclosing quoting up from the
6858        // executor's live DQ flag as well (D10nofork.ztst "return statement
6859        // inside, part 1+": trim must NOT eat `print`'s newline there).
6860        if !qt && with_executor(|exec| exec.in_dq_context) != 0 {
6861            qt = true;
6862        }
6863        // c:Src/subst.c — the split decision is read BEFORE the body runs.
6864        // D10nofork.ztst "test word splitting on result" pins that
6865        // explicitly ("setting option inside is too late for that
6866        // substitution"): a `setopt shwordsplit` executed by the body
6867        // must not retroactively split the value it produced.
6868        let split = !qt
6869            && !single
6870            && (crate::dash_mode::korn_mode()
6871                || crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT));
6872        let out = with_executor(|exec| {
6873            use std::sync::atomic::Ordering::Relaxed;
6874            exec.set_last_status(live_status);
6875            // c:2044 — `parse_string(cmdarg, 0)`: the body is parsed from the
6876            // running line, not from 1.
6877            let lineno: u64 = exec.scalar("LINENO").and_then(|s| s.parse().ok()).unwrap_or(0);
6878            // c:1908 — `int trim = (!EMULATION(EMULATE_ZSH)) ? 2 : !qt;`
6879            let trim: i32 = if crate::dash_mode::korn_mode()
6880                || !crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_ZSH)
6881            {
6882                2
6883            } else if qt {
6884                0
6885            } else {
6886                1
6887            };
6888            // c:1993-2007 — `${ cmd }` takes advantage of the parameter
6889            // scope and `$(<file)` semantics: the body runs in the CURRENT
6890            // shell as `>| TMP {\nBODY\n;}` and the file is read back. The
6891            // spelling is C's byte for byte; a body parse error's line
6892            // number depends on it.
6893            let rplytmp: Option<String> = if kind == 0 {
6894                crate::ported::utils::gettempname(None, true) // c:1996
6895            } else {
6896                None
6897            };
6898            let cmdarg: String = match (kind, &rplytmp) {
6899                (0, Some(t)) => format!(
6900                    ">| {} {{\n{}\n;}}", // c:1993 outfmt
6901                    crate::ported::utils::quotestring(t, crate::ported::zsh_h::QT_BACKSLASH),
6902                    body
6903                ),
6904                (0, None) => {
6905                    // c:2004-2007 — TMPPREFIX not writable: `cmdoutval =
6906                    // lastval; cmdarg = NULL;`.
6907                    crate::ported::exec::cmdoutval.store(live_status, Relaxed);
6908                    String::new()
6909                }
6910                _ => body.clone(),
6911            };
6912            // c:2016 — `startparamscope(); /* "local" behaves as if in a
6913            // function */`, paired with c:2093 `endparamscope()`. All three
6914            // forms take it (the C block is under `if (rplyvar)`), which is
6915            // what makes `outer=GLOBAL; ${| local outer=LOCAL; … }` leave
6916            // the outer value alone (D10nofork.ztst "local declaration
6917            // inside").
6918            crate::ported::utils::inc_locallevel(); // c:2016
6919            // c:2018-2024 — `${| cmd }`: `rplypm = createparam("REPLY",
6920            // PM_LOCAL|PM_UNSET|PM_HIDE)` inside the scope, so the body sees
6921            // NO outer REPLY and the outer value is intact afterwards
6922            // (D10nofork.ztst "Basic substitution and REPLY scoping"). mksh's
6923            // valsub is identical, so one save/clear/restore serves both.
6924            let saved_reply = if kind == 1 {
6925                let saved = crate::ported::params::getsparam(&rplyvar);
6926                crate::ported::params::unsetparam(&rplyvar);
6927                saved
6928            } else {
6929                None
6930            };
6931            // c:2025-2031 — `${ cmd }` scopes its result under the reserved
6932            // `.zsh.cmdsubst`: local, unset, hidden and PM_READONLY_SPECIAL,
6933            // so the body cannot assign it (D10nofork.ztst "reserved
6934            // parameter name").
6935            if kind == 0 {
6936                use crate::ported::zsh_h::{PM_HIDE, PM_LOCAL, PM_READONLY_SPECIAL, PM_UNSET};
6937                crate::ported::params::createparam(
6938                    ".zsh.cmdsubst",
6939                    (PM_LOCAL | PM_UNSET | PM_HIDE | PM_READONLY_SPECIAL) as i32,
6940                ); // c:2025-2027
6941                if let Ok(mut tab) = crate::ported::params::paramtab().write() {
6942                    if let Some(pm) = tab.get_mut(".zsh.cmdsubst") {
6943                        pm.level = crate::ported::utils::locallevel(); // c:2029
6944                    }
6945                }
6946            }
6947            if !cmdarg.trim().is_empty() {
6948                // c:2039-2053 — `int obreaks = breaks; … cmdprog =
6949                // parse_string(cmdarg, 0); if (cmdprog) { execode(cmdprog, 1,
6950                // 0, "cmdsubst"); cmdoutval = lastval; if (retflag) { retflag
6951                // = 0; breaks = obreaks; } } else errflag |= ERRFLAG_ERROR;`
6952                // — `return` ends the body only; `exit` stays pending for the
6953                // c:2094 zexit below; `break` keeps unwinding the loop around
6954                // the command.
6955                use crate::ported::builtin::{BREAKS, RETFLAG};
6956                let obreaks = BREAKS.load(Relaxed); // c:2041
6957                // The body's commands are not inside the word's quotes.
6958                let saved_dq = std::mem::replace(&mut exec.in_dq_context, 0);
6959                let saved_subexp =
6960                    crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.replace(0));
6961                // c:Src/exec.c:4410 save_params records only the names in the
6962                // prefix-assignment list (varspc); an assignment the body
6963                // makes while `X=${| … } cmd` is being evaluated is not one
6964                // of them. The inline-env frame records every SET_VAR while
6965                // open (D10nofork.ztst "environment assignment" left REPLY
6966                // unset), so park it for the body.
6967                let frames = std::mem::take(&mut exec.inline_env_stack);
6968                // c:Src/subst.c:2048 — execode pushes the "cmdsubst" context.
6969                let ctx = crate::ported::exec::EvalContextFrame::push("cmdsubst");
6970                let ran = exec.execute_string_at_lineno(&cmdarg, lineno);
6971                drop(ctx);
6972                exec.inline_env_stack = frames;
6973                match ran {
6974                    Ok(st) => {
6975                        exec.set_last_status(st);
6976                        crate::ported::exec::cmdoutval.store(st, Relaxed); // c:2047
6977                    }
6978                    Err(_) => {
6979                        crate::ported::utils::errflag
6980                            .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Relaxed); // c:2053
6981                    }
6982                }
6983                crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.set(saved_subexp));
6984                exec.in_dq_context = saved_dq;
6985                if RETFLAG.load(Relaxed) != 0 {
6986                    RETFLAG.store(0, Relaxed); // c:2050
6987                    BREAKS.store(obreaks, Relaxed); // c:2051
6988                }
6989            }
6990            let val = match kind {
6991                1 => {
6992                    // c:2082-2083 — `val = dupstring(getsparam(rplyvar))`.
6993                    let reply = crate::ported::params::getsparam(&rplyvar).unwrap_or_default();
6994                    match saved_reply {
6995                        Some(v) => {
6996                            crate::ported::params::setsparam(&rplyvar, &v);
6997                        }
6998                        None => {
6999                            crate::ported::params::unsetparam(&rplyvar);
7000                        }
7001                    }
7002                    Value::str(reply)
7003                }
7004                2 => {
7005                    // c:2026-2033 — `${{VAR} cmd }`: VAR is the result
7006                    // parameter and gets NO local scope, so an assignment
7007                    // inside is global. c:2089-2091 then re-enters the
7008                    // ordinary parameter path with `s = dyncat(rplyvar, s)`,
7009                    // which is why an ARRAY result stays an array.
7010                    match exec.array(&rplyvar) {
7011                        Some(items) => {
7012                            Value::array(items.into_iter().map(Value::str).collect::<Vec<_>>())
7013                        }
7014                        None => Value::str(
7015                            crate::ported::params::getsparam(&rplyvar).unwrap_or_default(),
7016                        ),
7017                    }
7018                }
7019                _ => {
7020                    // c:2056-2057 — `rplypm->node.flags &= ~PM_READONLY_SPECIAL;`
7021                    if let Ok(mut tab) = crate::ported::params::paramtab().write() {
7022                        if let Some(pm) = tab.get_mut(".zsh.cmdsubst") {
7023                            pm.node.flags &= !(crate::ported::zsh_h::PM_READONLY_SPECIAL as i32);
7024                        }
7025                    }
7026                    // c:2058-2075 — `if (rplytmp && !errflag) { noerrs = 2;
7027                    // rplylen = zstuff(&cmdarg, rplytmp); … }`: trim==2 strips
7028                    // EVERY trailing newline (ksh/bash), trim==1 exactly one,
7029                    // trim==0 none.
7030                    let mut b = String::new();
7031                    if let Some(t) = &rplytmp {
7032                        if crate::ported::utils::errflag.load(Relaxed) == 0 {
7033                            if let Ok((txt, _)) = crate::ported::input::zstuff(t) {
7034                                b = txt;
7035                            }
7036                        }
7037                        while trim > 0 && b.ends_with('\n') {
7038                            b.pop();
7039                            if trim == 1 {
7040                                break;
7041                            }
7042                        }
7043                        let _ = std::fs::remove_file(t); // c:2079-2080
7044                    }
7045                    Value::str(b)
7046                }
7047            };
7048            crate::ported::params::endparamscope(); // c:2093
7049            // c:2094-2103 — `if (exit_pending) { … stopmsg = 1; zexit(exit_val,
7050            // ZEXIT_NORMAL); }`: an `exit` in the body ran in the CURRENT shell
7051            // and was only deferred by the parameter scope; it ends the shell
7052            // here, before the command around the substitution runs.
7053            {
7054                use crate::ported::builtin::{zexit, EXIT_PENDING, EXIT_VAL, STOPMSG};
7055                use std::sync::atomic::Ordering::Relaxed;
7056                if EXIT_PENDING.load(Relaxed) != 0 {
7057                    STOPMSG.store(1, Relaxed); // c:2100
7058                    zexit(EXIT_VAL.load(Relaxed), crate::ported::zsh_h::ZEXIT_NORMAL); // c:2101
7059                    // c:2103 `_exit(exit_val)` — back here only when zexit deferred
7060                    // for an in-process subshell (SUBSHELL_DEPTH): the forked child
7061                    // C runs would already be gone, so abort the command around
7062                    // the substitution and let the subshell unwind with the value.
7063                    crate::ported::utils::errflag
7064                        .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Relaxed);
7065                }
7066            }
7067            val
7068        });
7069        // A nofork substitution IS a command substitution, so it publishes
7070        // the body's exit the way `$( … )` does: `ksh -c 'v=${ false; };
7071        // print "rc=$?"'` → `rc=1`. The body run above leaves it in the
7072        // executor; the VM's own counter
7073        // is what BUILTIN_SET_VAR hands back as the assignment's status
7074        // (c:Src/exec.c:3396 `lastval = cmdoutval`), so mirror it here.
7075        vm.last_status = with_executor(|exec| exec.last_status());
7076        // An UNQUOTED substitution is word-split only when the shell splits
7077        // ordinary expansions: always under ksh/mksh, and under zsh only
7078        // with SH_WORD_SPLIT. `split` was decided above, before the body
7079        // ran — see the comment there.
7080        match out {
7081            Value::Str(ref s) if split => {
7082                let (_joined, parts, _isarr, _flags) =
7083                    crate::ported::subst::multsub(s, crate::ported::zsh_h::PREFORK_SPLIT);
7084                Value::array(parts.into_iter().map(Value::str).collect::<Vec<_>>())
7085            }
7086            other => other,
7087        }
7088    });
7089
7090    // c:Src/subst.c:3032 `val = sepjoin(aval, sep, 1)` — see the
7091    // BUILTIN_QUOTED_STAR_ONE_WORD doc comment.
7092    vm.register_builtin(BUILTIN_QUOTED_STAR_ONE_WORD, |vm, _argc| match vm.pop() {
7093        Value::Array(items) if items.is_empty() => Value::str(String::new()),
7094        other => other,
7095    });
7096
7097    // BUILTIN_QUOTEDZPUTS — re-wrap top-of-stack scalar via the
7098    // canonical quotedzputs (Src/utils.c:6464). Non-printable bytes
7099    // come back as `$'…'` C-string form so the cond xtrace prefix
7100    // line preserves the source-quoting form for `[[ -n $'\C-[OP' ]]`
7101    // instead of leaking raw ESC + "OP" bytes through the terminal.
7102    vm.register_builtin(BUILTIN_QUOTEDZPUTS, |vm, _argc| {
7103        let s = vm.pop().to_str();
7104        Value::str(crate::ported::utils::quotedzputs(&s))
7105    });
7106
7107    // BUILTIN_QUOTE_TOKENIZED_OUTPUT — char-aware mirror of
7108    // c:Src/exec.c:2114 `quote_tokenized_output`. The canonical
7109    // port at exec::quote_tokenized_output operates on bytes
7110    // (zsh's metafied encoding); zshrs strings are UTF-8 so
7111    // `\u{e187}` Star is `[0xC2, 0x87]`, and a byte walk writes
7112    // 0xC2 raw (invalid UTF-8 lead → U+FFFD on lossy decode).
7113    // Walk by char and dispatch the same switch the byte port
7114    // uses, but with the token chars matching the UTF-8 form.
7115    vm.register_builtin(BUILTIN_QUOTE_TOKENIZED_OUTPUT, |vm, _argc| {
7116        let s = vm.pop().to_str();
7117        let mut out = String::with_capacity(s.len());
7118        let chars: Vec<char> = s.chars().collect();
7119        let mut i = 0;
7120        while i < chars.len() {
7121            let c = chars[i];
7122            // c:2120 — Meta-quoted byte: emit `*++s ^ 32`.
7123            // In UTF-8 strings Meta is `\u{83}`; the next char is
7124            // the metafied payload.
7125            if c == '\u{83}' {
7126                if let Some(&n) = chars.get(i + 1) {
7127                    if (n as u32) < 0x80 {
7128                        out.push(((n as u8) ^ 32) as char);
7129                    } else {
7130                        out.push(n);
7131                    }
7132                    i += 2;
7133                    continue;
7134                }
7135                i += 1;
7136                continue;
7137            }
7138            // c:2124 — Nularg: skip.
7139            if c == '\u{e1a1}' {
7140                i += 1;
7141                continue;
7142            }
7143            // c:2128-2143 — ASCII specials get backslash-prefixed
7144            // then fall through to emit the literal char.
7145            match c {
7146                '\\' | '<' | '>' | '(' | '|' | ')' | '^' | '#' | '~' | '[' | ']' | '*' | '?'
7147                | '$' | ' ' => {
7148                    out.push('\\');
7149                    out.push(c);
7150                    i += 1;
7151                    continue;
7152                }
7153                '\t' => {
7154                    out.push_str("$'\\t'");
7155                    i += 1;
7156                    continue;
7157                }
7158                '\n' => {
7159                    out.push_str("$'\\n'");
7160                    i += 1;
7161                    continue;
7162                }
7163                '\r' => {
7164                    out.push_str("$'\\r'");
7165                    i += 1;
7166                    continue;
7167                }
7168                '=' => {
7169                    if i == 0 {
7170                        out.push('\\');
7171                    }
7172                    out.push(c);
7173                    i += 1;
7174                    continue;
7175                }
7176                _ => {}
7177            }
7178            // c:2163 — `if (itok(*s)) putc(ztokens[*s - Pound]);`
7179            // Map zsh token chars (`\u{e184}`..`\u{e1a1}` range, the
7180            // ones the lexer emits for `#$^*()…`) back to their
7181            // source ASCII via the `ztokens` table.
7182            let cp = c as u32;
7183            if (0xe184..=0xe1a1).contains(&cp) {
7184                let idx = (cp - 0xe184) as usize;
7185                let ztokens = crate::ported::lex::ztokens.as_bytes();
7186                if idx < ztokens.len() {
7187                    out.push(ztokens[idx] as char);
7188                    i += 1;
7189                    continue;
7190                }
7191            }
7192            out.push(c);
7193            i += 1;
7194        }
7195        Value::str(out)
7196    });
7197
7198    // BUILTIN_WORD_SPLIT — `${=var}` IFS-split runtime.
7199    // PURE PASSTHRU: route through canonical `subst::multsub` with
7200    // PREFORK_SPLIT flag (C port of `Src/subst.c::multsub` at c:544
7201    // — the IFS-split walker with whitespace-vs-non-whitespace
7202    // gating, quote-aware parsing, and empty-field handling).
7203    vm.register_builtin(BUILTIN_WORD_SPLIT, |vm, _argc| {
7204        let s = vm.pop().to_str();
7205        let (_joined, parts, _isarr, _flags) =
7206            crate::ported::subst::multsub(&s, crate::ported::zsh_h::PREFORK_SPLIT);
7207        // Empty single-string special case → empty Array (drop empty arg).
7208        // The Array is a carrier for "no argv word", not an array-SHAPED
7209        // result: a single empty field came from an empty SCALAR (c:3922-3923
7210        // `if (!aval || !aval[0]) val = dupstring("");`). Record that, or
7211        // under RC_EXPAND_PARAM `concat_plan9` reads a stale bit and applies
7212        // c:4362's `uremnode` to a word zsh keeps — `x$(true)y` and
7213        // `v=""; x${=v}y` are each the single word `xy`, not zero words.
7214        if parts.len() == 1 && parts[0].is_empty() {
7215            note_empty_is_scalar(true);
7216            return Value::array(Vec::new());
7217        }
7218        // Zero parts is the same story reached by a different route: an empty
7219        // command substitution splits to nothing at all rather than to one
7220        // empty field. `to_str()` above means this builtin's input is always
7221        // a scalar, so an empty result here is never array-shaped.
7222        restore_empty_shape(nodes_to_value(parts), true)
7223    });
7224
7225    // BUILTIN_FORCE_SPLIT — `${=name}` / SH_WORD_SPLIT forced split.
7226    // c:Src/subst.c:3920-3928 —
7227    //     if (force_split && !isarr) {
7228    //         aval = sepsplit(val, spsep, 0, 1);
7229    //         if (!aval || !aval[0])   val = dupstring("");
7230    //         else if (!aval[1])       val = aval[0];
7231    //         else                     isarr = nojoin ? 1 : 2;
7232    //     }
7233    // with spsep == NULL for the `=` flag, so sepsplit falls through to
7234    // Src/utils.c:3711 spacesplit(s, allownull=0). See BUILTIN_FORCE_SPLIT's
7235    // doc comment for the empty-field rule and the argc contract.
7236    vm.register_builtin(BUILTIN_FORCE_SPLIT, |vm, argc| {
7237        // argc is a two-bit field, and there is exactly one emit site
7238        // (compile_zsh.rs, the `${=…}` fast path):
7239        //   bit 0  keep_empties — the word has neighbouring text or `Dnull`
7240        //          markers to attach the empty leading/trailing fields to.
7241        //   bit 1  the operand is a `[@]`/`[*]` SPLICE, so c:3931's `!isarr`
7242        //          gate is live (see below).
7243        let keep_empties = argc & 1 != 0;
7244        let splice_operand = argc & 2 != 0;
7245        let raw_val = vm.pop();
7246        // c:Src/subst.c:3931 — `if (force_split && !isarr)`. The `${=NAME[*]}`
7247        // / `${=NAME[@]}` splice fast paths hand this builtin whatever the
7248        // c:3914-3927 join arm produced, and that arm can leave the expansion
7249        // ARRAY-shaped: with `nojoin` set and IFS the EMPTY string, neither
7250        // c:3916 nor c:3919 fires (c:3919 wants IFS UNSET, `!ifs`, not merely
7251        // empty), so `isarr` is still -1 here and the split does not run at
7252        // all. Only c:184-187's empty-word removal still applies.
7253        //
7254        // Gated on the SPLICE bit, not on the value's Rust type: a scalar
7255        // `${=@}` reads through BUILTIN_GET_VAR_DQ, which also hands over a
7256        // Value::Array, but there `isarr` was already collapsed by c:3033's
7257        // quoted join and the split must still run over the joined text
7258        // (`set -- 'a b' 'c d'; print -l $=@` is FOUR words).
7259        if let (true, Value::Array(items)) = (splice_operand, &raw_val) {
7260            let kept: Vec<Value> = items
7261                .iter()
7262                .filter(|x| keep_empties || !x.to_str().is_empty())
7263                .cloned()
7264                .collect();
7265            return Value::array(kept);
7266        }
7267        let s = raw_val.to_str();
7268        // c:3932 — `sepsplit(val, spsep, 0, 1)`; spsep NULL → spacesplit, plus
7269        // c:184-187's empty-word removal. See `sepsplit_c3932` for the
7270        // `nulstring`-vs-`""` rule that decides which empties survive.
7271        // c:36 `nulstring` — inside a word whose end-of-word drop is still to
7272        // run (BUILTIN_WORD_DEFER_EMPTIES opened it), an IFS-non-whitespace
7273        // empty field keeps its Nularg so that drop can tell it from the
7274        // IFS-whitespace edges it must delete (c:183-186), then removes the
7275        // marker (c:170 remnulargs). `IFS=:; s=a::b; print -rl -- x${=s}y`
7276        // is `xa` `` `by`.
7277        let out = if keep_empties
7278            && crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get()) > 0
7279        {
7280            crate::ported::utils::sepsplit(&s, None, false) // c:3932, Nularg kept
7281        } else {
7282            sepsplit_c3932(&s, keep_empties)
7283        };
7284        if out.is_empty() {
7285            // c:3922-3923 — `if (!aval || !aval[0]) val = dupstring("");`:
7286            // the split produced nothing, so the value is the empty SCALAR.
7287            // Quoted, that is one empty word (c:4465 `if (qt && !*y) y =
7288            // dupstring(nulstring);` → `a=( "${=v}" )` has one element);
7289            // unquoted, prefork deletes it and the word vanishes.
7290            if keep_empties {
7291                return Value::str(String::new());
7292            }
7293            note_empty_is_scalar(true);
7294            return Value::array(Vec::new());
7295        }
7296        if out.len() == 1 {
7297            // c:3924 — `else if (!aval[1]) val = aval[0];` — a one-field
7298            // split stays a SCALAR (this is why `${#${(f)v}}` counts
7299            // characters when the split yields a single line).
7300            return Value::str(out.into_iter().next().unwrap());
7301        }
7302        // c:3927 — `isarr = nojoin ? 1 : 2;`
7303        Value::array(out.into_iter().map(Value::str).collect())
7304    });
7305
7306    // See BUILTIN_BARE_SPBREAK's declaration for the argc contract.
7307    vm.register_builtin(BUILTIN_BARE_SPBREAK, |vm, argc| {
7308        // c:2567 / c:2562 — `${=…}` sets spbreak 2, `${==…}` clears it.
7309        let spbreak = if argc & 1 != 0 { 2 } else { 0 };
7310        let qt = argc & 2 != 0; // c:3033 `qt`
7311        let name = vm.pop().to_str();
7312        let (elems, is_arr, ifs_opt, in_dq) = with_executor(|exec| {
7313            // c:Src/params.c:4745 `ifssetfn` — the C `ifs` global is NULL only
7314            // when IFS is genuinely UNSET, and c:3920's `(!ifs && …)` turns on
7315            // exactly that distinction, so carry the Option, not a defaulted
7316            // string.
7317            let ifs_opt = exec.scalar("IFS");
7318            let in_dq = qt || exec.in_dq_context > 0; // c:3033 `qt`
7319            let (elems, is_arr) = param_value_elems_c2916(exec, &name);
7320            (elems, is_arr, ifs_opt, in_dq)
7321        });
7322        // c:3899-3906 — "treat a one-element array as a scalar for purposes of
7323        // concatenation with surrounding text (some${param}thing) and
7324        // rc_expand_param handling":
7325        //     } else if (isarr && aval && aval[0] && !aval[1]) {
7326        //         val = aval[0];
7327        //         isarr = 0;
7328        // An EMPTY array fails `aval[0]` and keeps `isarr`.
7329        let mut isarr: i32 = i32::from(is_arr && elems.len() != 1);
7330        // c:1819 / c:2564 / c:2569 — `nojoin` for this spelling. `${==…}`
7331        // clears it outright; `${=…}` recomputes it as `!(ifs && *ifs)` (and,
7332        // unlike c:1819's SH_WORD_SPLIT form, with no `!qt` term).
7333        let nojoin = spbreak != 0 && !matches!(ifs_opt.as_deref(), Some(s) if !s.is_empty());
7334        // c:3030-3032 — `if (isarr) { if (nojoin) isarr = -1; }`.
7335        if isarr != 0 && nojoin {
7336            isarr = -1;
7337        }
7338        // c:3033-3036 — `if (qt && !getlen && isarr > 0) { val = sepjoin(aval,
7339        // sep, 1); isarr = 0; }`. This is why `"${==a}"` with a non-empty IFS
7340        // is ONE joined word while the unquoted form keeps its elements.
7341        if in_dq && isarr > 0 {
7342            return Value::str(crate::ported::utils::sepjoin(&elems, None));
7343        }
7344        // c:3912-3930 — the gate is open only for `${=…}` here (`ssub`,
7345        // `spsep`, `sep` and the quoted-with-offset case never reach this
7346        // spelling), and `force_split` is 1, so `join_c3914` is c:3916 and
7347        // c:3919 exactly. c:3931's `sepsplit` is BUILTIN_FORCE_SPLIT at the
7348        // compile site, which is why this stops at the join.
7349        if spbreak != 0 && isarr != 0 {
7350            return match join_c3914(elems, ifs_opt.as_deref()) {
7351                JoinC3914::Joined(val) => Value::str(val), // c:3917 / c:3925
7352                // Neither join arm fired (IFS set but EMPTY), so `isarr` is
7353                // still non-zero at c:3931 and the split does not run either —
7354                // BUILTIN_FORCE_SPLIT's splice bit passes the array through.
7355                JoinC3914::Elements(kept) => {
7356                    Value::array(kept.into_iter().map(Value::str).collect())
7357                }
7358            };
7359        }
7360        if isarr != 0 {
7361            // `${==NAME}` on an array: the block never ran, so the element
7362            // vector is the answer. c:184-187's empty-word removal is the
7363            // BUILTIN_ARRAY_DROP_EMPTY the compile site emits after this.
7364            return Value::array(elems.into_iter().map(Value::str).collect());
7365        }
7366        // c:2927 / c:3904 — a scalar (or the one-element array c:3899
7367        // collapsed): `isarr == 0`, so no join arm applies and c:3931 splits
7368        // this text directly.
7369        Value::str(elems.into_iter().next().unwrap_or_default())
7370    });
7371
7372    vm.register_builtin(BUILTIN_BRACE_EXPAND, |vm, _argc| {
7373        // c:Src/glob.c::xpandbraces — brace expansion runs per word.
7374        // When the upstream produced an array (e.g. `${a:e}` splat),
7375        // expand braces on each element separately so the splat
7376        // survives. `pop().to_str()` would join with space and lose
7377        // the array shape. Parity bug #28 cousin: the BRACE_EXPAND
7378        // emit always fires for any word containing `{` (including
7379        // `${...}` param-expansion braces), so its collapse hit even
7380        // pure-paramsubst args.
7381        let raw = vm.pop();
7382        // Brace expansion runs BETWEEN the expansion builtin that produced
7383        // this word and the concat that consumes it (`x${${P}}y` compiles to
7384        // EXPAND_TEXT, BRACE_EXPAND, CONCAT_DISTRIBUTE). It cannot change
7385        // whether an empty result was scalar- or array-SHAPED — c:Src/glob.c
7386        // xpandbraces only ever rewrites the text of existing words. But both
7387        // exits below funnel through `nodes_to_value`, which records
7388        // `note_empty_is_scalar(false)` for an empty result, so the shape bit
7389        // its producer set was being overwritten with "array" before
7390        // `concat_plan9` could read it. Under RC_EXPAND_PARAM that deleted
7391        // words zsh keeps: `unset P; x${${P}}y` and `x$(true)y` are each the
7392        // single word `xy` (c:4438-4467 scalar arm), not zero words.
7393        // Carry the incoming bit across an empty→empty pass-through.
7394        let incoming_empty_is_scalar = empty_is_scalar();
7395        // c:Src/options.c — `no_brace_expand` (negated braceexpand)
7396        // disables brace expansion entirely. When set, `{a,b}` stays
7397        // literal. Mirror by short-circuiting xpandbraces; pass the
7398        // input through unchanged.
7399        let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
7400        let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
7401        // c:Src/glob.c xpandbraces rewrites word TEXT; it never turns a
7402        // scalar word into an array one. Remember the incoming shape so a
7403        // scalar that brace-expands to exactly one word stays SCALAR:
7404        // nodes_to_value collapses a lone EMPTY node to zero words unless
7405        // in_dq_context > 0, and BUILTIN_EXPAND_TEXT has already decremented
7406        // that by the time this runs — so a quoted word whose expansion came
7407        // out empty, and which carries the Inbrace token so it reaches this
7408        // builtin at all, lost its argument entirely.
7409        let raw_was_scalar = !matches!(raw, Value::Array(_));
7410        let inputs: Vec<String> = match raw {
7411            Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
7412            other => vec![other.to_str()],
7413        };
7414        if !brace_expand {
7415            return restore_empty_shape(nodes_to_value(inputs), incoming_empty_is_scalar);
7416        }
7417        let mut out: Vec<String> = Vec::with_capacity(inputs.len());
7418        for s in inputs {
7419            for w in crate::ported::glob::xpandbraces(&s, brace_ccl) {
7420                out.push(w);
7421            }
7422        }
7423        if raw_was_scalar && out.len() == 1 {
7424            let mut only = out.into_iter().next().unwrap();
7425            crate::ported::glob::remnulargs(&mut only);
7426            return Value::str(only);
7427        }
7428        restore_empty_shape(nodes_to_value(out), incoming_empty_is_scalar)
7429    });
7430
7431    // `*(qual)` glob qualifier filter. Stack: [pattern, qualifier].
7432    // Pattern is glob-expanded normally, then each result is filtered by the
7433    // qualifier predicate. Common qualifiers:
7434    //   .  — regular files only
7435    //   /  — directories only
7436    //   @  — symlinks
7437    //   x  — executable
7438    //   r/w/x — readable/writable/executable
7439    //   N  — nullglob (no error if no match)
7440    //   L+N / L-N — size > N / size < N (in bytes)
7441    //   mh-N / mh+N — modified within N hours / older than N hours
7442    //   md-N / md+N — modified within N days / older than N days
7443    //   on/On — sort by name asc/desc (default)
7444    //   oL/OL — sort by length
7445    //   om/Om — sort by mtime
7446    // Pop a scalar pattern, run expand_glob, push Value::Array. Used
7447    // by the segment-concat compile path for `$D/*`-style words.
7448    vm.register_builtin(BUILTIN_GLOB_EXPAND, |vm, _argc| {
7449        // c:Src/glob.c:1872 — honour `setopt noglob` / `noglob CMD`
7450        // precommand. When the option is on, the word stays literal
7451        // (zsh skips the glob expansion entirely). Without this, the
7452        // segment-fast-path BUILTIN_GLOB_EXPAND fired even after
7453        // `noglob` set the option, so `noglob echo *.xyz` saw the
7454        // NOMATCH error instead of the literal pass-through.
7455        let raw = vm.pop();
7456        let noglob =
7457            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
7458        glob_word_noting_errflag(raw, noglob)
7459    });
7460    // Redirect-target variant of BUILTIN_GLOB_EXPAND. c:Src/glob.c:
7461    // 2161-2167 xpandredir — `prefork(&fake, isset(MULTIOS) ? 0 :
7462    // PREFORK_SINGLE, NULL)` then "Globbing is only done for
7463    // multios.": a redirect target word is only globbed when the
7464    // MULTIOS option is set. With it unset, `echo hi > *.txt`
7465    // creates the literal file `*.txt`, and `wc -c < *.txt` errors
7466    // "no such file or directory: *.txt". Bug #36 follow-up in
7467    // docs/BUGS.md.
7468    vm.register_builtin(BUILTIN_REDIR_GLOB_EXPAND, |vm, _argc| {
7469        let raw = vm.pop();
7470        let noglob =
7471            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
7472        let multios = opt_state_get("multios").unwrap_or(true);
7473        // c:Src/glob.c:2164-2166 — `in_expandredir = 1; globlist(&fake, 0);
7474        // in_expandredir = 0;`. The flag is what lets `zglob`'s no-match
7475        // dispatch (c:1888-1894) tell a redirect target apart from an
7476        // ordinary word: under NULL_GLOB an ordinary word is dropped, but
7477        // a redirect still needs exactly one target, so the empty result
7478        // is `redirection failed (no match)` instead.
7479        crate::ported::glob::IN_EXPANDREDIR.store(1, std::sync::atomic::Ordering::SeqCst); // c:2164
7480        let out = glob_expand_word_value(raw, noglob || !multios); // c:2165
7481        crate::ported::glob::IN_EXPANDREDIR.store(0, std::sync::atomic::Ordering::SeqCst); // c:2166
7482        out
7483    });
7484    // Clear the default-word glob-pending carrier before the word's
7485    // expansion runs, so a flag set by a prior word never leaks in.
7486    vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB_RESET, |_vm, _argc| {
7487        crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| c.set(false));
7488        crate::ported::subst::DEFAULT_WORD_GLOBSUBST_OFF.with(|c| c.set(false));
7489        crate::ported::subst::DEFAULT_WORD_GLOB_BARS.with(|b| b.borrow_mut().clear());
7490        Value::Status(0)
7491    });
7492    // After the word is assembled, run filename generation ONLY if the
7493    // default/alternate paramsubst arm flagged a source-glob default
7494    // (DEFAULT_WORD_GLOB_PENDING). Otherwise pass the word through
7495    // literally — a parameter VALUE must not glob. c:Src/subst.c globlist.
7496    vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB, |vm, _argc| {
7497        let raw = vm.pop();
7498        let pending = crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| {
7499            let v = c.get();
7500            c.set(false); // read + clear
7501            v
7502        });
7503        let bars = crate::ported::subst::DEFAULT_WORD_GLOB_BARS.with(|b| std::mem::take(&mut *b.borrow_mut()));
7504        if !pending {
7505            return raw;
7506        }
7507        let noglob =
7508            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
7509        if bars.is_empty() || noglob {
7510            return glob_expand_word_value(raw, noglob);
7511        }
7512        // c:Src/lex.c:1000-1008 + c:Src/subst.c:3207-3228 — the default word's
7513        // source `|` is a Bar token in the assembled word, so globlist reads a
7514        // top-level alternation (`${x:-b|a}` matches the file `b`;
7515        // `X${x:-b|a}` is the pattern `Xb` | `a`). Re-spell those `|`s from
7516        // the carrier before globbing, and put them back to `|` in a word the
7517        // glob hands back literally.
7518        let rebar = |s: String| -> String {
7519            let mut s = s;
7520            for (plain, barred) in &bars {
7521                if let Some(at) = s.find(plain.as_str()) {
7522                    s.replace_range(at..at + plain.len(), barred);
7523                }
7524            }
7525            s
7526        };
7527        let barred = match raw {
7528            Value::Array(items) => {
7529                Value::array(items.iter().map(|v| Value::str(rebar(v.to_str()))).collect())
7530            }
7531            other => Value::str(rebar(other.to_str())),
7532        };
7533        match glob_expand_word_value(barred, false) {
7534            Value::Array(items) => Value::array(
7535                items
7536                    .iter()
7537                    .map(|v| Value::str(crate::ported::lex::untokenize(&v.to_str())))
7538                    .collect(),
7539            ),
7540            other => Value::str(crate::ported::lex::untokenize(&other.to_str())),
7541        }
7542    });
7543
7544    // `break`/`continue` from a sub-VM body. The compile path emits
7545    // these when the keyword appears at chunk top-level (no enclosing
7546    // for/while in the current chunk's patch lists). Outer-loop
7547    // builtins (BUILTIN_RUN_SELECT and any future loop-via-builtin
7548    // construct) drain canonical BREAKS/CONTFLAG after each iteration.
7549    //
7550    // Writes match `bin_break`'s c:5836+ pattern:
7551    //   continue: contflag = 1; breaks++   (Src/builtin.c::bin_break)
7552    //   break:    breaks++
7553    vm.register_builtin(BUILTIN_SET_BREAK, |_vm, _argc| {
7554        use std::sync::atomic::Ordering::SeqCst;
7555        crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
7556        Value::Status(0)
7557    });
7558    vm.register_builtin(BUILTIN_SET_CONTINUE, |_vm, _argc| {
7559        use std::sync::atomic::Ordering::SeqCst;
7560        crate::ported::builtin::CONTFLAG.store(1, SeqCst);
7561        crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
7562        Value::Status(0)
7563    });
7564
7565    // `break N`/`continue N` with a RUNTIME level count. Pops [count,
7566    // name]; math-evaluates count (c:builtin.c:5811 `mathevali`); on
7567    // count <= 0 emits `argument is not positive: N` via zerrnam (sets
7568    // errflag → abort, c:5813) and pushes Int(0) (matches no jump-table
7569    // entry → control falls through to the errflag abort). Otherwise
7570    // pushes Int(count) for the compiled jump table to dispatch on.
7571    vm.register_builtin(BUILTIN_BREAK_COUNT_VALIDATE, |vm, _argc| {
7572        let name = vm.pop().to_str();
7573        let count_s = vm.pop().to_str();
7574        // c:5816 — mathevali zerr's a bad expression itself; its errflag
7575        // then suppresses the zerrnam below.
7576        let count = match crate::ported::math::mathevali(&count_s) {
7577            Ok(n) => n,
7578            Err(msg) => {
7579                crate::ported::utils::zerr(&msg);
7580                0
7581            }
7582        };
7583        if count <= 0 {
7584            crate::ported::utils::zerrnam(&name, &format!("argument is not positive: {count}"));
7585            return Value::Int(0);
7586        }
7587        Value::Int(count)
7588    });
7589
7590    // `${arr[*]}` — join array elements with the first IFS char into
7591    // a single string. Matches zsh: in DQ context this preserves the
7592    // join; in array context too the result is one Value::Str.
7593    // Set or clear a shell option directly. Used by `noglob CMD ...`
7594    // precommand wrapping — the compiler emits SET_RAW_OPT to flip the
7595    // option ON before compiling the inner words and OFF after, so glob
7596    // expansion of the inner args sees the temporary state.
7597    vm.register_builtin(BUILTIN_SET_RAW_OPT, |vm, _argc| {
7598        let on = vm.pop().to_int() != 0;
7599        let opt = vm.pop().to_str();
7600        // Pure passthru: canonical port lives in
7601        // src/ported/options.rs::opt_state_set_via_alias and
7602        // handles negation-alias resolution per c:Src/options.c.
7603        crate::ported::options::opt_state_set_via_alias(&opt, on);
7604        Value::Status(0)
7605    });
7606
7607    // c:Src/options.c GLOB_SUBST — runtime glob expansion of
7608    // substituted words. Pop a Value (Str or Array); when
7609    // GLOB_SUBST is ON, run expand_glob on each string element;
7610    // when OFF, pass through unchanged. Bug #119 in docs/BUGS.md.
7611    vm.register_builtin(BUILTIN_GLOB_SUBST_EXPAND, |vm, _argc| {
7612        let raw = vm.pop();
7613        // c:Src/subst.c:3231-3233 — after a default / alternate word C runs
7614        // `if (globsubst != 2) globsubst = 0;`, so the finished expansion is
7615        // not shtokenized and only glob tokens an UNQUOTED inner substitution
7616        // produced survive into it. The paramsubst arm reports both halves
7617        // (subst::DEFAULT_WORD_GLOBSUBST_OFF and DEFAULT_WORD_GLOB_PENDING);
7618        // take them for this word: `setopt globsubst; foo='boring*'`
7619        // `${foo+"$foo"}` stays `boring*`, `${foo+$foo}` still globs.
7620        let default_off = crate::ported::subst::DEFAULT_WORD_GLOBSUBST_OFF.with(|c| c.replace(false));
7621        let default_pending = default_off
7622            && crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| c.replace(false));
7623        let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
7624        if !glob_subst || (default_off && !default_pending) {
7625            return raw;
7626        }
7627        // Collect input strings (Str → vec![s]; Array → multiple).
7628        let inputs: Vec<String> = match raw {
7629            Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
7630            other => vec![other.to_str()],
7631        };
7632        // Run expand_glob on each. Empty matches collapse to a
7633        // single literal pass-through to mirror nullglob-off default.
7634        let mut out: Vec<String> = Vec::with_capacity(inputs.len());
7635        for pattern in inputs {
7636            // c:Src/subst.c — GLOB_SUBST subjects the value to the FULL
7637            // filename-generation pipeline: `filesub` (tilde/`=` expansion)
7638            // BEFORE globbing (prefork runs filesub then globlist). zshrs
7639            // globbed but skipped filesub, so `${~x}` / `setopt globsubst`
7640            // left `~/foo` un-expanded. filesubstr matches the Tilde TOKEN,
7641            // so shtokenize the value first (`~`→Tilde, glob metas active),
7642            // run filesub, then untokenize the surviving glob metas back to
7643            // raw for expand_glob (which re-tokenizes internally).
7644            // c:178 — that filesub is prefork's SECOND pass, gated on
7645            // `unset(SHFILEEXPANSION)`: with SH_FILE_EXPANSION the file
7646            // expansion ran before substitution, so a substituted `~`/`=`
7647            // stays literal.
7648            let pattern = if (pattern.contains('~') || pattern.contains('='))
7649                && !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHFILEEXPANSION)
7650            {
7651                let mut tok = pattern.clone();
7652                crate::ported::glob::shtokenize(&mut tok);
7653                let fs = crate::ported::subst::filesub(&tok, 0);
7654                crate::ported::lex::untokenize(&fs)
7655            } else {
7656                pattern
7657            };
7658            // c:Src/glob.c:1872-1886 — `expand_glob` already owns the whole
7659            // no-match dispatch: NULL_GLOB drops the word, NOMATCH errors, and
7660            // only with both off does it hand the literal back. Pushing the
7661            // literal again on an empty result undid NULL_GLOB and kept a
7662            // word whose filesub had failed, so under `_expand`'s options
7663            // `${~exp//…}` of `~[]` "expanded" to itself (Y01completion #10).
7664            out.extend(with_executor(|exec| exec.expand_glob(&pattern)));
7665        }
7666        if out.len() == 1 {
7667            Value::str(out.into_iter().next().unwrap())
7668        } else {
7669            Value::array(out.into_iter().map(Value::str).collect())
7670        }
7671    });
7672
7673    // c:Src/math.c:336-364 — `getmathparam` for ArithCompiler pre-load.
7674    // Pop a variable name, return its math value.
7675    //
7676    // This used to be a second, smaller getmathparam: `getsparam` then
7677    // `parse::<i64>` / `parse::<f64>` / `mathevali`. Two consequences,
7678    // both invisible until you compared the two arithmetic backends on
7679    // the same expression. It had no FORCEFLOAT coercion (c:359-362) and
7680    // no `unset(UNSET)` diagnostic (c:345-346), so which of `setopt
7681    // force_float` and `set -u` applied to `$(( x ))` depended on
7682    // whether `compile_arith` had routed the expression to the
7683    // ArithCompiler or to BUILTIN_ARITH_EVAL. And it re-derived a
7684    // typed param's value from `getsparam`'s printed form, the same
7685    // convbase round-trip c:2641 exists to avoid.
7686    //
7687    // There is one `getmathparam` in C; there is one here now.
7688    vm.register_builtin(BUILTIN_GET_MATH_VAR, |vm, _argc| {
7689        let name = vm.pop().to_str();
7690        let n = crate::ported::math::getmathparam(&name); // c:337
7691        if n.type_ == crate::ported::zsh_h::MN_FLOAT {
7692            Value::Float(n.d)
7693        } else {
7694            Value::Int(n.l)
7695        }
7696    });
7697
7698    // ── Compiled-arithmetic runtime ops ───────────────────────────────
7699    //
7700    // c:Src/math.c:1156 `op(int what)` — the arms `Op::Div` / `Op::Mod` /
7701    // `Op::Pow` cannot express, plus the two write-back / finish helpers
7702    // the compiled `(( … ))` path needs. Each pops its operands in C's
7703    // order: for `a OP b` the stack holds \[a, b\], so `b` pops first.
7704
7705    vm.register_builtin(BUILTIN_ARITH_DIV, |vm, _argc| {
7706        let b = arith_pop_mnumber(vm);
7707        let a = arith_pop_mnumber(vm);
7708        arith_push(arith_div(a, b)) // c:1237-1257
7709    });
7710
7711    vm.register_builtin(BUILTIN_ARITH_MOD, |vm, _argc| {
7712        let b = arith_pop_mnumber(vm);
7713        let a = arith_pop_mnumber(vm);
7714        arith_push(arith_mod(a, b)) // c:1258-1272
7715    });
7716
7717    vm.register_builtin(BUILTIN_ARITH_POW, |vm, _argc| {
7718        let b = arith_pop_mnumber(vm);
7719        let a = arith_pop_mnumber(vm);
7720        arith_push(arith_pow(a, b)) // c:1335-1358
7721    });
7722
7723    // c:Src/math.c:1200 — the `BOOL` operators yield an INTEGER 1/0
7724    // whatever their operands were.
7725    vm.register_builtin(BUILTIN_ARITH_LOGAND, |vm, _argc| {
7726        let b = arith_pop_mnumber(vm);
7727        let a = arith_pop_mnumber(vm);
7728        Value::Int(i64::from(mn_is_true(a) && mn_is_true(b)))
7729    });
7730    vm.register_builtin(BUILTIN_ARITH_LOGOR, |vm, _argc| {
7731        let b = arith_pop_mnumber(vm);
7732        let a = arith_pop_mnumber(vm);
7733        Value::Int(i64::from(mn_is_true(a) || mn_is_true(b)))
7734    });
7735    vm.register_builtin(BUILTIN_ARITH_LOGXOR, |vm, _argc| {
7736        let b = arith_pop_mnumber(vm);
7737        let a = arith_pop_mnumber(vm);
7738        Value::Int(i64::from(mn_is_true(a) != mn_is_true(b)))
7739    });
7740
7741    // c:Src/math.c:972 setmathvar — stack is \[name, value, pval\]; pval
7742    // (argc 3) says the operator read the lvalue first (c:975-993).
7743    vm.register_builtin(BUILTIN_SET_MATH_VAR, |vm, argc| {
7744        let pval = argc >= 3 && vm.pop().to_int() != 0;
7745        let value = arith_pop_mnumber(vm);
7746        let name = vm.pop().to_str();
7747        // c:Src/math.c:1161 — `op()` begins `if (errflag) return;`, so an
7748        // assignment operator reached after a math error (`x %= 0`, the
7749        // `y = 3` of `(( x = 5/0, y = 3 ))`) stores nothing.
7750        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
7751            return arith_push(value);
7752        }
7753        // c:Src/math.c:395 `xstack = stack` / c:455 `stack = xstack` —
7754        // `setmathvar` also memoises the value in the math-local read
7755        // cache (`mptr->pval`, c:340-343), which in C belongs to the
7756        // evaluation FRAME and dies with it. The compiled path has no
7757        // such frame, so without this save/restore the entry outlives
7758        // the expression: a later `sum=0` would update the parameter
7759        // while `getmathparam` kept answering from the cache, and the
7760        // next `(( sum += n ))` would resume from the stale total.
7761        // Every setmathvar write goes through to the parameter table
7762        // (`setnparam`, c:1005), so dropping the memo loses nothing but
7763        // one paramtab read. Saving rather than clearing keeps the
7764        // entries of an enclosing `matheval` frame intact, which is
7765        // what c:455 restores.
7766        let saved = crate::ported::math::m_variables_clone();
7767        // c:Src/math.c:1370-1371 — `c = setmathvar(mvp, c); push(c, …)`:
7768        // the stored value, converted to the parameter's type
7769        // (c:1014-1030), is the value of the assignment.
7770        let stored = crate::ported::math::setmathvar(&name, value, pval);
7771        crate::ported::math::m_variables_set(saved);
7772        arith_push(stored)
7773    });
7774
7775    // c:Src/exec.c:5267 execarith tail + c:Src/math.c:1500 lastmathval.
7776    vm.register_builtin(BUILTIN_ARITH_CMD_FINISH_VAL, |vm, _argc| {
7777        use std::sync::atomic::Ordering;
7778        let val = arith_pop_mnumber(vm);
7779        // c:1500 `lastmathval = z` — recorded even when the value is
7780        // only used for the exit status, because a `functions -M` math
7781        // function returns whatever its last `(( … ))` left here
7782        // (c:1115).
7783        crate::ported::math::M_LASTMATHVAL.with(|c| c.set(val));
7784        let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
7785        if live & crate::ported::zsh_h::ERRFLAG_ERROR != 0 {
7786            // Same split as BUILTIN_ARITH_CMD_FINISH: a HARD error
7787            // (`${v:?msg}`) must keep aborting the script, a soft math
7788            // error is consumed here so the next statement still runs.
7789            if live & crate::ported::zsh_h::ERRFLAG_HARD == 0 {
7790                crate::ported::utils::errflag
7791                    .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
7792            }
7793            vm.last_status = 2;
7794            return Value::Status(2);
7795        }
7796        // c:5267 — `(( 0 ))` is false, anything else is true.
7797        let status = i32::from(!mn_is_true(val));
7798        vm.last_status = status;
7799        Value::Status(status)
7800    });
7801
7802    // c:Src/subst.c:822/830 `if (glbsub) shtokenize(dest)` for the
7803    // `${~spec}` / `$~spec` FLAG, where the compiler emits no
7804    // GLOB_SUBST guard because the metas are meant to be active. Only
7805    // the value's backslashes still need settling — c:Src/glob.c:3651
7806    // leaves the ones before a non-`ztokens` char as data.
7807    // See BUILTIN_PAT_DATA_BACKSLASH docs below for full rationale.
7808    vm.register_builtin(BUILTIN_PAT_DATA_BACKSLASH, |vm, _argc| {
7809        let p = vm.pop().to_str();
7810        // The word arrives in EXPAND_TEXT mode 12's tokenized form (see the
7811        // untokenize there). This leg wants every metacharacter ACTIVE anyway
7812        // — `${~spec}` forced them — so both spellings collapse to the same
7813        // raw character here, exactly as they did when mode 9 untokenized one
7814        // step earlier.
7815        let p = crate::lex::untokenize(&p);
7816        let p = crate::pattern_data_escape::escape_data_backslashes(&p);
7817        // c:Src/glob.c:3575-3580 + 3617-3620 — the `shtokenize` this
7818        // builtin stands in for adds ZSHTOK_SHGLOB under SH_GLOB, and
7819        // that flag makes `(`, `|` and `)` of the VALUE stay ordinary
7820        // characters. Without this the value's parens reach patcompile
7821        // as live metacharacters and `emulate ksh; p='-([AMO]*|[0CRSWnsw])';
7822        // [[ "-s" = $~p ]]` becomes a bad pattern where zsh just fails
7823        // to match.
7824        if crate::pattern_data_escape::shglob_hides_parens() {
7825            return Value::str(crate::pattern_data_escape::escape_shglob_parens(
7826                &p,
7827                crate::pattern_data_escape::dropin_keeps_ksh_groups(),
7828            ));
7829        }
7830        Value::str(p)
7831    });
7832
7833    // c:Src/options.c GLOB_SUBST + Src/cond.c:552 cond_match.
7834    // Pop pattern string; when GLOB_SUBST is OFF, escape every glob
7835    // metachar with `\` so the downstream StrMatch + patcompile
7836    // treat them as literals (matching C's tokenization-based
7837    // gate). When GLOB_SUBST is ON, only the data-backslash respelling
7838    // runs and the metas stay active.
7839    // See BUILTIN_GLOB_SUBST_GUARD docs below for full rationale.
7840    vm.register_builtin(BUILTIN_GLOB_SUBST_GUARD, |vm, _argc| {
7841        let p = vm.pop().to_str();
7842        let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
7843        if glob_subst {
7844            // c:Src/subst.c:822/830 `if (glbsub) shtokenize(dest)` — the
7845            // value's metas go ACTIVE, but c:Src/glob.c:3651 still leaves a
7846            // backslash before a non-`ztokens` char as ordinary data. Respell
7847            // those in the normalizer's literal-backslash form; the ones
7848            // `zshtokenize` WOULD fold into a quote are left alone.
7849            // docs/BUGS.md #1090.
7850            let p = crate::pattern_data_escape::escape_data_backslashes(&p);
7851            // c:Src/glob.c:3575-3580 + 3617-3620 — same `shtokenize` call,
7852            // so the ZSHTOK_SHGLOB suppression of `(` / `|` / `)` applies
7853            // to the GLOB_SUBST leg exactly as it does to the `${~spec}`
7854            // leg above.
7855            if crate::pattern_data_escape::shglob_hides_parens() {
7856                return Value::str(crate::pattern_data_escape::escape_shglob_parens(
7857                    &p,
7858                    crate::pattern_data_escape::dropin_keeps_ksh_groups(),
7859                ));
7860            }
7861            return Value::str(p);
7862        }
7863        // The metacharacters that have to be spelled `\X` to reach
7864        // `ported::pattern`'s normalizer as ordinary characters.
7865        //
7866        // c:Src/lex.c:1390-1404 — `-` / `!` are Dash / Bang TOKENS only when
7867        // the LEXER sees them unquoted; pattern.c's range parser (c:1483) and
7868        // negation test look for the tokens, so a SUBSTITUTED `-` / `!` must
7869        // stay an ordinary character with GLOB_SUBST off. Without those two,
7870        // `cset='^a-z'; [[ - = ["$cset"] ]]` built a live `a-z` range out of
7871        // substituted text.
7872        fn is_pattern_meta(c: char) -> bool {
7873            matches!(
7874                c,
7875                '*' | '?'
7876                    | '['
7877                    | ']'
7878                    | '('
7879                    | ')'
7880                    | '|'
7881                    | '<'
7882                    | '>'
7883                    | '#'
7884                    | '^'
7885                    | '~'
7886                    | '-'
7887                    | '!'
7888                    | '\\'
7889            )
7890        }
7891        // The character an ACTIVE metacharacter token stands for, or None for
7892        // anything that is not one.
7893        //
7894        // c:Src/glob.c:3640-3645 — `shtokenize` arms a metacharacter by
7895        // rewriting it to `(t - ztokens) + Pound`; c:Src/lex.c:38 `ztokens` is
7896        // the table that maps back. c:Src/zsh.h:159-183 is the metacharacter
7897        // range (Pound 0x84 … Bang 0x9c); c:Src/zsh.h:193-206's Snull / Dnull /
7898        // Bnull / Bnullkeep / Nularg are quote and null MARKERS, never
7899        // metacharacters, so they are excluded here and travel with the
7900        // literal run below — where `untokenize` gives them the same reading
7901        // they had when it ran one step earlier (Bnull → `\`, which this pass
7902        // then escapes to the normalizer's literal-backslash `\\`).
7903        fn active_pattern_token(c: char) -> Option<char> {
7904            let cu = c as u32;
7905            if !(0xe184..=0xe19c).contains(&cu) {
7906                return None;
7907            }
7908            let m = crate::ported::lex::ztokens
7909                .chars()
7910                .nth((cu - 0xe184) as usize)?;
7911            is_pattern_meta(m).then_some(m)
7912        }
7913        // !!! RUST-ONLY HELPER — no C counterpart. C needs no such pass: its
7914        // `patcompile` consumes the token encoding itself (c:Src/pattern.c:248
7915        // — `zpc_chars` holds Star / Quest / Inbrack / …, so a RAW `*` never
7916        // dispatches as ZPC_STAR and is literal by construction). zshrs's
7917        // `ported::pattern` normalizer uses a raw-ASCII encoding whose literal
7918        // form is `\X`, so C's one-byte ACTIVE-vs-DATA distinction has to be
7919        // re-SPELLED: a metacharacter `shtokenize` armed becomes the bare
7920        // character, a metacharacter that is a raw byte of a VALUE becomes
7921        // `\X`. A pattern word holds both at once — c:Src/subst.c:1551-1552
7922        // tokenizes the `(~j)` join SEPARATOR while the array ELEMENTS it joins
7923        // keep their raw bytes — which is why escaping the word wholesale (or
7924        // not at all) is wrong in both directions.
7925        //
7926        // Non-token text is untokenized in RUNS rather than per character, so
7927        // `untokenize`'s multi-character decodes still see their whole input:
7928        // the `Meta` pair (lex.rs's metafied high byte) and the
7929        // `Qstring Snull` … `Snull` span of a `$'…'` literal
7930        // (c:Src/subst.c:301-304).
7931        fn flush_literal_run(lit: &mut String, out: &mut String) {
7932            if lit.is_empty() {
7933                return;
7934            }
7935            for c in crate::lex::untokenize(lit).chars() {
7936                if is_pattern_meta(c) {
7937                    out.push('\\');
7938                }
7939                out.push(c);
7940            }
7941            lit.clear();
7942        }
7943        let mut out = String::with_capacity(p.len() * 2);
7944        let mut lit = String::new();
7945        let cs: Vec<char> = p.chars().collect();
7946        let mut i = 0;
7947        while i < cs.len() {
7948            let c = cs[i];
7949            // c:Src/glob.c:3593-3596 `case Meta: s++;` — `Meta` and the
7950            // character after it are ONE unit and neither is a token.
7951            if c as u32 == crate::ported::zsh_h::Meta as u32 && i + 1 < cs.len() {
7952                lit.push(c);
7953                lit.push(cs[i + 1]);
7954                i += 2;
7955                continue;
7956            }
7957            match active_pattern_token(c) {
7958                Some(m) => {
7959                    flush_literal_run(&mut lit, &mut out);
7960                    out.push(m);
7961                }
7962                None => lit.push(c),
7963            }
7964            i += 1;
7965        }
7966        flush_literal_run(&mut lit, &mut out);
7967        Value::str(out)
7968    });
7969
7970    // argc contract — the caller names the `spbreak` (c:Src/subst.c:1707,
7971    // :2563, :2567) its spelling implies, because c:3912's whole join/split
7972    // block is gated on it:
7973    //
7974    //   0  plain `${NAME[*]}`, unquoted and not `ssub`. `spbreak` is
7975    //      SH_WORD_SPLIT, read at RUN time (the option can be set by an
7976    //      earlier statement in the same compiled unit).
7977    //   1  "JOIN and STOP" — a PREFORK_SINGLE (`ssub`) context: a scalar
7978    //      assignment RHS (c:Src/exec.c:2603), a `typeset NAME=…` argument, a
7979    //      `case` word (c:Src/loop.c:611), a `[[ … ]]` operand
7980    //      (c:Src/cond.c:53) or the here-string word (c:Src/exec.c:4711).
7981    //      c:Src/subst.c:4226
7982    //        if (isarr && ssub) { val = sepjoin(aval, NULL, 1); isarr = 0; }
7983    //      "prefork() wants a scalar, so join no matter what else" — the join
7984    //      is the LAST thing that happens to the value, and `singsub`
7985    //      (c:Src/subst.c:514-525) never reaches `globlist`, so nothing splits
7986    //      it afterwards. c:3913's `force_split = !ssub && …` says the same.
7987    //   2  `${==NAME[*]}` — c:2563 clears `spbreak`, so the gate stays SHUT
7988    //      whatever SH_WORD_SPLIT says and the element vector survives.
7989    //   3  `${=NAME[*]}` / `${=NAME[@]}` — c:2567 sets `spbreak = 2`. c:3917
7990    //      joins and c:3932 `sepsplit` splits, but that split is
7991    //      BUILTIN_FORCE_SPLIT at the compile site, so this handler stops at
7992    //      the join rather than splitting twice. When c:3914-3927 leaves the
7993    //      value ARRAY-shaped it returns the array and FORCE_SPLIT's c:3931
7994    //      `!isarr` gate passes it through.
7995    vm.register_builtin(BUILTIN_ARRAY_JOIN_STAR, |vm, argc| {
7996        let join_no_split = argc == 1;
7997        let name = vm.pop().to_str();
7998        let (elems, joined, ifs_opt, in_dq) = with_executor(|exec| {
7999            // c:Src/params.c — `"$*"` joins by IFS[0]. zsh
8000            // distinguishes IFS=unset (→ default `" "`) from
8001            // IFS="" (→ EMPTY separator → fields concatenate).
8002            // chars().next() collapsed both into the default, so
8003            // IFS="" was treated as IFS=" ".
8004            // c:Src/params.c:4745 `ifssetfn` — the C `ifs` global is NULL only
8005            // when IFS is genuinely UNSET, and c:3919's `(!ifs && …)` turns on
8006            // exactly that distinction, so carry the Option, not a defaulted
8007            // string.
8008            let ifs_opt = exec.scalar("IFS");
8009            let ifs_full = ifs_opt
8010                .clone()
8011                .unwrap_or_else(|| " \t\n".to_string());
8012            let sep = ifs_full
8013                .chars()
8014                .next()
8015                .map(|c| c.to_string())
8016                .unwrap_or_default();
8017            let in_dq = exec.in_dq_context > 0;
8018            // c:Src/subst.c:3914 — the join at c:3917 is `sepjoin(aval, …)`,
8019            // i.e. it runs on the ELEMENT vector, and c:3912's gate can leave
8020            // that vector untouched. Keep both shapes: the joined scalar for
8021            // the quoted / join-and-stop arms, and the elements for the plain
8022            // command-argument arm that must not join at all.
8023            let (elems, _) = param_value_elems_c2916(exec, &name);
8024            let joined = elems.join(&sep);
8025            (elems, joined, ifs_opt, in_dq)
8026        });
8027        // In QUOTED (`"${name[*]}"`) context the result IS a single scalar —
8028        // c:Src/subst.c:3033 `if (qt && !getlen && isarr > 0) { val =
8029        // sepjoin(aval, sep, 1); isarr = 0; }`. And argc 1 is the caller
8030        // saying it wants the join without the split (see the contract above).
8031        if in_dq || join_no_split {
8032            return Value::str(joined);
8033        }
8034        // c:Src/subst.c:3912 — the join-then-split block is gated:
8035        //
8036        //     if (ssub || spbreak || spsep || sep || quoted_array_with_offset) {
8037        //         int force_split = !ssub && (spbreak || spsep);
8038        //
8039        // `sep` / `spsep` are the `(j:…:)` / `(s:…:)` separators, which never
8040        // reach this fast path (a flagged expansion goes to paramsubst), and
8041        // the join-and-stop and quoted arms returned above. So the only way in
8042        // left from here is `spbreak`, c:Src/subst.c:1707
8043        //     int spbreak = (pf_flags & PREFORK_SHWORDSPLIT)
8044        //                   && !(pf_flags & PREFORK_SINGLE) && !qt;
8045        // i.e. SH_WORD_SPLIT — or an explicit `${=…}` / `${==…}`, which the
8046        // argc contract above hands down because c:2563/:2567 override the
8047        // option. Without `spbreak` the block does not run at all and the
8048        // expansion keeps its ARRAY shape untouched — `${a[*]}` unquoted is the
8049        // same splat as `${a[@]}`, elements intact:
8050        //     a=("p:q" r); IFS=:; print -rl -- ${a[*]}     -> `p:q`  `r`
8051        //     a=("p q" r);        print -rl -- ${a[*]}     -> `p q`  `r`
8052        //     a=(x y);     IFS=;  print -rl -- ${a[*]}     -> `x`    `y`
8053        // This handler ran the join and the split unconditionally, so the
8054        // first two came back as three words and the third — an EMPTY IFS
8055        // joins with "" and then has no separator left to split on — as the
8056        // single word `xy`.
8057        //
8058        // The empty-word removal below is c:Src/subst.c:184-187 `uremnode`,
8059        // the same prefork pass the `[@]` splat gets from
8060        // BUILTIN_ARRAY_DROP_EMPTY at its compile site; the joined+split path
8061        // used to supply it as a side effect of its `filter`.
8062        let spbreak = match argc {
8063            // c:2563 — `${==NAME[*]}` clears spbreak whatever the option says.
8064            2 => 0,
8065            // c:2567 — `${=NAME[*]}` / `${=NAME[@]}` force it to 2.
8066            3 => 2,
8067            _ => i32::from(crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT)),
8068        };
8069        if spbreak == 0 {
8070            return splice_words_value(prefork_c184_drop_empty(elems));
8071        }
8072        match join_c3914(elems, ifs_opt.as_deref()) {
8073            // c:3914-3927 left `isarr` non-zero, so c:3931's `!isarr` keeps the
8074            // split off as well and the ORIGINAL elements are the answer.
8075            JoinC3914::Elements(kept) => {
8076                if argc == 3 {
8077                    // BUILTIN_FORCE_SPLIT runs next; its c:3931 gate passes an
8078                    // array through and applies the empty-word removal itself.
8079                    return Value::array(kept.into_iter().map(Value::str).collect());
8080                }
8081                splice_words_value(prefork_c184_drop_empty(kept))
8082            }
8083            JoinC3914::Joined(val) => {
8084                if argc == 3 {
8085                    // c:3932's `sepsplit` is BUILTIN_FORCE_SPLIT at the compile
8086                    // site — hand it the joined scalar rather than split twice.
8087                    return Value::str(val);
8088                }
8089                split_words_c3932(&val)
8090            }
8091        }
8092    });
8093
8094    // argc contract — `0` and `1` are the historical callers (the bare
8095    // `$@`/`$*`/`$argv` read, `${^NAME}`, a for-list `$NAME`, a QUOTED
8096    // `"${NAME[@]}"`), which take the splat exactly as read. `2` and `3` are
8097    // the `${NAME[@]}` / `${=NAME[@]}` splice fast paths asking for
8098    // c:Src/subst.c:3912-3939 to run over the result — see
8099    // BUILTIN_ARRAY_JOIN_STAR's contract, whose `0` and `3` these mirror.
8100    // c:3030-3032 forces `isarr = -1` for a `[*]` splice whenever `nojoin` is
8101    // set, so `[@]` and `[*]` need the SAME answer here and share the code.
8102    vm.register_builtin(BUILTIN_ARRAY_ALL, |vm, argc| {
8103        let name = vm.pop().to_str();
8104        // c:Src/params.c:2027-2029 — a `[@]`/`[*]` subscript sets
8105        // SCANPM_ISVAR_AT, i.e. `isarr != 0` (c:2915). An empty result is
8106        // therefore an empty ARRAY, and plan9 deletes the whole word
8107        // (c:4362) rather than keeping the surrounding text.
8108        //
8109        // The note describes the EMPTY value this expansion produces, so it
8110        // must not survive a NON-empty one: the bit is read by concat_plan9
8111        // when the word folds left-associatively, and a later non-empty
8112        // segment overwriting it made `setopt rcexpandparam; e=''; a=(x y);
8113        // print -rl -- ${e}${a}` delete the whole word instead of printing
8114        // `x` and `y` (c:4437 keeps the surrounding text for a scalar
8115        // empty; only c:4362's empty ARRAY deletes it). Restore the
8116        // incoming bit whenever the result is not an empty array.
8117        let saved_empty_is_scalar = empty_is_scalar();
8118        note_empty_is_scalar(false);
8119        let array_all = |vm: &mut fusevm::VM| -> Value {
8120            let _ = &vm;
8121            // bash `"${PIPESTATUS[@]}"` / `"${FUNCNAME[@]}"` / `"${BASH_VERSINFO[@]}"`
8122            // splat — alias the zsh-native special. No-op in --zsh.
8123            if let Some(v) = crate::dash_mode::bash_special_array(&name) {
8124                return Value::array(v.into_iter().map(Value::str).collect());
8125            }
8126            with_executor(|exec| {
8127                // Special positional names — splice the positional list.
8128                if name == "@" || name == "*" || name == "argv" {
8129                    return Value::array(exec.pparams().iter().map(Value::str).collect());
8130                }
8131                // c:Src/Modules/parameter.c — funcstack/funcfiletrace/
8132                // funcsourcetrace/functrace are PM_ARRAY|PM_READONLY
8133                // specials backed by the canonical FUNCSTACK Vec.
8134                // `${funcstack[@]}` inside a function call should splat
8135                // the innermost-first names; without this branch the
8136                // runtime fell to the scalar fallback (get_variable
8137                // returns empty for these specials) and `[@]` came out
8138                // empty. Bug #276 in docs/BUGS.md. Mirrors the parallel
8139                // arrays_get handler at src/ported/subst.rs ~10685.
8140                // c:Src/Modules/datetime.c:256 — `epochtime` PM_ARRAY|
8141                // PM_READONLY backed by getcurrenttime(). Same parallel
8142                // arrangement as the FUNCSTACK-backed specials below.
8143                if name == "epochtime" {
8144                    // c:Src/params.c:589-594 getparamnode → c:563-585 loadparamnode —
8145                    // the `[@]` splat resolves the NAME, clearing PM_AUTOLOAD so
8146                    // paramtypestr (c:Src/Modules/parameter.c:48-50) reports the real
8147                    // type. Mirrors the arrays_get arm in src/ported/subst.rs.
8148                    if !crate::vm_helper::magic_special_shadowed(&name) {
8149                        crate::vm_helper::mark_module_param_used(&name);
8150                    }
8151                    let arr = crate::ported::modules::datetime::getcurrenttime();
8152                    return Value::array(arr.into_iter().map(Value::str).collect());
8153                }
8154                if matches!(
8155                    name.as_str(),
8156                    "funcstack" | "funcfiletrace" | "funcsourcetrace" | "functrace"
8157                ) {
8158                    // c:Src/params.c:589-594 — see the epochtime arm above.
8159                    if !crate::vm_helper::magic_special_shadowed(&name) {
8160                        crate::vm_helper::mark_module_param_used(&name);
8161                    }
8162                    // Route the three trace arrays through the canonical
8163                    // ported getfns (Src/Modules/parameter.c:648/:679/:711)
8164                    // — the previous inline copy emitted wrong shapes
8165                    // (bare filename for funcfiletrace, `name:lineno` for
8166                    // functrace instead of `caller:lineno`); same dedup as
8167                    // the parallel arrays_get handler in subst.rs.
8168                    let vals: Vec<String> = match name.as_str() {
8169                        "funcstack" => crate::ported::modules::parameter::FUNCSTACK
8170                            .lock()
8171                            .map(|f| f.iter().rev().map(|fs| fs.name.clone()).collect())
8172                            .unwrap_or_default(),
8173                        "funcfiletrace" => crate::ported::modules::parameter::funcfiletracegetfn(
8174                            std::ptr::null_mut(),
8175                        ),
8176                        "funcsourcetrace" => {
8177                            crate::ported::modules::parameter::funcsourcetracegetfn(
8178                                std::ptr::null_mut(),
8179                            )
8180                        }
8181                        _ => {
8182                            crate::ported::modules::parameter::functracegetfn(std::ptr::null_mut())
8183                        }
8184                    };
8185                    return Value::array(vals.into_iter().map(Value::str).collect());
8186                }
8187                // c:Src/params.c — `${assoc[@]}` enumerates VALUES (per
8188                // params.c:1696-1750 hashparam splat). Check assoc
8189                // storage BEFORE the scalar fallback so an associative
8190                // array named X resolves `${X[@]}` to the values, not
8191                // empty. Bug #109 in docs/BUGS.md: `${h[@]}` on an
8192                // assoc routed through BUILTIN_ARRAY_ALL, which only
8193                // consulted `exec.array(name)` (the indexed-array map)
8194                // — that lookup missed for assocs, fell through to
8195                // `get_variable("h")` (also empty for an assoc-only
8196                // name), and returned `Array(vec![])`. zsh's expected
8197                // behavior is to enumerate values.
8198                if let Some(assoc_map) = exec.assoc(&name) {
8199                    return Value::array(assoc_map.values().cloned().map(Value::str).collect());
8200                }
8201                match exec.array(&name) {
8202                    Some(v) => {
8203                        // bash sparse arrays: `"${a[@]}"` splats only LIVE
8204                        // elements, dropping hole slots (`a[5]=q` padding,
8205                        // `unset a[i]`). No-op in --zsh (no holes tracked).
8206                        let v = crate::bash_arrays::compact(&name, v);
8207                        Value::array(v.iter().map(Value::str).collect())
8208                    }
8209                    None => {
8210                        // c:Src/Modules/parameter.c:2235-2298 partab[] — the
8211                        // PM_HASHED magic assocs (aliases/functions/parameters/
8212                        // options/commands/builtins/modules/widgets/nameddirs/…)
8213                        // are real hash params in C, so `${aliases[@]}` takes the
8214                        // ordinary getvaluearr path and enumerates their VALUES.
8215                        // zshrs keeps them OUT of the executor's assoc storage
8216                        // (they are synthesized on demand by `subst::assoc_get`),
8217                        // so the `exec.assoc` probe above missed and this arm fell
8218                        // through to the scalar fallback, which returned an EMPTY
8219                        // array: `alias foo=bar; print -r -- "${aliases[@]}"` gave
8220                        // nothing where zsh gives `bar man whence`. Every other
8221                        // form already routed through paramsubst's own magic-assoc
8222                        // arms; only the flagless `[@]`/`[*]` splat compiles to
8223                        // BUILTIN_ARRAY_ALL and reached here.
8224                        //
8225                        // Placed in the `None` arm so a real indexed array or a
8226                        // user-defined assoc of the same name still wins, and so
8227                        // no ordinary array read pays for the PARTAB scan.
8228                        //
8229                        // Same gap on the PM_ARRAY side (c:2239-2291 partab[]
8230                        // rows: reswords/dis_reswords/patchars/dis_patchars/…):
8231                        // `getaparam` reads `pm->u.arr`, which is NULL on the
8232                        // placeholder node zshrs installs for a getfn-backed
8233                        // special, so `${reswords[@]}` splatted nothing. Route
8234                        // through the canonical `arrays_get` getfn dispatch.
8235                        if let Some(arr) = crate::ported::subst::arrays_get(&name) {
8236                            return Value::array(arr.into_iter().map(Value::str).collect());
8237                        }
8238                        if let Some(map) = crate::ported::subst::assoc_get(&name) {
8239                            return Value::array(map.values().cloned().map(Value::str).collect());
8240                        }
8241                        // Fall back to scalar lookup. zsh (unlike bash)
8242                        // does NOT IFS-split a scalar variable in a for
8243                        // list — `for w in $scalar` iterates ONCE with the
8244                        // scalar value. Word-splitting requires either
8245                        // sh_word_split option or explicit `${(s.,.)scalar}`.
8246                        let val = exec.get_variable(&name);
8247                        if val.is_empty() && !exec.has_scalar(&name) && env::var(&name).is_err() {
8248                            // c:Src/subst.c:3480-3485 — `${arr[@]}` on a genuinely
8249                            // UNSET parameter under NO_UNSET is a "parameter not set"
8250                            // error (vunset > 0 && unset(UNSET)), exactly like the
8251                            // scalar `$arr`, the `${arr[*]}` splat, and `${arr[1]}`
8252                            // — all of which already fire it via GET_VAR. The `[@]`
8253                            // splat path returned an empty array silently, so
8254                            // `setopt NO_UNSET; print "${arr[@]}"` exited 0 where zsh
8255                            // exits 1. A DECLARED-but-empty array (`arr=()`) resolves
8256                            // to `Some(vec![])` above and never reaches here, so it
8257                            // still splats to nothing without erroring — matching zsh.
8258                            if opt_state_get("nounset").unwrap_or(false) {
8259                                crate::ported::utils::zerr(&format!("{}: parameter not set", name));
8260                                crate::ported::utils::errflag.fetch_or(
8261                                    crate::ported::zsh_h::ERRFLAG_ERROR,
8262                                    std::sync::atomic::Ordering::Relaxed,
8263                                );
8264                                exec.set_last_status(1);
8265                            }
8266                            // c:Src/subst.c:3480-3485 — an UNSET parameter takes the
8267                            // `vunset` arm: `val = dupstring("")` with isarr left at
8268                            // 0. That is a SCALAR empty, so plan9 keeps the
8269                            // surrounding text (`setopt rcexpandparam;
8270                            // print -r -- "[${unset[@]}]"` → `[]`), unlike a
8271                            // DECLARED-but-empty array (`arr=()`, matched by the
8272                            // `Some(vec![])` arm above), which sets isarr and gets
8273                            // the word deleted at c:4362.
8274                            note_empty_is_scalar(true);
8275                            // c:Src/subst.c:3603-3610 — an UNSET parameter leaves `isarr` at 0
8276                            // and yields `val = ""`, i.e. a SCALAR empty, so a quoted
8277                            // `"${u[@]}"` is ONE empty word (`f "${u[@]}"` → $# == 1) while
8278                            // `"${empty_array[@]}"` is zero. Returning an empty ARRAY here
8279                            // collapsed both to zero words. The unquoted form still drops it:
8280                            // the compiler emits BUILTIN_ARRAY_DROP_EMPTY after this call for
8281                            // non-DQ splices (compile_zsh.rs:6149), and that builtin maps an
8282                            // empty Str to an empty array.
8283                            Value::str(String::new())
8284                        } else if opt_state_get("shwordsplit").unwrap_or(false)
8285                            // c:Src/subst.c:1707-1708 — `spbreak = (pf_flags &
8286                            // PREFORK_SHWORDSPLIT) && … && !qt`: a quoted
8287                            // `"${scalar[@]}"` (argc 4, see below) is never split.
8288                            && argc != 4
8289                        {
8290                            // c:3921 `aval = sepsplit(val, spsep, 0, 1)` — same
8291                            // splitter as `${=name}` (Src/utils.c:3711 spacesplit),
8292                            // not a naive `split().filter(non-empty)`: only the
8293                            // IFS-WHITESPACE-derived empty fields are elided; the
8294                            // `nulstring` ones an IFS-NON-whitespace separator makes
8295                            // survive (c:Src/subst.c:36).
8296                            let nulstring = crate::ported::zsh_h::Nularg.to_string();
8297                            let parts: Vec<Value> =
8298                                crate::ported::utils::sepsplit(&val, None, false)
8299                                    .into_iter()
8300                                    .filter_map(|w| {
8301                                        if w == nulstring {
8302                                            Some(Value::str(String::new()))
8303                                        } else if w.is_empty() {
8304                                            None // c:184-187 prefork uremnode
8305                                        } else {
8306                                            Some(Value::str(w))
8307                                        }
8308                                    })
8309                                    .collect();
8310                            Value::array(parts)
8311                        } else {
8312                            Value::array(vec![Value::str(val)])
8313                        }
8314                    }
8315                }
8316            })
8317        };
8318        let result = array_all(vm);
8319        if !matches!(&result, Value::Array(a) if a.is_empty()) {
8320            note_empty_is_scalar(saved_empty_is_scalar);
8321        }
8322        // argc 4 is the compile site's "this `[@]` splat is QUOTED" bit
8323        // (`compile_zsh.rs`, the `${NAME[@]}` fast path). c:Src/subst.c:4387
8324        // `if (qt && !*y && isarr != 2) y = dupstring(nulstring)` then keeps
8325        // this read's empty elements alive through c:186 — but only above
8326        // c:4261's two-element cut-off, which is why the count is applied here
8327        // and not at the compile site. See `QUOTED_SPLICE_KEEPS_EMPTIES`.
8328        // Every other argc is an UNQUOTED (or `ssub`-joined) read, so it must
8329        // CLEAR the bit rather than leave a previous word's set: a whole-word
8330        // `"${a[@]}"` emits no end-of-word drop, so nothing else spends it.
8331        let quoted_splat_elems = match &result {
8332            Value::Array(items) if argc == 4 => items.len(),
8333            _ => 0,
8334        };
8335        note_quoted_splice_elems(quoted_splat_elems);
8336        // c:Src/subst.c:3912-3939 for the `${NAME[@]}` splice fast paths. Only
8337        // an ARRAY-shaped result reaches the block (c:3914 `if (isarr || …)`);
8338        // the scalar arms above — an unset parameter (c:3480-3485), the
8339        // scalar-fallback read — leave `isarr` at 0 and pass straight through.
8340        if !matches!(argc, 2 | 3) {
8341            return result;
8342        }
8343        let Value::Array(items) = &result else {
8344            return result;
8345        };
8346        let elems: Vec<String> = items.iter().map(|v| v.to_str()).collect();
8347        // c:1707 / :2567 — `spbreak` is SH_WORD_SPLIT for a plain `${NAME[@]}`
8348        // (argc 2), forced to 2 by the `=` flag (argc 3).
8349        let spbreak = if argc == 3 {
8350            2
8351        } else {
8352            i32::from(crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT))
8353        };
8354        // The gate never opens / neither join arm fires: no join, no split,
8355        // only c:184-187's empty-word removal — which the compile site used to
8356        // append as a separate BUILTIN_ARRAY_DROP_EMPTY, and that builtin
8357        // always returned an ARRAY. Keep that exact shape so nothing
8358        // downstream (concat, plan9, argv splice) sees a one-element splat turn
8359        // into a scalar.
8360        // c:Src/subst.c:4366-4437 then c:183-186 — the removal tests the
8361        // FINISHED node, after the word's text is glued onto the first and
8362        // last elements. When this read is a segment of such a word
8363        // (BUILTIN_WORD_DEFER_EMPTIES opened it) the word's own end-of-word
8364        // drop runs that test; removing the empty elements here, before the
8365        // concat, dropped the ones the affixes land on:
8366        // `a=(x y ""); print -rl -- p${a[@]}q` is `px` `y` `q` in zsh.
8367        let defer_to_word = crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get()) > 0;
8368        let untouched = |e: Vec<String>| {
8369            let e = if defer_to_word { e } else { prefork_c184_drop_empty(e) };
8370            Value::array(e.into_iter().map(Value::str).collect())
8371        };
8372        if spbreak == 0 {
8373            return untouched(elems);
8374        }
8375        let ifs_opt = with_executor(|exec| exec.scalar("IFS"));
8376        match join_c3914(elems, ifs_opt.as_deref()) {
8377            JoinC3914::Elements(kept) => {
8378                if argc == 3 {
8379                    Value::array(kept.into_iter().map(Value::str).collect())
8380                } else {
8381                    untouched(kept)
8382                }
8383            }
8384            JoinC3914::Joined(val) => {
8385                if argc == 3 {
8386                    // BUILTIN_FORCE_SPLIT is c:3932 at the compile site.
8387                    Value::str(val)
8388                } else if defer_to_word {
8389                    // c:3932 sepsplit, then c:184-187 on the FINISHED word: the
8390                    // enclosing word's end-of-word drop removes the truly empty
8391                    // edge fields after its text is glued on and strips the c:36
8392                    // `nulstring` markers. `setopt shwordsplit; IFS=:; a=(a ""
8393                    // b); print -rl -- x${a[@]}y` is `xa` `` `by`.
8394                    Value::array(
8395                        crate::ported::utils::sepsplit(&val, None, false)
8396                            .into_iter()
8397                            .map(Value::str)
8398                            .collect(),
8399                    )
8400                } else {
8401                    splice_words_value(sepsplit_c3932(&val, false))
8402                }
8403            }
8404        }
8405    });
8406
8407    // BUILTIN_ARRAY_FLATTEN(N): pops N values, flattens one level of Array
8408    // nesting, pushes the resulting Array AND its length as a separate Int.
8409    // The two-value return shape lets the caller (for-loop compile path)
8410    // SetSlot the length before SetSlot'ing the array, without re-deriving
8411    // the length from the array via a second builtin call.
8412    // `coproc [name] { body }` — bidirectional pipe to backgrounded body.
8413    // Stack discipline (top first): [name (str, "" for default), sub_idx (int)].
8414    // On success: parent's `executor.arrays[name]` becomes [write_fd, read_fd]
8415    // and Status(0) is returned. The caller writes to the child's stdin via
8416    // write_fd, reads its stdout via read_fd, and closes both when done.
8417    //
8418    // Bash's coproc convention is `${NAME[0]}` = read_fd, `${NAME[1]}` =
8419    // write_fd. We follow that: arrays[name] = [read_fd_str, write_fd_str].
8420    vm.register_builtin(BUILTIN_RUN_COPROC, |vm, _argc| {
8421        let sub_idx = vm.pop().to_int() as usize;
8422        let job_text = vm.pop().to_str();
8423        let raw_name = vm.pop().to_str();
8424        let name = if raw_name.is_empty() {
8425            "COPROC".to_string()
8426        } else {
8427            raw_name
8428        };
8429        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
8430            Some(c) => c,
8431            None => return Value::Status(1),
8432        };
8433
8434        // c:Src/exec.c:1760 — `thisjob = newjob = initjob()` comes BEFORE the
8435        // old coproc's fds are closed (c:1768-1772), so that coproc, which
8436        // only starts exiting once they close, still holds its job slot and
8437        // the new one gets the next number: `coproc cat; …; coproc cat; jobs`
8438        // lists `[2]`. Taking the slot after the fork raced the reaper.
8439        let newjob = {
8440            let table = crate::ported::jobs::JOBTAB
8441                .get_or_init(|| std::sync::Mutex::new(Vec::new()));
8442            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
8443            crate::ported::jobs::initjob(&mut tab)
8444        };
8445        // A coproc that never starts gives the slot back.
8446        let release_newjob = || {
8447            if let Some(table) = crate::ported::jobs::JOBTAB.get() {
8448                let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
8449                tab[newjob].stat = 0;
8450            }
8451        };
8452        // c:Src/exec.c:1710-1712 — starting a new coproc closes the
8453        // previous one's fds FIRST:
8454        //     if (coprocin >= 0) { zclose(coprocin); zclose(coprocout); }
8455        // The old coproc child then sees EOF on its stdin and exits on
8456        // its own schedule (its job-table entry stays until it's
8457        // reaped) — zsh does NOT deletejob it here. This is also what
8458        // makes the `exec 4<&p; coproc exit; read -u4` EOF idiom work:
8459        // the replacement coproc closes the shell's write end to the
8460        // old one.
8461        {
8462            use std::sync::atomic::Ordering;
8463            let old_in = crate::ported::modules::clone::coprocin.load(Ordering::Relaxed);
8464            if old_in >= 0 {
8465                let old_out = crate::ported::modules::clone::coprocout.load(Ordering::Relaxed);
8466                unsafe {
8467                    libc::close(old_in);
8468                    if old_out >= 0 {
8469                        libc::close(old_out);
8470                    }
8471                }
8472                crate::ported::modules::clone::coprocin.store(-1, Ordering::Relaxed);
8473                crate::ported::modules::clone::coprocout.store(-1, Ordering::Relaxed);
8474            }
8475        }
8476
8477        // (parent_read ← child_stdout)
8478        let mut p2c = [0i32; 2]; // parent writes, child reads
8479        let mut c2p = [0i32; 2]; // child writes, parent reads
8480        if unsafe { libc::pipe(p2c.as_mut_ptr()) } < 0 {
8481            release_newjob();
8482            return Value::Status(1);
8483        }
8484        if unsafe { libc::pipe(c2p.as_mut_ptr()) } < 0 {
8485            unsafe {
8486                libc::close(p2c[0]);
8487                libc::close(p2c[1]);
8488            }
8489            release_newjob();
8490            return Value::Status(1);
8491        }
8492        // c:Src/exec.c:5160 mpipe — both pipes' fds are moved above
8493        // the user-visible range (movefd → F_DUPFD ≥ 10) so the
8494        // coproc fds never collide with explicit user fds like
8495        // `exec 3>&p`.
8496        for fd in p2c.iter_mut().chain(c2p.iter_mut()) {
8497            *fd = crate::ported::utils::movefd(*fd);
8498        }
8499
8500        // c:Src/exec.c:1748/1815 — a coproc is spawned by execpline's
8501        // Z_ASYNC arm, inside the same `child_block()` span as `cmd &`.
8502        #[cfg(unix)]
8503        let _child_block = ChildBlockSpan::enter();
8504
8505        match unsafe { libc::fork() } {
8506            -1 => {
8507                unsafe {
8508                    libc::close(p2c[0]);
8509                    libc::close(p2c[1]);
8510                    libc::close(c2p[0]);
8511                    libc::close(c2p[1]);
8512                }
8513                release_newjob();
8514                Value::Status(1)
8515            }
8516            0 => {
8517                forked_child_subsh_levels(); // c:Src/exec.c:1221
8518                EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
8519                // Child: stdin from p2c[0], stdout to c2p[1]. Close all
8520                // unused fds. setsid so SIGINT to fg doesn't hit us.
8521                unsafe {
8522                    libc::dup2(p2c[0], libc::STDIN_FILENO);
8523                    libc::dup2(c2p[1], libc::STDOUT_FILENO);
8524                    libc::close(p2c[0]);
8525                    libc::close(p2c[1]);
8526                    libc::close(c2p[0]);
8527                    libc::close(c2p[1]);
8528                    libc::setsid();
8529                }
8530                crate::fusevm_disasm::maybe_print_stdout("coproc:child", &chunk);
8531                let mut co_vm = fusevm::VM::new(chunk);
8532                register_builtins(&mut co_vm);
8533                let _ = co_vm.run();
8534                let _ = std::io::stdout().flush();
8535                let _ = std::io::stderr().flush();
8536                std::process::exit(co_vm.last_status);
8537            }
8538            pid => {
8539                // Parent: close child ends, store [read_fd, write_fd] in NAME.
8540                unsafe {
8541                    libc::close(p2c[0]);
8542                    libc::close(c2p[1]);
8543                }
8544                let read_fd = c2p[0];
8545                let write_fd = p2c[1];
8546                with_executor(|exec| {
8547                    exec.unset_scalar(&name);
8548                    exec.set_array(name, vec![read_fd.to_string(), write_fd.to_string()]);
8549                });
8550                // c:Src/exec.c — `coprocin`/`coprocout` are the
8551                // canonical globals that bin_read's `-p` arm
8552                // (Src/builtin.c:6510) and bin_print's `-p` arm
8553                // (Src/builtin.c:4827) read to find the
8554                // coprocess fds. The Rust port has the atomic
8555                // declarations at src/ported/modules/clone.rs:262
8556                // but the coproc-launch path never updated them,
8557                // so `read -p` / `print -p` always errored with
8558                // "-p: no coprocess" even when a coproc was
8559                // running. Bug #388 in docs/BUGS.md. Update them
8560                // here so the canonical builtins find the live
8561                // pipe.
8562                crate::ported::modules::clone::coprocin
8563                    .store(read_fd, std::sync::atomic::Ordering::Relaxed);
8564                crate::ported::modules::clone::coprocout
8565                    .store(write_fd, std::sync::atomic::Ordering::Relaxed);
8566                // c:Src/exec.c:1725 — `fdtable[coprocin] =
8567                // fdtable[coprocout] = FDT_UNUSED;`: the two kept ends
8568                // are user-reachable (via `>&p` / `<&p`), so they drop
8569                // the FDT_INTERNAL mark movefd gave them.
8570                crate::ported::utils::fdtable_set(read_fd, crate::ported::zsh_h::FDT_UNUSED);
8571                crate::ported::utils::fdtable_set(write_fd, crate::ported::zsh_h::FDT_UNUSED);
8572                // c:Src/exec.c:2837 — `lastpid = (zlong) pid;`. zsh
8573                // sets the `$!` global to the coproc child's PID so
8574                // subsequent `$!` reads return it. The Rust port at
8575                // exec.rs:6773 mirrors this for regular background
8576                // jobs but the coproc launch path was missing the
8577                // assignment, leaving `$!` at 0 after `coproc cmd`.
8578                crate::ported::modules::clone::lastpid
8579                    .store(pid, std::sync::atomic::Ordering::Relaxed);
8580                // c:Src/exec.c:1700-1758 — the coproc rides the SAME
8581                // Z_ASYNC job-table path as `cmd &`: `thisjob = newjob
8582                // = initjob()` (c:1700), addproc hangs the pid+text
8583                // proc entry off the job, `jobtab[thisjob].stat |=
8584                // STAT_NOSTTY` (c:1746), `clearoldjobtab()` (c:1744)
8585                // and `spawnjob()` (c:1758) promote it to curjob. This
8586                // is what makes `jobs` list the coproc as
8587                // `[1]  + running    cat` and `kill %1` resolve it.
8588                // Mirrors the BUILTIN_RUN_BG parent arm exactly.
8589                {
8590                    use crate::ported::jobs;
8591                    use std::sync::Mutex;
8592                    let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
8593                    let idx = {
8594                        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
8595                        let idx = newjob; // c:exec.c:1760, reserved above
8596                        jobs::addproc(
8597                            &mut tab[idx],
8598                            pid,
8599                            &job_text,
8600                            false,
8601                            Some(std::time::Instant::now()),
8602                            -1,
8603                            -1,
8604                        );
8605                        tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
8606                        idx
8607                    };
8608                    jobs::clearoldjobtab(); // c:exec.c:1744
8609                    if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
8610                        *tj = idx as i32;
8611                    }
8612                    jobs::spawnjob(); // c:exec.c:1758
8613                }
8614                with_executor(|exec| {
8615                    exec.jobs
8616                        .add_pid_job(pid, job_text.clone(), JobState::Running);
8617                });
8618                Value::Status(0)
8619            }
8620        }
8621    });
8622
8623    vm.register_builtin(BUILTIN_ARRAY_FLATTEN, |vm, argc| {
8624        // `${~spec}` carrier: a `for`/`select` WORD LIST is a word-
8625        // pipeline boundary too. In C the `globsubst` flag is a
8626        // paramsubst-LOCAL int (Src/subst.c:1671 `int globsubst =
8627        // isset(GLOBSUBST);`, forced to 2 by `${~…}` at
8628        // Src/subst.c:2603) whose only lasting effect is the
8629        // `shtokenize` of that substitution's own result — it never
8630        // reaches the option table, so `execfor`'s list prefork
8631        // (Src/loop.c:196-235) cannot leak it into the loop BODY.
8632        // zshrs carries the flag through the global option table so
8633        // the compile-emitted glob ops of the SAME word can see it
8634        // (documented deviation at subst.rs:3190), and restores it at
8635        // command-dispatch boundaries — but a `for` list has no
8636        // trailing dispatch of its own, so `for i in "${a:#${~p}*}"`
8637        // left GLOB_SUBST ON and filename-generated the FIRST body
8638        // command's words (`_parameters:34` → `ary+=($i:"$v")` glob-
8639        // erroring "bad pattern: HISTCHARS:!^#", which aborted the
8640        // whole `pr<TAB>` completion). This builtin ends EVERY for/
8641        // select list expansion and runs AFTER each word's
8642        // GLOB_SUBST_EXPAND op, so the carrier has been read by then.
8643        consume_tilde_globsubst_carrier();
8644        let n = argc as usize;
8645        let start = vm.stack.len().saturating_sub(n);
8646        let raw: Vec<Value> = vm.stack.drain(start..).collect();
8647        let mut flat: Vec<Value> = Vec::with_capacity(raw.len());
8648        for v in raw {
8649            match v {
8650                Value::Array(items) => flat.extend(items.iter().cloned()),
8651                other => flat.push(other),
8652            }
8653        }
8654        let len = flat.len() as i64;
8655        // Push the array first; the Int(len) becomes the builtin's return
8656        // value (which CallBuiltin already pushes). Caller consumes in
8657        // reverse: SetSlot(len_slot) pops Int, SetSlot(arr_slot) pops Array.
8658        vm.push(Value::array(flat));
8659        Value::Int(len)
8660    });
8661
8662    // Shell variable get/set — routes through executor.variables so nested
8663    // VMs (function calls) and tree-walker callers see the same storage.
8664    // GET_VAR / GET_VAR_DQ share one body via `get_var_impl`; the only
8665    // difference is `force_dq`, which the compiler sets for QUOTED simple
8666    // reads (`"$name"`) so an array's empty elements are preserved (the
8667    // `in_dq_context` runtime flag is 0 for these compiler-direct reads).
8668    fn get_var_impl(vm: &mut fusevm::VM, argc: u8, force_dq: bool) -> Value {
8669        let args = pop_args(vm, argc);
8670        let name = args.into_iter().next().unwrap_or_default();
8671        let live_status = vm.last_status;
8672        // `$@` and `$*` need splice semantics — return Value::Array of
8673        // positional params so for-loop's BUILTIN_ARRAY_FLATTEN spreads them
8674        // and pop_args splits them into argv slots. zsh's `"$@"` bslashquote-each-
8675        // word semantics matches: each pos-param becomes its own arg.
8676        // Same for arrays accessed by name (e.g. `$arr` in some contexts).
8677        //
8678        // vm.last_status is authoritative: `subshell_end` now returns
8679        // Some(status) and fusevm's `Op::SubshellEnd` writes it into
8680        // vm.last_status, so a deferred subshell `exit N` is visible
8681        // here. Suppressing this sync (as an older revision did, back
8682        // when the host hook returned nothing) made LASTVAL win over
8683        // any status the VM set AFTER SubshellEnd — which dropped the
8684        // `!` negation of `Src/exec.c:1979-1980`
8685        //   if ((slflags & WC_SUBLIST_NOT) && !errflag && !retflag)
8686        //       lastval = !lastval;
8687        // for `! (exit 7)` (emit_negate_status' SetStatus updated
8688        // vm.last_status, then `$?` read the stale LASTVAL=7).
8689        let sync_status = |exec: &mut ShellExecutor| {
8690            exec.set_last_status(live_status);
8691        };
8692        if name == "@" || name == "*" {
8693            // Quoting decides empty-word retention (c:Src/subst.c:
8694            // 184-187): the COMPILE site knows it and emits
8695            // BUILTIN_ARRAY_DROP_EMPTY after this read for the
8696            // unquoted form only — in_dq_context is NOT a valid
8697            // discriminator here (the quoted "$@" fast path emits
8698            // GET_VAR directly without an EXPAND_TEXT wrapper).
8699            let pp = with_executor(|exec| {
8700                sync_status(exec);
8701                exec.pparams()
8702            });
8703            // c:Src/subst.c:1817 — `int nojoin = (pf_flags &
8704            // PREFORK_SHWORDSPLIT) ? !(ifs && *ifs) && !qt : 0;`
8705            // c:Src/subst.c:3908-3911 — `if (nojoin == 0 || sep) { val =
8706            //     sepjoin(aval, sep, 1); isarr = 0; }`
8707            // c:Src/subst.c:3919-3921 — `if (force_split && !isarr) { aval =
8708            //     sepsplit(val, spsep, 0, 1); … }`
8709            //
8710            // So under SH_WORD_SPLIT an UNQUOTED `$@`/`$*` with a NON-EMPTY
8711            // `$IFS` is first JOINED on `$IFS[1]` and then re-split on `$IFS`
8712            // — which is why `setopt shwordsplit; set -- one:two b:c; IFS=:;
8713            // print -l $@` is four words in zsh (and in bash, and in ksh).
8714            // The port returned the raw positional list, so an element
8715            // carrying an IFS byte was never broken up
8716            // (D04parameter.ztst "Splitting of $@ on IFS: single element";
8717            // `zshrs --bash -c 'set -- "a b" c; printf "[%s]\n" $@'` printed
8718            // `[a b]` where bash prints `[a]` `[b]`).
8719            //
8720            // The gates are C's, verbatim: `!force_dq` is `!qt`, an UNSET or
8721            // EMPTY `$IFS` leaves `nojoin` at 1 (no join, no split), and the
8722            // whole rule is inert without SH_WORD_SPLIT (`nojoin = 0` there,
8723            // but `force_split` at c:3913 is `!ssub && (spbreak || spsep)`,
8724            // all clear, so neither branch runs).
8725            if !force_dq && crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT) {
8726                let ifs = crate::ported::params::getsparam("IFS").unwrap_or_default(); // c:1817
8727                if !ifs.is_empty() {
8728                    // c:3909 `sepjoin(aval, sep, 1)` with sep NULL → $IFS[1].
8729                    let sep0: String = ifs.chars().next().map(String::from).unwrap_or_default();
8730                    let joined = pp.join(&sep0);
8731                    // c:3919 `sepsplit(val, spsep, 0, 1)` with spsep NULL →
8732                    // spacesplit (c:Src/utils.c:3711), then c:184-187. The
8733                    // IFS-whitespace edge fields are truly empty and c:186
8734                    // deletes them only from the FINISHED word, after the
8735                    // word's text is glued on (c:4366-4437): `setopt
8736                    // shwordsplit; set -- "" x ""; print -rl -- p$@q` is `p`
8737                    // `x` `q`. So inside a word that defers that removal
8738                    // (BUILTIN_WORD_DEFER_EMPTIES) the fields go out raw, the
8739                    // c:36 `nulstring` markers included, exactly as
8740                    // BUILTIN_FORCE_SPLIT hands them over; a whole word gets
8741                    // the removal from the compile site's BUILTIN_ARRAY_DROP_EMPTY,
8742                    // which also strips the markers (c:170). multsub's
8743                    // PREFORK_SPLIT walker, used before, dropped the edge fields
8744                    // outright and folded `nulstring` to "" (`IFS=:; set -- a::b;
8745                    // print -rl -- $@` lost its middle field).
8746                    let parts = crate::ported::utils::sepsplit(&joined, None, false); // c:3919, Nularg kept
8747                    return Value::array(parts.into_iter().map(Value::str).collect());
8748                }
8749            }
8750            // c:Src/subst.c:4387 — `"$@"` is the quoted splat of the positional
8751            // list, so its empty elements become `nulstring` and survive
8752            // c:186: `set -- x '' y; PRE"$@"POST` is three words in zsh. `*` is
8753            // excluded because c:3032's quoted `sepjoin` has already collapsed
8754            // it to one scalar (the compile site routes `"$*"` through
8755            // BUILTIN_EXPAND_TEXT and discards this value). c:4261's
8756            // element-count cut-off is applied by `note_quoted_splice_elems`.
8757            // An UNQUOTED `$@` passes 0, which CLEARS the bit: a preceding
8758            // whole-word `"${a[@]}"` emits no end-of-word drop, so its bit is
8759            // still standing and this word's drop must not honour it.
8760            let quoted_at_elems = if force_dq && name == "@" { pp.len() } else { 0 };
8761            note_quoted_splice_elems(quoted_at_elems);
8762            return Value::array(pp.iter().map(Value::str).collect());
8763        }
8764        // RC_EXPAND_PARAM: when the option is set and `name` refers to
8765        // an array, return Value::Array so the enclosing word's
8766        // BUILTIN_CONCAT_DISTRIBUTE distributes element-wise. Without
8767        // the option, arrays still join to a space-separated scalar
8768        // (zsh's default unquoted-array-as-scalar semantics).
8769        // `isset` is the single atomic load C's `isset(RCEXPANDPARAM)` is
8770        // (c:Src/zsh.h:2557); `opt_state_get` re-resolved the name through the
8771        // option table under a lock on every parameter read.
8772        let rc_expand = isset(crate::ported::zsh_h::RCEXPANDPARAM);
8773        // c:Src/subst.c — under KSHARRAYS a bare `$name` (no [@]/[*] subscript;
8774        // this GET_VAR path only handles the bare form) is element 1 ONLY — a
8775        // scalar. RC_EXPAND_PARAM then has a single value to distribute, so
8776        // `$acc` → "p1", NOT the whole array. Skip the whole-array rc_expand
8777        // shortcut when KSHARRAYS is set and fall through to the normal path,
8778        // which applies the element-1 collapse. Without this gate,
8779        // `setopt KSH_ARRAYS rc_expand_param; print -r -- $acc` splatted every
8780        // element while zsh prints just "p1".
8781        let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
8782        // c:Src/subst.c:4245 `if (isarr)` gates the whole plan9 block
8783        // (c:4316), and TWO earlier arms have already zeroed `isarr` for a
8784        // bare array read that is quoted or scalar-substituted:
8785        //   c:3032 `if (qt && !getlen && isarr > 0) { val = sepjoin(aval,
8786        //           sep, 1); isarr = 0; }`                       — DQ context
8787        //   c:3905 `if (nojoin == 0 || sep) { val = sepjoin(aval, sep, 1);
8788        //           isarr = 0; }` under `if (ssub || …)` at c:3901
8789        //                                          — PREFORK_SINGLE (scalar
8790        //                                            assignment RHS)
8791        // So RC_EXPAND_PARAM never cross-products a plain `"$a"` / `b=$a`;
8792        // the array collapses to one IFS-joined scalar first. `force_dq` is
8793        // exactly the compiler's flag for those two contexts (it is set for
8794        // `in_dq || scalar_assign_depth > 0 || assign_builtin_arg_depth > 0`),
8795        // Without this gate `setopt rcexpandparam; a=(x y); print -rl -- "$a"Z`
8796        // emitted `xZ` / `yZ` instead of zsh's single `x yZ` — which is how
8797        // `_sqlite`'s `"($exclusive)"$^dashes'-header[…]'` reached
8798        // `comparguments` as five words starting `(-noheader`.
8799        //
8800        // Only the compile-time flag is consulted. The runtime
8801        // `in_dq_context` counter stays set while a `$(…)` INSIDE double
8802        // quotes runs its body, so reading it here would join an unquoted
8803        // `$a` in `"$(print -l -- $a)"`.
8804        if rc_expand && !ksh_arrays && !force_dq {
8805            let arr_val = with_executor(|exec| {
8806                sync_status(exec);
8807                exec.array(&name)
8808            });
8809            if let Some(arr) = arr_val {
8810                // c:4245 — a real array reference (`isarr != 0`). An empty one
8811                // takes plan9's word-removal path (c:4362), so clear the
8812                // scalar bit; a preceding empty-SCALAR expansion in the same
8813                // word would otherwise leave it set and keep the word alive
8814                // (`empty=''; e=(); a=("$empty"); print -rl -- x$e y`).
8815                // Only an EMPTY array may clear it: a non-empty one carries
8816                // no emptiness of its own, and clearing on it wiped the bit a
8817                // preceding empty SCALAR had set — `setopt rcexpandparam;
8818                // e=''; a=(x y); print -rl -- $e$a` lost the whole word.
8819                if arr.is_empty() {
8820                    note_empty_is_scalar(false);
8821                }
8822                // A genuine array reference, so an empty element that
8823                // survives the plan9 cross-product IS an empty word:
8824                // `setopt rcexpandparam; a=('' x); print -rl -- ${a}`
8825                // is one word (`x`) in zsh. See `ARRAY_EMPTIES_ELIDABLE`.
8826                note_array_empties_elidable();
8827                return Value::array(arr.into_iter().map(Value::str).collect());
8828            }
8829        }
8830        // Magic-assoc fallback FIRST — `${aliases}` / `${functions}`
8831        // / `${commands}` / etc. should return the value list per
8832        // zsh's bare-assoc semantics. Without this, those names fell
8833        // through to `get_variable` which is empty (they live in
8834        // separate executor tables, not `assoc_arrays`). Return as
8835        // a Value::Array so `arr=(${aliases})` distributes into
8836        // multiple elements, matching zsh's array-context word
8837        // splitting for assoc-bare references.
8838        // Only a name that is a special hash or array row, or a module
8839        // parameter that autoloads, can answer here. For every other name
8840        // `partab_array_get` / `partab_scan_keys` return None after their
8841        // shadow check, module gate and bookkeeping insert — work that cost
8842        // about a third of each parameter read in compinit. The AUTOLOAD
8843        // check keeps `mark_module_param_used`'s first-touch load for those.
8844        let partab_candidate = crate::ported::modules::parameter::PARTAB
8845            .iter()
8846            .any(|e| e.name == name.as_str())
8847            || crate::ported::modules::parameter::PARTAB_ARRAY
8848                .iter()
8849                .any(|e| e.name == name.as_str())
8850            || crate::vm_helper::AUTOLOAD_PARAMS.iter().any(|(p, _)| *p == name.as_str());
8851        let magic_vals = if !partab_candidate {
8852            None
8853        } else {
8854            with_executor(|exec| {
8855            sync_status(exec);
8856            // Canonical PARTAB dispatch (Src/Modules/parameter.c:2235-
8857            // 2298 + SPECIALPMDEFs in mapfile/terminfo/termcap/system/
8858            // zleparameter): PARTAB_ARRAY entries → whole-array getfn;
8859            // PARTAB entries → scan keys + per-key getpm/scanpm fn
8860            // pointers.
8861            let _ = exec;
8862            if let Some(values) = partab_array_get(&name) {
8863                Some(values)
8864            } else if let Some(keys) = partab_scan_keys(&name) {
8865                Some(
8866                    keys.iter()
8867                        .map(|k| partab_get(&name, k).unwrap_or_default())
8868                        .collect::<Vec<_>>(),
8869                )
8870            } else {
8871                None
8872            }
8873            })
8874        };
8875        if let Some(vals) = magic_vals {
8876            // Distinguish "name IS a magic-assoc with no entries"
8877            // (return Array(empty)) from "name is unknown — fall
8878            // through to get_variable".
8879            // c:Src/params.c:2293-2296 — KSHARRAYS bare reference
8880            // collapses to the FIRST element in scan order
8881            // (`v->end = 1, v->isarr = 0`). For `options` the scan
8882            // order is optiontab bucket order (OPTIONTAB), so zsh 5.9
8883            // prints `off` (posixargzero) for
8884            // `setopt ksharrays; print $options`.
8885            if isset(crate::ported::zsh_h::KSHARRAYS) {
8886                // c:Src/params.c:2342-2350 — a PM_HASHED special takes the
8887                // same `!v->scanflags && EMULATION(EMULATE_KSH)` arm as a
8888                // plain association: `s = "[0]"; getindex(...)`, i.e. the
8889                // value under key "0" through the special's getfn, empty
8890                // when absent. PARTAB_ARRAY rows are PM_ARRAY and keep the
8891                // first element.
8892                if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH)
8893                    && partab_array_get(&name).is_none()
8894                {
8895                    return Value::str(partab_get(&name, "0").unwrap_or_default());
8896                }
8897                return Value::str(vals.into_iter().next().unwrap_or_default());
8898            }
8899            // Same array reference shape as the indexed-array arm below.
8900            if !force_dq {
8901                note_array_empties_elidable();
8902            }
8903            return Value::array(vals.into_iter().map(Value::str).collect());
8904        }
8905        // Indexed-array path: return Value::Array so pop_args splats
8906        // each element into its own argv slot. Direct port of zsh's
8907        // unquoted `$arr` semantics — each element becomes a separate
8908        // word in command-arg position.
8909        //
8910        // DQ context exception: inside `"...$arr..."`, zsh joins with
8911        // the first char of $IFS (default space) so the DQ word stays
8912        // a single argv slot. Detect via in_dq_context (bumped by
8913        // BUILTIN_EXPAND_TEXT mode 1) and return the joined scalar.
8914        // Direct port of Src/subst.c:1759-1813 nojoin/sepjoin: in DQ
8915        // (qt=1) without explicit `(@)`, sepjoin runs and the result
8916        // is one word.
8917        let arr_assoc_data = with_executor(|exec| {
8918            sync_status(exec);
8919            let in_dq = force_dq || exec.in_dq_context > 0;
8920            // KSH_ARRAYS: bare `$arr` returns ONLY arr[0] (zero-
8921            // based first-element-only semantics). Direct port of
8922            // Src/params.c getstrvalue's KSH_ARRAYS gate which
8923            // returns aval[0] instead of the whole array.
8924            let ksh_arrays = isset(crate::ported::zsh_h::KSHARRAYS);
8925            if let Some(arr) = exec.array(&name) {
8926                if ksh_arrays {
8927                    return Some((vec![arr.first().cloned().unwrap_or_default()], in_dq));
8928                }
8929                return Some((arr, in_dq));
8930            }
8931            // Existence only: `exec.assoc()` cloned the whole map to answer it.
8932            if exec.has_assoc(&name) {
8933                // c:Src/params.c:2351-2358 — under KSH EMULATION a bare
8934                // `$assoc` is `${assoc[0]}` (a KEY-"0" lookup), so it is
8935                // EMPTY unless the hash actually has a key "0". This is
8936                // EMULATION-gated, not KSHARRAYS-option-gated: `emulate -L
8937                // ksh; typeset -A h=(a 1 b 2); print $h` is empty, whereas
8938                // `setopt ksharrays; …; print $h` collapses to the bucket-
8939                // first value below.
8940                if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
8941                    let v = crate::ported::subst::assoc_get(&name)
8942                        .and_then(|m| m.get("0").cloned())
8943                        .unwrap_or_default();
8944                    return Some((vec![v], in_dq));
8945                }
8946                // c:Src/hashtable.c scanhashtable — a bare `$assoc` joins its
8947                // VALUES in zsh hash-BUCKET order (the same order `(k)`/`(v)`
8948                // enumerate), NOT sorted or insertion order. `assoc_get`
8949                // rebuilds zsh's bucket layout; use it so `$as` matches
8950                // `${(v)as}` (`as=(zebra 9 apple 1)` → `9 1`, not the
8951                // alphabetical `1 9`). Under KSHARRAYS the bare form collapses
8952                // to the bucket-FIRST value (`9`), matching zsh.
8953                let values: Vec<String> = crate::ported::subst::assoc_get(&name)
8954                    .map(|m| m.values().cloned().collect())
8955                    .unwrap_or_default();
8956                if ksh_arrays {
8957                    return Some((vec![values.into_iter().next().unwrap_or_default()], in_dq));
8958                }
8959                return Some((values, in_dq));
8960            }
8961            None
8962        });
8963        if let Some((items, in_dq)) = arr_assoc_data {
8964            if in_dq {
8965                // c:Src/utils.c:3936-3945 sepjoin default-sep rule:
8966                // set-but-empty IFS joins with "" (`IFS=""; echo
8967                // "$arr"` concatenates); only unset / space-leading
8968                // IFS yields " ". The previous get_variable read
8969                // couldn't distinguish unset from set-empty.
8970                return Value::str(crate::ported::utils::sepjoin(&items, None));
8971            }
8972            // c:Src/subst.c:3912 — the join-then-split block:
8973            //
8974            //     if (ssub || spbreak || spsep || sep || quoted_array_with_offset) {
8975            //         int force_split = !ssub && (spbreak || spsep);
8976            //         if (isarr || quoted_array_with_offset) {
8977            //
8978            // A BARE `$NAME` / `${NAME}` read of an ARRAY is `isarr != 0`, so it
8979            // takes the same block the `${NAME[@]}` / `${NAME[*]}` splice does
8980            // (BUILTIN_ARRAY_ALL / BUILTIN_ARRAY_JOIN_STAR above) — c:3030-3032
8981            // makes the three spellings indistinguishable once inside it. The
8982            // only door in from HERE is `spbreak`, c:Src/subst.c:1707
8983            //     int spbreak = (pf_flags & PREFORK_SHWORDSPLIT)
8984            //                   && !(pf_flags & PREFORK_SINGLE) && !qt;
8985            // — SH_WORD_SPLIT. `qt` and PREFORK_SINGLE both reach this handler
8986            // as `force_dq` (the compile site emits GET_VAR_DQ for either), and
8987            // that arm returned above, so getting here already means `!qt` and
8988            // `!ssub`; the explicit `${=…}` / `${==…}` spellings have their own
8989            // fast path. `sep` / `spsep` are the `(j:…:)` / `(s:…:)` separators,
8990            // which never reach a compiler fast path.
8991            //
8992            // This handler had no such block at all: it returned the element
8993            // vector whatever the option said, so an element carrying an IFS
8994            // byte was never broken up —
8995            //     setopt shwordsplit; a=('p:q' r); IFS=:;    print -rl -- $a
8996            //       zsh: `p` `q` `r`            zshrs: `p:q` `r`
8997            //     setopt shwordsplit; a=('p q' r); unset IFS; print -rl -- ${a}
8998            //       zsh: `p` `q` `r`            zshrs: `p q` `r`
8999            //     setopt shwordsplit; a=(x '' y); IFS=:;     print -rl -- $a
9000            //       zsh: `x` `` `y`             zshrs: `x` `y`
9001            // The last is `sepsplit_c3932`'s `nulstring` rule: the join makes
9002            // `x::y`, and an empty field delimited by IFS-NON-whitespace comes
9003            // back as c:Src/subst.c:36's one-byte `nulstring`, which c:186 KEEPS.
9004            //
9005            // Only the JOINED outcome changes shape. When c:3914-3927 leaves the
9006            // vector alone (an IFS that is set but EMPTY: c:1819 `nojoin` is 1
9007            // and c:3919's second arm needs `!ifs`, which a set-empty IFS fails)
9008            // c:3931's `!isarr` closes the split too, and the answer is the
9009            // untouched array the code below already returns.
9010            if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT) {
9011                let ifs_opt = with_executor(|exec| exec.scalar("IFS"));
9012                if let JoinC3914::Joined(val) = join_c3914(items.clone(), ifs_opt.as_deref()) {
9013                    // c:3932 `aval = sepsplit(val, spsep, 0, 1)`, then
9014                    // c:3933-3938's 0/1/N result shape.
9015                    return split_words_c3932(&val);
9016                }
9017            }
9018            // c:Src/subst.c:184-187 — prefork's `else if (!keep)
9019            // uremnode(list, node)`: UNQUOTED expansion drops empty
9020            // list nodes before they reach argv, so `a=(y '' x);
9021            // print -- $a` passes TWO args in zsh (`y x`), while the
9022            // quoted "${a[@]}" splat keeps the empty slot.
9023            //
9024            // The removal must NOT happen HERE. c:186 deletes an
9025            // assembled list NODE — it runs after stringsubst has
9026            // concatenated the word's surrounding text onto the
9027            // elements (the array emit block is c:4245 onward, the
9028            // non-plan9 prefix/suffix attach c:4366-4437), so an
9029            // element that is empty at READ time can still finish as a
9030            // non-empty WORD. Filtering on the way out of the read cost
9031            // exactly that: `a=(x y ""); print -rl -- ${a}POST` dropped
9032            // the slot the suffix was going to land on and printed
9033            // `x` `yPOST` where zsh prints `x` `y` `POST`. The elision
9034            // now lives at the end of word assembly
9035            // (`BUILTIN_WORD_ELIDE_EMPTY`, emitted by compile_zsh once
9036            // the word's segments are folded); flag the value so that
9037            // builtin can tell a genuine array's empty element from a
9038            // split field's `nulstring` (see `ARRAY_EMPTIES_ELIDABLE`).
9039            note_array_empties_elidable();
9040            // c:4245 — a real array reference: `isarr != 0`, so an empty
9041            // one takes plan9's word-removal path, not the scalar path.
9042            // Only note it when the array IS empty — see the rc_expand arm
9043            // above for why a non-empty read must not touch the bit.
9044            if items.is_empty() {
9045                note_empty_is_scalar(false);
9046            }
9047            return Value::array(items.into_iter().map(Value::str).collect());
9048        }
9049        let (val, in_dq, is_known) = with_executor(|exec| {
9050            sync_status(exec);
9051            let v = exec.get_variable(&name);
9052            // For nounset detection: a name is "known" when it has a
9053            // paramtab/array/assoc/env entry. Special chars ($?, $#,
9054            // $@, $*, $-, $$, $!, $_, $0) always count as known
9055            // regardless of value. Pure-digit positional params
9056            // count as known iff index <= $# (set -- has populated
9057            // that slot). c:Src/subst.c:1689 — NOUNSET fires on
9058            // unset positional param too: `set --; echo "$1"` with
9059            // nounset must diagnose.
9060            let is_special_single = name.len() == 1
9061                && matches!(
9062                    name.chars().next().unwrap(),
9063                    '?' | '#' | '@' | '*' | '-' | '$' | '!' | '_' | '0'
9064                );
9065            let is_pure_digit = !name.is_empty() && name.chars().all(|c| c.is_ascii_digit());
9066            let positional_known = if is_pure_digit {
9067                let idx: usize = name.parse().unwrap_or(0);
9068                if idx == 0 {
9069                    true // $0 always set
9070                } else {
9071                    idx <= exec.pparams().len()
9072                }
9073            } else {
9074                false
9075            };
9076            let known = !v.is_empty()
9077                || name.is_empty()
9078                || is_special_single
9079                || positional_known
9080                || crate::ported::params::paramtab()
9081                    .read()
9082                    .ok()
9083                    .map(|t| t.contains_key(&name))
9084                    .unwrap_or(false)
9085                || env::var(&name).is_ok();
9086            (v, force_dq || exec.in_dq_context > 0, known)
9087        });
9088        // c:Src/subst.c:1689 — NO_UNSET / nounset: reading an unset
9089        // parameter fires "parameter not set" diagnostic and aborts
9090        // the substitution. Direct port of the noerrs gate at c:1689
9091        // (zerr + errflag). Matches `set -u` POSIX semantics.
9092        // `nounset` is the negated alias of UNSET (c:Src/options.c optns), so
9093        // this is C's `unset(UNSET)`: one atomic load, not a name lookup.
9094        if !is_known && !isset(crate::ported::zsh_h::UNSET) {
9095            crate::ported::utils::zerr(&format!("{}: parameter not set", name));
9096            crate::ported::utils::errflag.fetch_or(
9097                crate::ported::zsh_h::ERRFLAG_ERROR,
9098                std::sync::atomic::Ordering::Relaxed,
9099            );
9100            with_executor(|exec| exec.set_last_status(1));
9101            return Value::str("");
9102        }
9103        // Empty unquoted scalar → drop the arg (zsh "remove empty
9104        // unquoted words" rule). Returning empty Value::Array makes
9105        // pop_args contribute zero items. DQ context keeps the empty
9106        // string so "$a" stays a single empty arg. Direct port of
9107        // subst.c's elide-empty pass.
9108        if val.is_empty() && !in_dq {
9109            // c:1650-1656 / c:4437 — a SCALAR parameter has `isarr == 0`,
9110            // so it never reaches plan9's word-removal at c:4362. Flag the
9111            // empty Array below as a scalar so `setopt rcexpandparam;
9112            // v=; print -rl -- x$v y` still emits `x` (only an empty
9113            // ARRAY deletes the word).
9114            note_empty_is_scalar(true);
9115            return Value::array(Vec::new());
9116        }
9117        // c:Src/subst.c:1759 SH_WORD_SPLIT — when shwordsplit is set and
9118        // we're in unquoted command-arg position (not DQ), split scalar
9119        // value on IFS into multiple words. Matches BUILTIN_ARRAY_ALL's
9120        // shwordsplit arm (fusevm_bridge.rs:2200). Without this, bare
9121        // `$s` in `print $s` stayed a single arg even with the option
9122        // set, breaking POSIX-style scalar word-splitting.
9123        if !in_dq && isset(crate::ported::zsh_h::SHWORDSPLIT) {
9124            // c:Src/subst.c:318-324 → c:1707 `spbreak`, c:3921 `sepsplit`, then
9125            // c:4366-4437 attach the word's affixes BEFORE c:184-187 deletes the
9126            // empty nodes. `spacesplit` (c:Src/utils.c:3730-3760, allownull 0)
9127            // gives a leading/trailing run of IFS-whitespace a real `""` node,
9128            // which survives only once text is glued onto it:
9129            //     setopt shwordsplit; s=' foo bar '; print -rl -- x${s}y
9130            //     zsh: x / foo / bar / y      (the edges became `x` and `y`)
9131            // while an IFS-non-whitespace empty field is `nulstring` and always
9132            // survives. This read cannot see the affixes, and dropping every
9133            // truly-empty field here made that `xfoo` / `bary`. paramsubst
9134            // already carries both rules (PARAMSUBST_AFFIXES_DEFERRED leaves the
9135            // removal to the word's end-of-word drop), and it is the same
9136            // expansion C runs, so hand it the `${name}` spelling.
9137            return paramsubst_to_value(&format!("${{{}}}", name));
9138        }
9139        Value::str(val)
9140    }
9141    // Provenance: BUILTIN_GET_VAR / _DQ are the bytecode-level parameter
9142    // READ ops, so this is the tap that hands a tracked parameter's
9143    // lineage to the value the read produced. The name is peeked off the
9144    // stack before `get_var_impl` consumes it.
9145    fn get_var_prov(vm: &mut fusevm::VM, argc: u8, force_dq: bool) -> Value {
9146        if !crate::provenance::active() {
9147            return get_var_impl(vm, argc, force_dq);
9148        }
9149        let name = vm.peek().to_str();
9150        let value = get_var_impl(vm, argc, force_dq);
9151        crate::provenance::on_param_read(&name, &value);
9152        value
9153    }
9154    vm.register_builtin(BUILTIN_GET_VAR, |vm, argc| get_var_prov(vm, argc, false));
9155    vm.register_builtin(BUILTIN_GET_VAR_DQ, |vm, argc| get_var_prov(vm, argc, true));
9156
9157    // `name+=val` (no parens) — runtime dispatch:
9158    //   - if `name` is in `arrays` → push `val` as new element
9159    //   - if `name` is in `assoc_arrays` → refuse (zsh errors here)
9160    //   - else → scalar concat (existing behavior)
9161    // Stack: [name, value].
9162    vm.register_builtin(BUILTIN_APPEND_SCALAR_OR_PUSH, |vm, argc| {
9163        let args = pop_args(vm, argc);
9164        let mut iter = args.into_iter();
9165        let name = iter.next().unwrap_or_default();
9166        let value = iter.next().unwrap_or_default();
9167        with_executor(|exec| {
9168            // Array form: `arr+=elem` pushes a single element.
9169            // Routes through canonical assignaparam(name, [value],
9170            // ASSPM_AUGMENT) — Src/params.c:3357 c:3402-3412 augment
9171            // path prepends prior scalar / appends to existing array.
9172            // Existence probe uses the non-cloning `has_array` — the
9173            // owning `exec.array()` clone here made `arr+=x` in a loop
9174            // O(n²) (see the assoc-store fix above).
9175            if exec.has_array(&name) {
9176                // c:Src/params.c — under KSHARRAYS a bare array name
9177                // addresses element 0 (ksh), so `a+=X` (scalar augment)
9178                // CONCATENATES onto the first element ("firstlast second"),
9179                // it does NOT push a new element. C routes scalar `+=`
9180                // through assignsparam (which targets the elem-0 value);
9181                // zshrs's APPEND_SCALAR_OR_PUSH would otherwise push.
9182                if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
9183                    let mut arr = exec.array(&name).unwrap_or_default();
9184                    if arr.is_empty() {
9185                        arr.push(value.clone());
9186                    } else {
9187                        arr[0] = format!("{}{}", arr[0], value);
9188                    }
9189                    exec.set_array(name.clone(), arr);
9190                    #[cfg(feature = "recorder")]
9191                    if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
9192                        let ctx = exec.recorder_ctx();
9193                        let attrs = exec.recorder_attrs_for(&name);
9194                        emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
9195                    }
9196                    return;
9197                }
9198                let _ = crate::ported::params::assignaparam(
9199                    &name,
9200                    vec![value.clone()],
9201                    crate::ported::zsh_h::ASSPM_AUGMENT
9202                        | crate::ported::zsh_h::ASSPM_WARN, // c:Src/exec.c:2573
9203                );
9204                #[cfg(feature = "recorder")]
9205                if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
9206                    let ctx = exec.recorder_ctx();
9207                    let attrs = exec.recorder_attrs_for(&name);
9208                    emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
9209                }
9210                return;
9211            }
9212            // An association falls through too: c:Src/params.c:3178-3192
9213            // `resetparam(v->pm, PM_SCALAR)`s a non-special PM_HASHED
9214            // target (unset KSHARRAYS) and the augment then appends to the
9215            // fresh empty scalar, so `typeset -A h; h+=x` leaves h="x".
9216            // Scalar / integer / float form: route through canonical
9217            // assignsparam(name, value, ASSPM_AUGMENT) which
9218            // dispatches PM_TYPE — PM_SCALAR concats, PM_INTEGER
9219            // arith-adds (c:2775-2778), PM_FLOAT float-adds.
9220            let _ = crate::ported::params::assignsparam(
9221                &name,
9222                &value,
9223                crate::ported::zsh_h::ASSPM_AUGMENT | crate::ported::zsh_h::ASSPM_WARN, // c:Src/exec.c:2573 addvars
9224            );
9225            #[cfg(feature = "recorder")]
9226            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
9227                let ctx = exec.recorder_ctx();
9228                let attrs = exec.recorder_attrs_for(&name);
9229                // Re-read the canonical value via get_variable for the
9230                // recorder bundle (assignsparam may have transformed it
9231                // through integer/float arithmetic).
9232                let final_val = exec.get_variable(&name);
9233                let lower = name.to_ascii_lowercase();
9234                if matches!(
9235                    lower.as_str(),
9236                    "path" | "fpath" | "manpath" | "module_path" | "cdpath"
9237                ) {
9238                    emit_path_or_assign(&name, std::slice::from_ref(&final_val), attrs, true, &ctx);
9239                } else {
9240                    crate::recorder::emit_assign_typed(&name, &final_val, attrs, ctx);
9241                }
9242            }
9243        });
9244        Value::Status(0)
9245    });
9246
9247    // BUILTIN_SET_VAR — `name=value` runtime scalar assignment.
9248    // PURE PASSTHRU: hand to canonical `setsparam` (C port of
9249    // `Src/params.c::setsparam`). That walks assignsparam →
9250    // assignstrvalue which already does:
9251    //   - readonly rejection (zerr + errflag at c:2701)
9252    //   - PM_INTEGER math evaluation (mathevali at c:3590)
9253    //   - PM_EFLOAT / PM_FFLOAT float coercion (c:3608)
9254    //   - PM_LOWER / PM_UPPER case fold (via setstrvalue)
9255    //   - GSU special-param dispatch (homesetfn / ifssetfn / etc.)
9256    //   - allexport env mirror via the PM_EXPORTED setfn
9257    //
9258    // Bridge-only concerns kept here:
9259    //   - inline_env_stack (zsh `X=foo cmd` scoped env)
9260    //   - recorder emission (PFA-SMR)
9261    //   - vm.last_status propagation for `a=$(cmd)` exit-code chaining
9262    // Sets the GLOB_ASSIGN-eligibility flag consumed by the NEXT BUILTIN_SET_VAR.
9263    // Emitted only when a scalar-assign RHS had an unquoted glob token. Takes no
9264    // args; its pushed return is discarded by a following Op::Pop.
9265    vm.register_builtin(BUILTIN_MARK_GLOB_ELIGIBLE, |_vm, _argc| {
9266        SET_VAR_GLOB_ELIGIBLE.with(|c| c.set(true));
9267        fusevm::Value::Int(0)
9268    });
9269    vm.register_builtin(BUILTIN_SET_VAR, |vm, argc| {
9270        // `${~spec}` carrier: an assignment statement is a word-
9271        // pipeline boundary too — restore the user's GLOB_SUBST
9272        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
9273        // ${options[globsubst]}` must read the user value).
9274        consume_tilde_globsubst_carrier();
9275        // Snapshot the raw Values BEFORE pop_args's to_str
9276        // flattening — needed to distinguish Int (arith assignment,
9277        // integer-typed param) from Str (scalar assignment).
9278        let mut raw_values: Vec<fusevm::Value> = Vec::with_capacity(argc as usize);
9279        for _ in 0..argc {
9280            raw_values.push(vm.pop());
9281        }
9282        raw_values.reverse();
9283        let name = raw_values.first().map(|v| v.to_str()).unwrap_or_default();
9284        let value_raw = raw_values.get(1).cloned();
9285        let value = value_raw.as_ref().map(|v| v.to_str()).unwrap_or_default();
9286        // c:Src/params.c — when the bytecode hands us an Int value
9287        // (only the arith assignment paths emit this — `(( X = N ))`
9288        // is the canonical site), route through setiparam so the
9289        // param ends up PM_INTEGER + inherits the math layer's
9290        // `lastbase` for display formatting (`(( X = 16#ff ));
9291        // echo \$X` → `16#FF`). Scalar `X=val` and `$((expr))`
9292        // assignments still take the setsparam path below.
9293        let int_assign = matches!(value_raw, Some(fusevm::Value::Int(_)));
9294        let float_assign = matches!(value_raw, Some(fusevm::Value::Float(_)));
9295        let mut assign_failed = false;
9296        with_executor(|exec| {
9297            // c:Src/params.c assignsparam — PM_READONLY rejection
9298            // BEFORE any env mutation. The inline-env-prefix path
9299            // (`X=2 env`) called env::set_var unconditionally before
9300            // the readonly check fired in setsparam, so the OS env
9301            // got X=2 even though the assignment errored. env then
9302            // inherited the polluted env from fork, leaking the
9303            // attempted override past the readonly guard. Mirror
9304            // C's order: readonly check → zerr → bail; only mutate
9305            // env when the assignment is admissible. Bug #551
9306            // (security-relevant).
9307            if exec.is_readonly_param(&name) {
9308                crate::ported::utils::zerr(&format!("read-only variable: {}", name));
9309                return;
9310            }
9311            // Inline-assignment frame tracking (`X=foo cmd` reverts on
9312            // command return). Only the PREFIX assignments belong in
9313            // the frame: c:Src/exec.c:4410 save_params snapshots the
9314            // parsed WC_ASSIGN chain and nothing else. The frame stays
9315            // on the stack while the command runs, so gate on
9316            // `recording` (cleared by SEAL_INLINE_ENV once the prefix
9317            // assignments have committed) — otherwise every assignment
9318            // the command itself makes gets recorded and then reverted
9319            // (`X=y . file` wiped every global the file defined).
9320            let prefix_restored = exec
9321                .inline_env_stack
9322                .last()
9323                .is_some_and(|frame| frame.recording);
9324            if prefix_restored {
9325                save_inline_prefix_param(exec, &name);
9326                let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value));
9327                // c:Src/params.c:5354
9328            }
9329            // c:Src/exec.c:2641 —
9330            //     if ((addflags & ADDVAR_EXPORT) && !strchr(name, '[')) {
9331            //         ...
9332            //         allexp = opts[ALLEXPORT];
9333            //         opts[ALLEXPORT] = 1;
9334            //         if (isset(KSHARRAYS))
9335            //             unsetparam(name);
9336            //         pm = assignsparam(name, val, myflags);
9337            //         opts[ALLEXPORT] = allexp;
9338            //     } else
9339            //         pm = assignsparam(name, val, myflags);
9340            // The assignment itself is what has to run under ALLEXPORT —
9341            // `createparam` ORs in PM_EXPORTED for a name that did not
9342            // exist (c:Src/params.c:1170-1171) and `assignstrvalue`'s tail
9343            // calls `export_param` for one that did (c:2836-2841) — so the
9344            // flip wraps the setsparam/setiparam/setnparam dispatch and the
9345            // env mirror below, and nothing else. Doing it any other way
9346            // would be special-casing the `${(t)}` readout instead of
9347            // marking the parameter.
9348            //
9349            // !!! NOT PORTED — the STTY leg (c:2642-2645 `STTYval =
9350            // ztrdup(val)`). `execute` consumes STTYval on the next
9351            // external command (exec.rs:4081) and C drops it again at
9352            // c:4310 / c:4400-4401 when the prefixed command finishes;
9353            // the bridge has no counterpart for those clears, so capturing
9354            // it here would apply `STTY=… cmd`'s settings to whatever
9355            // command ran next instead.
9356            let addvar_export = exec
9357                .inline_env_stack
9358                .last()
9359                .is_some_and(|frame| frame.recording && frame.export)
9360                && !name.contains('['); // c:2641
9361            let saved_allexport = if addvar_export {
9362                let allexp = isset(crate::ported::zsh_h::ALLEXPORT); // c:2646
9363                crate::ported::options::opt_state_set("allexport", true); // c:2647
9364                if isset(crate::ported::zsh_h::KSHARRAYS) {
9365                    // c:2648 — force the name to be recreated by the scalar
9366                    // assignment rather than written into the array that is
9367                    // already there: `setopt ksharrays; A=(1 2); A=x f`
9368                    // reports `scalar-export` inside the call in zsh.
9369                    crate::ported::params::unsetparam(&name); // c:2649
9370                }
9371                Some(allexp)
9372            } else {
9373                None
9374            };
9375            // c:Src/exec.c:2573 — `flags = !(addflags & ADDVAR_RESTORE) ?
9376            // ASSPM_WARN : 0;`, with the comment "Don't do this if there is a
9377            // list of variables marked to be restored after the command, since
9378            // then the assignment is implicitly scoped." A prefix assignment
9379            // recorded into the open inline-env frame is exactly that: it is
9380            // put back by restore_params when the command returns (c:4144-4145
9381            // `if (restorelist) flags |= ADDVAR_RESTORE;`, and c:4346-4347 for
9382            // an external's forked child). The non-recording arms (POSIX
9383            // special builtins, the do_save = 0 case) and plain assignments
9384            // keep the warning.
9385            let asg_flags = if prefix_restored {
9386                0
9387            } else {
9388                crate::ported::zsh_h::ASSPM_WARN as i32
9389            };
9390            // Canonical setsparam handles readonly, integer math, case
9391            // fold, GSU dispatch. For Int values (arith assigns) route
9392            // through setiparam so the param is PM_INTEGER + inherits
9393            // the math layer's lastbase for display formatting. For
9394            // Float (arith assigns producing MN_FLOAT) route through
9395            // setnparam so the param is PM_FFLOAT — `(( b = a * 2 ))`
9396            // with scalar `a="3.14"` should create b as typeset -F,
9397            // not a scalar holding "6.28".
9398            if int_assign {
9399                if let Some(fusevm::Value::Int(i)) = value_raw {
9400                    crate::ported::params::assigniparam(&name, i, asg_flags);
9401                } else {
9402                    assign_failed = crate::ported::params::assignsparam(&name, &value, asg_flags).is_none();
9403                }
9404            } else if float_assign {
9405                if let Some(fusevm::Value::Float(f)) = value_raw {
9406                    // ArithCompiler returns Value::Float whenever any
9407                    // operand came through Str (BUILTIN_GET_VAR yields
9408                    // Value::Str even for integer-shaped scalars). To
9409                    // avoid forcing every `(( b = a + 3 ))` to PM_FFLOAT
9410                    // when `a="5"` (integer-shaped), detect integer-
9411                    // valued floats and route through setiparam instead.
9412                    // True floats (non-integral) reach setnparam →
9413                    // PM_FFLOAT so `typeset -p b` shows `typeset -F …`.
9414                    if f.fract() == 0.0 && f.is_finite() && f.abs() <= i64::MAX as f64 {
9415                        crate::ported::params::assigniparam(&name, f as i64, asg_flags);
9416                    } else {
9417                        let mnval = crate::ported::math::mnumber {
9418                            l: 0,
9419                            d: f,
9420                            type_: crate::ported::math::MN_FLOAT,
9421                        };
9422                        crate::ported::params::assignnparam(&name, mnval, asg_flags);
9423                    }
9424                } else {
9425                    assign_failed = crate::ported::params::assignsparam(&name, &value, asg_flags).is_none();
9426                }
9427            } else {
9428                // c:Src/exec.c:2554-2567 — GLOB_ASSIGN. When the
9429                // `globassign` option is on and the scalar RHS is a glob
9430                // pattern, glob it and recreate the parameter as a scalar
9431                // (≤1 match) or array (>1) — csh-style assignment. The
9432                // bridge hands `value` UNTOKENIZED, so re-tokenize
9433                // (shtokenize) before haswilds/globlist; zsh's wordcode
9434                // value arrives pre-tokenized via `htok`. The
9435                // `isset(GLOBASSIGN)` gate is first and cheap (option off
9436                // by default), so the common path is unchanged.
9437                let mut globbed = false;
9438                // Only glob the RHS when the compiler flagged an UNQUOTED glob
9439                // token in the literal wordcode (SET_VAR_GLOB_ELIGIBLE). zsh's
9440                // GLOB_ASSIGN (Src/exec.c:2554) globs literal patterns only —
9441                // `x="/tmp/*"`, `x='/tmp/*'`, `x=$param`, `x=$(cmd)` all assign
9442                // verbatim. The value arrives here untokenized (DQ-wrapped by
9443                // the compiler), so this compile-time flag is the only surviving
9444                // signal of whether the pattern was quote-protected.
9445                let glob_eligible = SET_VAR_GLOB_ELIGIBLE.with(|c| c.replace(false));
9446                if glob_eligible && crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBASSIGN) {
9447                    let mut tv = value.clone();
9448                    crate::ported::glob::shtokenize(&mut tv);
9449                    if crate::ported::pattern::haswilds(&tv) {
9450                        // Committed to the glob path: never fall back to
9451                        // assigning the literal pattern (zsh errors on
9452                        // no-match instead).
9453                        globbed = true;
9454                        // globlist tokenizes its input internally (for
9455                        // haswilds + glob_path) and prints the ORIGINAL
9456                        // string verbatim in its "no matches found" error,
9457                        // so feed it the UNtokenized value — passing the
9458                        // tokenized form would leak the Star/Quest token
9459                        // bytes into the error message.
9460                        let mut ll: crate::ported::linklist::LinkList<String> = Default::default();
9461                        ll.push_back(value.clone());
9462                        crate::ported::subst::globlist(&mut ll, 0); // c:2556
9463                        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
9464                            == 0
9465                        {
9466                            let matches: Vec<String> = ll
9467                                .nodes
9468                                .iter()
9469                                .map(|s| crate::ported::lex::untokenize(s).to_string())
9470                                .collect();
9471                            crate::ported::params::unsetparam(&name); // c:2562
9472                            if matches.len() <= 1 {
9473                                let v = matches.into_iter().next().unwrap_or_default();
9474                                assign_failed =
9475                                    crate::ported::params::assignsparam(&name, &v, asg_flags).is_none();
9476                            } else {
9477                                crate::ported::params::assignaparam(&name, matches, asg_flags);
9478                            }
9479                        }
9480                        // errflag set → globlist already reported
9481                        // "no matches found"; leave the param unassigned
9482                        // to match zsh's abort.
9483                    }
9484                }
9485                // c:Src/exec.c addvars — a NULL return from
9486                // assignsparam (e.g. nameref resolving out of scope,
9487                // createparam refusal at c:1108-1118) fails the
9488                // assignment with status 1.
9489                if !globbed {
9490                    assign_failed = crate::ported::params::assignsparam(&name, &value, asg_flags).is_none();
9491                }
9492            }
9493            // PM_EXPORTED / allexport env mirror — read AFTER setsparam
9494            // so the flag bit reflects any GSU setfn side-effects.
9495            let allexport = opt_state_get("allexport").unwrap_or(false);
9496            let already_exported =
9497                (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
9498            if allexport || already_exported {
9499                // c:Src/params.c:3024 — the env mirror is `addenv(pm, value)`,
9500                // and addenv builds its string with `mkenvstr(nam, value,
9501                // pm->flags)` (c:5463) so `copyenvstr` (c:5434) can apply the
9502                // PM_LOWER / PM_UPPER fold. Formatting `name=value` by hand
9503                // skipped that: `typeset -lx v; v=HeLLo` exported `HeLLo`
9504                // where zsh exports `hello`. The fold has to happen HERE
9505                // because the paramtab now stores the value verbatim.
9506                let envstr = crate::ported::params::mkenvstr(
9507                    &name,
9508                    &value,
9509                    exec.param_flags(&name), // c:5463 pm->flags
9510                );
9511                let _ = crate::ported::params::zputenv(&envstr); // c:Src/params.c:5354
9512            }
9513            // c:2651 — `opts[ALLEXPORT] = allexp;`
9514            if let Some(allexp) = saved_allexport {
9515                crate::ported::options::opt_state_set("allexport", allexp);
9516            }
9517            #[cfg(feature = "recorder")]
9518            if crate::recorder::is_enabled()
9519                && exec.local_scope_depth == 0
9520                && !matches!(
9521                    name.as_str(),
9522                    "PPID" | "LINENO" | "ZSH_ARGZERO" | "argv0" | "ARGC" | "?" | "_" | "RANDOM"
9523                )
9524            {
9525                let ctx = exec.recorder_ctx();
9526                let attrs = exec.recorder_attrs_for(&name);
9527                crate::recorder::emit_assign_typed(&name, &value, attrs, ctx);
9528            }
9529            // c:Src/exec.c:1367-1370 — `if (code == WC_ASSIGN) { cmdoutval = 0;
9530            // addvars(state, state->pc - 1, 0); setunderscore(""); … }`. A
9531            // simple command consisting ONLY of scalar assignments clears `$_`;
9532            // it never goes through execcmd's c:3545-3547
9533            // `setunderscore(lastnode(args))`. src/ported/exec.rs:6946-6950
9534            // already ports that arm, but the WC_ASSIGN wordcode never
9535            // executes under fusevm — a bare `x=1` arrives here as
9536            // BUILTIN_SET_VAR, so `$_` kept the PREVIOUS command's last
9537            // argument. Symptom: in the `unset <TAB>` listing (the user's
9538            // `_parameters` override runs `maxLen=50` right before the
9539            // `$parameters` walk) zsh shows `_` empty while zshrs showed the
9540            // completion-internal `^a*` — `_parameters -g '^a*'`'s last arg.
9541            //
9542            // Two exclusions, both verified against zsh 5.9.2
9543            // (`true aa; <form>; print -r -- "[$_]"`):
9544            //   * PREFIX assignments (`x=1 true dd` → `dd`) are part of a
9545            //     command, so c:3545-3547 owns `$_`. They are exactly the
9546            //     assignments recorded into an open inline-env frame above.
9547            //   * `(( q = 1 ))` (→ `aa`, unchanged) is WC_ARITH, not
9548            //     WC_ASSIGN; the arith paths are the only ones that hand this
9549            //     builtin an Int/Float `Value` (see the `int_assign` note).
9550            if !int_assign
9551                && !float_assign
9552                && !exec
9553                    .inline_env_stack
9554                    .last()
9555                    .is_some_and(|frame| frame.recording)
9556            {
9557                // c:1369 — assignment-only command clears `$_`. The
9558                // former DUAL-STATE note here is obsolete: `set_zunderscore`
9559                // and the ported `setunderscore` now write the SAME
9560                // `init::zunderscore` global (params.rs `zunderscore_lock`
9561                // points at it), matching C's single store, so this one call
9562                // is the whole effect.
9563                crate::ported::exec::setunderscore(""); // c:1369
9564            }
9565        });
9566        // c:Src/exec.c:2654-2663 — `if (!pm) { lastval = 1; ... if
9567        // (!cmdoutval) cmdoutval = 1; }`; BUILTIN_ASSIGN_ONLY_STATUS
9568        // reads the flag back as the command's status.
9569        if assign_failed {
9570            ASSIGN_FAILED_FLAG.store(true, std::sync::atomic::Ordering::Relaxed);
9571        }
9572        Value::Status(vm.last_status)
9573    });
9574
9575    // c:Src/exec.c execfor → Src/params.c:6362 setloopvar — the
9576    // for-loop variable bind. Distinct from BUILTIN_SET_VAR because a
9577    // PM_NAMEREF loop variable REBINDS (new refname) instead of
9578    // assigning through the resolved chain.
9579    vm.register_builtin(BUILTIN_SET_LOOP_VAR, |vm, argc| {
9580        let args = pop_args(vm, argc);
9581        let name = args.first().cloned().unwrap_or_default();
9582        let value = args.get(1).cloned().unwrap_or_default();
9583        if crate::vm_helper::is_nameref(&name) {
9584            let ef_before =
9585                crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
9586            crate::ported::params::setloopvar(&name, &value); // c:6362
9587            let ef_after = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
9588            if (ef_after & crate::ported::utils::ERRFLAG_ERROR) != 0 && ef_after != ef_before {
9589                // zerr fired (read-only reference / invalid self
9590                // reference) — abort the loop, status 1 (C errflag).
9591                vm.last_status = 1;
9592                return Value::Bool(false);
9593            }
9594            return Value::Bool(true);
9595        }
9596        // Plain loop var — canonical scalar path (same shape as
9597        // BUILTIN_SET_VAR's setsparam arm).
9598        with_executor(|exec| {
9599            if exec.is_readonly_param(&name) {
9600                crate::ported::utils::zerr(&format!("read-only variable: {}", name));
9601                return;
9602            }
9603            crate::ported::params::setsparam(&name, &value);
9604            let allexport = opt_state_get("allexport").unwrap_or(false);
9605            let already_exported =
9606                (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
9607            if allexport || already_exported {
9608                // c:Src/params.c:3024 — the env mirror is `addenv(pm, value)`,
9609                // and addenv builds its string with `mkenvstr(nam, value,
9610                // pm->flags)` (c:5463) so `copyenvstr` (c:5434) can apply the
9611                // PM_LOWER / PM_UPPER fold. Formatting `name=value` by hand
9612                // skipped that: `typeset -lx v; v=HeLLo` exported `HeLLo`
9613                // where zsh exports `hello`. The fold has to happen HERE
9614                // because the paramtab now stores the value verbatim.
9615                let envstr = crate::ported::params::mkenvstr(
9616                    &name,
9617                    &value,
9618                    exec.param_flags(&name), // c:5463 pm->flags
9619                );
9620                let _ = crate::ported::params::zputenv(&envstr); // c:Src/params.c:5354
9621            }
9622        });
9623        Value::Bool(true)
9624    });
9625
9626    // Pre-compiled function registration — used by compile_zsh.rs's
9627    // FuncDef path. Stack: [name, base64-bincode-of-Chunk]. We decode
9628    // the base64, deserialize the Chunk, and store directly in
9629    // executor.functions_compiled. Bypasses the ShellCommand JSON layer.
9630    // BUILTIN_VAR_EXISTS — `[[ -v name ]]` set-test.
9631    // PURE PASSTHRU: build `${+name}` and route through canonical
9632    // `subst::paramsubst` which returns "1" for set / "0" for unset
9633    // (C port of `Src/subst.c::paramsubst` plus-prefix arm).
9634    // paramsubst handles all the shapes the 48-line hand-roll did:
9635    //   - bare scalar / array / assoc
9636    //   - subscripted `a[N]` / `h[key]`
9637    //   - positional params (any digit-only name)
9638    //   - env-var fallback (`HOME` set via getsparam → lookup_special_var)
9639    vm.register_builtin(BUILTIN_VAR_EXISTS, |vm, _argc| {
9640        let name = vm.pop().to_str();
9641        // c:Src/cond.c:361 `case 'v': return !issetvar(left)`. `-v` is
9642        // NOT `${+name}` — issetvar (params.c:751) additionally rejects
9643        // trailing chars after the parsed name/subscript (`arr[3]extra`,
9644        // nested `arr[2][1]`) and validates array-slice bounds (an
9645        // out-of-range `(i)`-not-found index is "unset"). `${+}` is
9646        // lenient and reported those as set.
9647        Value::Bool(crate::ported::params::issetvar(&name) != 0)
9648    });
9649
9650    // `time { compound; ... }` — runs the sub-chunk and prints elapsed
9651    // wall-clock time. zsh's full `time` also tracks user/system CPU via
9652    // getrusage on the *child*; we approximate via wall-time only since
9653    // the sub-chunk runs in-process (no fork). Output format matches
9654    // `time simple-cmd` (already implemented elsewhere via exectime).
9655    vm.register_builtin(BUILTIN_TIME_SUBLIST, |vm, argc| {
9656        // A negative sub-chunk index is the compiler's marker for the BARE
9657        // `time` keyword, which has no body to run.
9658        // c:Src/exec.c:5331-5334 exectime:
9659        //   if (WC_TIMED_TYPE(state->pc[-1]) == WC_TIMED_EMPTY) {
9660        //       shelltime(NULL,NULL,NULL,0);
9661        //       return 0;
9662        //   }
9663        // `shelltime(NULL, NULL, NULL, 0)` is the ONE call that prints the
9664        // shell/children pair: with delta==0 and both pointers NULL, both
9665        // `!delta == !shell` (c:Src/jobs.c:1964) and `!delta == !kids`
9666        // (c:1985) hold. Verified: `zsh -fc 'time'` prints
9667        //   shell  0.00s user 0.00s system … / children  0.00s user …
9668        // while every `time <body>` form prints nothing (see the is_cursh
9669        // note below). zshrs previously compiled bare `time` to a plain
9670        // `status = 0` and printed nothing at all.
9671        let sub_idx_raw = vm.pop().to_int();
9672        let sub_idx = sub_idx_raw as usize;
9673        // c:Src/jobs.c:1028-1029 — `pn->text` arg to printtime. argc==2
9674        // means the compiler also pushed a desc string (bug #66 fix);
9675        // older callers with argc==1 push only sub_idx and we synthesize
9676        // an empty desc for backward compat with cached bytecode that
9677        // predates the desc-threading patch.
9678        let desc = if argc >= 2 {
9679            vm.pop().to_str().to_string()
9680        } else {
9681            String::new()
9682        };
9683        // c:Src/exec.c:3536-3538 — `if (!text && (!sfcontext && (jobbing ||
9684        // (how & Z_TIMED)))) text = getjobtext(…)`: inside a shell function
9685        // (sfcontext = SFC_DIRECT, c:5632-5633) no job text is taken, so the
9686        // report's %J is empty: `f(){ time /bin/sleep 0 }; f` prints
9687        // `  0.00s user …` with no command.
9688        let desc = if crate::ported::exec::sfcontext.load(std::sync::atomic::Ordering::Relaxed)
9689            != crate::ported::zsh_h::SFC_NONE
9690        {
9691            String::new()
9692        } else {
9693            desc
9694        };
9695        // c:Src/exec.c:3690 — the compiler's `is_cursh` verdict for the
9696        // timed body (see compile_zsh.rs `time_cursh_hint`): 1 = current
9697        // shell, 0 = forked job, 2 = decide from the command name below.
9698        // argc < 4 means bytecode cached before this operand pair existed;
9699        // fall back to the old fork-counter heuristic in that case.
9700        let (cursh_hint, cursh_name) = if argc >= 4 {
9701            let hint = vm.pop().to_int();
9702            let name = vm.pop().to_str().to_string();
9703            (hint, name)
9704        } else {
9705            (-1, String::new())
9706        };
9707        // c:Src/jobs.c:1029-1037 dumptime — a timed multi-stage pipeline
9708        // arrives with its per-stage texts and the last stage's is_cursh
9709        // verdict (compile_zsh.rs Time arm): [texts…, last_hint, last_name, n].
9710        let timed_pipeline = if argc > 4 {
9711            let n = vm.pop().to_int().max(0) as usize;
9712            let last_name = vm.pop().to_str().to_string();
9713            let last_hint = vm.pop().to_int();
9714            let mut texts: Vec<String> = (0..n).map(|_| vm.pop().to_str().to_string()).collect();
9715            texts.reverse();
9716            Some(TimedPipeline {
9717                texts,
9718                last_forks: !time_body_is_cursh(last_hint, &last_name),
9719                procs: Vec::new(),
9720            })
9721        } else {
9722            None
9723        };
9724        if sub_idx_raw < 0 {
9725            crate::ported::jobs::shelltime(None, None, None, 0); // c:5333
9726            return Value::Status(0); // c:5334
9727        }
9728        let chunk_opt = vm.chunk.sub_chunks.get(sub_idx).cloned();
9729        let Some(chunk) = chunk_opt else {
9730            return Value::Status(0);
9731        };
9732        // c:Src/jobs.c:1968 — `getrusage(RUSAGE_CHILDREN, &ti)` before
9733        // and after the timed sublist gives accurate per-stage user/sys
9734        // CPU. Wall-time-only approximation (0.7×/0.1× fudge factors)
9735        // produced bogus user/sys columns and ignored TIMEFMT. Bug #66
9736        // in docs/BUGS.md.
9737        let ru_before: libc::rusage = unsafe {
9738            let mut r: libc::rusage = std::mem::zeroed();
9739            libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
9740            r
9741        };
9742        // c:Src/jobs.c — zsh's `time` reports only for JOBS (forked
9743        // work). Builtins/brace-groups/functions run in the shell
9744        // process with no job, so `zsh -fc 'time true'` emits NOTHING.
9745        // Snapshot the fork-event counter; report only if the timed
9746        // body forked (external command or subshell).
9747        let forks_before = crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed);
9748        // c:Src/jobs.c:1943 — `getrusage(RUSAGE_SELF, &ti)` — shelltime's
9749        // "shell" line reports the SHELL PROCESS's own CPU delta, which is
9750        // where a current-shell (`is_cursh`) body's work lands.
9751        let ru_self_before: libc::rusage = unsafe {
9752            let mut r: libc::rusage = std::mem::zeroed();
9753            libc::getrusage(libc::RUSAGE_SELF, &mut r);
9754            r
9755        };
9756        let start = Instant::now();
9757        crate::fusevm_disasm::maybe_print_stdout("time_sublist", &chunk);
9758        let mut sub_vm = fusevm::VM::new(chunk);
9759        register_builtins(&mut sub_vm);
9760        let outer_timed = TIMED_PIPELINE.with(|t| t.replace(timed_pipeline));
9761        let _ = sub_vm.run();
9762        let timed_pipeline = TIMED_PIPELINE.with(|t| t.replace(outer_timed));
9763        let status = sub_vm.last_status;
9764        let elapsed = start.elapsed();
9765        let ru_self_after: libc::rusage = unsafe {
9766            let mut r: libc::rusage = std::mem::zeroed();
9767            libc::getrusage(libc::RUSAGE_SELF, &mut r);
9768            r
9769        };
9770        let ru_after: libc::rusage = unsafe {
9771            let mut r: libc::rusage = std::mem::zeroed();
9772            libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
9773            r
9774        };
9775        // Delta children rusage = timed work's CPU.
9776        let mut delta = ru_after;
9777        let sub = timeval_sub;
9778        delta.ru_utime = sub(ru_after.ru_utime, ru_before.ru_utime);
9779        delta.ru_stime = sub(ru_after.ru_stime, ru_before.ru_stime);
9780        let ti = crate::ported::zsh_h::timeinfo::from_rusage(&delta);
9781        // c:Src/jobs.c:808-809 — `s = getsparam("TIMEFMT"); s ||
9782        // DEFAULT_TIMEFMT`. Honor user-set TIMEFMT, fall back to the
9783        // canonical default.
9784        let fmt = crate::ported::params::getsparam("TIMEFMT")
9785            .unwrap_or_else(|| crate::ported::zsh_system_h::DEFAULT_TIMEFMT.to_string());
9786        // c:Src/jobs.c:768 `desc` arg — for the `time { sublist }` /
9787        // `time simple-cmd` keyword path, zsh passes the sublist's
9788        // source text (used by %J via printtime). The compiler now
9789        // threads the rendered source text through as the desc operand
9790        // (compile_zsh.rs Time arm, argc==2 form). Bug #66.
9791        // c:Src/exec.c:3690 — resolve the compiler's verdict. Hint 2 means
9792        // "a simple command whose head word decides it": `is_builtin ||
9793        // is_shfunc`. A reserved-word / builtin / function head runs in the
9794        // current shell; anything else forks and becomes a job.
9795        let is_cursh = match cursh_hint {
9796            1 => true,
9797            0 => false,
9798            2 => time_body_is_cursh(2, &cursh_name),
9799            // Bytecode cached before the hint operands existed: keep the
9800            // historical fork-counter heuristic (report only if something
9801            // forked) so a stale cache doesn't start emitting shell/children
9802            // lines for every builtin.
9803            _ => {
9804                let forked = crate::vm_helper::FORK_EVENTS
9805                    .load(std::sync::atomic::Ordering::Relaxed)
9806                    != forks_before;
9807                if forked {
9808                    let line =
9809                        crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
9810                    eprintln!("{}", line);
9811                }
9812                return Value::Status(status);
9813            }
9814        };
9815        let _ = forks_before;
9816        // c:Src/exec.c:1699 — execpline hands `how` (Z_TIMED included) to
9817        // the pipeline's last stage, and a last stage that runs in the
9818        // current shell reaches the c:4443 shelltime call below just as a
9819        // timed builtin does: `time /bin/echo a | read x` prints the shell
9820        // and children lines before dumptime's line for the forked stage.
9821        let last_stage_cursh = timed_pipeline.as_ref().is_some_and(|t| !t.last_forks);
9822        if is_cursh || last_stage_cursh {
9823            // c:Src/exec.c:4443-4444 — `if ((is_cursh || do_exec) && (how &
9824            // Z_TIMED)) shelltime(&shti, &chti, &then, 1);`
9825            //
9826            // c:Src/jobs.c:1933-1993 shelltime(shell, kids, then, delta=1):
9827            //   getrusage(RUSAGE_SELF, &ti);  dtime_tv(… shell delta …);
9828            //   dtime_ts(&dtimespec, then, &now);
9829            //   if (!delta == !shell)  printtime(&dtimespec, &ti, "shell");
9830            //   getrusage(RUSAGE_CHILDREN, &ti); dtime_tv(… kids delta …);
9831            //   if (!delta == !kids)   printtime(&dtimespec, &ti, "children");
9832            // With delta=1 and both pointers non-NULL, BOTH lines print.
9833            //
9834            // !!! DO NOT "FIX" THIS TO PRINT NOTHING !!!
9835            // The locally-installed `zsh` may print nothing here and look
9836            // like the oracle. It is not: this behaviour was ADDED by
9837            // upstream 53088 (ChangeLog 2024-09-14, Bart Schaefer) —
9838            // "Src/exec.c, Src/jobs.c, Test/A01grammar.ztst,
9839            //  Test/A08time.ztst: enable `time' on builtins, assignments,
9840            //  and other current-shell actions, including failed commands."
9841            // — which also ADDED Test/A08time.ztst chunks 8-15, the ones
9842            // that assert `shell*` / `children*` for `time x=1`,
9843            // `time echo $(…)`, `time for ((…))`, `time builtin nonesuch`
9844            // and `time false`. zsh 5.9 (2022-05) predates 53088, so a 5.9.x
9845            // binary is silent for every one of those shapes while both
9846            // vendored C trees (src/zsh 5.9.0.3-test and 5.9.999.3-test)
9847            // carry the c:4443 call. Corpus + C source are the spec here.
9848            let mut d_self = ru_self_after;
9849            d_self.ru_utime = sub(ru_self_after.ru_utime, ru_self_before.ru_utime); // c:1954
9850            d_self.ru_stime = sub(ru_self_after.ru_stime, ru_self_before.ru_stime); // c:1955
9851            let ti_self = crate::ported::zsh_h::timeinfo::from_rusage(&d_self);
9852            // c:1972 — `printtime(&dtimespec, &ti, "shell");`
9853            eprintln!(
9854                "{}",
9855                crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti_self, &fmt, "shell")
9856            );
9857            // c:1993 — `printtime(&dtimespec, &ti, "children");`
9858            eprintln!(
9859                "{}",
9860                crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, "children")
9861            );
9862        }
9863        if is_cursh {
9864            // A current-shell body is no job: nothing more to report.
9865        } else if let Some(report) = timed_pipeline.filter(|t| !t.procs.is_empty()).and_then(|t| {
9866            // c:Src/jobs.c:1029-1037 — `for (pn = jn->procs; pn; pn = pn->next)
9867            // printtime(dtime_ts(…, &pn->bgtime, &pn->endtime), &pn->ti,
9868            // pn->text);` — one line per process the pipeline forked.
9869            let jn = crate::ported::zsh_h::job {
9870                procs: t.procs,
9871                ..Default::default()
9872            };
9873            crate::ported::jobs::dumptime(&jn)
9874        }) {
9875            eprintln!("{}", report);
9876        } else if !last_stage_cursh {
9877            // c:Src/jobs.c:1037 — the forked job's own printtime line, with
9878            // `pn->text` (the command source) as %J.
9879            let line = crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
9880            eprintln!("{}", line);
9881        }
9882        Value::Status(status)
9883    });
9884
9885    // `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocator.
9886    // Stack: [path, varid, op_byte]. Opens path with the appropriate mode
9887    // and stores the resulting fd number in $varid as a string. We use
9888    // a high starting fd (10+) by allocating then dup'ing — matches zsh's
9889    // "fresh fd >= 10" promise so subsequent commands don't collide on
9890    // stdin/out/err.
9891    vm.register_builtin(BUILTIN_OPEN_NAMED_FD, |vm, _argc| {
9892        use std::sync::atomic::Ordering;
9893        let op_byte = vm.pop().to_int() as u8;
9894        let varid = vm.pop().to_str();
9895        let path = vm.pop().to_str();
9896        // Param introspection used by both the open and close forms.
9897        // c:Src/exec.c:2188 `getvalue(&vbuf, &s, 0)` reaches the node through
9898        // realparamtab->getnode, which is getprivatenode once zsh/param/private
9899        // is set up (Src/Modules/param_private.c:678): a private from an
9900        // enclosing scope is not this parameter.
9901        let param_flags = crate::ported::params::paramtab().read().ok().and_then(|t| {
9902            t.get(&varid).and_then(|p| {
9903                let vis = crate::ported::modules::param_private::getprivatenode(&**p as *const _);
9904                (!vis.is_null()).then(|| unsafe { (*vis).node.flags })
9905            })
9906        });
9907        let param_readonly = param_flags
9908            .map(|f| (f & crate::ported::zsh_h::PM_READONLY as i32) != 0)
9909            .unwrap_or(false);
9910        // `{varid}>&-` / `{varid}<&-` — REDIR_CLOSE with varid.
9911        // Direct port of Src/exec.c:3805-3850.
9912        if matches!(
9913            op_byte,
9914            b if b == fusevm::op::redirect_op::DUP_WRITE
9915                || b == fusevm::op::redirect_op::DUP_READ
9916        ) {
9917            let n = path.trim_start_matches('&');
9918            if n == "-" {
9919                let val = with_executor(|exec| exec.scalar(&varid)).unwrap_or_default();
9920                let fd1 = val.parse::<i32>();
9921                // c:3811-3816 — bad=1: parameter doesn't contain an fd.
9922                let Ok(fd1) = fd1 else {
9923                    crate::ported::utils::zwarn(&format!(
9924                        "parameter {} does not contain a file descriptor",
9925                        varid
9926                    ));
9927                    with_executor(|exec| exec.redirect_failed = true);
9928                    return Value::Status(1);
9929                };
9930                // c:3813-3814 — bad=2: readonly parameter.
9931                if param_readonly {
9932                    crate::ported::utils::zwarn(&format!(
9933                        "can't close file descriptor from readonly parameter {}",
9934                        varid
9935                    ));
9936                    with_executor(|exec| exec.redirect_failed = true);
9937                    return Value::Status(1);
9938                }
9939                // c:3830-3835 — bad=3: fd >= 10 marked FDT_INTERNAL.
9940                if fd1 >= 10
9941                    && fd1 <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
9942                    && crate::ported::utils::fdtable_get(fd1) == crate::ported::zsh_h::FDT_INTERNAL
9943                {
9944                    crate::ported::utils::zwarn(&format!(
9945                        "file descriptor {} used by shell, not closed",
9946                        fd1
9947                    ));
9948                    with_executor(|exec| exec.redirect_failed = true);
9949                    return Value::Status(1);
9950                }
9951                // c:3870-3873 — close; report failure (varid form
9952                // always reports, unlike bare `N>&-`).
9953                // In an in-process subshell only the body's copy closes.
9954                crate::ported::exec::SubshFdFrame::touch(fd1);
9955                if crate::ported::utils::zclose(fd1) < 0 {
9956                    crate::ported::utils::zwarn(&format!(
9957                        "failed to close file descriptor {}: {}",
9958                        fd1,
9959                        crate::ported::utils::zsh_errno_msg(
9960                            std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
9961                        ) // c:Src/exec.c:3929 `%e`
9962                    ));
9963                    return Value::Status(1);
9964                }
9965                return Value::Status(0);
9966            }
9967            // `{varid}>&N` — dup N to a fresh fd >= 10, store in varid.
9968            if let Ok(src) = n.parse::<i32>() {
9969                if param_readonly {
9970                    crate::ported::utils::zwarn(&format!(
9971                        "can't allocate file descriptor to readonly parameter {}",
9972                        varid
9973                    ));
9974                    with_executor(|exec| exec.redirect_failed = true);
9975                    return Value::Status(1);
9976                }
9977                let dup = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
9978                if dup < 0 {
9979                    crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src));
9980                    with_executor(|exec| exec.redirect_failed = true);
9981                    return Value::Status(1);
9982                }
9983                // c:2404-2412 addfd varid arm — movefd + FDT_EXTERNAL.
9984                let final_fd = crate::ported::utils::movefd(dup);
9985                crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
9986                // In an in-process subshell the descriptor dies with the body.
9987                crate::ported::exec::SubshFdFrame::opened(final_fd);
9988                with_executor(|exec| {
9989                    #[cfg(feature = "recorder")]
9990                    crate::recorder::note_descriptor_param(&varid);
9991                    exec.set_scalar(varid, final_fd.to_string());
9992                });
9993                return Value::Status(0);
9994            }
9995            return Value::Status(1);
9996        }
9997        // `{varid}<<HERE` (op byte 255) / `{varid}<<<str` (op byte
9998        // 254) — zshrs-side contract with compile_redir; fusevm's
9999        // redirect_op stops at 8. C path: gethere/getherestr write the
10000        // body to a temp file (Src/exec.c:4660-4682), then addfd's
10001        // varid arm moves the read fd >= 10, marks FDT_EXTERNAL and
10002        // sets the param (c:2402-2412). `path` carries the BODY text
10003        // here.
10004        //
10005        // The two markers ARE the `REDIRF_FROM_HEREDOC` distinction of
10006        // c:Src/exec.c:4671-4672 (flag set at c:Src/parse.c:2970-2971):
10007        // 255 is the here-DOCUMENT spelling, whose body reaches the
10008        // consumer byte-for-byte, and 254 the genuine here-STRING,
10009        // which gains one trailing newline "as if the string given was
10010        // a complete command line" (c:4665-4666).
10011        if op_byte == 255 || op_byte == 254 {
10012            let from_heredoc = op_byte == 255;
10013            if param_readonly {
10014                crate::ported::utils::zwarn(&format!(
10015                    "can't allocate file descriptor to readonly parameter {}",
10016                    varid
10017                ));
10018                with_executor(|exec| exec.redirect_failed = true);
10019                return Value::Status(1);
10020            }
10021            // c:4671-4672 — `trim_end_matches('\n')` + unconditional
10022            // append was lossy in both directions: `exec {f}<<EOF`
10023            // with two trailing blank lines produced "hello\n" where
10024            // zsh produces "hello\n\n\n".
10025            let body = if from_heredoc {
10026                path
10027            } else {
10028                format!("{}\n", path)
10029            };
10030            let mut tmpl: Vec<u8> = b"/tmp/zshrs_hd_XXXXXX\0".to_vec();
10031            let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
10032            if write_fd < 0 {
10033                crate::ported::utils::zwarn(&format!(
10034                    "can't create temp file for here document: {}",
10035                    crate::ported::utils::zsh_errno_msg(
10036                        std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
10037                    ) // c:Src/exec.c:3828 `%e`
10038                ));
10039                return Value::Status(1);
10040            }
10041            let bytes = body.as_bytes();
10042            let mut off = 0;
10043            while off < bytes.len() {
10044                let n = unsafe {
10045                    libc::write(
10046                        write_fd,
10047                        bytes[off..].as_ptr() as *const libc::c_void,
10048                        bytes.len() - off,
10049                    )
10050                };
10051                if n <= 0 {
10052                    unsafe { libc::close(write_fd) };
10053                    return Value::Status(1);
10054                }
10055                off += n as usize;
10056            }
10057            unsafe { libc::close(write_fd) };
10058            let read_fd =
10059                unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
10060            unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
10061            if read_fd < 0 {
10062                return Value::Status(1);
10063            }
10064            let final_fd = crate::ported::utils::movefd(read_fd);
10065            if final_fd < 0 {
10066                return Value::Status(1);
10067            }
10068            crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
10069            // In an in-process subshell the descriptor dies with the body.
10070            crate::ported::exec::SubshFdFrame::opened(final_fd);
10071            with_executor(|exec| {
10072                #[cfg(feature = "recorder")]
10073                crate::recorder::note_descriptor_param(&varid);
10074                exec.set_scalar(varid, final_fd.to_string());
10075            });
10076            return Value::Status(0);
10077        }
10078        // Open form: `{varid}>file` etc.
10079        // c:Src/exec.c:2177-2215 checkclobberparam — gate BEFORE open.
10080        if param_readonly {
10081            // c:2191-2197
10082            crate::ported::utils::zwarn(&format!(
10083                "can't allocate file descriptor to readonly parameter {}",
10084                varid
10085            ));
10086            with_executor(|exec| exec.redirect_failed = true);
10087            return Value::Status(1);
10088        }
10089        // c:2199-2213 — NO_CLOBBER refuses to overwrite a parameter
10090        // already holding an OPEN fd (decimal value, fdtable says
10091        // FDT_EXTERNAL).
10092        if !isset(crate::ported::zsh_h::CLOBBER) && op_byte != fusevm::op::redirect_op::CLOBBER {
10093            if let Some(val) = with_executor(|exec| exec.scalar(&varid)) {
10094                if let Ok(fd) = val.parse::<i32>() {
10095                    if fd >= 0
10096                        && fd <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
10097                        && crate::ported::utils::fdtable_get(fd)
10098                            == crate::ported::zsh_h::FDT_EXTERNAL
10099                    {
10100                        crate::ported::utils::zwarn(&format!(
10101                            "can't clobber parameter {} containing file descriptor {}",
10102                            varid, fd
10103                        ));
10104                        with_executor(|exec| exec.redirect_failed = true);
10105                        return Value::Status(1);
10106                    }
10107                }
10108            }
10109        }
10110        let path_c = match CString::new(path.clone()) {
10111            Ok(c) => c,
10112            Err(_) => return Value::Status(1),
10113        };
10114        let flags = match op_byte {
10115            b if b == fusevm::op::redirect_op::READ => libc::O_RDONLY,
10116            b if b == fusevm::op::redirect_op::WRITE || b == fusevm::op::redirect_op::CLOBBER => {
10117                libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC
10118            }
10119            b if b == fusevm::op::redirect_op::APPEND => {
10120                libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND
10121            }
10122            b if b == fusevm::op::redirect_op::READ_WRITE => libc::O_RDWR | libc::O_CREAT,
10123            _ => return Value::Status(1),
10124        };
10125        let fd = unsafe { libc::open(path_c.as_ptr(), flags, 0o666) };
10126        if fd < 0 {
10127            // c:Src/exec.c:3790-3795 — report the open failure and mark the
10128            // redirect failed so the command is SKIPPED, matching the numeric-fd
10129            // (`3< file`) and non-varid (`< file`) paths. Previously this
10130            // silently returned Status(1) with no diagnostic and no
10131            // redirect_failed flag, so `{fd}< /nonexistent` ran the command
10132            // anyway with exit 0 (zsh errors "no such file or directory" +
10133            // skips the command). `%e: %s` = strerror(errno) : filename.
10134            let e = std::io::Error::last_os_error();
10135            let msg = redir_errno_msg(&e);
10136            crate::ported::utils::zwarn(&format!("{}: {}", msg, path));
10137            with_executor(|exec| exec.redirect_failed = true);
10138            return Value::Status(1);
10139        }
10140        // c:2404-2412 addfd varid arm — `fd1 = movefd(fd2);
10141        // fdtable[fd1] = FDT_EXTERNAL; setiparam(varid, fd1);`.
10142        // FDT_EXTERNAL (not INTERNAL): the user owns this fd — the
10143        // NO_CLOBBER gate above and `{fd}>&-` close both key off it.
10144        let final_fd = crate::ported::utils::movefd(fd);
10145        if final_fd < 0 {
10146            crate::ported::utils::zerr(&format!(
10147                "cannot move fd {}: {}",
10148                fd,
10149                crate::ported::utils::zsh_errno_msg(
10150                    std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
10151                ) // c:Src/exec.c:2463 `%e`
10152            ));
10153            return Value::Status(1);
10154        }
10155        crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
10156        // In an in-process subshell the descriptor dies with the body.
10157        crate::ported::exec::SubshFdFrame::opened(final_fd);
10158        let _ = Ordering::Relaxed;
10159        with_executor(|exec| {
10160            #[cfg(feature = "recorder")]
10161            crate::recorder::note_descriptor_param(&varid);
10162            exec.set_scalar(varid, final_fd.to_string());
10163        });
10164        Value::Status(0)
10165    });
10166
10167    // BUILTIN_SET_TRY_BLOCK_ERROR — capture the try-block's exit
10168    // status into `__zshrs_try_block_saved_status` (a scratch
10169    // scalar) so the always-arm can later restore it. Also set
10170    // `TRY_BLOCK_ERROR` per zsh semantics: it stays at -1 unless
10171    // the try-block fired an explicit error (errflag), per
10172    // c:Src/exec.c execlist's WC_TRYBLOCK arm.
10173    vm.register_builtin(BUILTIN_SET_TRY_BLOCK_ERROR, |vm, _argc| {
10174        use std::sync::atomic::Ordering;
10175        let vm_status = vm.last_status;
10176        // c:Src/exec.c WC_TRYBLOCK — the always-arm runs with a
10177        // clean escape state. Snapshot RETFLAG / BREAKS / CONTFLAG /
10178        // EXIT_PENDING here and clear them; RESTORE_TRY_BLOCK_STATUS
10179        // re-applies them at always-arm exit so the propagation jump
10180        // emitted by compile_zsh fires correctly.
10181        let ret_save = crate::ported::builtin::RETFLAG.swap(0, Ordering::Relaxed); // c:769-770
10182        let brk_save = crate::ported::builtin::BREAKS.swap(0, Ordering::Relaxed); // c:771-772
10183        let cont_save = crate::ported::builtin::CONTFLAG.swap(0, Ordering::Relaxed); // c:773-774
10184        let exit_save = crate::ported::builtin::EXIT_PENDING.swap(0, Ordering::Relaxed);
10185        // c:Src/loop.c:762-763 — `save_try_errflag = try_errflag;
10186        // save_try_interrupt = try_interrupt;`. Restored at c:778-779
10187        // by RESTORE_TRY_BLOCK_STATUS so a nested try block doesn't
10188        // clobber the enclosing one's `$TRY_BLOCK_ERROR`.
10189        let try_err_save = crate::ported::r#loop::try_errflag.load(Ordering::Relaxed); // c:762
10190        let try_int_save = crate::ported::r#loop::try_interrupt.load(Ordering::Relaxed); // c:763
10191        TRY_ESCAPE_SAVE.with(|s| {
10192            s.borrow_mut().push((
10193                ret_save,
10194                brk_save,
10195                cont_save,
10196                exit_save,
10197                try_err_save,
10198                try_int_save,
10199            ));
10200        });
10201        // c:Src/loop.c:764-766 — `try_errflag = (zlong)(errflag &
10202        // ERRFLAG_ERROR); try_interrupt = (zlong)((errflag &
10203        // ERRFLAG_INT) ? 1 : 0);`. Both are the RAW FLAG BITS, not the
10204        // try-list's exit status: ERRFLAG_ERROR is 1 (zsh.h:2972), so
10205        // `$TRY_BLOCK_ERROR` is 1-or-0 in zsh regardless of what the
10206        // failing command's `$?` was. The try-list's status is carried
10207        // separately in `__zshrs_try_block_saved_status`.
10208        let live_errflag = crate::ported::utils::errflag.load(Ordering::Relaxed);
10209        let try_err = (live_errflag & crate::ported::zsh_h::ERRFLAG_ERROR) as i64; // c:765
10210        let try_int = if (live_errflag & crate::ported::zsh_h::ERRFLAG_INT) != 0 {
10211            1i64
10212        } else {
10213            0i64
10214        }; // c:766
10215        crate::ported::r#loop::try_errflag.store(try_err, Ordering::Relaxed); // c:765
10216        crate::ported::r#loop::try_interrupt.store(try_int, Ordering::Relaxed); // c:766
10217                                                                                // c:Src/loop.c:755 — `endval = lastval ? lastval : errflag;`.
10218                                                                                // The status of the WHOLE `{…} always {…}` construct, captured
10219                                                                                // BEFORE the always-list runs (exectry returns it at c:801) and
10220                                                                                // deliberately including the errflag fallback: a try-list that
10221                                                                                // failed with `lastval == 0` but raised errflag still reports 1.
10222        let endval = if vm_status != 0 {
10223            vm_status
10224        } else {
10225            live_errflag
10226        }; // c:755
10227        with_executor(|exec| {
10228            // flags=0 (not setsparam's ASSPM_WARN): VM-internal scratch —
10229            // must never surface as a WARN_CREATE_GLOBAL diagnostic inside
10230            // a user function running `{...} always {...}` (f-sy-h's
10231            // `_zsh_highlight` does exactly that under warncreateglobal).
10232            crate::ported::params::assignsparam(
10233                "__zshrs_try_block_saved_status",
10234                &endval.to_string(),
10235                0,
10236            );
10237            let _ = exec;
10238            // Mirror into paramtab so `${parameters[TRY_BLOCK_ERROR]}`
10239            // and the PM_INTEGER `u_val` shadow agree with the atomic
10240            // the special-var getter reads. (setsparam → intsetfn's
10241            // TRY_BLOCK_ERROR arm re-stores the same value.)
10242            exec.set_scalar("TRY_BLOCK_ERROR".to_string(), try_err.to_string());
10243            exec.set_scalar("TRY_BLOCK_INTERRUPT".to_string(), try_int.to_string());
10244        });
10245        // c:Src/loop.c:768 — `errflag = 0;` ("We need to reset all
10246        // errors to allow the block to execute"). C clears the WHOLE
10247        // word, not just ERRFLAG_ERROR.
10248        crate::ported::utils::errflag.store(0, Ordering::Relaxed); // c:768
10249        Value::Status(0)
10250    });
10251
10252    // BUILTIN_BEGIN_INLINE_ENV / END_INLINE_ENV — wrap an
10253    // inline-assignment-prefixed command (`X=foo Y=bar cmd`):
10254    // BEGIN pushes a save frame; SET_VAR fires for each assign and
10255    // ALSO env::set_var's the value (visible to cmd's child); the
10256    // command runs; END pops the frame and restores both shell-var
10257    // and process-env state. Direct port of zsh's addvars() →
10258    // execute_simple → restore-after-exec contract.
10259    vm.register_builtin(BUILTIN_BEGIN_INLINE_ENV, |vm, argc| {
10260        // c:Src/exec.c:4114-4126 — whether the frame RECORDS anything is
10261        // `do_save`:
10262        //     if (isset(POSIXBUILTINS)) {
10263        //         if (is_shfunc || (hn->flags & (BINF_PSPECIAL|BINF_ASSIGN)))
10264        //             do_save = (orig_cflags & BINF_COMMAND);
10265        //         else
10266        //             do_save = 1;
10267        //     } else { ... }
10268        //     if (do_save && varspc) save_params(...);
10269        // A frame is pushed either way so BEGIN/END stay balanced; an
10270        // empty save list simply restores nothing, which IS the
10271        // assignment persisting. POSIX.1-2017 XCU 2.9.1: "If the command
10272        // name is a special built-in utility, variable assignments shall
10273        // affect the current execution environment." Verified:
10274        // `dash|ksh|mksh -c 'v=0; v=1 :; printf "[%s]\n" "$v"'` → `[1]`,
10275        // and `v=2 true` → `[1]` because `true` is NOT special.
10276        //
10277        // The name arrives as a compile-time constant (empty when the
10278        // command word is an expansion, which takes the save arm).
10279        let name = if argc >= 1 {
10280            vm.pop().to_str()
10281        } else {
10282            String::new()
10283        };
10284        let mut frame = crate::vm_helper::InlineEnvFrame::new();
10285        // !!! bash EXCEPTION — bash(1), "POSIX Mode": "Assignment
10286        // statements preceding POSIX special builtins persist in the shell
10287        // environment after the builtin completes." bash does this ONLY in
10288        // posix mode, so default `--bash` keeps zsh's save/restore
10289        // (`bash -c 'v=0; v=1 :; printf "[%s]\n" "$v"'` → `[0]`, while
10290        // dash / ksh93 / mksh / bash-as-sh all print `[1]`). zshrs tracks
10291        // bash's `set -o posix` in dash_mode::BASH_ONLY_OPTS, so honor it.
10292        let bash_suppresses =
10293            crate::dash_mode::bash_mode() && !crate::dash_mode::bash_set_o_get("posix");
10294        if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
10295            && !name.is_empty()
10296            && !bash_suppresses
10297        {
10298            // c:4123 `do_save = (orig_cflags & BINF_COMMAND)` — the
10299            // `command` prefix is BINF_COMMAND (c:Src/builtin.c:44
10300            // `BIN_PREFIX("command", BINF_COMMAND)`) and resets the
10301            // behavior, so it keeps the save.
10302            let is_command_prefix = name == "command";
10303            // !!! dash / pdksh EXCEPTION — C's `is_shfunc` leg is not
10304            // universal. `f(){ :; }; v=0; v=4 f` leaves `v` at 0 in dash,
10305            // ash AND mksh, while ksh93 and bash-as-sh (the `--sh`
10306            // reference) leave it at 4. Only the builtin legs are shared.
10307            let is_shfunc = !crate::dash_mode::dash_strict()
10308                && !crate::dash_mode::pdksh_family()
10309                && crate::ported::hashtable::shfunctab_lock()
10310                    .read()
10311                    .map(|t| t.get(&name).is_some())
10312                    .unwrap_or(false);
10313            if !is_command_prefix
10314                && (is_shfunc || builtin_is_pspecial(&name) || builtin_is_assign_family(&name))
10315            {
10316                frame.recording = false; // c:4122-4123 do_save = 0
10317            }
10318        }
10319        // c:4137-4147 — the OTHER decision the command word drives, and the
10320        // one that has nothing to do with POSIX_BUILTINS:
10321        //     /* Export this if the command is a shell function,
10322        //      * but not if it's a builtin.
10323        //      */
10324        //     int flags = 0;
10325        //     if (is_shfunc)
10326        //         flags |= ADDVAR_EXPORT;
10327        //     ...
10328        //     addvars(state, varspc, flags);
10329        // `addvars` turns that bit into `opts[ALLEXPORT] = 1` around the
10330        // assignment (c:2641-2651), so `X=y shellfn` leaves `X` EXPORTED —
10331        // `${(t)X}` reads `scalar-export` inside the call and `export -p`
10332        // lists it — while `X=y eval …` leaves a plain `scalar`. Verified
10333        // against /opt/homebrew/bin/zsh: `f(){ print ${(t)Q} }; Q=y f` →
10334        // `scalar-export`, `Q=y eval 'print ${(t)Q}'` → `scalar`.
10335        //
10336        // The external-command leg (c:4343-4348 sets ADDVAR_EXPORT
10337        // unconditionally) runs in the FORKED child, after which nothing
10338        // reads the child's parameter table; the value reaches the child's
10339        // environment through the `zputenv` the set-var builtin already
10340        // does either way, so only the shell-function leg is observable and
10341        // only it is mirrored here.
10342        //
10343        // !!! KNOWN GAP — an EXPANDED command word (`c=f; X=y $c`) has no
10344        // compile-time literal, so `name` is the unexpanded text and the
10345        // lookup misses: the prefix assignment stays unexported where zsh
10346        // exports it. Same limitation, same cause as the POSIX_BUILTINS
10347        // `do_save` decision above (compile_zsh.rs:2133-2137) — C resolves
10348        // `hn` at runtime from the expanded word and the bridge is handed a
10349        // constant.
10350        frame.export = crate::ported::hashtable::shfunctab_lock()
10351            .read()
10352            .map(|t| t.get(&name).is_some())
10353            .unwrap_or(false); // c:4142-4143
10354        with_executor(|exec| {
10355            exec.inline_env_stack.push(frame);
10356        });
10357        Value::Status(0)
10358    });
10359    // Closes the frame's save list — see BUILTIN_SEAL_INLINE_ENV.
10360    vm.register_builtin(BUILTIN_SEAL_INLINE_ENV, |_vm, _argc| {
10361        with_executor(|exec| {
10362            if let Some(frame) = exec.inline_env_stack.last_mut() {
10363                frame.recording = false;
10364            }
10365        });
10366        // c:Src/exec.c:4147-4148 — `addvars(state, varspc, flags); if
10367        // (errflag) {` — see PREFIX_ASSIGN_FAILED.
10368        let failed = (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
10369            & crate::ported::zsh_h::ERRFLAG_ERROR)
10370            != 0;
10371        PREFIX_ASSIGN_FAILED.with(|c| c.set(failed));
10372        // The command word is known now unless it is an expansion, which
10373        // BUILTIN_EXEC_DYNAMIC settles.
10374        if let Some(name) = PREFIX_ASSIGN_CMD.with(|c| c.borrow_mut().take()) {
10375            if !name.is_empty() {
10376                settle_prefix_assign_exit(&name);
10377            }
10378        }
10379        Value::Status(0)
10380    });
10381    // See BUILTIN_PREFIX_ASSIGNS_START.
10382    vm.register_builtin(BUILTIN_PREFIX_ASSIGNS_START, |vm, argc| {
10383        let name = if argc >= 1 { vm.pop().to_str() } else { String::new() };
10384        PREFIX_ASSIGN_CMD.with(|c| *c.borrow_mut() = Some(name));
10385        PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.set(false));
10386        Value::Status(0)
10387    });
10388    vm.register_builtin(BUILTIN_END_INLINE_ENV, |_vm, _argc| {
10389        PREFIX_ASSIGN_FAILED.with(|c| c.set(false));
10390        // No dispatch resolved the command: the assignments ran in the shell.
10391        if PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.get()) {
10392            settle_prefix_assign_exit("");
10393        }
10394        with_executor(|exec| {
10395            if let Some(frame) = exec.inline_env_stack.pop() {
10396                frame.restore(); // c:Src/exec.c:4519 restore_params
10397            }
10398        });
10399        Value::Status(0)
10400    });
10401    // c:Src/exec.c:3969-3976 — bare-exec assignment epilogue: see the
10402    // const's doc block. POSIX_BUILTINS → assignments persist (pop the
10403    // frame, discard the saved state); otherwise → restore_params
10404    // (same walk as END_INLINE_ENV).
10405    vm.register_builtin(BUILTIN_EXEC_INLINE_ENV_DONE, |_vm, _argc| {
10406        let persist = isset(crate::ported::zsh_h::POSIXBUILTINS);
10407        with_executor(|exec| {
10408            if let Some(frame) = exec.inline_env_stack.pop() {
10409                if persist {
10410                    return; // c:3971 — no save/restore under POSIX_BUILTINS
10411                }
10412                frame.restore(); // c:Src/exec.c:4519 restore_params
10413            }
10414        });
10415        Value::Status(0)
10416    });
10417
10418    // BUILTIN_RESTORE_TRY_BLOCK_STATUS — emitted at the end of an
10419    // `always` arm. Per zshmisc, the exit status of the entire
10420    // `{ try } always { finally }` construct is the try-list's
10421    // status, regardless of what happens in the always-list (the
10422    // exception is `return`/`exit` inside always, which short-
10423    // circuits and the cleanup is the only thing that runs). So
10424    // restore TRY_BLOCK_ERROR unconditionally — the always-list's
10425    // exit status is discarded for the construct.
10426    vm.register_builtin(BUILTIN_RESTORE_TRY_BLOCK_STATUS, |_vm, _argc| {
10427        use std::sync::atomic::Ordering;
10428        // c:Src/loop.c:801 — `return endval;`. The construct's exit
10429        // status is the try-list's (captured at c:755 by
10430        // SET_TRY_BLOCK_ERROR), never the always-list's.
10431        let saved = with_executor(|exec| {
10432            exec.scalar("__zshrs_try_block_saved_status")
10433                .and_then(|s| s.parse::<i32>().ok())
10434                .unwrap_or(0)
10435        });
10436        // c:Src/exec.c:1375 — `lastval = lv;` on the exectry return.
10437        // The always-list's own commands left their status in LASTVAL
10438        // (`always { : }` → 0); without this store the errflag re-raise
10439        // below aborts the shell with the always-list's 0 instead of
10440        // the try-list's failure status.
10441        crate::ported::builtin::LASTVAL.store(saved, Ordering::Relaxed); // c:1375
10442                                                                         // c:Src/loop.c:774-777 — the error RE-RAISE. This is the
10443                                                                         // whole point of TRY_BLOCK_ERROR being writable:
10444                                                                         //
10445                                                                         //     if (try_errflag)  errflag |= ERRFLAG_ERROR;
10446                                                                         //     else              errflag &= ~ERRFLAG_ERROR;
10447                                                                         //     if (try_interrupt) errflag |= ERRFLAG_INT;
10448                                                                         //     else               errflag &= ~ERRFLAG_INT;
10449                                                                         //
10450                                                                         // SET_TRY_BLOCK_ERROR cleared errflag (c:768) so the always-arm
10451                                                                         // could run; the try-block's error is PARKED in `try_errflag`
10452                                                                         // and re-raised HERE unless the always-arm zeroed it
10453                                                                         // (`TRY_BLOCK_ERROR=0`, the documented swallow idiom — routed
10454                                                                         // to the atomic by intsetfn's IPDEF6 arm, params.rs).
10455                                                                         //
10456                                                                         // zshrs used to just drop the parked error, so
10457                                                                         // `f() { { typeset -r ro=1; ro=2 } always { … }; print reached }`
10458                                                                         // kept running and exited 0, where zsh aborts f with status 1.
10459        let te = crate::ported::r#loop::try_errflag.load(Ordering::Relaxed); // c:774
10460        let ti = crate::ported::r#loop::try_interrupt.load(Ordering::Relaxed); // c:776
10461        if te != 0 {
10462            crate::ported::utils::errflag
10463                .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
10464        // c:775
10465        } else {
10466            crate::ported::utils::errflag
10467                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
10468            // c:777
10469        }
10470        if ti != 0 {
10471            crate::ported::utils::errflag
10472                .fetch_or(crate::ported::zsh_h::ERRFLAG_INT, Ordering::Relaxed);
10473        // c:779
10474        } else {
10475            crate::ported::utils::errflag
10476                .fetch_and(!crate::ported::zsh_h::ERRFLAG_INT, Ordering::Relaxed);
10477            // c:781
10478        }
10479        // Re-apply the escape flags captured by SET_TRY_BLOCK_ERROR.
10480        // If the always-arm itself fired return/break/continue/exit,
10481        // its handler already overwrote the canonical atomics; let
10482        // those win — the always-arm's own escape always takes
10483        // priority over the try-block's deferred one.
10484        if let Some((ret, brk, cont, exit_p, try_err_save, try_int_save)) =
10485            TRY_ESCAPE_SAVE.with(|s| s.borrow_mut().pop())
10486        {
10487            // c:Src/loop.c:782-783 — `try_errflag = save_try_errflag;
10488            // try_interrupt = save_try_interrupt;`
10489            crate::ported::r#loop::try_errflag.store(try_err_save, Ordering::Relaxed); // c:782
10490            crate::ported::r#loop::try_interrupt.store(try_int_save, Ordering::Relaxed);
10491            // c:783
10492            if crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) == 0 {
10493                crate::ported::builtin::RETFLAG.store(ret, Ordering::Relaxed);
10494            }
10495            if crate::ported::builtin::BREAKS.load(Ordering::Relaxed) == 0 {
10496                crate::ported::builtin::BREAKS.store(brk, Ordering::Relaxed);
10497            }
10498            if crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed) == 0 {
10499                crate::ported::builtin::CONTFLAG.store(cont, Ordering::Relaxed);
10500            }
10501            if crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) == 0 {
10502                crate::ported::builtin::EXIT_PENDING.store(exit_p, Ordering::Relaxed);
10503            }
10504        }
10505        Value::Status(saved)
10506    });
10507
10508    // `[[ -r/-w/-x file ]]` — the cond path must use access(2) (the
10509    // C-faithful doaccess), NOT fusevm's generic Op::TestFile which only
10510    // checks existence for -r/-w (so a `chmod 000` file read as readable;
10511    // C02cond.ztst:13). Stack: [path, mode]; mode is the access(2) bit
10512    // (R_OK=4, W_OK=2, X_OK=1). Mirrors cond.rs:232/238/267 `doaccess`.
10513    vm.register_builtin(BUILTIN_COND_ACCESS, |vm, _argc| {
10514        let mode = vm.pop().to_int() as i32;
10515        let path = vm.pop().to_str();
10516        Value::Bool(crate::ported::cond::doaccess(&path, mode) != 0)
10517    });
10518
10519    // `[[ -prefix PAT ]]` / `-suffix` / `-after` / `-between` module condition.
10520    // Stack (pushed by the ModCond compile arm): arg0 … argN-1, then the
10521    // operator word last. argc = N+1.
10522    // c:Src/subst.c:4419-4420 `if (globsubst) shtokenize(y)` — see the
10523    // BUILTIN_COND_SHTOKENIZE doc for why a module condition needs it.
10524    vm.register_builtin(BUILTIN_COND_SHTOKENIZE, |vm, _argc| {
10525        let mut s = vm.pop().to_str();
10526        crate::ported::glob::shtokenize(&mut s);
10527        Value::str(s)
10528    });
10529
10530    vm.register_builtin(BUILTIN_COND_MOD, |vm, argc| {
10531        use crate::ported::zle::complete::{cond_psfix, cond_range, CVT_PREPAT, CVT_SUFPAT};
10532        let op = vm.pop().to_str(); // operator word (pushed last → popped first)
10533        let n = (argc as usize).saturating_sub(1);
10534        let mut args: Vec<String> = Vec::with_capacity(n);
10535        for _ in 0..n {
10536            args.push(vm.pop().to_str());
10537        }
10538        args.reverse(); // restore arg0 … argN-1 order
10539                        // Dispatch the module/completion condition (C evalcond COND_MOD path:
10540                        // condtab lookup + arity check, cond.c:149-185, over the four cotab[]
10541                        // entries at complete.c:1697-1702). Handlers return 1=match/true.
10542        let name: String = op
10543            .trim_start_matches(|c: char| c == '-' || c == '\u{e19b}')
10544            .to_string();
10545        // c:Src/cond.c:149-150 — `cd = getconddef((ctype == COND_MODI),
10546        // name + 1, 1)`. The `autol = 1` argument is what makes an
10547        // autoloadable condition LOAD its module: `getconddef`
10548        // (Src/module.c:647) sees the `c:`-stub's `p->module` and calls
10549        // `ensurefeature(p->module, "c:", name)`, then re-looks-up the
10550        // now-real definition that `zsh/complete`'s cotab installed.
10551        // Without this call zshrs answered `[[ -prefix … ]]` straight
10552        // from the compiled-in handler table and left `zsh/complete`
10553        // unloaded, where `zsh -f` reports it loaded after the first use.
10554        // `try_lock`: every other MODULESTAB caller holds the same mutex
10555        // for a moment and the load chain re-enters it; falling through
10556        // to the compiled-in table is the safe outcome, not a deadlock.
10557        let cd = match crate::ported::module::MODULESTAB.try_lock() {
10558            Ok(mut tab) => crate::ported::module::getconddef(0, &name, 1, &mut tab), // c:150
10559            Err(_) => None,
10560        };
10561        // c:151-155 — arity check against the conddef's own min/max
10562        // (`if (l < cd->min || (cd->max >= 0 && l > cd->max))`). The
10563        // fallback pins the same numbers the cotab rows carry
10564        // (complete.c:1698-1701) for the window before zsh/complete is
10565        // loaded, when `getconddef` has only the module-less stub.
10566        let (min, max): (usize, usize) = match cd.as_ref() {
10567            Some(c) if c.max >= 0 => (c.min.max(0) as usize, c.max as usize), // c:152
10568            _ => match name.as_str() {
10569                "prefix" | "suffix" => (1, 2), // c:1700-1701
10570                "after" => (1, 1),             // c:1698
10571                "between" => (2, 2),           // c:1699
10572                _ => {
10573                    // c:Src/cond.c:186-193 — no conddef matched, so C falls out
10574                    // of both `getconddef` arms to `zwarnnam(fromtest, "unknown
10575                    // condition: %s", errname)` and then `return 2;` (the
10576                    // "module not found, error" exit). Status 2 — not 1 — is
10577                    // what `evalcond` hands back, and c:Src/exec.c:5216-5221
10578                    // turns a 2 into a shell error. Arm the same carrier
10579                    // BUILTIN_COND_UNKNOWN uses so the shared
10580                    // BUILTIN_COND_STATUS_FROM_BOOL tail emits 2 and aborts;
10581                    // returning a bare Bool(false) collapsed it to 1, so
10582                    // `[[ -zz a ]]` exited 1 where zsh exits 2.
10583                    COND_BAD_PATTERN.with(|c| c.set(true)); // c:193
10584                    crate::ported::utils::zerr(&format!(
10585                        "unknown condition: {}",
10586                        op.replace('\u{e19b}', "-")
10587                    ));
10588                    return Value::Bool(false);
10589                }
10590            },
10591        };
10592        if args.len() < min || args.len() > max {
10593            // c:Src/cond.c:177-181 — `if (l < cd->min || (cd->max >= 0 &&
10594            // l > cd->max)) { zwarnnam(fromtest, "unknown condition: %s",
10595            // errname); return 2; }`. Status 2, same as the module-not-found
10596            // arm above.
10597            //
10598            // This arm previously refused to arm the carrier because the
10599            // PARSER turned a zero-operand `-word` into a ModCond, so
10600            // `[[ -prefix ]]` would have exited 2 where zsh exits 0. That
10601            // parser bug is fixed (src/ported/parse.rs par_cond_2: a
10602            // multi-char `-word` with no operand now takes c:2590's
10603            // `par_cond_double("-n", s1)` string-test arm, and a two-char
10604            // one takes c:2592's `par_cond_multi(s1, newlinklist())`), so
10605            // the only way to reach here is a genuine arity violation —
10606            // `[[ -between a ]]`, which zsh answers 2.
10607            COND_BAD_PATTERN.with(|c| c.set(true)); // c:180
10608            crate::ported::utils::zerr(&format!(
10609                "unknown condition: {}",
10610                op.replace('\u{e19b}', "-")
10611            ));
10612            return Value::Bool(false);
10613        }
10614        // c:158 — `return !cd->handler(strs, cd->condid);`
10615        let r = match cd.as_ref().and_then(|c| c.handler.map(|h| (h, c.condid))) {
10616            Some((handler, condid)) => handler(&args, condid),
10617            // Pre-load window: zsh/complete's cotab is not installed yet,
10618            // so dispatch through the compiled-in handlers directly.
10619            None => match name.as_str() {
10620                "prefix" => cond_psfix(&args, CVT_PREPAT),
10621                "suffix" => cond_psfix(&args, CVT_SUFPAT),
10622                "after" => cond_range(&args, 0),
10623                "between" => cond_range(&args, 1),
10624                _ => 0,
10625            },
10626        };
10627        Value::Bool(r == 1)
10628    });
10629
10630    vm.register_builtin(BUILTIN_IS_TTY, |vm, _argc| {
10631        let fd_str = vm.pop().to_str();
10632        let fd: i32 = fd_str.trim().parse().unwrap_or(-1);
10633        let is_tty = if fd < 0 {
10634            false
10635        } else {
10636            unsafe { libc::isatty(fd) != 0 }
10637        };
10638        Value::Bool(is_tty)
10639    });
10640
10641    // c:Src/exec.c:4918/5040/5069 — a process substitution used inside a
10642    // `[[ … ]]` cond operand errors "process substitution %s cannot be
10643    // used here" (getoutputfile/getproc run with thisjob == -1). Emitted
10644    // by the compiler in place of ProcessSubIn/Out when in_cond_operand.
10645    vm.register_builtin(BUILTIN_PROCSUB_COND_ERROR, |_vm, _argc| {
10646        let cmd = _vm.pop().to_str();
10647        crate::ported::utils::zerr(&format!("process substitution {} cannot be used here", cmd));
10648        // c:getoutputfile returns NULL with errflag set → the enclosing
10649        // statement aborts (empty stdout, exit 1), rather than the cond
10650        // merely evaluating false.
10651        crate::ported::utils::errflag.fetch_or(
10652            crate::ported::zsh_h::ERRFLAG_ERROR,
10653            std::sync::atomic::Ordering::Relaxed,
10654        );
10655        with_executor(|exec| exec.set_last_status(1));
10656        _vm.last_status = 1;
10657        Value::str("")
10658    });
10659
10660    // Set $LINENO before executing the next statement. Direct
10661    // port of zsh's `lineno` global tracking from Src/input.c
10662    // (`if ((inbufflags & INP_LINENO) || !strin) && c == '\n')
10663    // lineno++;`). The compiler emits one of these before each
10664    // top-level pipe in `compile_sublist`, carrying the line
10665    // number captured by the parser at `ZshPipe.lineno`. Pops
10666    // [n], updates `$LINENO` in the variable table.
10667    vm.register_builtin(BUILTIN_SET_LINENO, |vm, _argc| {
10668        let n = vm.pop().to_int();
10669        set_lineno_impl(n)
10670    });
10671
10672    // Direct port of Src/prompt.c:1623 cmdpush. Token is a `CS_*`
10673    // value (zsh.h:2775-2806) emitted by compile_zsh around each
10674    // compound command (if/while/[[…]]/((…))/$(…)) and consumed by
10675    // `%_` in PS4 / prompt expansion.
10676    vm.register_builtin(BUILTIN_CMD_PUSH, |vm, _argc| {
10677        let token = vm.pop().to_int() as u8;
10678        // Route through canonical cmdpush (Src/prompt.c:1623). The
10679        // prompt expander reads from the file-static `CMDSTACK` at
10680        // `prompt.rs:2006`, not `exec.cmd_stack` — without this,
10681        // `%_` in PS4 saw an empty stack during xtrace.
10682        if (token as i32) < crate::ported::zsh_h::CS_COUNT {
10683            crate::ported::prompt::cmdpush(token);
10684        }
10685        Value::Status(0)
10686    });
10687
10688    // Direct port of Src/prompt.c:1631 cmdpop.
10689    vm.register_builtin(BUILTIN_CMD_POP, |_vm, _argc| {
10690        crate::ported::prompt::cmdpop();
10691        Value::Status(0)
10692    });
10693
10694    vm.register_builtin(BUILTIN_OPTION_SET, |vm, _argc| {
10695        let name = vm.pop().to_str();
10696        // Direct port of `optison(char *name, char *s)` at Src/cond.c:502 — `[[ -o NAME ]]`
10697        // reads through the same `opts[]` array that `setopt NAME`
10698        // writes via `dosetopt`. Earlier code read a duplicate Executor
10699        // HashMap which never saw `bin_setopt`'s writes (those land in
10700        // `OPTS_LIVE` via `opt_state_set`). Routing through the canonical
10701        // C port restores the single-store invariant: one `opts[]`,
10702        // shared between setopt/unsetopt and `[[ -o ]]`.
10703        let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
10704        match r {
10705            0 => Value::Bool(true),  // c:cond.c:520 set
10706            1 => Value::Bool(false), // c:cond.c:518/520 unset
10707            _ => {
10708                // c:cond.c:514 — unknown option. optison already emitted the
10709                // diagnostic (via zwarn, now that fromtest=NULL for
10710                // `[[ -o ]]`); re-printing here double-emitted for
10711                // `[[ ! -o bad ]]` / `[[ -o a || -o b ]]`.
10712                Value::Bool(false)
10713            }
10714        }
10715    });
10716    // Tri-state `-o` for compile_cond's direct status path. Returns
10717    // 0 / 1 / 3 as a Value::Int that compile_cond consumes via
10718    // Op::SetStatus. Mirrors zsh's `[[ -o invalid ]]` returning $?=3.
10719    vm.register_builtin(BUILTIN_OPTION_CHECK_TRISTATE, |vm, _argc| {
10720        let name = vm.pop().to_str();
10721        let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
10722                                                           // optison itself prints the diagnostic via zwarnnam when r=3
10723                                                           // and POSIXBUILTINS is unset (the canonical path). Don't
10724                                                           // double-emit here. r is already 0/1/3.
10725        Value::Int(r as i64)
10726    });
10727
10728    // BUILTIN_PARAM_FILTER — `${var:#pat}` / `${var:|name}` etc.
10729    // PURE PASSTHRU: rebuild `${name:#pat}` and route to paramsubst.
10730    vm.register_builtin(BUILTIN_PARAM_FILTER, |vm, argc| {
10731        // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
10732        // compile_zsh::emit_param_modifier (see the note there).
10733        let pf_flags = if argc as usize > 2 && vm.pop().to_int() != 0 {
10734            crate::ported::zsh_h::PREFORK_SINGLE
10735        } else {
10736            0
10737        };
10738        let pattern = vm.pop().to_str();
10739        let name = vm.pop().to_str();
10740        let body = format!("${{{}:#{}}}", name, pattern);
10741        paramsubst_to_value_status(vm, &body, pf_flags)
10742    });
10743
10744    // `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()`
10745    // — subscripted-array assign with array RHS. Stack pushed by
10746    // compile_assign as: [elem0, elem1, …, elemN-1, name, key].
10747    vm.register_builtin(BUILTIN_SET_SUBSCRIPT_RANGE, |vm, argc| {
10748        let n = argc as usize;
10749        let mut popped: Vec<Value> = Vec::with_capacity(n);
10750        for _ in 0..n {
10751            popped.push(vm.pop());
10752        }
10753        popped.reverse();
10754        if popped.len() < 3 {
10755            return Value::Status(1);
10756        }
10757        // c:Src/params.c:3511-3526 — trailing append flag. The ARRAY
10758        // subscript path (`a[N]+=(v)` / `a[lo,hi]+=(v)`) sets it so the
10759        // AUGMENT transform below collapses the range to an empty range
10760        // after the slice end and inserts ONLY the new value. The scalar
10761        // path pre-concats the old slice (ARRAY_INDEX+Concat) and passes
10762        // 0, so it keeps plain-replace semantics.
10763        // The trailing marker is 0/1 for the ARRAY-RHS emitter and 2 for
10764        // the SCALAR-RHS comma emitter (compile_zsh::compile_assign),
10765        // which also pushes the SOURCE subscript just below it. The
10766        // scalar form is the one C does NOT necessarily treat as a
10767        // range: c:Src/params.c:1515 `(c != Outbrack && (ishash || c !=
10768        // ','))` stops the comma from separating subscripts when the
10769        // parameter is a hash, so `h[1,2]=Z` is the ordinary key `1,2`.
10770        // The compile-time split cannot know the type, so it defers here.
10771        let marker = popped.pop().map_or(String::new(), |v| v.to_str());
10772        // Marker 3 is the scalar-RHS form of `+=` (old slice already
10773        // concatenated in, like 2); it differs only for a numeric target.
10774        let scalar_append = marker == "3";
10775        let scalar_rhs = marker == "2" || scalar_append; // c:1515 deferral
10776        let key_src = if scalar_rhs {
10777            popped.pop().map(|v| v.to_str())
10778        } else {
10779            None
10780        };
10781        let append = marker == "1";
10782        let key = popped.pop().unwrap().to_str();
10783        let name = popped.pop().unwrap().to_str();
10784        // c:Src/exec.c:4475-4478 (54941) — a subscripted prefix assignment
10785        // (`H[2,4]=oo f`) saves its base parameter for restore_params.
10786        with_executor(|exec| {
10787            if exec
10788                .inline_env_stack
10789                .last()
10790                .is_some_and(|frame| frame.recording)
10791            {
10792                save_inline_prefix_param(exec, &name);
10793            }
10794        });
10795        // c:Src/params.c:1585-1592 — `if (needtok) { parsestr(&s);
10796        // singsub(&s); }`: the subscript body is parameter-substituted
10797        // BEFORE it is read, whether it goes on to `mathevalarg`
10798        // (c:1601, the array/scalar range bounds) or straight into the
10799        // hash as a key (c:1596-1616). The ARRAY-RHS emitter pre-expands
10800        // its subscript at word-compile time, but the SCALAR-RHS comma
10801        // emitter hands over the raw source, so this round has to happen
10802        // here: `mathevali("$n")` is 0, which silently turned
10803        // `a=abcdef; n=3; a[$n,-1]=X` into a whole-string overwrite
10804        // (`X` instead of `abX`), and it is why the fzf-tab
10805        // `t[$#MATCH/2+1,-1]=""` form still lost its bound.
10806        //
10807        // Only for a source-level LIVE expansion: an escaped `\$`
10808        // reached `parsestr` as the Bnull marker and `singsub` leaves it
10809        // alone, so `h[\$x,y]` keys on the literal `$x,y`.
10810        let key = if scalar_rhs
10811            && key_src.as_deref().is_some_and(|src| {
10812                let b = src.as_bytes();
10813                (0..b.len())
10814                    .any(|i| (b[i] == b'$' || b[i] == b'`') && (i == 0 || b[i - 1] != b'\\'))
10815            }) {
10816            crate::ported::subst::singsub(&key) // c:1592
10817        } else {
10818            key
10819        };
10820        let mut values: Vec<String> = Vec::new();
10821        for v in popped {
10822            match v {
10823                Value::Array(items) => {
10824                    for it in items.iter() {
10825                        values.push(it.to_str());
10826                    }
10827                }
10828                other => values.push(other.to_str()),
10829            }
10830        }
10831        // c:Src/params.c:3316/3365 — assignaparam's subscript arm finds the
10832        // parameter through getvalue/fetchvalue, whose getparamnode
10833        // (c:570-575) follows a PM_NAMEREF chain to its endpoint, so
10834        // `ptr[2,4]=(x y)` splices the array `ptr` names. Everything below
10835        // looks `name` up directly and spliced the reference itself ("ptr:
10836        // attempt to assign array value to non-array"). A chain ending on a
10837        // visible parameter is that parameter's name; any other outcome
10838        // (subscripted refname, hidden binding, placeholder, loop) goes to
10839        // the ported assignaparam, which resolves the chain itself.
10840        let name = if crate::ported::params::is_nameref(&name) {
10841            use crate::ported::params::nameref_resolution as NR;
10842            let visible = |t: &str, level: i32, has_pm: bool| {
10843                !has_pm
10844                    || crate::ported::params::paramtab()
10845                        .read()
10846                        .ok()
10847                        .and_then(|tb| tb.get(t).map(|p| p.level))
10848                        == Some(level)
10849            };
10850            match crate::ported::params::resolve_nameref_name(&name, None) {
10851                NR::Target { name: t, subscript: None, pm, level } if visible(&t, level, pm.is_some()) => t,
10852                _ if !scalar_rhs => {
10853                    let asspm = if append { crate::ported::zsh_h::ASSPM_AUGMENT } else { 0 };
10854                    let done =
10855                        crate::ported::params::assignaparam(&format!("{name}[{key}]"), values, asspm);
10856                    return Value::Status(if done.is_some() { 0 } else { 1 });
10857                }
10858                _ => name,
10859            }
10860        } else {
10861            name
10862        };
10863        // Bash sparse-array tracking: a single-index `a[i]=v` that pads the
10864        // dense Vec past its old end leaves indices old_len..i as HOLES (not
10865        // real elements), so `${#a[@]}`/`${!a[@]}` skip them like bash. Only
10866        // in bash mode, only for a plain non-append single index (0-based
10867        // under ksharrays). Captured before the assign; applied after.
10868        let sparse_track: Option<(String, usize, usize)> =
10869            if crate::dash_mode::sparse_arrays() && !append && !key.contains(',') {
10870                key.trim().parse::<usize>().ok().map(|i| {
10871                    let old_len =
10872                        with_executor(|exec| exec.array(&name).map(|a| a.len()).unwrap_or(0));
10873                    (name.clone(), old_len, i)
10874                })
10875            } else {
10876                None
10877            };
10878        // c:Src/params.c:3383-3389 — a subscripted ARRAY assignment to an
10879        // associative array is an error, whatever the subscript looks like:
10880        //     if (v && PM_TYPE(v->pm->node.flags) == PM_HASHED) {
10881        //         unqueue_signals();
10882        //         zerr("%s: attempt to set slice of associative array",
10883        //              v->pm->node.nam);
10884        //         freearray(val);
10885        //         errflag |= ERRFLAG_ERROR;
10886        //         return NULL;
10887        //     }
10888        // assignaparam (params.rs) ports this, but a single-key `h[k]=(1 2)`
10889        // never reaches it: the VM lowers subscripted assignment to this
10890        // builtin instead. The comma form `h[a,b]=(1 2)` DID error — it takes a
10891        // different route — so the gap looked like a subscript-parsing quirk
10892        // when it was really "the check lives on a path this form doesn't
10893        // take". Untreated, the assignment was SILENTLY DISCARDED: rc=0 and
10894        // `${h[k]}` still read its old value.
10895        //
10896        // Only array-valued assignment is rejected; `h[k]=x` is a scalar
10897        // element store and stays legal.
10898        {
10899            let is_hashed = crate::ported::params::paramtab()
10900                .read()
10901                .ok()
10902                .and_then(|t| {
10903                    t.get(&name).map(|pm| {
10904                        crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
10905                            == crate::ported::zsh_h::PM_HASHED
10906                    })
10907                })
10908                .unwrap_or(false);
10909            if is_hashed {
10910                if scalar_rhs {
10911                    // c:1515 — for a hash the comma is not a subscript
10912                    // separator, so this was never a range: hand the
10913                    // WHOLE subscript to the element path. `h[1,2]=Z`
10914                    // keys on `1,2` in zsh; rejecting it here was the
10915                    // compile-time range split leaking through.
10916                    let src = key_src.unwrap_or_else(|| key.clone());
10917                    let val = values.first().cloned().unwrap_or_default();
10918                    return Value::Status(assign_hash_element(&name, &key, &src, &val));
10919                }
10920                crate::ported::utils::zerr(&format!(
10921                    "{name}: attempt to set slice of associative array" // c:3385
10922                ));
10923                crate::ported::utils::errflag.fetch_or(
10924                    crate::ported::zsh_h::ERRFLAG_ERROR,
10925                    std::sync::atomic::Ordering::Relaxed,
10926                ); // c:3387
10927                return Value::Status(1); // c:3388
10928            }
10929        }
10930
10931        with_executor(|exec| {
10932            // Parse subscript: slice `lo,hi` or single index `i`.
10933            // setarrvalue (Src/params.c:2895) expects 1-based start/
10934            // end inclusive where start==end means replace one
10935            // element. Negative bounds translate to len+n+1 (1-based).
10936            //
10937            // c:Src/params.c — the END side accepts 0 as a valid value
10938            // that signals "insert BEFORE start position" (the canonical
10939            // `a[N,N-1]=val` prepend / mid-insert idiom). Bug #275 in
10940            // docs/BUGS.md: the previous Rust port clamped end up to 1,
10941            // collapsing `a[1,0]=(X Y)` into `a[1,1]=(X Y)` which
10942            // OVERWRITES position 1 instead of prepending. Provide two
10943            // translators — start_translate clamps to 1 (1-based);
10944            // end_translate keeps 0 intact so the splice in
10945            // setarrvalue (start_idx=0..end_idx=0) inserts at the front.
10946            // Bug #589: for scalars (no array), use the scalar's char
10947            // count as `len` so negative-index translation (`a[2,-1]`)
10948            // computes against the actual string length, not 0.
10949            let len = exec
10950                .array(&name)
10951                .map(|a| a.len() as i64)
10952                .or_else(|| {
10953                    crate::ported::params::paramtab().read().ok().and_then(|t| {
10954                        t.get(&name).and_then(|pm| {
10955                            if crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
10956                                == crate::ported::zsh_h::PM_SCALAR
10957                            {
10958                                pm.u_str.as_ref().map(|s| s.chars().count() as i64)
10959                            } else {
10960                                None
10961                            }
10962                        })
10963                    })
10964                })
10965                .unwrap_or(0);
10966            let start_translate = |raw: i64| -> i32 {
10967                if raw < 0 {
10968                    (len + raw + 1).max(1) as i32
10969                } else {
10970                    raw.max(1) as i32
10971                }
10972            };
10973            let end_translate = |raw: i64| -> i32 {
10974                if raw < 0 {
10975                    (len + raw + 1).max(0) as i32
10976                } else {
10977                    raw.max(0) as i32
10978                }
10979            };
10980            // c:Src/params.c — KSH_ARRAYS option flips array subscripts
10981            // from 1-based to 0-based. setarrvalue expects 1-based
10982            // inclusive bounds, so under KSH_ARRAYS we shift positive
10983            // inputs by +1 before translation. Negative bounds left
10984            // alone (count from end). Sibling of #610/#611/#612.
10985            // Bug #613.
10986            let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
10987            let ksh_shift = |raw: i64| -> i64 {
10988                if ksh_arrays && raw >= 0 {
10989                    raw + 1
10990                } else {
10991                    raw
10992                }
10993            };
10994            // c:Src/params.c getindex — subscript bounds are MATH
10995            // expressions, not bare integers: `a[(( ${#a}+1 ))]=(x)`,
10996            // `a[n+1]=(x)`. Plain `parse::<i64>()` returned 0 on any
10997            // arithmetic subscript, which the `i == 0` guard turned into
10998            // a silent no-op (computed-index append never landed). Parse
10999            // the literal fast-path first, then fall back to mathevali
11000            // (which handles `(( ))` grouping, var refs, and operators).
11001            // c:Src/params.c:2036 + c:2118 — getindex resolves EACH range
11002            // bound with `getarg`, not with mathevalarg directly, so a bound
11003            // may be a `(r)`/`(R)`/`(i)`/`(I)`/`(k)`/`(K)` SEARCH rather than
11004            // an arithmetic expression. On an array getarg's search arm
11005            // (c:1672-1719) returns the 1-BASED INDEX `r` of the match — the
11006            // value-vs-index distinction only sets `*inv`, which getindex
11007            // rejects for a range START (c:2121) and otherwise ignores. So map
11008            // the VALUE-returning direction letters onto their INDEX twins and
11009            // read the position back. Without this both bounds evaluated to 0
11010            // through mathevali and `array[(R)nomatch,(r)nomatch]=(…)` became
11011            // a front INSERT instead of the whole-array replace zsh performs.
11012            let cur_arr: Vec<String> = exec.array(&name).unwrap_or_default();
11013            let eval_bound = |s: &str| -> i64 {
11014                let t = s.trim();
11015                if let Some(rest) = t.strip_prefix('(') {
11016                    if let Some(close) = rest.find(')') {
11017                        let grp = &rest[..close];
11018                        if !grp.is_empty()
11019                            && grp
11020                                .chars()
11021                                .all(|c| matches!(c, 'r' | 'R' | 'i' | 'I' | 'k' | 'K' | 'e'))
11022                        {
11023                            // c:1394-1410 — r/k select the FIRST match, R/K the
11024                            // LAST; i/I are the same searches returning the index.
11025                            let mapped: String = grp
11026                                .chars()
11027                                .map(|c| match c {
11028                                    'r' | 'k' => 'i',
11029                                    'R' | 'K' => 'I',
11030                                    other => other,
11031                                })
11032                                .collect();
11033                            let expr = format!("({}){}", mapped, &rest[close + 1..]);
11034                            if let Some(crate::ported::params::getarg_out::Value(v)) =
11035                                crate::ported::params::getarg(&expr, Some(&cur_arr), None, None)
11036                            {
11037                                return v.to_str().trim().parse::<i64>().unwrap_or(0);
11038                            }
11039                        }
11040                    }
11041                }
11042                t.parse::<i64>()
11043                    .ok()
11044                    .or_else(|| crate::ported::math::mathevali(t).ok())
11045                    .unwrap_or(0)
11046            };
11047            let (raw_start, raw_end) = if let Some((s_str, e_str)) = key.split_once(',') {
11048                (ksh_shift(eval_bound(s_str)), ksh_shift(eval_bound(e_str)))
11049            } else {
11050                let i = ksh_shift(eval_bound(&key));
11051                (i, i)
11052            };
11053            // c:Src/params.c:2124-2151 (getindex) — `start == 0 && end == 0`
11054            // is the range entirely off the START of the index range. With
11055            // KSH_ZERO_SUBSCRIPT it degrades to "the first element"
11056            // (`end = startnextlen;` c:2140, i.e. the 0-based range [0,1)).
11057            // Without it the range is flagged VALFLAG_EMPTY (c:2148) and
11058            // setarrvalue (c:2910) rejects the assignment with
11059            // "assignment to invalid subscript range". zshrs silently
11060            // returned for `a[0]=(x)` and front-INSERTED for `a[0,0]=(x)`.
11061            let mut valflags = 0i32;
11062            let (raw_start, raw_end) = if raw_start == 0 && raw_end == 0 {
11063                if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT) {
11064                    (1, 1) // c:2140 — first element, in the 1-based convention below
11065                } else {
11066                    valflags |= crate::ported::zsh_h::VALFLAG_EMPTY; // c:2148
11067                    (-1, 0) // c:2149 — bounds unused; setarrvalue errors first
11068                }
11069            } else {
11070                (raw_start, raw_end)
11071            };
11072            // c:Src/params.c:2114 (getindex) — a SINGLE subscript is the
11073            // range `[i, i]`: `end = we ? we : start;` sets BOTH bounds to
11074            // the same RAW user index, and only the START is then shifted
11075            // down by one (`if (start > 0) start -= startprevlen;` c:2120).
11076            // setarrvalue (c:2944-2953) afterwards resolves each bound
11077            // INDEPENDENTLY — `start += len` clamped at 0, `end += len + 1`
11078            // clamped at 0 — so an out-of-range negative index such as
11079            // `a[-10]` on a 3-element array yields start==end==0, an EMPTY
11080            // range at the front that INSERTS. Running the end bound of a
11081            // single subscript through `start_translate` (which floors at 1)
11082            // instead collapsed that to [0,1) and OVERWROTE element 1:
11083            // `a=(some sunny day); a[-10]=(we'll meet again)` dropped "some"
11084            // (Test/D04parameter.ztst:1420 "Out of range negative array
11085            // subscripts"). Both forms therefore share one translator pair.
11086            let (start, end) = (start_translate(raw_start), end_translate(raw_end));
11087            // c:Src/params.c:3518-3520 (assignaparam ASSPM_AUGMENT) — a
11088            // subscripted `+=` to an array does NOT prepend the old slice;
11089            // it collapses the range to an EMPTY range positioned right
11090            // AFTER the slice end (`v->start = v->end--`) and splices in
11091            // ONLY the new value: `a[2]+=(d)` on (a b c) → (a b d c);
11092            // `a[2,3]+=(x)` on (1 2 3 4) → (1 2 3 x 4). In setarrvalue's
11093            // 1-based convention here that means start = end+1 (so
11094            // start_idx == end_idx == end → splice arr[end..end]).
11095            let (start, end) = if append && end > 0 {
11096                (end + 1, end)
11097            } else {
11098                (start, end)
11099            };
11100            // c:Src/params.c:392-430 IPDEF9("argv"/"@"/"*", &pparams) —
11101            // the positional parameters live in the `pparams` vector, NOT
11102            // paramtab, so a subscript splice (`argv[2]=(X Y Z)`,
11103            // `2=(X Y Z)`) must read/write pparams. Splice a synthetic
11104            // array param holding the current positionals via the
11105            // canonical setarrvalue, then store the result back to
11106            // pparams — mirroring assignaparam's argv/@/* special-case
11107            // (params.rs:6937) for the whole-array form.
11108            if name == "argv" || name == "@" || name == "*" {
11109                let mut pm = {
11110                    crate::ported::params::createparam(
11111                        &name,
11112                        crate::ported::zsh_h::PM_ARRAY as i32,
11113                    );
11114                    crate::ported::params::paramtab()
11115                        .write()
11116                        .ok()
11117                        .and_then(|mut t| t.remove(&name))
11118                };
11119                if let Some(ref mut p) = pm {
11120                    p.u_arr = Some(exec.pparams());
11121                }
11122                let mut v = crate::ported::zsh_h::value {
11123                    pm,
11124                    arr: Vec::new(),
11125                    scanflags: 0,
11126                    valflags,
11127                    start,
11128                    end,
11129                };
11130                crate::ported::params::setarrvalue(&mut v, values);
11131                let result = v.pm.and_then(|p| p.u_arr).unwrap_or_default();
11132                exec.set_pparams(result);
11133                return;
11134            }
11135            // Route through canonical setarrvalue (Src/params.c:2895).
11136            // It handles PM_READONLY rejection, PM_HASHED slice-error,
11137            // PM_ARRAY splice + bounds clamp + padding (c:2980+).
11138            let taken = match crate::ported::params::paramtab().write() {
11139                Ok(mut tab) => tab.remove(&name),
11140                Err(_) => None,
11141            };
11142            // c:Src/exec.c:2640 / getvalue(…, 1) — a subscript assignment to
11143            // a NONEXISTENT parameter auto-creates it. getindex/fetchvalue
11144            // with the create flag calls createparam(name, PM_ARRAY) so the
11145            // splice has an array to write into; `unset u; u[1,2]=(a z)`
11146            // then yields the array (a z). Without this, setarrvalue saw
11147            // v.pm == None and silently stored nothing. The single-index
11148            // scalar-value path (SET_ASSOC/SET_ARRAY_AT) already vivifies;
11149            // this brings the range/array-value path to parity.
11150            let taken = taken.or_else(|| {
11151                crate::ported::params::createparam(&name, crate::ported::zsh_h::PM_ARRAY as i32);
11152                crate::ported::params::paramtab()
11153                    .write()
11154                    .ok()
11155                    .and_then(|mut t| t.remove(&name))
11156            });
11157            // c:Src/params.c:2748+ — PM_SCALAR with subscript range
11158            // SPLICES the value into the scalar's char string. Bug
11159            // #589: zshrs's slice handler always called setarrvalue,
11160            // erroring "attempt to assign array value to non-array"
11161            // for `a=hello; a[2,3]=XYZ`. Detect PM_SCALAR and route
11162            // through assignstrvalue (which does scalar splice via
11163            // the PM_SCALAR arm at params.rs:3709-3789).
11164            //
11165            // Only a SCALAR RHS takes that route: it is assignsparam
11166            // (c:Src/params.c:3183), whose assignstrvalue also covers an
11167            // integer/float target (c:2777 — the whole value is replaced,
11168            // `integer i=5; i[1,2]=3` gives 3). An ARRAY RHS is assignaparam
11169            // → setarrvalue, which rejects every non-array target
11170            // (c:2896-2900 "attempt to assign array value to non-array":
11171            // `s=abc; s[2]=(x y)`).
11172            let target_type = taken
11173                .as_ref()
11174                .map(|pm| crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32));
11175            let is_scalar = scalar_rhs
11176                && target_type.is_some_and(|t| {
11177                    t & (crate::ported::zsh_h::PM_ARRAY | crate::ported::zsh_h::PM_HASHED) == 0
11178                });
11179            // c:Src/params.c:3250-3266 — `+=` on a subscripted integer/float:
11180            //     case PM_INTEGER: case PM_EFLOAT: case PM_FFLOAT:
11181            //         zsfree(val); unqueue_signals();
11182            //         zerr("attempt to add to slice of a numeric variable");
11183            //         return NULL;
11184            if scalar_append
11185                && target_type.is_some_and(|t| {
11186                    t == crate::ported::zsh_h::PM_INTEGER
11187                        || t == crate::ported::zsh_h::PM_EFLOAT
11188                        || t == crate::ported::zsh_h::PM_FFLOAT
11189                })
11190            {
11191                if let (Some(pm), Ok(mut tab)) = (taken, crate::ported::params::paramtab().write()) {
11192                    tab.insert(name, pm);
11193                }
11194                crate::ported::utils::zerr("attempt to add to slice of a numeric variable"); // c:3264
11195                return;
11196            }
11197            let mut v = crate::ported::zsh_h::value {
11198                pm: taken,
11199                arr: Vec::new(),
11200                scanflags: 0,
11201                valflags,
11202                start,
11203                end,
11204            };
11205            if is_scalar {
11206                // Scalar splice — concat values, route through
11207                // assignstrvalue which dispatches by PM_TYPE.
11208                // start_translate returns 1-based positions; assignstrvalue's
11209                // PM_SCALAR arm at params.rs:3735+ expects 0-based start
11210                // (chars before start are kept) and 0-based end-exclusive
11211                // (chars from end are kept). Convert: start-=1.
11212                if v.start > 0 {
11213                    v.start -= 1;
11214                }
11215                let val: String = values.join("");
11216                crate::ported::params::assignstrvalue(Some(&mut v), Some(val), 0);
11217            } else {
11218                crate::ported::params::setarrvalue(&mut v, values);
11219            }
11220            // Write the mutated Param back to paramtab — setarrvalue
11221            // mutated v.pm in-place; the prior `tab.remove(&name)` at
11222            // the top of this handler took ownership, so we re-insert
11223            // here. setarrvalue + this re-insert IS the canonical
11224            // store (Src/params.c:2895). No further mirror needed.
11225            if let Some(pm) = v.pm {
11226                if let Ok(mut tab) = crate::ported::params::paramtab().write() {
11227                    tab.insert(name, pm);
11228                }
11229            }
11230        });
11231        if let Some((nm, old_len, i)) = sparse_track {
11232            crate::bash_arrays::note_subscript_set(&nm, old_len, i);
11233        }
11234        Value::Status(0)
11235    });
11236
11237    // BUILTIN_CONCAT_SPLICE — word-segment concat for an expansion whose
11238    // ARRAY shape survives into the word (`${arr[@]}`, `$@`, `${(@)a}`,
11239    // `${=v}`, slices). c:Src/subst.c:4245 `if (isarr)` gates the two
11240    // emit shapes and c:1663 `int plan9 = isset(RCEXPANDPARAM);` picks
11241    // between them at RUNTIME — so the option, not the compile-time
11242    // segment shape, decides splice-vs-cross-product here.
11243    vm.register_builtin(BUILTIN_CONCAT_SPLICE, |vm, _argc| {
11244        let rhs = vm.pop();
11245        let lhs = vm.pop();
11246        if plan9_active() {
11247            return concat_plan9_prov(lhs, rhs);
11248        }
11249        concat_splice_prov(lhs, rhs)
11250    });
11251
11252    // BUILTIN_CONCAT_DISTRIBUTE — word-segment concat. With
11253    // rcexpandparam (zsh option), distributes element-wise (cartesian
11254    // product). Default mode: joins arrays with IFS first char to a
11255    // single scalar before concat, matching zsh's default unquoted
11256    // and DQ semantics. Direct port of Src/subst.c sepjoin path
11257    // (line ~1813) which gates element-vs-join on the rc_expand_param
11258    // option, defaulting to join.
11259    // BUILTIN_CONCAT_DISTRIBUTE_FORCED — same shape as
11260    // CONCAT_DISTRIBUTE, but always cartesian-distributes when one
11261    // side is Array. Used for compile-time-detected explicit
11262    // distribution forms (`${^arr}` etc.) where the source flag
11263    // overrides the rcexpandparam option default.
11264    // `${^arr}` — RC_EXPAND_PARAM forced on by the flag. concat_plan9 carries
11265    // both halves of C's plan9 block: the c:4316-4350 cartesian emit AND the
11266    // c:4362 `uremnode` word deletion for an empty array. The DISTRIBUTE_FORCED
11267    // handler below cannot be reused: it is shared with `${(@)a}` / `${(f)v}` /
11268    // `${a[@]}`, which KEEP the word on empty (`x${(@)a}y` → `xy`).
11269    vm.register_builtin(BUILTIN_CONCAT_PLAN9, |vm, _argc| {
11270        let rhs = vm.pop();
11271        let lhs = vm.pop();
11272        concat_plan9_prov(lhs, rhs)
11273    });
11274
11275    // `${^^arr}` — RC_EXPAND_PARAM forced OFF (c:2553-2555 `plan9 = 0`). Every
11276    // other concat builtin re-checks plan9_active(), which is the OPTION, so
11277    // under `setopt rcexpandparam` they cross-product regardless of the flag.
11278    // Go straight to concat_splice — C's non-plan9 path (c:4366-4437).
11279    vm.register_builtin(BUILTIN_CONCAT_SPLICE_NOPLAN9, |vm, _argc| {
11280        let rhs = vm.pop();
11281        let lhs = vm.pop();
11282        concat_splice_prov(lhs, rhs)
11283    });
11284
11285    vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE_FORCED, |vm, _argc| {
11286        let rhs = vm.pop();
11287        let lhs = vm.pop();
11288        match (lhs, rhs) {
11289            (Value::Array(la), Value::Array(ra)) => {
11290                if ra.is_empty() {
11291                    return Value::Array(la);
11292                }
11293                if la.is_empty() {
11294                    return Value::Array(ra);
11295                }
11296                let mut out = Vec::with_capacity(la.len() * ra.len());
11297                for a in la.iter() {
11298                    let a_s = a.as_str_cow();
11299                    for b in ra.iter() {
11300                        let b_s = b.as_str_cow();
11301                        let mut s = String::with_capacity(a_s.len() + b_s.len());
11302                        s.push_str(&a_s);
11303                        s.push_str(&b_s);
11304                        out.push(Value::str(s));
11305                    }
11306                }
11307                Value::array(out)
11308            }
11309            (Value::Array(la), rhs_scalar) => {
11310                // An EMPTY array contributes nothing to a concatenated
11311                // word — the surrounding scalar text survives. zsh:
11312                // `x${^a}y` (a=()) / `x${(P)scalar-empty}y` → "xy", NOT
11313                // a dropped word. Without this, a `(P)` indirect to an
11314                // unset/empty scalar (which nodes_to_value collapses to
11315                // Value::Array([]) for standalone-removal semantics)
11316                // cartesian-dropped the whole word — p10k's
11317                // `typeset -g _$2=${(P)2}` then arrived as a bare
11318                // `typeset` and dumped every parameter (~217× → 19 MB
11319                // terminal flood → startup hang).
11320                if la.is_empty() {
11321                    return rhs_scalar;
11322                }
11323                let r = rhs_scalar.as_str_cow();
11324                let out: Vec<Value> = la
11325                    .iter()
11326                    .map(|a| {
11327                        let a_s = a.as_str_cow();
11328                        let mut s = String::with_capacity(a_s.len() + r.len());
11329                        s.push_str(&a_s);
11330                        s.push_str(&r);
11331                        Value::str(s)
11332                    })
11333                    .collect();
11334                Value::array(out)
11335            }
11336            (lhs_scalar, Value::Array(ra)) => {
11337                // Symmetric empty-array-contributes-nothing rule; see
11338                // the (Array, scalar) arm above.
11339                if ra.is_empty() {
11340                    return lhs_scalar;
11341                }
11342                let l = lhs_scalar.as_str_cow();
11343                let out: Vec<Value> = ra
11344                    .iter()
11345                    .map(|b| {
11346                        let b_s = b.as_str_cow();
11347                        let mut s = String::with_capacity(l.len() + b_s.len());
11348                        s.push_str(&l);
11349                        s.push_str(&b_s);
11350                        Value::str(s)
11351                    })
11352                    .collect();
11353                Value::array(out)
11354            }
11355            (lhs_s, rhs_s) => {
11356                let l = lhs_s.as_str_cow();
11357                let r = rhs_s.as_str_cow();
11358                let mut s = String::with_capacity(l.len() + r.len());
11359                s.push_str(&l);
11360                s.push_str(&r);
11361                Value::str(s)
11362            }
11363        }
11364    });
11365
11366    vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE, |vm, argc| {
11367        let rhs = vm.pop();
11368        let lhs = vm.pop();
11369        // c:Src/subst.c:4245 `if (isarr)` — an unquoted array embedded
11370        // in a word ALWAYS emits one word per element, never a scalar
11371        // join. The shape (splice vs plan9 cross-product) is chosen at
11372        // RUNTIME by c:1663 `int plan9 = isset(RCEXPANDPARAM);`, exactly
11373        // as BUILTIN_CONCAT_SPLICE does. The only extra case DISTRIBUTE
11374        // handles is the DQ context: the compiler emits
11375        // CallBuiltin(BUILTIN_CONCAT_DISTRIBUTE, 1) when the parent word
11376        // is DQ-wrapped (compile_zsh.rs parent_is_dq), and inside DQ
11377        // `"pre${arr}post"` joins via $IFS[0] to a single scalar
11378        // regardless of the option (c:Src/subst.c:1650-1656 isarr
11379        // comment). The default UNQUOTED path emits argc=2 (lhs + rhs).
11380        // Bug #246 in docs/BUGS.md.
11381        if argc == 1 {
11382            // DQ context: join any Array side to scalar via sepjoin's
11383            // IFS default. c:Src/utils.c:3936-3945 — set-but-empty IFS
11384            // joins with "" (`IFS=""; echo "x$*y"` → `xabcy`); only
11385            // unset / space-leading IFS yields " ".
11386            let join_arr = |arr: &[Value]| -> String {
11387                let strs: Vec<String> = arr.iter().map(|v| v.as_str_cow().into_owned()).collect();
11388                crate::ported::utils::sepjoin(&strs, None)
11389            };
11390            // Provenance: the DQ-join arm consumes both operands, so
11391            // capture them first (Arc clones, only while armed).
11392            let prov_operands = crate::provenance::active().then(|| (lhs.clone(), rhs.clone()));
11393            let l = match lhs {
11394                Value::Array(a) => join_arr(&a),
11395                other => other.as_str_cow().into_owned(),
11396            };
11397            let r = match rhs {
11398                Value::Array(a) => join_arr(&a),
11399                other => other.as_str_cow().into_owned(),
11400            };
11401            let mut s = String::with_capacity(l.len() + r.len());
11402            s.push_str(&l);
11403            s.push_str(&r);
11404            let out = Value::str(s);
11405            if let Some((pl, pr)) = prov_operands {
11406                crate::provenance::on_concat(&pl, &pr, &out);
11407            }
11408            return out;
11409        }
11410        // Unquoted plain `${arr}`: same runtime dispatch as
11411        // BUILTIN_CONCAT_SPLICE — c:4245 `if (isarr)` always distributes
11412        // one word per element; c:1663 picks splice (default, first/last
11413        // sticking, c:4366-4437) vs plan9 cross-product (c:4316-4365).
11414        // concat_splice / concat_plan9 both honor EMPTY_EXPANSION_IS_SCALAR
11415        // so the p10k `${(P)2}` empty-array word-removal semantics survive.
11416        if plan9_active() {
11417            return concat_plan9_prov(lhs, rhs);
11418        }
11419        concat_splice_prov(lhs, rhs)
11420    });
11421
11422    // See BUILTIN_WORD_ASSEMBLE_PLAN9's doc comment for the stack contract.
11423    vm.register_builtin(BUILTIN_WORD_ASSEMBLE_PLAN9, |vm, argc| {
11424        // Pop argc values: descriptor was pushed FIRST (bottom), segments
11425        // after it, so popping top-first then reversing yields
11426        // [descriptor, seg0, …, seg(n-1)].
11427        let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
11428        for _ in 0..argc {
11429            popped.push(vm.pop());
11430        }
11431        popped.reverse();
11432        let mut it = popped.into_iter();
11433        let descriptor = it.next().map(|v| v.to_str()).unwrap_or_default();
11434        let plan9_flags: Vec<bool> = descriptor.chars().map(|c| c == '1').collect();
11435        let segments: Vec<Value> = it.collect();
11436        word_assemble_plan9(&segments, &plan9_flags)
11437    });
11438
11439    // `[[ a -ef b ]]` — same-inode test. Resolves both paths via fs::metadata
11440    // (follows symlinks the way zsh's -ef does) and compares (dev, inode).
11441    // Returns false on any I/O error (path missing, permission denied, etc.).
11442    vm.register_builtin(BUILTIN_SAME_FILE, |vm, _argc| {
11443        let b = vm.pop().to_str();
11444        let a = vm.pop().to_str();
11445        let same = match (fs::metadata(&a), fs::metadata(&b)) {
11446            (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
11447            _ => false,
11448        };
11449        Value::Bool(same)
11450    });
11451
11452    // `[[ -c path ]]` — character device.
11453    vm.register_builtin(BUILTIN_IS_CHARDEV, |vm, _argc| {
11454        let path = vm.pop().to_str();
11455        let result = fs::metadata(&path)
11456            .map(|m| m.file_type().is_char_device())
11457            .unwrap_or(false);
11458        Value::Bool(result)
11459    });
11460    // `[[ -b path ]]` — block device.
11461    vm.register_builtin(BUILTIN_IS_BLOCKDEV, |vm, _argc| {
11462        let path = vm.pop().to_str();
11463        let result = fs::metadata(&path)
11464            .map(|m| m.file_type().is_block_device())
11465            .unwrap_or(false);
11466        Value::Bool(result)
11467    });
11468    // `[[ -p path ]]` — FIFO (named pipe).
11469    vm.register_builtin(BUILTIN_IS_FIFO, |vm, _argc| {
11470        let path = vm.pop().to_str();
11471        let result = fs::metadata(&path)
11472            .map(|m| m.file_type().is_fifo())
11473            .unwrap_or(false);
11474        Value::Bool(result)
11475    });
11476    // `[[ -S path ]]` — socket.
11477    vm.register_builtin(BUILTIN_IS_SOCKET, |vm, _argc| {
11478        let path = vm.pop().to_str();
11479        let result = fs::symlink_metadata(&path)
11480            .map(|m| m.file_type().is_socket())
11481            .unwrap_or(false);
11482        Value::Bool(result)
11483    });
11484
11485    // `[[ -k path ]]` / `-u` / `-g` — sticky / setuid / setgid bit.
11486    vm.register_builtin(BUILTIN_HAS_STICKY, |vm, _argc| {
11487        let path = vm.pop().to_str();
11488        let result = fs::metadata(&path)
11489            .map(|m| m.permissions().mode() & libc::S_ISVTX as u32 != 0)
11490            .unwrap_or(false);
11491        Value::Bool(result)
11492    });
11493    vm.register_builtin(BUILTIN_HAS_SETUID, |vm, _argc| {
11494        let path = vm.pop().to_str();
11495        let result = fs::metadata(&path)
11496            .map(|m| m.permissions().mode() & libc::S_ISUID as u32 != 0)
11497            .unwrap_or(false);
11498        Value::Bool(result)
11499    });
11500    vm.register_builtin(BUILTIN_HAS_SETGID, |vm, _argc| {
11501        let path = vm.pop().to_str();
11502        let result = fs::metadata(&path)
11503            .map(|m| m.permissions().mode() & libc::S_ISGID as u32 != 0)
11504            .unwrap_or(false);
11505        Value::Bool(result)
11506    });
11507    vm.register_builtin(BUILTIN_OWNED_BY_USER, |vm, _argc| {
11508        let path = vm.pop().to_str();
11509        let euid = unsafe { libc::geteuid() };
11510        let result = fs::metadata(&path)
11511            .map(|m| m.uid() == euid)
11512            .unwrap_or(false);
11513        Value::Bool(result)
11514    });
11515    vm.register_builtin(BUILTIN_OWNED_BY_GROUP, |vm, _argc| {
11516        let path = vm.pop().to_str();
11517        let egid = unsafe { libc::getegid() };
11518        let result = fs::metadata(&path)
11519            .map(|m| m.gid() == egid)
11520            .unwrap_or(false);
11521        Value::Bool(result)
11522    });
11523
11524    // `[[ -N path ]]` — file's access time is NOT newer than its
11525    // modification time (zsh man: "true if file exists and its
11526    // access time is not newer than its modification time"). Used
11527    // by zsh's mailbox-watching code. The semantic is `atime <=
11528    // mtime` (equivalent to `mtime >= atime`) — equal counts as
11529    // true, which a strict `mtime > atime` check missed for newly
11530    // created files where both stamps are identical.
11531    vm.register_builtin(BUILTIN_FILE_MODIFIED_SINCE_ACCESS, |vm, _argc| {
11532        let path = vm.pop().to_str();
11533        let result = fs::metadata(&path)
11534            .map(|m| m.atime() <= m.mtime())
11535            .unwrap_or(false);
11536        Value::Bool(result)
11537    });
11538
11539    // `[[ a -nt b ]]` — true if `a`'s mtime is strictly later than `b`'s.
11540    // BOTH files must exist; if either is missing the result is false.
11541    // (Earlier behavior was bash's "missing == infinitely-old"; zsh
11542    // strictly requires both files to exist.)
11543    vm.register_builtin(BUILTIN_FILE_NEWER, |vm, _argc| {
11544        let b = vm.pop().to_str();
11545        let a = vm.pop().to_str();
11546        // Use SystemTime modified() for nanosecond precision —
11547        // MetadataExt::mtime() returns seconds only, so two files
11548        // touched within the same second compared equal even when
11549        // 500ms apart. zsh tracks ns and uses `>=` for ties (touching
11550        // a then b in quick succession should still report b newer).
11551        let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
11552        let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
11553        let result = match (ta, tb) {
11554            (Some(ta), Some(tb)) => ta > tb,
11555            _ => false,
11556        };
11557        Value::Bool(result)
11558    });
11559
11560    // `[[ a -ot b ]]` — mirror of -nt. Same both-must-exist contract.
11561    vm.register_builtin(BUILTIN_FILE_OLDER, |vm, _argc| {
11562        let b = vm.pop().to_str();
11563        let a = vm.pop().to_str();
11564        let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
11565        let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
11566        let result = match (ta, tb) {
11567            (Some(ta), Some(tb)) => ta < tb,
11568            _ => false,
11569        };
11570        Value::Bool(result)
11571    });
11572
11573    // `set -e` / `setopt errexit` post-command check. Compiler emits
11574    // this after each top-level command's SetStatus (skipped inside
11575    // conditionals/pipelines/&&||/`!`). If errexit is on AND the last
11576    // command exited non-zero AND it's not a `return` from a function,
11577    // exit the shell with that status.
11578    // `set -x` / `setopt xtrace` — print each command before it runs.
11579    // The compiler emits this BEFORE the actual builtin/external call
11580    // with the command's literal text as a single string arg. We
11581    // print to stderr if xtrace is on. Honors `$PS4` (default `+ `).
11582    //
11583    // ── XTRACE flow control ────────────────────────────────────────
11584    // Mirror of C zsh's `doneps4` flag in execcmd_exec (Src/exec.c).
11585    // When an assignment trace fires (XTRACE_ASSIGN), it emits PS4
11586    // and sets this flag so the subsequent XTRACE_ARGS skips its own
11587    // PS4 emission — the assignment + command end up on the SAME
11588    // line: `<PS4>a=1 echo hello\n`. XTRACE_ARGS / XTRACE_NEWLINE
11589    // reset the flag after emitting the trailing `\n`.
11590    vm.register_builtin(BUILTIN_XTRACE_IS_ON, |_vm, _argc| {
11591        // Push live xtrace state. Caller pairs this with JumpIfFalse
11592        // to skip the trace-string-building block when xtrace is off,
11593        // avoiding side-effectful operand re-evaluation. Bug #159 in
11594        // docs/BUGS.md.
11595        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11596        Value::Int(if on { 1 } else { 0 })
11597    });
11598
11599    vm.register_builtin(BUILTIN_XTRACE_LINE, |vm, _argc| {
11600        // Keep the Value; `to_str()` allocates a String, and this handler
11601        // runs on EVERY `(( … ))` / `[[ … ]]` / loop-head evaluation, where
11602        // xtrace is off essentially always. Defer the allocation to the
11603        // `on` branch below.
11604        let cmd_val = vm.pop();
11605        // Sync exec.last_status with the live vm.last_status BEFORE
11606        // the next command runs. Direct port of the zsh exec.c
11607        // contract — `$?` reads the exit status of the *most recent*
11608        // command. XTRACE_LINE is emitted by the compiler BEFORE
11609        // every simple command, so it's the natural sync point.
11610        let live = vm.last_status;
11611        with_executor(|exec| {
11612            exec.set_last_status(live);
11613        });
11614        // C zsh emits xtrace for `(( … ))` / `[[ … ]]` / `case` /
11615        // `if/while/until/for/repeat` head expressions via
11616        // `printprompt4(); fprintf(xtrerr, "%s\n", expr)` at
11617        // Src/exec.c:5240 (math), c:5286 (cond), c:4117 (for), etc.
11618        // The compiler emits BUILTIN_XTRACE_LINE only at those
11619        // construct boundaries (compile_arith / compile_cond /
11620        // compile_if / compile_while / compile_for / compile_case);
11621        // simple commands route to BUILTIN_XTRACE_ARGS instead. So
11622        // this handler always emits when xtrace is on — no prefix-
11623        // string heuristic.
11624        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11625        if on {
11626            let already = XTRACE_DONE_PS4.with(|f| f.get());
11627            if !already {
11628                printprompt4();
11629            }
11630            // c:exec.c:5240/5286 — `fprintf(xtrerr, "%s\n", expr)`. Buffer
11631            // the line + newline, flush once (single write).
11632            xtrerr_fputs(&cmd_val.to_str());
11633            xtrerr_fputs("\n");
11634            xtrerr_flush();
11635            XTRACE_DONE_PS4.with(|f| f.set(false));
11636        }
11637        Value::Status(0)
11638    });
11639
11640    // BUILTIN_XTRACE_ARRAY_LINE — xtrace line for an `arr=(...)` / `arr+=(...)`
11641    // assignment. Stack on entry: [array, prefix] (argc = 2); pops prefix
11642    // ("name=( " / "name+=( "), then the whole assembled Value::Array. Direct
11643    // port of c:Src/exec.c::addvars:2624-2632, guarded on the live xtrace
11644    // state like C's `if (xtr)`: prints `prefix qz(e0) qz(e1) … ) ` with each
11645    // element shell-quoted (quotedzputs). Replaces the former one-VM-slot-per-
11646    // element trace, which overflowed next_slot (u16) on large literals.
11647    vm.register_builtin(BUILTIN_XTRACE_ARRAY_LINE, |vm, _argc| {
11648        let prefix = vm.pop().to_str();
11649        let arr = vm.pop();
11650        let live = vm.last_status;
11651        with_executor(|exec| {
11652            exec.set_last_status(live);
11653        });
11654        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11655        if on {
11656            let already = XTRACE_DONE_PS4.with(|f| f.get());
11657            if !already {
11658                printprompt4();
11659            }
11660            let mut line = String::with_capacity(prefix.len() + 16);
11661            line.push_str(&prefix);
11662            if let Value::Array(items) = arr {
11663                for it in items.iter() {
11664                    line.push_str(&crate::ported::utils::quotedzputs(&it.to_str()));
11665                    line.push(' ');
11666                }
11667            }
11668            line.push_str(") ");
11669            line.push('\n');
11670            xtrerr_fputs(&line);
11671            xtrerr_flush();
11672            XTRACE_DONE_PS4.with(|f| f.set(false));
11673        }
11674        Value::Status(0)
11675    });
11676
11677    // BUILTIN_MAKE_ARRAY_COUNTED — pop a count Int (top), then pop that many
11678    // values below it, and push them as one Value::Array (bottom-of-group
11679    // first). Same result as Op::MakeArray(N) but N comes from the stack as an
11680    // i64, dodging MakeArray's u16 operand cap. The compiler emits this only
11681    // when a literal `arr=(...)` has more than u16::MAX elements.
11682    vm.register_builtin(BUILTIN_MAKE_ARRAY_COUNTED, |vm, _argc| {
11683        let count = vm.pop().to_int().max(0) as usize;
11684        let mut items: Vec<Value> = Vec::with_capacity(count);
11685        for _ in 0..count {
11686            items.push(vm.pop());
11687        }
11688        items.reverse();
11689        Value::array(items)
11690    });
11691
11692    // BUILTIN_ARGV_RFLATTEN — recursively flatten a MakeArray-packed argv
11693    // bundle so a >255-arg Call/CallFunction/CallBuiltin (dispatched with
11694    // argc=1 over the single packed Array) recovers every positional arg. The
11695    // call ops flatten only one level; a brace/glob/`$arr` word contributes a
11696    // nested Array that would otherwise stringify. See the const doc.
11697    vm.register_builtin(BUILTIN_ARGV_RFLATTEN, |vm, _argc| {
11698        let v = vm.pop();
11699        let mut out: Vec<String> = Vec::new();
11700        flatten_array_value(v, &mut out);
11701        Value::array(out.into_iter().map(Value::str).collect())
11702    });
11703
11704    // Like XTRACE_LINE but reads the top `argc - 1` values from the
11705    // VM stack WITHOUT consuming them (peek), then pops a prefix
11706    // string at the top. Joins prefix + peeked args with spaces using
11707    // zsh's quotedzputs-equivalent quoting. Direct port of
11708    // Src/exec.c:2055-2066 — emit AFTER expansion, with each arg
11709    // shell-quoted, so `for i in a b; echo for $i` traces as
11710    // `echo for a` / `echo for b`, not `echo for $i`.
11711    //
11712    // Stack contract on entry: [arg1, arg2, ..., argN, prefix].
11713    // Pops prefix; peeks argN..arg1 below. argc = N + 1.
11714    vm.register_builtin(BUILTIN_XTRACE_ARGS, |vm, argc| {
11715        let prefix = vm.pop().to_str();
11716        let live = vm.last_status;
11717        with_executor(|exec| {
11718            exec.set_last_status(live);
11719        });
11720        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11721        if on {
11722            let n_args = argc.saturating_sub(1) as usize;
11723            let len = vm.stack.len();
11724            // c:Src/exec.c:2055 — argv is the POST-expansion word
11725            // list, so an arg that expanded to multiple words splats
11726            // into multiple trace tokens AND an arg that expanded to
11727            // zero words (empty unquoted `${UNSET}`) emits nothing.
11728            // pop_args (line 6243) already does this splat for the
11729            // real handler; mirror the same Array → splat / empty →
11730            // drop logic here so xtrace renders `echo ${UNSET}` as
11731            // `echo` (zsh) instead of `echo ''` (the previous
11732            // single-arg stringify path returned "" and then
11733            // quotedzputs wrapped it in `''`).
11734            let arg_strs: Vec<String> = if n_args > 0 && len >= n_args {
11735                let mut out = Vec::new();
11736                for v in &vm.stack[len - n_args..] {
11737                    match v {
11738                        Value::Array(items) => {
11739                            for item in items.iter() {
11740                                out.push(quotedzputs(&item.to_str()));
11741                            }
11742                        }
11743                        other => out.push(quotedzputs(&other.to_str())),
11744                    }
11745                }
11746                out
11747            } else {
11748                Vec::new()
11749            };
11750            // Builtins dispatch through `execbuiltin` (Src/builtin.c:442)
11751            // which emits its own PS4 + name + args xtrace. To avoid
11752            // double-emission, skip our emission here when the first
11753            // arg is a known builtin with a registered HandlerFunc —
11754            // those go through execbuiltin and will trace themselves.
11755            // Externals + builtins-not-yet-routed-through-execbuiltin
11756            // keep our emission as a stand-in.
11757            let goes_through_execbuiltin = crate::ported::builtin::BUILTINS
11758                .iter()
11759                .any(|b| b.node.nam == prefix && b.handlerfunc.is_some());
11760            if !goes_through_execbuiltin {
11761                let line = if arg_strs.is_empty() {
11762                    prefix
11763                } else {
11764                    format!("{} {}", prefix, arg_strs.join(" "))
11765                };
11766                // Mirrors Src/exec.c:2055 xtrace emission. C does:
11767                //   if (!doneps4) printprompt4();
11768                //   ... emit args + spaces ...
11769                //   fputc('\n', xtrerr); fflush(xtrerr);
11770                // printprompt4 + the args + `\n` all land in the xtrerr
11771                // buffer; the single fflush below writes the whole line in
11772                // one syscall so concurrent pipeline stages never
11773                // interleave (c:makecline:2122-2123).
11774                let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
11775                if !already_ps4 {
11776                    printprompt4();
11777                }
11778                xtrerr_fputs(&line);
11779                xtrerr_fputs("\n"); // c:2122 fputc('\n', xtrerr)
11780                xtrerr_flush(); // c:2123 fflush(xtrerr)
11781            }
11782            XTRACE_DONE_PS4.with(|f| f.set(false));
11783        }
11784        Value::Status(0)
11785    });
11786
11787    // BUILTIN_XTRACE_ASSIGN — direct port of the per-assignment
11788    // trace block at Src/exec.c:2517-2582. C body excerpt:
11789    //   xtr = isset(XTRACE);
11790    //   if (xtr) { printprompt4(); doneps4 = 1; }
11791    //   while (assign) {
11792    //       if (xtr) fprintf(xtrerr, "%s+=" or "%s=", name);
11793    //       ... eval value into `val` ...
11794    //       if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
11795    //       ...
11796    //   }
11797    //
11798    // Stack on entry: [..., name, value]. PEEKS both (they're left
11799    // on stack for SET_VAR to pop). Emits `name=<quoted-val> ` with
11800    // no newline; trailing `\n` comes from XTRACE_ARGS (cmd path)
11801    // or XTRACE_NEWLINE (assignment-only path).
11802    vm.register_builtin(BUILTIN_XTRACE_ASSIGN, |vm, _argc| {
11803        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11804        if on {
11805            // PEEK [..., name, value] — argc==2 by contract.
11806            let len = vm.stack.len();
11807            if len >= 2 {
11808                let name = vm.stack[len - 2].to_str();
11809                let value = vm.stack[len - 1].to_str();
11810                let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
11811                if !already_ps4 {
11812                    printprompt4();
11813                    XTRACE_DONE_PS4.with(|f| f.set(true));
11814                }
11815                // C: `fprintf(xtrerr, "%s=", name)` then `quotedzputs
11816                // (val); fputc(' ', xtrerr);`. Append to the xtrerr buffer
11817                // (no newline / no flush — the line continues with the
11818                // command via XTRACE_ARGS, or ends at XTRACE_NEWLINE).
11819                xtrerr_fputs(&format!("{}={} ", name, quotedzputs(&value)));
11820            }
11821        }
11822        Value::Status(0)
11823    });
11824
11825    // BUILTIN_XTRACE_NEWLINE — emit trailing `\n` + flush iff a
11826    // prior XTRACE_ASSIGN this line already emitted PS4. Mirrors
11827    // C's `fputc('\n', xtrerr); fflush(xtrerr);` at exec.c:3398
11828    // (the assignment-only path through execcmd_exec).
11829    vm.register_builtin(BUILTIN_XTRACE_NEWLINE, |_vm, _argc| {
11830        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11831        if on {
11832            let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
11833            if already_ps4 {
11834                xtrerr_fputs("\n"); // c:3398 fputc('\n', xtrerr)
11835                xtrerr_flush(); // c:3398 fflush(xtrerr)
11836                XTRACE_DONE_PS4.with(|f| f.set(false));
11837            }
11838        }
11839        Value::Status(0)
11840    });
11841
11842    // c:Src/exec.c WC_TRYBLOCK — post-always re-jump probes. Each
11843    // returns 1 + consumes the atomic when the corresponding
11844    // escape flag is set; the try-block compile pairs each with
11845    // a JumpIfFalse + Jump → outer scope's return / break /
11846    // continue patches.
11847    vm.register_builtin(BUILTIN_RETFLAG_CHECK, |_vm, _argc| {
11848        use std::sync::atomic::Ordering;
11849        let r = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
11850        if r != 0 {
11851            // Don't clear here — doshfunc owns the clear at c:6047
11852            // when the function unwinds. Leaving it set propagates
11853            // through nested `eval`/`source` callers correctly.
11854            Value::Int(1)
11855        } else {
11856            Value::Int(0)
11857        }
11858    });
11859    vm.register_builtin(BUILTIN_BREAKS_CHECK, |_vm, _argc| {
11860        use std::sync::atomic::Ordering;
11861        let b = crate::ported::builtin::BREAKS.load(Ordering::Relaxed);
11862        let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
11863        // `break` sets BREAKS but NOT CONTFLAG; `continue` sets both.
11864        // Filter out the continue path here so the two checks are
11865        // mutually exclusive.
11866        if b != 0 && c == 0 {
11867            // Consume BREAKS so the outer loop's break_patches
11868            // landing doesn't double-decrement.
11869            crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
11870            Value::Int(1)
11871        } else {
11872            Value::Int(0)
11873        }
11874    });
11875    vm.register_builtin(BUILTIN_CONTFLAG_CHECK, |_vm, _argc| {
11876        use std::sync::atomic::Ordering;
11877        let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
11878        if c != 0 {
11879            crate::ported::builtin::CONTFLAG.store(0, Ordering::Relaxed);
11880            crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
11881            Value::Int(1)
11882        } else {
11883            Value::Int(0)
11884        }
11885    });
11886    // c:Src/loop.c — `loops++` / `loops--` bracket every iterative
11887    // construct: execfor c:114/188, execwhile c:427/491, execrepeat
11888    // c:523/546. `loops` is a GLOBAL, not a per-frame counter, and
11889    // `bin_break` reads it (`if (!loops)`) to decide whether `break` /
11890    // `continue` is legal. Because doshfunc does NOT reset it (only
11891    // restores it under LOCAL_LOOPS, c:6104-6112), a function called
11892    // from inside a loop sees the CALLER's count and its `break` ends
11893    // the caller's loop. zshrs's compiled for/while/until/repeat lower
11894    // to raw jumps, so without these two ops the counter stayed 0 and
11895    // every such `break` errored out instead.
11896    vm.register_builtin(BUILTIN_LOOP_ENTER, |_vm, _argc| {
11897        crate::ported::builtin::LOOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); // c:114
11898        Value::Int(0)
11899    });
11900    // c:Src/loop.c:141-145 + :199-203 (execfor), :478-481 (execwhile),
11901    // :534-537 (execrepeat) — every loop that abandons its body because
11902    // `errflag` is set FORCES the escaping status first:
11903    //     if (errflag) { if (breaks) breaks--; lastval = 1; break; }
11904    // so a fatal error inside a loop leaves 1, not whatever the failing
11905    // command set. `setopt extendedglob; for i in 1 2; do [[ abc == [ ]]; done`
11906    // exits 1 in zsh while the bare `[[ abc == [ ]]` exits 2; zshrs's compiled
11907    // loops jumped straight to the chunk-end landing and carried the cond's 2
11908    // out. `execselect` has no such assignment (c:217+), which is why
11909    // `compile_select` does not bump `open_loop_depth` and never emits this.
11910    // c:Src/loop.c:479-482 — `if (errflag) { lastval = 1; break; }`, the
11911    // guard every C loop runs AFTER its body. Without it a completer whose
11912    // loop body raises an error spins forever in an interactive shell: the
11913    // errflag makes the prologue skip every statement, but the loop's exit
11914    // test reads a residual `$? == 0` and loops again.  Reproduced at 98% CPU
11915    // with `_files -g "*(-.):t:source files" -g "(a:t:directories"` behind a
11916    // `#compdef` — `while _next_label …; do` (Completion/Unix/Type/_files
11917    // sh:121) never terminated, so `<cmd> <TAB>` produced nothing, ever.
11918    // lldb pinned three samples at the same loop head in the `_files` chunk.
11919    //
11920    // C tests the WHOLE errflag word here (ERRFLAG_INT included), exactly as
11921    // the list loop does at c:Src/exec.c:1443.
11922    vm.register_builtin(BUILTIN_LOOP_ERRFLAG_BREAK, |vm, _argc| {
11923        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) == 0 {
11924            return Value::Int(0);
11925        }
11926        vm.last_status = 1; // c:481/201/536
11927        with_executor(|exec| exec.set_last_status(1)); // c:481/201/536
11928        Value::Int(1) // c:482/202/537 — break
11929    });
11930
11931    vm.register_builtin(BUILTIN_LOOP_ERRFLAG_STATUS, |vm, _argc| {
11932        // The `if (errflag)` half of the C guard is re-tested HERE, not at
11933        // compile time: the same abort edge also carries an ERREXIT
11934        // (`set -e`) exit, which in C leaves execlist via `zexit(lastval)`
11935        // and never reaches the loop's `if (errflag)` arm — so that status
11936        // must survive untouched.
11937        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
11938            & crate::ported::zsh_h::ERRFLAG_ERROR)
11939            != 0
11940        {
11941            vm.last_status = 1; // c:144/201/480/536
11942            with_executor(|exec| exec.set_last_status(1)); // c:144/201/480/536
11943        }
11944        Value::Int(0)
11945    });
11946    vm.register_builtin(BUILTIN_LOOP_EXIT, |_vm, _argc| {
11947        use std::sync::atomic::Ordering::SeqCst;
11948        // Saturating: a chunk aborted mid-loop (errflag, `return`)
11949        // unwinds through `run_chunk`'s restore rather than this op, so
11950        // never let a stray decrement drive the count negative.
11951        let _ = crate::ported::builtin::LOOPS
11952            .fetch_update(SeqCst, SeqCst, |n| Some(if n > 0 { n - 1 } else { 0 })); // c:188
11953        Value::Int(0)
11954    });
11955
11956    // c:Src/loop.c:529-534 (execwhile), :180-185 (execfor), :540-545
11957    // (execrepeat) — the identical post-body drain every loop runs:
11958    //     if (breaks) {
11959    //         breaks--;
11960    //         if (breaks || !contflag) break;
11961    //         contflag = 0;
11962    //     }
11963    // Returns Int(1) when this loop must terminate, Int(0) when it
11964    // should proceed to the next iteration. Only a `break`/`continue`
11965    // executed in a DIFFERENT chunk (a called function, `eval`, a
11966    // sourced file) reaches here — an in-chunk `break` compiles to a
11967    // direct jump and never touches the counter.
11968    vm.register_builtin(BUILTIN_LOOP_BREAK_DRAIN, |_vm, _argc| {
11969        use std::sync::atomic::Ordering::SeqCst;
11970        let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
11971        if breaks == 0 {
11972            return Value::Int(0);
11973        }
11974        let remaining = breaks - 1;
11975        crate::ported::builtin::BREAKS.store(remaining, SeqCst); // c:530
11976        let contflag = crate::ported::builtin::CONTFLAG.load(SeqCst);
11977        if remaining != 0 || contflag == 0 {
11978            return Value::Int(1); // c:532 — `break`
11979        }
11980        crate::ported::builtin::CONTFLAG.store(0, SeqCst); // c:533
11981        Value::Int(0)
11982    });
11983
11984    // c:Src/exec.c:1370 execlist — `while (wc_code(code) == WC_LIST &&
11985    // !breaks && !retflag && !errflag)`. A pending `breaks` stops the
11986    // CURRENT list at the next statement boundary WITHOUT consuming it,
11987    // so the flag keeps travelling outward until a loop's drain eats it.
11988    // Non-consuming by design: the drain above is the only consumer.
11989    vm.register_builtin(BUILTIN_BREAKS_PENDING, |_vm, _argc| {
11990        let b = crate::ported::builtin::BREAKS.load(std::sync::atomic::Ordering::SeqCst);
11991        Value::Int(if b != 0 { 1 } else { 0 })
11992    });
11993
11994    vm.register_builtin(BUILTIN_NOEXEC_CHECK, |vm, _argc| {
11995        // c:Src/exec.c:1390 — `set -n` / `noexec` option: parse but
11996        // don't execute. Returns Int(1) when noexec is set so the
11997        // emit-side JumpIfTrue skips the statement body.
11998        //
11999        // Arg: 1 when every command of the sublist is a simple command
12000        // with a command word. c:Src/parse.c:1928 marks those cmplx, so C
12001        // never takes execsimple's `if (!isset(EXECOPT)) return lastval =
12002        // 0;` (c:Src/exec.c:1351) for them: execcmd_exec expands the words
12003        // first and skips only the execution (c:4051). Those commands
12004        // carry their own BUILTIN_NOEXEC_CMD_GATE, so noexec must not
12005        // skip them here.
12006        let gated_in_command = vm.pop().to_int() != 0;
12007        if !gated_in_command && opt_state_get("noexec").unwrap_or(false) {
12008            return Value::Int(1);
12009        }
12010        // c:Src/exec.c:1390 — execlist's list-loop gate:
12011        //   `while (wc_code(code) == WC_LIST && !breaks && !retflag
12012        //          && !errflag)`
12013        // — once errflag is set, the NEXT sublist never starts, so
12014        // lastval survives untouched to the shell exit. Without this
12015        // prologue gate the follow-up statement RAN, its dispatch
12016        // saw errflag, returned 1, and SetStatus clobbered lastval —
12017        // `[[ x == [a- ]]; print rc=$?` exited 1 instead of zsh's 2
12018        // (the cond syntax error set lastval=2 per exec.c:5216-5221).
12019        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
12020            & crate::ported::zsh_h::ERRFLAG_ERROR)
12021            != 0
12022        {
12023            return Value::Int(1);
12024        }
12025        Value::Int(0)
12026    });
12027    // See BUILTIN_NOEXEC_CMD_GATE.
12028    vm.register_builtin(BUILTIN_NOEXEC_CMD_GATE, |vm, _argc| {
12029        if !opt_state_get("noexec").unwrap_or(false) {
12030            return Value::Int(0);
12031        }
12032        // c:Src/exec.c:3760-3763 — `if (errflag) { lastval = 1; goto err; }`
12033        // runs after globlist and before the EXECOPT test, so an expansion
12034        // error still sets the status under NO_EXEC.
12035        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
12036            & crate::ported::zsh_h::ERRFLAG_ERROR)
12037            != 0
12038        {
12039            vm.last_status = 1; // c:3761 `lastval = 1;`
12040            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
12041        }
12042        Value::Int(1) // c:4051 — the execution branch is not taken
12043    });
12044    // c:Src/exec.c:1536-1538 —
12045    //     /* suppress errexit for commands before && and || and after ! */
12046    //     if (isandor || isnot)
12047    //         noerrexit |= NOERREXIT_EXIT | NOERREXIT_RETURN;
12048    // The bits live on the PROCESS-GLOBAL `noerrexit`, so they are still
12049    // in force inside a shell function called from that position (doshfunc
12050    // clears only NOERREXIT_RETURN, c:5930). zshrs suppressed the check
12051    // purely at COMPILE time (`errexit_suppress_depth`), which cannot
12052    // reach a separately-compiled function body — so
12053    //   TRAPZERR(){ print E }; f(){ print f; false; }; f && t
12054    // fired the ZERR trap from inside `f` where zsh stays silent
12055    // (C03traps:14, E01options:18,19,21).
12056    vm.register_builtin(BUILTIN_NOERREXIT_SUPPRESS, |_vm, _argc| {
12057        use std::sync::atomic::Ordering;
12058        let old = crate::ported::exec::noerrexit.load(Ordering::Relaxed); // c:1417
12059        NOERREXIT_SAVES.with(|st| st.borrow_mut().push(old));
12060        crate::ported::exec::noerrexit.store(
12061            old | crate::ported::zsh_h::NOERREXIT_EXIT | crate::ported::zsh_h::NOERREXIT_RETURN,
12062            Ordering::Relaxed,
12063        ); // c:1538
12064        Value::Int(0)
12065    });
12066    // c:Src/exec.c:1621 / c:1626 — `noerrexit = oldnoerrexit;`
12067    vm.register_builtin(BUILTIN_NOERREXIT_RESTORE, |_vm, _argc| {
12068        use std::sync::atomic::Ordering;
12069        if let Some(old) = NOERREXIT_SAVES.with(|st| st.borrow_mut().pop()) {
12070            crate::ported::exec::noerrexit.store(old, Ordering::Relaxed); // c:1621
12071        }
12072        Value::Int(0)
12073    });
12074    vm.register_builtin(BUILTIN_DONETRAP_RESET, |_vm, _argc| {
12075        donetrap_reset_impl()
12076    });
12077    // See BUILTIN_FORKED_REDIRS.
12078    vm.register_builtin(BUILTIN_FORKED_REDIRS, |vm, _argc| {
12079        if FORKED_REDIR_VM.with(|f| f.replace(0)) == vm as *const fusevm::VM as usize {
12080            // The next op is the command's WithRedirectsBegin.
12081            let depth = with_executor(|exec| exec.redirect_scope_stack.len()) + 1;
12082            FORKED_REDIR_SCOPE.with(|c| c.set(depth));
12083        }
12084        Value::Int(0)
12085    });
12086    vm.register_builtin(BUILTIN_EXECCMD_FORKED_LEVEL, |vm, _argc| {
12087        let is_subsh = vm.pop().to_int() != 0;
12088        execcmd_forked_level(is_subsh, vm as *const fusevm::VM as usize);
12089        Value::Int(0)
12090    });
12091    vm.register_builtin(BUILTIN_EXITING_EXIT_TRAP, |_vm, _argc| {
12092        use crate::ported::signals_h::SIGEXIT;
12093        use std::sync::atomic::Ordering;
12094        // `trap '…' EXIT` bodies live in traps_table, TRAPEXIT in sigtrapped.
12095        let trapped = crate::ported::signals::sigtrapped.lock().ok().is_some_and(|st| st.get(SIGEXIT as usize).is_some_and(|&s| s != 0))
12096            || crate::ported::builtin::traps_table().lock().ok().is_some_and(|t| t.contains_key("EXIT"));
12097        if trapped {
12098            let eflag = crate::ported::utils::errflag.load(Ordering::Relaxed); // c:1701
12099            crate::ported::utils::errflag.store(0, Ordering::Relaxed); // c:1702
12100            crate::ported::signals::dotrap(SIGEXIT); // c:1703
12101            // c:1705 — `sigtrapped[SIGEXIT] = 0;`
12102            if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
12103                if let Some(slot) = st.get_mut(SIGEXIT as usize) {
12104                    *slot = 0;
12105                }
12106            }
12107            if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
12108                t.remove("EXIT");
12109            }
12110            crate::ported::utils::errflag.store(eflag, Ordering::Relaxed); // c:1706
12111        }
12112        Value::Int(0)
12113    });
12114
12115    // c:Src/exec.c:1451/1455/1476/1390 — see BUILTIN_STMT_PROLOGUE_FAST.
12116    vm.register_builtin(BUILTIN_STMT_PROLOGUE_FAST, |vm, _argc| {
12117        use std::sync::atomic::Ordering;
12118        let line = vm.pop().to_int();
12119        SUBLIST_SERIAL.with(|c| c.set(c.get().wrapping_add(1)));
12120        GLOB_WORD_ERRFLAG.with(|c| c.set(false));
12121        PREFORK_CUT.with(|c| c.set(false));
12122        if line >= 0 {
12123            set_lineno_impl(line); // c:1451
12124        }
12125        donetrap_reset_impl(); // c:1455
12126        // c:1390 — `set -n`, and execlist's own `!errflag` list gate.
12127        if opt_state_get("noexec").unwrap_or(false)
12128            || (crate::ported::utils::errflag.load(Ordering::Relaxed)
12129                & crate::ported::zsh_h::ERRFLAG_ERROR)
12130                != 0
12131        {
12132            return Value::Bool(false);
12133        }
12134        // c:1423 `sigtrapped[SIGDEBUG]` — checked through both
12135        // registries because `settrap` and `bin_trap` populate
12136        // different ones (signals.rs:1481-1511 dotrap).
12137        let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
12138        let debug_trapped = crate::ported::signals::sigtrapped
12139            .lock()
12140            .map(|v| v.get(sig_debug).copied().unwrap_or(0))
12141            .unwrap_or(0);
12142        if debug_trapped != 0 {
12143            return Value::Bool(false);
12144        }
12145        let debug_in_table = crate::ported::builtin::traps_table()
12146            .lock()
12147            .map(|t| t.contains_key("DEBUG"))
12148            .unwrap_or(false);
12149        Value::Bool(!debug_in_table)
12150    });
12151
12152    vm.register_builtin(BUILTIN_SUBLIST_FINISH, |vm, _argc| {
12153        // c:Src/jobs.c:1754 — `pipestats[0] = lastval;`. C has ONE
12154        // `lastval` global (c:Src/exec.c:120), so `waitonejob` reads
12155        // exactly the status the finished sublist just produced.
12156        //
12157        // zshrs splits that global in two: the fusevm status cell that
12158        // `Op::SetStatus`/`Op::GetStatus` and therefore `$?` use, and
12159        // the `builtin::LASTVAL` mirror that the ported `waitonejob`
12160        // reads. The compound-command compilers (compile_if,
12161        // compile_while, compile_for, compile_case, …) settle their
12162        // result with `Op::SetStatus` alone, so LASTVAL still holds
12163        // whatever the last dispatched BUILTIN returned — the loop
12164        // condition, typically. `if [[ -z x ]]; then :; fi` and
12165        // `while false; do :; done` both end with `$? == 0` and a
12166        // stale LASTVAL of 1.
12167        //
12168        // compile_sublist pushes `Op::GetStatus` ahead of this call so
12169        // the authoritative status arrives as an argument; republish it
12170        // through LASTVAL to reunify the two before the ported
12171        // waitonejob reads it, exactly as the single-command dispatch
12172        // sites at c:Src/exec.c:4367 do.
12173        let status = vm.pop().to_int() as i32;
12174        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
12175        // c:Src/jobs.c:1750-1756 — `compile_sublist` only emits this
12176        // marker for a cmplx sublist element that is not a multi-stage
12177        // pipeline, i.e. exactly the case where C's job carries no
12178        // procs, so drive the canonical port with a procs-less job the
12179        // same way the single-command sites do.
12180        let mut synth = crate::ported::zsh_h::job::default();
12181        crate::ported::jobs::waitonejob(&mut synth);
12182        Value::Status(0)
12183    });
12184
12185    // `[[ -z X ]]` / `[[ -n X ]]` — pop one Value, route through
12186    // canonical `src/ported/cond.rs::evalcond` so the actual
12187    // empty/non-empty test reuses the C-port at `cond.rs:270-271`
12188    // (`'n' => !arg.is_empty()`, `'z' => arg.is_empty()`).
12189    //
12190    // The Array→args conversion lives at the bridge because cond.rs
12191    // expects `&[&str]` (C `cond_str` signature equivalent). For
12192    // `"${arr[@]}"` in DQ context the splice yields `Value::Array`
12193    // — an empty array still expands to one implicit empty word
12194    // (per zsh's "${arr[@]}" splat preserving at least one slot
12195    // in cond context), so:
12196    //   - Array(0)   → ["-z", ""]            → evalcond → 0 (true)
12197    //   - Array(1)   → ["-z", word]          → evalcond → 0/1
12198    //   - Array(2+)  → ["-z", w1, w2, ...]   → evalcond → 2 (parse
12199    //                                          error: too many ops)
12200    //                                          → coerced to false
12201    //   - Str(s)     → ["-z", s]             → evalcond → 0/1
12202    //
12203    // Bug #185 in docs/BUGS.md.
12204    fn run_cond_str_empty(v: Value, op: &str) -> Value {
12205        let words: Vec<String> = match v {
12206            Value::Array(arr) => arr.iter().map(|x| x.to_str()).collect(),
12207            Value::Str(s) => vec![s.to_string()],
12208            other => vec![other.to_str()],
12209        };
12210        let mut args: Vec<&str> = vec![op];
12211        if words.is_empty() {
12212            args.push("");
12213        } else {
12214            args.extend(words.iter().map(|s| s.as_str()));
12215        }
12216        let opts: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
12217        let vars: std::collections::HashMap<String, String> = std::collections::HashMap::new();
12218        // c:Src/cond.c:62-66 — `evalcond` returns 0=true, 1=false,
12219        // 2=syntax-error. Coerce error to false (observable behavior
12220        // in zsh: `[[ -z a b ]]` errors and the test as a whole
12221        // returns non-zero).
12222        // `[[ ]]` dispatch — C's `evalcond(state, NULL)` calling convention.
12223        // `None` for from_test → mathevali integer-compare coercion path.
12224        let ret = crate::ported::cond::evalcond(&args, &opts, &vars, false, None);
12225        Value::Int(if ret == 0 { 1 } else { 0 })
12226    }
12227    vm.register_builtin(BUILTIN_COND_STR_EMPTY, |vm, _argc| {
12228        let v = vm.pop();
12229        run_cond_str_empty(v, "-z")
12230    });
12231    vm.register_builtin(BUILTIN_COND_STR_NONEMPTY, |vm, _argc| {
12232        let v = vm.pop();
12233        run_cond_str_empty(v, "-n")
12234    });
12235
12236    // `exec N<<<"str"` — herestring redirect to explicit fd, applied
12237    // permanently. Direct port of `Src/exec.c:4655 getherestr` +
12238    // `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)` at c:3766-
12239    // 3780 for the nullexec=1 bare-exec-redir path. Bug #205 in
12240    // docs/BUGS.md.
12241    // c:Src/exec.c:4671-4672 — a here-string DERIVED FROM a here-doc
12242    // gets no appended newline. See BUILTIN_HEREDOC_BODY_SINK.
12243    vm.register_builtin(BUILTIN_HEREDOC_BODY_SINK, |vm, _argc| {
12244        let body = vm.pop().to_str();
12245        if crate::provenance::active() {
12246            crate::provenance::on_heredoc("heredoc", &body);
12247        }
12248        with_executor(|exec| exec.host_set_pending_stdin(body));
12249        Value::Int(0)
12250    });
12251
12252    vm.register_builtin(BUILTIN_EXEC_HERESTR_FD, |vm, _argc| {
12253        // Stack (pushed by compile_redir): [content, fd, from_heredoc].
12254        let from_heredoc = vm.pop().to_int() != 0;
12255        let fd = vm.pop().to_int() as i32;
12256        let content = vm.pop().to_str();
12257        // c:Src/exec.c:4671-4672 — `getherestr` appends the newline
12258        // only for a REAL here-string:
12259        //     if (!(fn->flags & REDIRF_FROM_HEREDOC))
12260        //         t[len++] = '\n';
12261        // The `REDIRF_FROM_HEREDOC` flag is set by
12262        // c:Src/parse.c:2970-2971 when the redirection was written as
12263        // `<<WORD` and the parser turned the collected body into a
12264        // here-string. This helper serves BOTH spellings — `exec
12265        // 3<<<str` (append) and `exec 3<<EOF` (verbatim) — so the flag
12266        // has to be threaded in rather than assumed. It used to
12267        // unconditionally append, and the here-doc call site
12268        // compensated with `trim_end_matches('\n')`, which collapsed
12269        // N trailing blank lines to one and added a newline to a body
12270        // that ended without one.
12271        let body = if from_heredoc {
12272            content
12273        } else {
12274            format!("{}\n", content)
12275        };
12276        // c:Src/exec.c:3779 `addfd(forked, save, mfds, fn->fd1, fil, 0,
12277        // NULL)` → c:2421-2443: park the OLD contents of fd1 so
12278        // `fixfds` (c:4530) can put them back at the end of the
12279        // command; a bare `exec` (nullexec==1, c:3978-3986) skips it
12280        // and the redirection persists. This runs BEFORE the temp
12281        // file is opened on purpose: C reads fd1's pre-redirect state
12282        // too, and taking it afterwards would dup the here-document
12283        // itself and "restore" fd N to the body at scope end.
12284        with_executor(|exec| exec.save_fd_for_scope(fd));
12285        // c:4673-4679 — gettempfile → write_loop → close → reopen
12286        // read-only → unlink. Rust equivalent via tempfile crate or
12287        // explicit O_TMPFILE; use mkstemp + unlink-immediately to
12288        // mirror C exactly.
12289        use std::ffi::CString;
12290        let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
12291        let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
12292        if write_fd < 0 {
12293            crate::ported::utils::zwarn(&format!(
12294                "can't create temp file for here document: {}",
12295                crate::ported::utils::zsh_errno_msg(
12296                    std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
12297                ) // c:Src/exec.c:3828 `%e`
12298            ));
12299            return Value::Status(1);
12300        }
12301        // c:4675 — write_loop(fd, t, len)
12302        let bytes = body.as_bytes();
12303        let mut off = 0;
12304        while off < bytes.len() {
12305            let n = unsafe {
12306                libc::write(
12307                    write_fd,
12308                    bytes[off..].as_ptr() as *const libc::c_void,
12309                    bytes.len() - off,
12310                )
12311            };
12312            if n <= 0 {
12313                unsafe { libc::close(write_fd) };
12314                return Value::Status(1);
12315            }
12316            off += n as usize;
12317        }
12318        unsafe { libc::close(write_fd) }; // c:4676
12319                                          // Path null-terminated by mkstemp; reopen for reading.
12320        let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
12321        // c:4678 — unlink immediately so the file disappears on
12322        // close, leaving only the fd reference.
12323        unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
12324        if read_fd < 0 {
12325            return Value::Status(1);
12326        }
12327        // c:Src/utils.c:2047-2065 `redup(x, y)` — the `zclose(x)` lives
12328        // INSIDE the `else if (x != y)` arm:
12329        //     if(x < 0)        zclose(y);
12330        //     else if (x != y) { dup2(x, y); …; zclose(x); }
12331        // When x == y, redup is a NO-OP. This helper closed `read_fd`
12332        // unconditionally, and `read_fd == fd` is the COMMON case for
12333        // `exec 3<<…`: mkstemp takes the lowest free fd (3), closes it
12334        // at c:4676, then `open` reclaims the same 3, so `dup2(3, 3)`
12335        // returned 3 and the very next `close(3)` threw the redirect
12336        // away. Every `exec N<<…` / `exec N<<<…` left N closed, and
12337        // `cat <&N` reported "N: bad file descriptor".
12338        if read_fd != fd {
12339            let r = unsafe { libc::dup2(read_fd, fd) };
12340            unsafe { libc::close(read_fd) };
12341            if r < 0 {
12342                return Value::Status(1);
12343            }
12344        }
12345        Value::Status(0)
12346    });
12347    // c:Src/exec.c:2418 + addfd splice — MULTIOS fan-out. Stack
12348    // layout pushed by compile_zsh's coalescing pass:
12349    //   [target_1, op_byte_1, target_2, op_byte_2, …, target_N,
12350    //    op_byte_N, fd]
12351    // argc = 2N + 1. Pops, opens every target, sets up a pipe +
12352    // splitter thread that reads pipe → writes every chunk to
12353    // every opened target, dup2's pipe-write-end onto fd. The
12354    // splitter is closed + joined by host_redirect_scope_end.
12355    // Bug #36 in docs/BUGS.md.
12356    vm.register_builtin(BUILTIN_MULTIOS_REDIRECT, |vm, argc| {
12357        if argc < 3 || argc % 2 == 0 {
12358            // Bad shape — bail.
12359            return Value::Status(1);
12360        }
12361        // Pop fd first (top of stack).
12362        let fd = vm.pop().to_int() as i32;
12363        // Then pop (op, target) pairs in reverse compile order. Keep
12364        // targets as Values — a glob-bearing target arrives as a
12365        // Value::Array of matches.
12366        let n_targets = ((argc - 1) / 2) as usize;
12367        let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_targets);
12368        for _ in 0..n_targets {
12369            let op_byte = vm.pop().to_int() as u8;
12370            let target = vm.pop();
12371            pairs.push((op_byte, target));
12372        }
12373        // Restore compile order (target_1 first).
12374        pairs.reverse();
12375        // c:Src/exec.c:3785-3796 — an errflag from expanding a target fails
12376        // the redirection (see redir_target_expansion_failed).
12377        if with_executor(redir_target_expansion_failed) {
12378            return Value::Status(1);
12379        }
12380
12381        // c:Src/glob.c:2195-2203 xpandredir — "Loop over matches,
12382        // duplicating the redirection for each file found": a glob
12383        // target with N matches becomes N members of the same multio
12384        // (`echo hi > *.txt` with two matches writes both files).
12385        let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
12386        for (op_byte, target) in pairs {
12387            match target {
12388                // c:Src/glob.c:2161 — without MULTIOS the word is preforked
12389                // PREFORK_SINGLE: one word, an array joined by sepjoin.
12390                Value::Array(items) if !opt_state_get("multios").unwrap_or(true) => {
12391                    let words: Vec<String> = items.iter().map(|v| v.to_str()).collect();
12392                    entries.push((op_byte, crate::ported::utils::sepjoin(&words, None)));
12393                }
12394                Value::Array(items) => {
12395                    for item in items.iter() {
12396                        entries.push((op_byte, item.to_str()));
12397                    }
12398                }
12399                other => entries.push((op_byte, other.to_str())),
12400            }
12401        }
12402        if entries.is_empty() {
12403            return Value::Status(1);
12404        }
12405
12406        // c:Src/exec.c:2418 — `else if (!mfds[fd1] || unset(MULTIOS))`:
12407        // with MULTIOS unset every redirect takes the REPLACE path in
12408        // script order — each target is still opened (created /
12409        // truncated) and dup2'd over the fd, so the LAST one wins and
12410        // earlier files end up empty (`unsetopt multios; print x > a
12411        // > b` leaves `a` empty, `x` in `b`). host_apply_redirect is
12412        // exactly one replace step, noclobber gate included.
12413        let multios_on = opt_state_get("multios").unwrap_or(true);
12414        if !multios_on {
12415            with_executor(|exec| {
12416                for (op_byte, target) in &entries {
12417                    exec.host_apply_redirect(fd as u8, *op_byte, target);
12418                    if exec.redirect_failed {
12419                        // c:Src/exec.c execerr — abort the remaining
12420                        // redirect list on failure.
12421                        break;
12422                    }
12423                }
12424            });
12425            return Value::Status(0);
12426        }
12427
12428        if entries.len() == 1 {
12429            // Single member after splicing — a plain replace
12430            // (c:2418 new-multio arm). Route through
12431            // host_apply_redirect so the noclobber gate, the
12432            // pipeline-output split partial, and error handling all
12433            // apply exactly as for an un-bagged redirect.
12434            let (op_byte, target) = &entries[0];
12435            with_executor(|exec| {
12436                exec.host_apply_redirect(fd as u8, *op_byte, target);
12437            });
12438            return Value::Status(0);
12439        }
12440
12441        // c:Src/exec.c:3722-3724 — when this command's stdout IS the
12442        // pipeline output, C seeds mfds[1] with the pipe BEFORE
12443        // walking the redirect list, so the pipe is the multio's
12444        // first member (`print x >&1 > f | cat` sends `x` down the
12445        // pipe TWICE: once for the seed, once for the `>&1` dup).
12446        let pipe_seed = fd == 1
12447            && with_executor(|exec| {
12448                exec.pipe_output_scope
12449                    .is_some_and(|d| d + 1 == exec.redirect_scope_stack.len())
12450            });
12451
12452        // Save current fd state for scope-end restoration — BEFORE
12453        // the first member's replace dup2 below.
12454        // c:Src/exec.c:2425 — `int fdN = movefd(fd1); save[fd1] = fdN;`. A SAVED
12455        // descriptor is shell state and must live above the script's fd range:
12456        // plain dup() returns the LOWEST free fd, which parked the saved stdout
12457        // on fd 3, so `print -u 3 -r -- X 2>/dev/null` wrote into the shell's own
12458        // saved descriptor and reported success where zsh says `bad file number`.
12459        // F_DUPFD with a floor of 10 is exactly what movefd does.
12460        // c:Src/exec.c:3978-3986 — a bare `exec`'s redirections (nullexec==1)
12461        // are permanent: the saved copy is closed, not restored, and the
12462        // tee process outlives this command (see the closemn arm below).
12463        let permanent = with_executor(|exec| exec.exec_redirs_permanent);
12464        if permanent {
12465            // Inside an in-process `( … )` / `$( … )` the change is permanent
12466            // only for the subshell (see SubshFdFrame), as for a single target.
12467            crate::ported::exec::SubshFdFrame::touch(fd);
12468        }
12469        // c:2478 — `if (!forked && save[fd1] == -2)`.
12470        let forked = with_executor(|exec| redir_scope_forked(exec.redirect_scope_stack.len()));
12471        let saved = if forked {
12472            -1
12473        } else {
12474            unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) }
12475        };
12476        // c:2426-2437 — "fd1 may already be closed here, so ignore bad
12477        // file descriptor error": `save[fd1] = fdN` stores -1 for a closed
12478        // fd, and the scope-end restore (c:Src/utils.c:2047 `if (x < 0)
12479        // zclose(y)`) then CLOSES it. Without that slot the splitter's pipe
12480        // write end stayed dup'd on the fd, the splitter never saw EOF and
12481        // the scope-end join hung: `print -u3 x 3>a 3>b` never returned.
12482        // A forked child parks no copy, but the splitter's pipe write end
12483        // on `fd` must still go at scope end or its join never sees EOF;
12484        // the -1 slot closes it there, as the child's exit does in C.
12485        let saved_slot = if forked {
12486            Some(-1)
12487        } else if saved >= 0 {
12488            Some(saved)
12489        } else if std::io::Error::last_os_error().raw_os_error() == Some(libc::EBADF) {
12490            Some(-1)
12491        } else {
12492            None
12493        };
12494        if permanent {
12495            // c:4038-4040 — `for (i = 0; i < 10; i++) if (save[i] != -2)
12496            // zclose(save[i]);`
12497            if saved >= 0 {
12498                unsafe { libc::close(saved) };
12499            }
12500        } else if let Some(saved) = saved_slot {
12501            with_executor(|exec| {
12502                if let Some(top) = exec.redirect_scope_stack.last_mut() {
12503                    top.push((fd, saved));
12504                    mark_saved_fd(saved);
12505                } else if saved >= 0 {
12506                    unsafe { libc::close(saved) };
12507                }
12508            });
12509        }
12510
12511        // Accumulate member fds in redirect order. c:Src/exec.c:
12512        // 2447-2480 addfd — the FIRST member REPLACES the fd
12513        // (c:2448-2450 `mfds[fd1]->ct=1; mfds[fd1]->fds[0]=fd1;`), so
12514        // a later numeric `>&N` self-dup resolves against the fd's
12515        // value at that point in the sequence: `print x > f >&1`
12516        // writes f TWICE; `print x >&1 > f` writes the ORIGINAL
12517        // stdout + f.
12518        let mut target_fds: Vec<i32> = Vec::with_capacity(entries.len() + 1);
12519        if pipe_seed {
12520            let p = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
12521            if p >= 0 {
12522                target_fds.push(p);
12523            }
12524        }
12525        let noclobber = opt_state_get("noclobber").unwrap_or(false)
12526            || !opt_state_get("clobber").unwrap_or(true);
12527        for (i, (op_byte, target)) in entries.iter().enumerate() {
12528            let open_result: std::io::Result<i32> = match *op_byte {
12529                r::DUP_WRITE | r::DUP_READ => {
12530                    // Numeric `>&N` — dup the LIVE fd N (after any
12531                    // earlier member's replace).
12532                    match target.trim_start_matches('&').parse::<i32>() {
12533                        Ok(src) => {
12534                            let d = unsafe { libc::fcntl(src, libc::F_DUPFD_CLOEXEC, 10) };
12535                            if d >= 0 {
12536                                Ok(d)
12537                            } else {
12538                                Err(std::io::Error::last_os_error())
12539                            }
12540                        }
12541                        Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
12542                    }
12543                }
12544                r::WRITE => {
12545                    // c:Src/exec.c clobber_open — noclobber applies
12546                    // to multio file targets too; failure aborts the
12547                    // remaining redirect list (execerr), so `setopt
12548                    // noclobber; touch a; print x > a > b` errors on
12549                    // `a` and never creates `b`.
12550                    let target_meta = std::fs::metadata(target).ok();
12551                    let target_is_regular_file = target_meta
12552                        .as_ref()
12553                        .map(|m| m.file_type().is_file())
12554                        .unwrap_or(false);
12555                    // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY re-uses
12556                    // an empty regular file under noclobber (same allowance
12557                    // as the single-redirect path).
12558                    let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
12559                        && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
12560                    if noclobber && target_is_regular_file && !clobber_empty_ok {
12561                        eprintln!(
12562                            "{}:{}: file exists: {}",
12563                            shname(),
12564                            crate::ported::lex::lineno(),
12565                            target
12566                        );
12567                        for prev in &target_fds {
12568                            unsafe {
12569                                libc::close(*prev);
12570                            }
12571                        }
12572                        with_executor(|exec| {
12573                            exec.redirect_failed = true;
12574                        });
12575                        // Sink the upcoming command's output (mirrors
12576                        // the single-redirect noclobber arm in
12577                        // host_apply_redirect).
12578                        if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
12579                            let new_fd = file.into_raw_fd();
12580                            unsafe {
12581                                libc::dup2(new_fd, fd);
12582                                libc::close(new_fd);
12583                            }
12584                        }
12585                        return Value::Status(1);
12586                    }
12587                    fs::OpenOptions::new()
12588                        .write(true)
12589                        .create(true)
12590                        .truncate(true)
12591                        .open(target)
12592                        .map(|f| f.into_raw_fd())
12593                }
12594                r::APPEND => fs::OpenOptions::new()
12595                    .write(true)
12596                    .create(true)
12597                    .append(true)
12598                    .open(target)
12599                    .map(|f| f.into_raw_fd()),
12600                _ => fs::OpenOptions::new()
12601                    .write(true)
12602                    .create(true)
12603                    .truncate(true)
12604                    .open(target)
12605                    .map(|f| f.into_raw_fd()),
12606            };
12607            match open_result {
12608                Ok(tfd) => {
12609                    // c:2463-2470 / c:2481 — every member of a split stream
12610                    // is `movefd`'d above the script's fd range. A file opened
12611                    // while the target fd is closed comes back AS that fd
12612                    // (`print -u3 x 3>a 3>b`: `a` opens on 3), and the pipe
12613                    // dup2 below then replaced the member with the splitter's
12614                    // own write end — `a` stayed empty and the splitter fed
12615                    // its output back into itself.
12616                    let tfd = crate::ported::utils::movefd(tfd);
12617                    if i == 0 && !pipe_seed {
12618                        // c:2448-2450 — first member replaces the fd.
12619                        unsafe {
12620                            libc::dup2(tfd, fd);
12621                        }
12622                    }
12623                    target_fds.push(tfd);
12624                }
12625                Err(e) => {
12626                    // c:Src/exec.c:3741 — `zwarn("%e: %s", errno, fname)`:
12627                    // zwarning supplies the `name:LINE:` prefix with the
12628                    // REAL current lineno; redir_errno_msg builds the `%e`
12629                    // errno message (was a hardcoded ErrorKind match that
12630                    // showed generic "redirect failed" for EROFS/etc.).
12631                    let msg = redir_errno_msg(&e);
12632                    crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
12633                    // Close already-opened fds to avoid leaks.
12634                    for prev in &target_fds {
12635                        unsafe {
12636                            libc::close(*prev);
12637                        }
12638                    }
12639                    with_executor(|exec| {
12640                        exec.redirect_failed = true;
12641                    });
12642                    return Value::Status(1);
12643                }
12644            }
12645        }
12646
12647        // Create the splitter pipe.
12648        let (read_end, write_end) = match os_pipe::pipe() {
12649            Ok(p) => p,
12650            Err(_) => {
12651                for f in &target_fds {
12652                    unsafe {
12653                        libc::close(*f);
12654                    }
12655                }
12656                return Value::Status(1);
12657            }
12658        };
12659        // c:Src/exec.c:5222 — `pp[0] = movefd(pp[0]);` in `mpipe()`.
12660        // c:Src/utils.c:1990-2012 movefd — "if(fd != -1 && fd < 10)"
12661        // dup into the >=10 range and zclose the low copy, then mark
12662        // the result FDT_INTERNAL. Every shell-internal fd goes
12663        // through this so it can never share a number with the
12664        // user-visible `>&N` range that the redirect bookkeeping
12665        // opens/dups/closes. This splitter kept the raw (low) pipe
12666        // read end, so an unrelated close of that number shut it
12667        // under the splitter thread and dropping the owned
12668        // PipeReader aborted the process with std's "IO Safety
12669        // violation: owned file descriptor already closed"
12670        // (E01options.ztst:46 `( echo hello ) >a >b`, ~2 runs in 3).
12671        let read_end = unsafe {
12672            <os_pipe::PipeReader as std::os::unix::io::FromRawFd>::from_raw_fd(
12673                crate::extensions::fds::movefd(read_end.into_raw_fd()),
12674            )
12675        };
12676        let pipe_write_raw = AsRawFd::as_raw_fd(&write_end);
12677        if permanent && (0..10).contains(&fd) {
12678            // c:Src/exec.c:4014-4016 — "We are done with redirection. close
12679            // the mnodes, spawning tee/cat processes as necessary." A bare
12680            // `exec 3>&1 3>&2` keeps the multio for the rest of the shell's
12681            // life, so the scope-bound splitter thread below (drained and
12682            // joined at scope end) cannot serve it: nothing ever ends the
12683            // scope, and the thread dies with the process before it copies
12684            // what the last command wrote. closemn forks the tee process C
12685            // uses for every multio; it reads until the shell's last copy
12686            // of the pipe write end closes, i.e. after the shell exits.
12687            // c:4045 — "We're done with this job, no need to wait for it."
12688            let write_dup = unsafe { libc::fcntl(pipe_write_raw, libc::F_DUPFD_CLOEXEC, 10) };
12689            drop(write_end);
12690            if write_dup < 0 {
12691                for f in &target_fds {
12692                    unsafe { libc::close(*f) };
12693                }
12694                return Value::Status(1);
12695            }
12696            unsafe {
12697                libc::dup2(write_dup, fd);
12698                libc::close(write_dup);
12699            }
12700            let mn = crate::ported::zsh_h::multio {
12701                ct: target_fds.len() as i32,
12702                rflag: 1, // c:2462 — output multio: a tee, not a cat
12703                pipe: std::os::unix::io::IntoRawFd::into_raw_fd(read_end),
12704                fds: target_fds,
12705            };
12706            let mut mfds: [Option<Box<crate::ported::zsh_h::multio>>; 10] = Default::default();
12707            mfds[fd as usize] = Some(Box::new(mn));
12708            crate::ported::exec::closemn(&mut mfds, fd, crate::ported::zsh_h::REDIR_CLOSE);
12709            return Value::Status(0);
12710        }
12711        // Spawn the splitter thread: read pipe → write every chunk
12712        // to every target fd. Each write inside the thread uses
12713        // libc::write directly on the raw fd (no Rust File ownership
12714        // so the splitter can close after EOF without racing main).
12715        let target_fds_for_thread = target_fds.clone();
12716        let handle = std::thread::spawn(move || {
12717            let mut r = read_end;
12718            let mut buf = [0u8; 8192];
12719            loop {
12720                match std::io::Read::read(&mut r, &mut buf) {
12721                    Ok(0) => break,
12722                    Ok(n) => {
12723                        for &tfd in &target_fds_for_thread {
12724                            let mut off = 0;
12725                            while off < n {
12726                                let w = unsafe {
12727                                    libc::write(
12728                                        tfd,
12729                                        buf[off..n].as_ptr() as *const libc::c_void,
12730                                        n - off,
12731                                    )
12732                                };
12733                                if w <= 0 {
12734                                    break;
12735                                }
12736                                off += w as usize;
12737                            }
12738                        }
12739                    }
12740                    Err(_) => break,
12741                }
12742            }
12743            // Close every target so file contents flush.
12744            for tfd in target_fds_for_thread {
12745                unsafe {
12746                    libc::close(tfd);
12747                }
12748            }
12749        });
12750
12751        // Dup the pipe write-end onto the target fd; close the
12752        // original write_end so EOF arrives when host_redirect_scope_end
12753        // closes our tracked pipe_write_fd.
12754        let write_dup = unsafe { libc::fcntl(pipe_write_raw, libc::F_DUPFD_CLOEXEC, 10) };
12755        drop(write_end);
12756        if write_dup < 0 {
12757            return Value::Status(1);
12758        }
12759        unsafe {
12760            libc::dup2(write_dup, fd);
12761            libc::close(write_dup);
12762        }
12763        // Track the running splitter so scope-end can drain + join.
12764        // The "write_fd" we store is the user-visible fd (e.g. 1).
12765        // Closing that fd at scope-end isn't quite right; we need a
12766        // way to send EOF. Solution: track the write_dup we just
12767        // closed; instead keep a second dup for the close-on-end.
12768        // Shell-internal bookkeeping fd — above the script's range (movefd).
12769        let close_on_end = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
12770        with_executor(|exec| {
12771            if let Some(top) = exec.multios_scope_stack.last_mut() {
12772                top.push((close_on_end, handle));
12773            } else {
12774                // No scope — leak the dup; thread will keep running
12775                // until process exit. Should not happen because
12776                // host_redirect_scope_begin pushed a frame.
12777                unsafe { libc::close(close_on_end) };
12778            }
12779        });
12780        Value::Status(0)
12781    });
12782    // c:Src/exec.c:2418 input-arm — MULTIOS read fan-in. Stack
12783    // layout pushed by compile_zsh (mirrors the write side):
12784    //   [source_1, op_1, source_2, op_2, …, source_N, op_N, fd]
12785    // argc = 2N + 1; op distinguishes file opens (READ) from numeric
12786    // `<&N` dups (DUP_READ); a glob source arrives as Value::Array
12787    // and splices into one member per match (c:Src/glob.c:2195-2203).
12788    // Opens every source, sets up a pipe + producer thread that
12789    // reads each source in order and writes to the pipe write-end,
12790    // then closes its write-end so the consumer gets EOF. dup2 the
12791    // pipe read-end onto fd. Bug #36 input side in docs/BUGS.md.
12792    vm.register_builtin(BUILTIN_MULTIOS_READ, |vm, argc| {
12793        if argc < 3 || argc % 2 == 0 {
12794            return Value::Status(1);
12795        }
12796        let fd = vm.pop().to_int() as i32;
12797        let n_sources = ((argc - 1) / 2) as usize;
12798        let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_sources);
12799        for _ in 0..n_sources {
12800            let op_byte = vm.pop().to_int() as u8;
12801            let source = vm.pop();
12802            pairs.push((op_byte, source));
12803        }
12804        pairs.reverse();
12805        // c:Src/exec.c:3785-3796 — an errflag from expanding a target fails
12806        // the redirection (see redir_target_expansion_failed).
12807        if with_executor(redir_target_expansion_failed) {
12808            return Value::Status(1);
12809        }
12810
12811        // Splice glob match arrays (c:Src/glob.c:2195-2203).
12812        let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
12813        for (op_byte, source) in pairs {
12814            match source {
12815                // c:Src/glob.c:2161 — without MULTIOS the word is preforked
12816                // PREFORK_SINGLE: one word, an array joined by sepjoin.
12817                Value::Array(items) if !opt_state_get("multios").unwrap_or(true) => {
12818                    let words: Vec<String> = items.iter().map(|v| v.to_str()).collect();
12819                    entries.push((op_byte, crate::ported::utils::sepjoin(&words, None)));
12820                }
12821                Value::Array(items) => {
12822                    for item in items.iter() {
12823                        entries.push((op_byte, item.to_str()));
12824                    }
12825                }
12826                other => entries.push((op_byte, other.to_str())),
12827            }
12828        }
12829        if entries.is_empty() {
12830            return Value::Status(1);
12831        }
12832
12833        // c:Src/exec.c:2418 — `unset(MULTIOS)`: sequential replace,
12834        // last source wins (`unsetopt multios; cat < a < b` reads
12835        // only b; a is still opened — and errors still surface).
12836        let multios_on = opt_state_get("multios").unwrap_or(true);
12837        if !multios_on {
12838            with_executor(|exec| {
12839                for (op_byte, source) in &entries {
12840                    multios_apply_member(exec, fd, *op_byte, source);
12841                    if exec.redirect_failed {
12842                        break;
12843                    }
12844                }
12845            });
12846            return Value::Status(0);
12847        }
12848
12849        // c:Src/exec.c:3775-3777 — a pipeline stage's input pipe is already
12850        // mfds[0]'s first member, so even one source concatenates.
12851        let pipe_seed = with_executor(|exec| take_pipe_input_seed(exec, fd));
12852        if entries.len() == 1 && pipe_seed.is_none() {
12853            // Single member after splicing — plain replace.
12854            let (op_byte, source) = &entries[0];
12855            with_executor(|exec| {
12856                multios_apply_member(exec, fd, *op_byte, source);
12857            });
12858            return Value::Status(0);
12859        }
12860
12861        // Save current fd state for scope-end restoration — BEFORE
12862        // the first member's replace dup2 below.
12863        // c:Src/exec.c:2425 — `int fdN = movefd(fd1); save[fd1] = fdN;`. A SAVED
12864        // descriptor is shell state and must live above the script's fd range:
12865        // plain dup() returns the LOWEST free fd, which parked the saved stdout
12866        // on fd 3, so `print -u 3 -r -- X 2>/dev/null` wrote into the shell's own
12867        // saved descriptor and reported success where zsh says `bad file number`.
12868        // F_DUPFD with a floor of 10 is exactly what movefd does.
12869        // c:2478 — `if (!forked && save[fd1] == -2)`.
12870        let forked = with_executor(|exec| redir_scope_forked(exec.redirect_scope_stack.len()));
12871        let saved = if forked {
12872            -1
12873        } else {
12874            unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) }
12875        };
12876        // c:2426-2437 — a closed fd saves -1, restored by closing it (see
12877        // the write-side multio above).
12878        // A forked child parks no copy, but the pipe read end on `fd` must
12879        // still go at scope end or a splitter blocked on a full pipe never
12880        // finishes its join; the -1 slot closes it there, as the child's
12881        // exit does in C.
12882        let saved_slot = if forked {
12883            Some(-1)
12884        } else if saved >= 0 {
12885            Some(saved)
12886        } else if std::io::Error::last_os_error().raw_os_error() == Some(libc::EBADF) {
12887            Some(-1)
12888        } else {
12889            None
12890        };
12891        if let Some(saved) = saved_slot {
12892            with_executor(|exec| {
12893                if let Some(top) = exec.redirect_scope_stack.last_mut() {
12894                    top.push((fd, saved));
12895                    mark_saved_fd(saved);
12896                } else if saved >= 0 {
12897                    unsafe { libc::close(saved) };
12898                }
12899            });
12900        }
12901
12902        // Open every source in redirect order; numeric `<&N` dups
12903        // resolve against the LIVE fd table. First member replaces
12904        // the fd (c:2448-2450) so later self-dups see it.
12905        let mut source_fds: Vec<i32> = Vec::with_capacity(entries.len() + 1);
12906        source_fds.extend(pipe_seed);
12907        for (i, (op_byte, source)) in entries.iter().enumerate() {
12908            let open_result: std::io::Result<i32> = match *op_byte {
12909                r::DUP_READ | r::DUP_WRITE => match source.trim_start_matches('&').parse::<i32>() {
12910                    Ok(src) => {
12911                        let d = unsafe { libc::fcntl(src, libc::F_DUPFD_CLOEXEC, 10) };
12912                        if d >= 0 {
12913                            Ok(d)
12914                        } else {
12915                            Err(std::io::Error::last_os_error())
12916                        }
12917                    }
12918                    Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
12919                },
12920                // c:Src/exec.c:3821-3835 — `fil = getherestr(fn);`
12921                MULTIOS_OP_HERESTR_BODY | MULTIOS_OP_HEREDOC_BODY => {
12922                    multios_body_fd(source, *op_byte == MULTIOS_OP_HERESTR_BODY)
12923                }
12924                _ => fs::File::open(source).map(|f| f.into_raw_fd()),
12925            };
12926            match open_result {
12927                Ok(opened) => {
12928                    // c:Src/exec.c:2458-2465 — addfd moves every multio member
12929                    // out of the script's fd range (`fdN = movefd(fd1)`,
12930                    // `fdN = movefd(fd2)`). A member opened by `open` gets the
12931                    // LOWEST free descriptor, which for `cat 3<o1 3<o2 <&3` is
12932                    // 3 itself: the `dup2` of the concatenator pipe onto fd 3
12933                    // below then closed o1, the producer read its own empty
12934                    // pipe instead, and the command hung.
12935                    let tfd = crate::ported::utils::movefd(opened);
12936                    if i == 0 {
12937                        unsafe {
12938                            libc::dup2(tfd, fd);
12939                        }
12940                    }
12941                    source_fds.push(tfd);
12942                }
12943                Err(e) => {
12944                    let msg = match e.kind() {
12945                        std::io::ErrorKind::PermissionDenied => "permission denied",
12946                        std::io::ErrorKind::NotFound => "no such file or directory",
12947                        _ => "open failed",
12948                    };
12949                    // c:Src/exec.c:3741 — zwarn with real lineno prefix.
12950                    crate::ported::utils::zwarn(&format!("{}: {}", msg, source));
12951                    for prev in &source_fds {
12952                        unsafe {
12953                            libc::close(*prev);
12954                        }
12955                    }
12956                    with_executor(|exec| {
12957                        exec.redirect_failed = true;
12958                    });
12959                    return Value::Status(1);
12960                }
12961            }
12962        }
12963
12964        // Create the concatenator pipe.
12965        if !with_executor(|exec| multios_read_concat(exec, fd, source_fds)) {
12966            return Value::Status(1);
12967        }
12968        Value::Status(0)
12969    });
12970    // c:Src/exec.c:3978-3986 — nullexec==1 marker. See the const's
12971    // doc block. Arg: 1 = entering a bare-exec redirect, 0 = leaving.
12972    vm.register_builtin(BUILTIN_EXEC_PERM_REDIRS, |vm, _argc| {
12973        let on = vm.pop().to_int() != 0;
12974        with_executor(|exec| exec.exec_redirs_permanent = on);
12975        Value::Status(0)
12976    });
12977    // Bare-exec redirect epilogue — see the const's doc block.
12978    // c:Src/exec.c:252-259 (execerr) + c:4367-4386 (done: POSIX gate).
12979    vm.register_builtin(BUILTIN_EXEC_REDIR_DONE, |vm, _argc| {
12980        use std::sync::atomic::Ordering;
12981        let failed = with_executor(|exec| {
12982            let f = exec.redirect_failed;
12983            exec.redirect_failed = false;
12984            f
12985        });
12986        if !failed {
12987            // c:Src/exec.c:4032 — `lastval = errflag ? errflag : cmdoutval;`
12988            // cmdoutval is NOT reset by a command without assignments, so
12989            // a `$(...)` from an earlier command (`x=$(false); exec 3>f`)
12990            // still decides this status.
12991            let ef = crate::ported::utils::errflag.load(Ordering::Relaxed);
12992            return Value::Status(if ef != 0 { ef } else { crate::ported::exec::cmdoutval.load(Ordering::Relaxed) });
12993        }
12994        // c:255 — `redir_err = lastval = 1`.
12995        vm.last_status = 1;
12996        if isset(crate::ported::zsh_h::POSIXBUILTINS) && !isset(crate::ported::zsh_h::INTERACTIVE) {
12997            // c:4379-4383 — non-interactive POSIX fatal: exit(1).
12998            // In-process equivalent: arm EXIT_PENDING/EXIT_VAL so the
12999            // next BUILTIN_ERREXIT_CHECK (trigger 2) unwinds the
13000            // script with status 1 — same deferred-exit shape the
13001            // `exit` builtin uses inside subshell contexts.
13002            crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
13003            crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
13004        }
13005        Value::Status(1)
13006    });
13007    // c:Src/exec.c:3722-3724 — see the const's doc block. No args.
13008    // BUILTIN_XTRERR_COPY — c:Src/exec.c:3765-3773, emitted right before a
13009    // simple command's WithRedirectsBegin. No args.
13010    vm.register_builtin(BUILTIN_XTRERR_COPY, |_vm, _argc| {
13011        xtrerr_flush(); // c:3766 fflush(xtrerr)
13012        if isset(crate::ported::zsh_h::XTRACE)
13013            && crate::ported::utils::xtrerr.load(std::sync::atomic::Ordering::Relaxed) == libc::STDERR_FILENO
13014        {
13015            // c:3767-3769
13016            let fd = crate::ported::utils::movefd(unsafe { libc::dup(libc::STDERR_FILENO) });
13017            if fd >= 0 {
13018                // c:Src/exec.c:779 `closem(FDT_XTRACE, 0)` closes this fd in a
13019                // child before it execs; zshrs's spawns run no closem, so
13020                // close-on-exec stands in for it.
13021                unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
13022                crate::ported::utils::xtrerr.store(fd, std::sync::atomic::Ordering::Relaxed); // c:3770
13023                crate::ported::utils::fdtable_set(fd, crate::ported::zsh_h::FDT_XTRACE); // c:3771
13024                let depth = with_executor(|exec| exec.redirect_scope_stack.len()) + 1;
13025                XTRERR_COPIES.with(|v| v.borrow_mut().push((depth, fd)));
13026            }
13027        }
13028        Value::Status(0)
13029    });
13030    vm.register_builtin(BUILTIN_PIPE_OUTPUT_MARK, |_vm, _argc| {
13031        with_executor(|exec| exec.pipe_output_pending = true);
13032        Value::Status(0)
13033    });
13034    // c:Src/exec.c:3710-3724 — install this pipeline stage's fds.
13035    //     /* Make a copy of stderr for xtrace output before redirecting */
13036    //     fflush(xtrerr);
13037    //     ...
13038    //     /* Add pipeline input/output to mnodes */
13039    //     if (input)  addfd(forked, save, mfds, 0, input, 0, NULL);
13040    //     if (output) addfd(forked, save, mfds, 1, output, 1, NULL);
13041    // Emitted into the stage chunk by compile_zsh.rs (after the arg
13042    // words' expansion ops, before the redirect scope), and fed by
13043    // BUILTIN_RUN_PIPELINE via `stage_fds_park`. Doing the dup2 HERE
13044    // rather than before the chunk runs is what makes a `$(...)` in a
13045    // stage's arguments see the shell's fd 0 instead of the pipe.
13046    vm.register_builtin(BUILTIN_PIPE_FDS_INSTALL, |vm, argc| {
13047        // Arg: `|&` merge-stderr flag (compile_zsh always passes it).
13048        let merge_stderr = pop_args(vm, argc)
13049            .first()
13050            .map(|s| s != "0" && !s.is_empty())
13051            .unwrap_or(false);
13052        let (in_fd, out_fd) = stage_fds_take();
13053        if in_fd < 0 && out_fd < 0 {
13054            return Value::Status(0);
13055        }
13056        // c:3711 `fflush(xtrerr)` — flush before the fds move, so
13057        // anything buffered from the expansion phase lands on the
13058        // ORIGINAL fd, not on the pipe.
13059        let _ = std::io::stdout().flush();
13060        let _ = std::io::stderr().flush();
13061        unsafe {
13062            if in_fd >= 0 {
13063                libc::dup2(in_fd, libc::STDIN_FILENO);
13064                if in_fd != libc::STDIN_FILENO {
13065                    libc::close(in_fd);
13066                }
13067                // c:3775-3776 — the pipe is mfds[0]'s first member.
13068                PIPE_INPUT_INSTALLED.with(|c| c.set(Some(SUBLIST_SERIAL.with(|s| s.get()))));
13069            }
13070            if out_fd >= 0 {
13071                libc::dup2(out_fd, libc::STDOUT_FILENO);
13072                if out_fd != libc::STDOUT_FILENO {
13073                    libc::close(out_fd);
13074                }
13075                // `cmd |& next`: the `2>&1` C appends to cmd's redirect
13076                // list (walked at c:3730+, i.e. after this addfd), so
13077                // stderr follows the pipe, not the shell's stdout.
13078                if merge_stderr {
13079                    libc::dup2(libc::STDOUT_FILENO, libc::STDERR_FILENO);
13080                }
13081            }
13082        }
13083        Value::Status(0)
13084    });
13085    // c:Src/exec.c — block-level redirect-failure gate. When a
13086    // compound command (`{ … } < file`, `( … ) > file`, etc.) has a
13087    // failing redirect (e.g. `< /nonexistent`), zsh skips the entire
13088    // body AND sets lastval to 1. The simple-command path's
13089    // redirect_failed check (line 215-221 above) only catches the
13090    // failure when a builtin dispatches and is consumed by that
13091    // single builtin call — so a multi-statement block kept running
13092    // its remaining statements after the redir error. Emit-side at
13093    // compile_zsh.rs::compile_command's Redirected arm pairs this
13094    // with a JumpIfTrue → WithRedirectsEnd to abandon the body.
13095    vm.register_builtin(BUILTIN_REDIRECT_FAILED_CHECK, |vm, _argc| {
13096        let failed = with_executor(|exec| {
13097            let f = exec.redirect_failed;
13098            exec.redirect_failed = false;
13099            f
13100        });
13101        if failed {
13102            vm.last_status = 1;
13103            Value::Int(1)
13104        } else {
13105            Value::Int(0)
13106        }
13107    });
13108    // c:Src/exec.c — drop-in replacement for fusevm's Op::Exec used by
13109    // the dynamic-first-word path (`$cmd`, `$(cmd)`, glob-named cmds).
13110    // fusevm's Op::Exec returns Value::Status(0) when post-expansion
13111    // argv is empty (vm.rs:1722) — that clobbers \$? for the
13112    // `\$(exit 1); echo \$?` case where the cmd-subst left
13113    // last_status = 1 but the empty expansion gets exec'd to 0.
13114    // Mirror C zsh: when the word list is empty after expansion,
13115    // \$? becomes whatever the inner cmd-subst's last_status is
13116    // (preserved here by returning Value::Status(last_status)).
13117    // c:Src/cond.c:308-316 — `if (!(pprog = patcompile(right, ...)))
13118    //   { zwarnnam(fromtest, "bad pattern: %s", right); return 2; }`.
13119    // The cond path must NOT use str_match/glob_match_static: the
13120    // case-statement consumer of those follows Src/loop.c:667 zerr
13121    // semantics (errflag abort), while cond is a zwarn + status-2
13122    // soft failure. COND_BAD_PATTERN carries the 2 across the
13123    // Bool-shaped stack contract (so `!=`'s LogNot can't lose it).
13124    thread_local! {
13125        static COND_BAD_PATTERN: std::cell::Cell<bool> =
13126            const { std::cell::Cell::new(false) };
13127    }
13128    vm.register_builtin(BUILTIN_COND_STRMATCH, |vm, _argc| {
13129        let pat = pattern_filesub(&vm.pop().to_str());
13130        let s = vm.pop().to_str();
13131        // bash `shopt -s nocasematch` → case-insensitive `[[ == ]]` / `[[ != ]]`.
13132        // Lowercase BOTH sides for the match decision (glob metacharacters are
13133        // not letters, so the pattern's `*`/`?`/`[…]` structure is preserved).
13134        // No-op unless the bash shopt is active. --zsh unaffected.
13135        let (s, pat) = if crate::dash_mode::nocasematch() {
13136            (s.to_lowercase(), pat.to_lowercase())
13137        } else {
13138            (s, pat)
13139        };
13140        // A bare POSIX-family drop-in reads a source-level `(` by its own
13141        // rules, not by zsh's parse-time tokenization — see
13142        // `dropin_source_pattern_parens_literal`.
13143        let pat_eff = if crate::pattern_data_escape::dropin_source_pattern_parens_literal() {
13144            crate::pattern_data_escape::escape_shglob_parens(
13145                &pat,
13146                crate::pattern_data_escape::dropin_keeps_ksh_groups(),
13147            )
13148        } else {
13149            pat.clone()
13150        };
13151        let mut pat_tok = pat_eff.clone();
13152        crate::ported::glob::tokenize(&mut pat_tok);
13153        if crate::ported::pattern::patcompile(
13154            &pat_tok,
13155            crate::ported::zsh_h::PAT_STATIC as i32,
13156            None,
13157        )
13158        .is_none()
13159        {
13160            // c:314 — zwarnnam(fromtest, "bad pattern: %s", right).
13161            crate::ported::utils::zwarn(&format!("bad pattern: {}", pat));
13162            COND_BAD_PATTERN.with(|c| c.set(true));
13163            return Value::Bool(false);
13164        }
13165        // Match via the shared engine so `(#b)`/`(#m)` backref and
13166        // MATCH-variable population stays in one place.
13167        Value::Bool(crate::vm_helper::glob_match_static(&s, &pat_eff))
13168    });
13169    // See BUILTIN_RM_STAR_CHECK.
13170    vm.register_builtin(BUILTIN_RM_STAR_CHECK, |vm, argc| {
13171        use crate::ported::zsh_h::{interact, isset, ERRFLAG_ERROR, RMSTARSILENT, SHINSTDIN, Star};
13172        use std::sync::atomic::Ordering;
13173        let mask = vm.pop().to_int() as u64;
13174        let n = (argc as usize).saturating_sub(1);
13175        let mut words: Vec<Value> = (0..n).map(|_| vm.pop()).collect();
13176        words.reverse();
13177        // c:3549-3551 — `type == WC_SIMPLE && interact && unset(RMSTARSILENT)
13178        // && isset(SHINSTDIN)` and at least one argument after `rm`.
13179        if interact() && !isset(RMSTARSILENT) && isset(SHINSTDIN) && n > 0 {
13180            let is_star = |c: char| c == Star || c == '*';
13181            for (i, word) in words.iter().enumerate() {
13182                // c:3554 — `node && !errflag`
13183                if crate::ported::utils::errflag.load(Ordering::Relaxed) != 0 {
13184                    break;
13185                }
13186                if i >= 64 || mask & (1u64 << i) == 0 {
13187                    continue;
13188                }
13189                let s: Vec<char> = word.to_str().chars().collect();
13190                let l = s.len();
13191                let rmall = if l == 1 && is_star(s[0]) {
13192                    // c:3559-3560 — `checkrmall(pwd)`
13193                    let pwd = crate::ported::params::getsparam("PWD").unwrap_or_default();
13194                    crate::ported::utils::checkrmall(&pwd)
13195                } else if l >= 2 && s[l - 2] == '/' && is_star(s[l - 1]) {
13196                    // c:3564-3570 — the word up to the `/`, or `/` itself.
13197                    let dir: String = if l == 2 { "/".to_string() } else { s[..l - 2].iter().collect() };
13198                    crate::ported::utils::checkrmall(&dir)
13199                } else {
13200                    true
13201                };
13202                if !rmall {
13203                    crate::ported::utils::errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed); // c:3561 / c:3573
13204                    break;
13205                }
13206            }
13207        }
13208        for w in words {
13209            vm.push(w);
13210        }
13211        Value::Int(0)
13212    });
13213    // c:Src/cond.c:122-193, the COND_MODI arm: `[[ a -op b ]]` with an `-op`
13214    // that is not one of get_cond_num's (Src/parse.c:2697-2701). See
13215    // BUILTIN_COND_MODI.
13216    vm.register_builtin(BUILTIN_COND_MODI, |vm, _argc| {
13217        let is_dash = |s: &str| s.starts_with('-') || s.starts_with(crate::ported::zsh_h::Dash);
13218        let mut name = vm.pop().to_str(); // c:130 — the operator word
13219        let right = vm.pop().to_str();
13220        let left = vm.pop().to_str();
13221        let mut strs = vec![left, right]; // c:136-141
13222        let l: i32 = 2; // c:141
13223        // c:143-148
13224        let errname = if is_dash(&name) {
13225            crate::lex::untokenize(&name)
13226        } else if is_dash(&strs[0]) {
13227            crate::lex::untokenize(&strs[0])
13228        } else {
13229            "<null>".to_string()
13230        };
13231        let lookup = |inf: i32, word: &str| {
13232            let cond = word.chars().skip(1).collect::<String>(); // `name + 1`
13233            match crate::ported::module::MODULESTAB.try_lock() {
13234                Ok(mut tab) => crate::ported::module::getconddef(inf, &cond, 1, &mut tab),
13235                Err(_) => None,
13236            }
13237        };
13238        // c:149-150
13239        let mut cd = if is_dash(&name) { lookup(1, &name) } else { None };
13240        if cd.is_none() {
13241            // c:161-174 — retry as a prefix condition on the left word, with
13242            // the operator word moved into its place.
13243            let s = std::mem::replace(&mut strs[0], name); // c:173
13244            name = s; // c:174
13245            cd = if is_dash(&name) { lookup(0, &name) } else { None }; // c:176-177
13246            match cd.as_ref() {
13247                Some(c) if l < c.min || (c.max >= 0 && l > c.max) => {
13248                    // c:178-181
13249                    crate::ported::utils::zerr(&format!("unknown condition: {}", errname));
13250                    COND_BAD_PATTERN.with(|c| c.set(true));
13251                    return Value::Bool(false);
13252                }
13253                Some(_) => {}
13254                None => {
13255                    // c:186-192 — `zwarnnam(…, "unknown condition: %s",
13256                    // errname)` then `return 2;`, the status the shared
13257                    // BUILTIN_COND_STATUS_FROM_BOOL tail turns into an error.
13258                    crate::ported::utils::zerr(&format!("unknown condition: {}", errname));
13259                    COND_BAD_PATTERN.with(|c| c.set(true));
13260                    return Value::Bool(false);
13261                }
13262            }
13263        }
13264        // c:158 / c:185 — `return !cd->handler(strs, cd->condid);`
13265        let r = cd
13266            .and_then(|c| c.handler.map(|h| h(&strs, c.condid)))
13267            .unwrap_or(0);
13268        Value::Bool(r != 0)
13269    });
13270
13271    vm.register_builtin(BUILTIN_COND_UNKNOWN, |vm, _argc| {
13272        // c:Src/cond.c:150-188 — `zwarnnam(fromtest, "unknown condition: %s",
13273        // name)` for a `-X` op with no matching cond module. Like a cond
13274        // syntax error it yields status 2 and aborts: arm COND_BAD_PATTERN so
13275        // the downstream BUILTIN_COND_STATUS_FROM_BOOL carries the 2 across the
13276        // Bool-shaped stack and runs the shared errflag+set_last_status(2)+abort
13277        // path (c:Src/exec.c:5216-5221). Returns Bool(false) as the operand.
13278        let op = vm.pop().to_str();
13279        crate::ported::utils::zerr(&format!("unknown condition: {}", op));
13280        COND_BAD_PATTERN.with(|c| c.set(true));
13281        Value::Bool(false)
13282    });
13283    vm.register_builtin(BUILTIN_COND_STATUS_FROM_BOOL, |vm, _argc| {
13284        // `${~pat}` / `${(P)~pat}` inside a `[[ … ]]` operand flips
13285        // GLOB_SUBST on via the tilde carrier so the pattern match sees
13286        // active metacharacters. In C that flag is prefork-scoped and
13287        // gone once the operand is consumed; zshrs restores it at the
13288        // next command-dispatch boundary, but a bare `[[ … ]]` has no
13289        // trailing assignment to trigger that — so globsubst leaked ON
13290        // into the NEXT command's word expansion, filename-generating a
13291        // scalar value it should not (p10k `_p9k_set_prompt`: line 45
13292        // `[[ … != ${(P)~disabled} ]]` leaked into line 46's
13293        // `local val=$arr[idx]`, whose glob-char-laden value then hit
13294        // "no matches found" and aborted the whole prompt build →
13295        // garbled 25-line prompt / interactive hang). Consume the
13296        // carrier here: this builtin ends EVERY `[[ … ]]`, and runs
13297        // after the operands (and their pattern match) are done.
13298        consume_tilde_globsubst_carrier();
13299        let ok = vm.pop().to_int() != 0;
13300        let bad = COND_BAD_PATTERN.with(|c| {
13301            let b = c.get();
13302            c.set(false);
13303            b
13304        });
13305        if bad {
13306            // c:Src/exec.c:5216-5221 — `stat = evalcond(...);
13307            //   /* 2 indicates a syntax error. For compatibility,
13308            //      turn this into a shell error. */
13309            //   if (stat == 2) errflag |= ERRFLAG_ERROR;`
13310            // The errflag abort exits the script with lastval (2),
13311            // matching `zsh -fc '[[ x == [a- ]]; print rc=$?'`
13312            // printing nothing after the diagnostic and exiting 2.
13313            crate::ported::utils::errflag.fetch_or(
13314                crate::ported::zsh_h::ERRFLAG_ERROR,
13315                std::sync::atomic::Ordering::Relaxed,
13316            );
13317            with_executor(|exec| exec.set_last_status(2));
13318            return Value::Int(2); // c:Src/cond.c:316 `return 2;`
13319        }
13320        let status: i32 = if ok { 0 } else { 1 };
13321        // c:Src/exec.c:5216 — `lastval = evalcond(...)`: the conditional's
13322        // result IS the command's lastval, and c:Src/cond.c's evalcond
13323        // never inspects errflag while evaluating. So when a `[[ … ]]`
13324        // operand raised errflag (e.g. a nounset "parameter not set" zerr
13325        // on `${arr[99]}` under NO_UNSET), zsh STILL completes the test and
13326        // exits with the cond result; the errflag only aborts the FOLLOWING
13327        // commands. Sync the result to the executor's live lastval HERE —
13328        // the nounset site left it at a transient 1, and the next
13329        // BUILTIN_ERREXIT_CHECK reads the executor (not vm.last_status), so
13330        // without this sync `setopt NO_UNSET; [[ -z ${arr[99]} ]]` exited 1
13331        // instead of 0. The Op::SetStatus that follows sets vm.last_status;
13332        // this keeps the executor coherent with it before the abort check.
13333        with_executor(|exec| exec.set_last_status(status));
13334        Value::Int(status as i64)
13335    });
13336    // c:Src/exec.c:2796 (execcmd_analyse) / c:1368 (execsimple) — a simple
13337    // command that carries assignments starts with `cmdoutval = 0;`.
13338    vm.register_builtin(BUILTIN_CMDOUTVAL_RESET, |_vm, _argc| {
13339        crate::ported::exec::cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
13340        Value::Status(0)
13341    });
13342    vm.register_builtin(BUILTIN_USE_CMDOUTVAL_RESET, |_vm, _argc| {
13343        crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
13344        Value::Status(0)
13345    });
13346
13347    // See BUILTIN_MARK_CURSH.
13348    vm.register_builtin(BUILTIN_MARK_CURSH, |_vm, _argc| {
13349        crate::exec_jobs::mark_thisjob_cursh(true); // c:Src/exec.c:3674-3682
13350        Value::Int(0)
13351    });
13352    // See BUILTIN_EXEC_DYNAMIC_REDIRS.
13353    vm.register_builtin(BUILTIN_EXEC_DYNAMIC_REDIRS, |_vm, _argc| {
13354        EXEC_DYNAMIC_REDIRS.with(|c| c.set(true));
13355        Value::Int(0)
13356    });
13357    vm.register_builtin(BUILTIN_EXEC_DYNAMIC, |vm, argc| {
13358        let raw = pop_args(vm, argc);
13359        // Taken first so every return path below clears it.
13360        let had_redirs = EXEC_DYNAMIC_REDIRS.with(|c| c.replace(false));
13361        // Flatten Array entries into argv slots (matches fusevm
13362        // Op::Exec's flatten at vm.rs:1660-1665) so `${arr[@]}` /
13363        // splice expansions produce one argv slot per element.
13364        let args: Vec<String> = raw.into_iter().collect();
13365        // c:Src/subst.c paramsubst — when `${var:?msg}` or
13366        // `${var?msg}` set errflag, the expansion may produce empty
13367        // argv[0] which would fall into the EACCES/permission-denied
13368        // path below, masking the real paramsubst diagnostic with a
13369        // spurious "permission denied:" line and rc=126. Honour
13370        // errflag so the simple command ends with the paramsubst
13371        // error as the sole diagnostic, rc=1. Bug #86.
13372        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
13373            & crate::ported::zsh_h::ERRFLAG_ERROR)
13374            != 0
13375        {
13376            if PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.get()) {
13377                settle_prefix_assign_exit(args.first().map(String::as_str).unwrap_or(""));
13378            }
13379            // c:Src/exec.c:4343-4350 — when the error came from the prefix
13380            // assignments and the expanded name is an external command, they
13381            // ran in the forked child (`addvars(…); if (errflag) _exit(1);`):
13382            // the shell sees status 1 and its errflag is untouched.
13383            if PREFIX_ASSIGN_FAILED.with(|c| c.replace(false)) {
13384                if let Some(name) = args.first() {
13385                    let is_external = !with_executor(|exec| exec.function_exists(name))
13386                        && !crate::ported::builtin::createbuiltintable().contains_key(name.as_str());
13387                    if is_external {
13388                        // Both bits: `${name?msg}` also sets ERRFLAG_HARD.
13389                        crate::ported::utils::errflag.fetch_and(
13390                            !(crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
13391                            std::sync::atomic::Ordering::Relaxed,
13392                        );
13393                        with_executor(|exec| exec.set_last_status(1));
13394                    }
13395                }
13396            }
13397            // c:Src/exec.c:3719 + 3755-3763 — the argument glob of an
13398            // external runs in the forked child as well (`x=cat; $x
13399            // *nomatch; print after` prints `after`).
13400            if let Some(name) = args.first() {
13401                let is_external = !with_executor(|exec| exec.function_exists(name))
13402                    && (files_builtin_runs_from_path(name)
13403                        || !crate::ported::builtin::createbuiltintable().contains_key(name.as_str()));
13404                if is_external {
13405                    if let Some(status) = external_glob_failure_status() {
13406                        return Value::Status(status);
13407                    }
13408                }
13409            }
13410            return Value::Status(1);
13411        }
13412        if args.is_empty() {
13413            // c:Src/exec.c:3442 — a command whose words expand to ZERO
13414            // words is a NULL command: `cmdoutval = use_cmdoutval ?
13415            // lastval : 0`. `use_cmdoutval` is set (below, in
13416            // BUILTIN_CMD_SUBST_TEXT) only when a command substitution
13417            // ran during this command's word expansion, so:
13418            //   `false; $(exit 5)`  → keep the subst status (5)
13419            //   `false; $nonexistent` → reset to 0 (null command).
13420            // The previous port unconditionally kept `$?`, so
13421            // `false; $unset` wrongly stayed 1 (A01grammar.ztst:5).
13422            let keep =
13423                crate::ported::exec::use_cmdoutval.load(std::sync::atomic::Ordering::Relaxed) != 0;
13424            let status = if keep { vm.last_status } else { 0 };
13425            crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
13426            return Value::Status(status);
13427        }
13428        if args[0].is_empty() {
13429            // Explicit empty command word — exec returns EACCES.
13430            let script_name =
13431                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
13432            let lineno: u64 = with_executor(|exec| {
13433                exec.scalar("LINENO")
13434                    .and_then(|s| s.parse::<u64>().ok())
13435                    .unwrap_or(1)
13436            });
13437            eprintln!("{}:{}: permission denied: ", script_name, lineno);
13438            return Value::Status(126);
13439        }
13440        // AOP intercepts (zshrs extension, no C counterpart) — same
13441        // gate as host_exec_external (the static-head path): dynamic
13442        // command names (`cmd=/bin/echo; $cmd payload`) must consult
13443        // registered intercepts before dispatch, else `intercept
13444        // before /bin/echo ...` fires for the literal spelling but
13445        // not the variable one. run_intercepts runs before-advice
13446        // in-place and returns None to continue; Some(status) means
13447        // an around/after advice fully handled the command.
13448        let intercepted = with_executor(|exec| {
13449            if exec.intercepts.is_empty() {
13450                return None;
13451            }
13452            let full_cmd = if args.len() == 1 {
13453                args[0].clone()
13454            } else {
13455                args.join(" ")
13456            };
13457            let rest: Vec<String> = args[1..].to_vec();
13458            exec.run_intercepts(&args[0], &full_cmd, &rest)
13459        });
13460        if let Some(result) = intercepted {
13461            return Value::Status(result.unwrap_or(127));
13462        }
13463        // zshrs-original builtins (async, doctor, peach, …, and the daemon
13464        // z* family) reached via a run-time-resolved head (`$var`): they
13465        // are absent from the static BUILTINS port table / builtintab, so
13466        // execcmd_exec below would treat the head as external and report
13467        // "command not found" — even though `whence` calls it a builtin and
13468        // a literal head runs it via CallBuiltin. Dispatch by name here, but
13469        // ONLY when the head is neither
13470        // a user function nor a ported builtin, so the shell's
13471        // function -> builtin -> external order is preserved.
13472        if let Some(head) = args.first() {
13473            let is_fn = with_executor(|e| e.function_exists(head));
13474            let is_ported =
13475                crate::ported::builtin::createbuiltintable().contains_key(head.as_str());
13476            if !is_fn && !is_ported {
13477                let status = run_daemon_builtin(head, &args[1..])
13478                    .or_else(|| try_run_registered_builtin(head, &args[1..]));
13479                if let Some(status) = status {
13480                    crate::ported::builtin::LASTVAL
13481                        .store(status, std::sync::atomic::Ordering::Relaxed);
13482                    return Value::Status(status);
13483                }
13484            }
13485        }
13486        // c:Src/exec.c:2900 execcmd_exec — canonical simple-command
13487        // dispatcher. Runs precmd-modifier walk (c:3013-3091), then
13488        // dispatches to execbuiltin (c:4233) / runshfunc (c:3431+) /
13489        // execute (c:4314) per the resolved head. zshrs's bytecode VM
13490        // expanded the args before reaching here; we feed them in via
13491        // eparams.args and let execcmd_exec do the rest exactly as C
13492        // does for static heads. Without this, `c=builtin; $c source X`
13493        // skipped the precmd walk and emitted "command not found:
13494        // builtin".
13495        let mut state = crate::ported::zsh_h::estate {
13496            prog: Box::<crate::ported::zsh_h::eprog>::default(),
13497            pc: 0,
13498            strs: None,
13499            strs_offset: 0,
13500        };
13501        let mut eparams = crate::ported::zsh_h::execcmd_params {
13502            args: Some(args),
13503            redir: None,
13504            beg: 0,
13505            varspc: None,
13506            assignspc: None,
13507            typ: crate::ported::zsh_h::WC_SIMPLE as i32,
13508            postassigns: 0,
13509            htok: 0,
13510        };
13511        // input/output=0 → no pipe redirection (use shell stdio
13512        // directly); `output != 0` at c:2988 forks immediately. last1=2
13513        // (c:Src/exec.c:2014 `last1 ? 1 : 2`): terminal pipe stage but
13514        // the shell IS needed afterward — the VM keeps executing
13515        // bytecode after this op. last1=1 would arm the fake-exec
13516        // optimization (c:3646-3651, gate at c:3662 `last1 != 1`),
13517        // making `execute()` execve THIS process for external heads:
13518        // `p=/bin/echo; $p hi; echo after` replaced the shell and
13519        // `after` never ran (D04parameter chunk 11 shell-killer).
13520        // c:Src/exec.c:1690-1700 — execpline's job frame: save thisjob
13521        // (`pj = thisjob`) and allocate the jobtab slot that
13522        // execcmd_fork's addproc (c:2853) hangs the child pid off.
13523        // Without a live thisjob, the fork at c:3662 (last1 != 1 →
13524        // external must fork) registers no proc, nothing waits, and
13525        // the child races the rest of the script.
13526        let pj = {
13527            use crate::ported::jobs;
13528            *jobs::THISJOB
13529                .get_or_init(|| std::sync::Mutex::new(-1))
13530                .lock()
13531                .unwrap_or_else(|e| e.into_inner())
13532        };
13533        // The pipeline's job frame (BUILTIN_EXECPLINE_CHILD_BLOCK) already
13534        // holds this command's slot; only a chunk compiled without one
13535        // allocates here.
13536        let held = crate::exec_jobs::execpline_slot_current();
13537        let newjob = held.unwrap_or_else(|| {
13538            use crate::ported::jobs;
13539            let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
13540            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
13541            jobs::initjob(&mut tab) // c:1700 `thisjob = newjob = initjob()`
13542        });
13543        {
13544            use crate::ported::jobs;
13545            *jobs::THISJOB
13546                .get_or_init(|| std::sync::Mutex::new(-1))
13547                .lock()
13548                .unwrap_or_else(|e| e.into_inner()) = newjob as i32;
13549        }
13550        crate::ported::exec::REDIRS_PREAPPLIED.with(|c| c.set(had_redirs));
13551        crate::ported::exec::execcmd_exec(
13552            &mut state,
13553            &mut eparams,
13554            0,                                   // input  (c:2989)
13555            0,                                   // output (c:2988)
13556            crate::ported::zsh_h::Z_SYNC as i32, // how
13557            2,                                   // last1=2 — shell continues (c:2014)
13558            -1,                                  // close_if_forked
13559        );
13560        crate::ported::exec::REDIRS_PREAPPLIED.with(|c| c.set(false));
13561        // c:Src/exec.c:1828-1835 — execpline's Z_SYNC tail: waitjobs()
13562        // reaps the forked external. c:Src/jobs.c:487-495 + 551-552 —
13563        // the job's LAST proc sets lastval (0200|sig when signalled,
13564        // else WEXITSTATUS). Builtin/shfunc heads never forked (job
13565        // has no procs) — LASTVAL was already set by execbuiltin /
13566        // doshfunc inside execcmd_exec; skip the wait.
13567        {
13568            use crate::ported::jobs;
13569            let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
13570            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
13571            if jobs::hasprocs(&tab, newjob) {
13572                jobs::waitjobs(&mut tab, newjob); // c:1835
13573                if let Some(p) = tab[newjob].procs.last() {
13574                    let val = if p.is_signaled() {
13575                        0o200 | p.term_sig() // c:Src/jobs.c:489-490
13576                    } else {
13577                        p.exit_status() // c:Src/jobs.c:494
13578                    };
13579                    crate::ported::builtin::LASTVAL
13580                        .store(val, std::sync::atomic::Ordering::Relaxed);
13581                }
13582            }
13583            // c:1977-1979 — `deletejob(jn, 0)` once done; c:1981
13584            // `thisjob = pj` restores the caller's job. A held slot is the
13585            // job frame's to delete when the pipeline closes.
13586            if held.is_none() && newjob < tab.len() {
13587                jobs::deletejob(&mut tab[newjob], false);
13588            }
13589            *jobs::THISJOB
13590                .get_or_init(|| std::sync::Mutex::new(-1))
13591                .lock()
13592                .unwrap_or_else(|e| e.into_inner()) = pj;
13593        }
13594        let status = crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
13595        let mut synth = crate::ported::zsh_h::job::default();
13596        crate::ported::jobs::waitonejob(&mut synth);
13597        Value::Status(status)
13598    });
13599    // c:Src/exec.c:3386-3419 — `< file` / `> file` with no command
13600    // word. Resolves NULLCMD/READNULLCMD at runtime, then dispatches the
13601    // resulting word the way execcmd's fall-through does (shell function →
13602    // builtin → external). Redirects are already applied by the surrounding
13603    // WithRedirectsBegin scope.
13604    vm.register_builtin(BUILTIN_NULLCMD_CHECK, |vm, _argc| {
13605        use std::sync::atomic::Ordering;
13606        let nullcmd = crate::ported::params::getsparam("NULLCMD");
13607        // c:3395 — `!nullcmd || !*nullcmd || opts[CSHNULLCMD]`
13608        if nullcmd.as_deref().map_or(true, str::is_empty)
13609            || crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLCMD)
13610        {
13611            crate::ported::utils::zerr("redirection with no command"); // c:3397
13612            crate::ported::builtin::LASTVAL.store(1, Ordering::Relaxed); // c:3398
13613            vm.last_status = 1;
13614            crate::ported::utils::errflag
13615                .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed); // c:3399
13616            return Value::Bool(false);
13617        }
13618        Value::Bool(true)
13619    });
13620    vm.register_builtin(BUILTIN_NULLCMD_EXEC, |vm, argc| {
13621        let args = pop_args(vm, argc);
13622        let is_single_read = args
13623            .first()
13624            .map(|s| s != "0" && !s.is_empty())
13625            .unwrap_or(false);
13626        // c:Src/exec.c — when the surrounding redir-open failed
13627        // (e.g. `< /nonexistent`), zerr already printed the diag
13628        // and set redirect_failed. Don't invoke NULLCMD — return
13629        // status 1 like the wordcode path does.
13630        let redir_failed = with_executor(|exec| {
13631            let f = exec.redirect_failed;
13632            exec.redirect_failed = false;
13633            f
13634        });
13635        if redir_failed {
13636            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
13637            return Value::Status(1);
13638        }
13639        // c:3395-3399 — the no-NULLCMD / CSH_NULLCMD refusal already ran in
13640        // BUILTIN_NULLCMD_CHECK, before the redirections opened.
13641        let nullcmd = crate::ported::params::getsparam("NULLCMD");
13642        let nc_str = nullcmd.as_deref().unwrap_or("");
13643        // c:3350 — SHNULLCMD → run `:`.
13644        let cmd: String = if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHNULLCMD) {
13645            ":".to_string()
13646        } else if is_single_read {
13647            // c:3354-3359 — single REDIR_READ + READNULLCMD set → readnullcmd.
13648            let rnc = crate::ported::params::getsparam("READNULLCMD");
13649            let rnc_str = rnc.as_deref().unwrap_or("");
13650            if !rnc_str.is_empty() {
13651                rnc_str.to_string()
13652            } else {
13653                nc_str.to_string() // c:3360-3363 fallback
13654            }
13655        } else {
13656            nc_str.to_string() // c:3360-3363
13657        };
13658        // c:Src/exec.c:3408/3414/3418 — C does not "run NULLCMD" as a special
13659        // case: it APPENDS the word to the command's arg list
13660        // (`addlinknode(args, dupstring(nullcmd))`) and falls through to
13661        // execcmd's ORDINARY dispatch, which resolves that word in this order:
13662        //   c:3484-3487 `shfunctab->getnode(shfunctab, cmdarg)` → shell function
13663        //   c:3489      `builtintab->getnode(builtintab, cmdarg)` → builtin
13664        //   otherwise   → external command (PATH lookup).
13665        // `host_exec_external` already implements the shell-function arm and
13666        // the external arm (plus the AOP intercepts and the module-builtin
13667        // name arms), so only the builtintab arm has to be decided here.
13668        //
13669        // The builtintab question must be asked of the TABLE.
13670        // `builtin_in_builtintab` alone is NOT a membership test — it is the
13671        // module *gate*, and `builtin_owning_module` returns None for any name
13672        // it does not know, whose `None => true` arm
13673        // (src/extensions/ext_builtins.rs:179-182) then reports EVERY string as
13674        // an available builtin. With that as the only predicate, the default
13675        // `READNULLCMD=more` (config.h DEFAULT_READNULLCMD), a user's
13676        // `READNULLCMD=less`, `NULLCMD=/bin/cat` and every other external name
13677        // were classified as core builtins and handed to
13678        // `dispatch_builtin_raw("more", vec![])`, which cannot work: plain
13679        // `< file` printed NOTHING and returned 1, and a missing NULLCMD
13680        // returned a silent 1 instead of `command not found` / 127.
13681        //
13682        // Membership first, gate second. The zshrs-original coreutils-shaped
13683        // builtins (`cat`, `basename`, … EXT_BUILTIN_NAMES) are not entries of
13684        // `createbuiltintable()` at all, so the documented `NULLCMD=cat` /
13685        // `READNULLCMD=cat` idioms keep reaching the real `/bin/cat` the way
13686        // `zsh -f` does without needing an explicit exclusion.
13687        //
13688        // `dispatch_builtin` (not `dispatch_builtin_raw`) is the correct entry:
13689        // C's `builtintab->getnode` filters DISABLED nodes, so `disable :;
13690        // NULLCMD=:; > f` must fall through to PATH — the raw dispatcher
13691        // deliberately bypasses that set (it is what `builtin NAME` uses).
13692        let is_shfunc = with_executor(|exec| exec.function_exists(&cmd)); // c:3485
13693        let is_builtin = crate::ported::builtin::createbuiltintable().contains_key(&cmd)
13694            && crate::extensions::ext_builtins::builtin_in_builtintab(&cmd); // c:3489
13695        let status = if !is_shfunc && is_builtin {
13696            dispatch_builtin(&cmd, Vec::new()) // c:3489-3504
13697        } else {
13698            with_executor(|exec| exec.host_exec_external(&[cmd]))
13699        };
13700        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
13701        Value::Status(status)
13702    });
13703    // c:Src/exec.c:3342 — `zerr("redirection with no command")`.
13704    // Bare prefix-keyword (`builtin`, `command`, `exec`, `noglob`,
13705    // `nocorrect`) with a redirect but no command word. Emits the
13706    // canonical diagnostic via zerr (which sets errflag) and
13707    // returns Status(1). Bug #534.
13708    // c:Src/exec.c:3104-3290 then c:3380-3402 — the precommand walk for a
13709    // `builtin`/`command`/`exec` head that carries redirections, run on the
13710    // expanded words BEFORE the redirect scope opens (c:3720), as C orders
13711    // it. Operands: [prefix, words…]. Returns the words without the prefix
13712    // as one array for the prefix opcode; BUILTIN_PRECMD_CHECK_OK then says
13713    // whether the walk let the command run. A failed walk has already
13714    // printed its diagnostic to the still-unredirected stderr.
13715    vm.register_builtin(BUILTIN_PRECMD_CHECK, |vm, argc| {
13716        let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
13717        for _ in 0..argc {
13718            popped.push(vm.pop());
13719        }
13720        popped.reverse();
13721        let mut full: Vec<String> = Vec::with_capacity(popped.len());
13722        for v in popped {
13723            match v {
13724                Value::Array(items) => full.extend(items.iter().map(|i| i.to_str())),
13725                other => full.push(other.to_str()),
13726            }
13727        }
13728        let words: Vec<Value> = full.iter().skip(1).map(|s| Value::str(s.as_str())).collect();
13729        let ok = precmd_walk_ok(&full);
13730        if !ok {
13731            // c:3205 / c:3398 — `lastval = 1;` before `goto done` / return.
13732            vm.last_status = 1;
13733            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
13734        }
13735        PRECMD_CHECK_PASSED.with(|c| c.set(ok));
13736        Value::array(words)
13737    });
13738    vm.register_builtin(BUILTIN_PRECMD_CHECK_OK, |_vm, _argc| {
13739        Value::Bool(PRECMD_CHECK_PASSED.with(|c| c.replace(true)))
13740    });
13741    vm.register_builtin(BUILTIN_REDIR_NO_CMD, |_vm, _argc| {
13742        crate::ported::utils::zerr("redirection with no command");
13743        Value::Status(1)
13744    });
13745    vm.register_builtin(BUILTIN_DEBUG_TRAP, |vm, _argc| {
13746        // c:Src/signals.c:1245 dotrap(SIGDEBUG) — fires the DEBUG
13747        // trap body once per statement. The body sees the parent
13748        // shell's $? (LASTVAL). Guard against re-entry: commands
13749        // inside the DEBUG trap body would otherwise trigger
13750        // DEBUG_TRAP recursively → stack overflow. zsh guards via
13751        // its in_trap counter; we mirror with a thread-local Cell.
13752        //
13753        // c:Src/exec.c::trapcmd — before dotrap, the C source sets
13754        // `ZSH_DEBUG_CMD` to the about-to-run command text via
13755        // `dupstring(text)`. The trap body reads the parameter;
13756        // C unsets it after the trap returns. compile_list emits
13757        // the rendered statement text as the single arg here so the
13758        // shell-visible parameter reflects the command. Bug #263 in
13759        // docs/BUGS.md.
13760        // Return value: `Int(1)` tells the emit-side JumpIfTrue to SKIP the
13761        // statement this trap ran in front of — C's `donedebug == 2`
13762        // (c:Src/exec.c:1499/1511/1519-1529) plus the forced-return case
13763        // where C's list loop (`while (… && !retflag …)`, c:1443) never
13764        // reaches the command. `Int(0)` = run it.
13765        //
13766        // Two call sites, distinguished by the `mode` operand:
13767        //   0 — c:1476-1502, the DEBUG_BEFORE_CMD block that runs BEFORE the
13768        //       sublist (only when the option is set),
13769        //   1 — c:1628-1644, the `sublist_done:` block that runs AFTER it
13770        //       (only when the option is NOT set).
13771        // Firing the pre-sublist arm in both modes made the default
13772        // (post-command) DEBUG trap observe the NEXT statement's `$LINENO`
13773        // — A05execution:19 saw "Line 2 / Line 3" for a trap zsh reports as
13774        // "Line 1 / Line 2".
13775        let mode = vm.pop().to_int();
13776        let cmd_text = vm.pop().to_str();
13777        let before = mode == 0;
13778        DEBUG_TRAP_REENTRY.with(|c| {
13779            if c.get() {
13780                return Value::Int(0);
13781            }
13782            // c:1476 `isset(DEBUGBEFORECMD)` / c:1628 `!isset(DEBUGBEFORECMD)`.
13783            if before != isset(crate::ported::zsh_h::DEBUGBEFORECMD) {
13784                return Value::Int(0);
13785            }
13786            // c:Src/exec.c:1423 — `if (sigtrapped[SIGDEBUG] &&
13787            // isset(DEBUGBEFORECMD) && !intrap)`. Bug #573: without
13788            // this gate, every sublist boundary called
13789            // setsparam("ZSH_DEBUG_CMD", ...) even when no DEBUG trap
13790            // was set, polluting the param table and (under
13791            // WARN_CREATE_GLOBAL) emitting a spurious
13792            // `scalar parameter ZSH_DEBUG_CMD created globally`
13793            // warning at every function call.
13794            //
13795            // Two trap registries exist (per signals.rs:1481-1511 dotrap):
13796            //   - settrap path → sigtrapped[SIGDEBUG] bits set
13797            //   - bin_trap path → traps_table["DEBUG"] populated, sigtrapped untouched
13798            // Mirror the dotrap dispatch decision: skip only when BOTH
13799            // are absent.
13800            let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
13801            let debug_trapped = crate::ported::signals::sigtrapped
13802                .lock()
13803                .map(|v| v.get(sig_debug).copied().unwrap_or(0))
13804                .unwrap_or(0);
13805            let debug_in_table = crate::ported::builtin::traps_table()
13806                .lock()
13807                .map(|t| t.contains_key("DEBUG"))
13808                .unwrap_or(false);
13809            if debug_trapped == 0 && !debug_in_table {
13810                return Value::Int(0);
13811            }
13812            c.set(true);
13813            // c:1478-1481 — `int oerrexit_opt = opts[ERREXIT]; Param pm;
13814            // opts[ERREXIT] = 0; noerrexit |= NOERREXIT_EXIT |
13815            // NOERREXIT_RETURN;`. ERREXIT is forced OFF across the trap
13816            // body so the option can be used as the "skip this command"
13817            // signal (c:1499) without the body's own failing commands
13818            // exiting the shell.
13819            let oerrexit_opt = isset(crate::ported::zsh_h::ERREXIT); // c:1478
13820            crate::ported::options::opt_state_set("errexit", false); // c:1480
13821            let oldnoerrexit =
13822                crate::ported::exec::noerrexit.load(std::sync::atomic::Ordering::Relaxed);
13823            crate::ported::exec::noerrexit.store(
13824                oldnoerrexit
13825                    | crate::ported::zsh_h::NOERREXIT_EXIT
13826                    | crate::ported::zsh_h::NOERREXIT_RETURN,
13827                std::sync::atomic::Ordering::Relaxed,
13828            ); // c:1481
13829               // c:Src/exec.c:1484 — set ZSH_DEBUG_CMD scalar (PM_READONLY
13830               // is NOT set on ZSH_DEBUG_CMD, so the canonical
13831               // setsparam path is fine here — no direct paramtab
13832               // mutation needed).
13833               // c:1636 — the post-sublist arm has no ZSH_DEBUG_CMD assignment;
13834               // the parameter is a DEBUG_BEFORE_CMD feature only.
13835            if before {
13836                // c:1484-1485 — `getpermtext(state->prog, pc2, 0)`: the
13837                // PERMANENT text, with newlines and tab indents (tnewlins=1,
13838                // c:Src/text.c:279), so `while false; do :; done` reads
13839                // "while false\ndo\n\t:\ndone". The compiled operand is the
13840                // one-line job text; re-parse it and render it through the
13841                // ported getpermtext, as printshfuncnode does for a function
13842                // body (hashtable.rs). The text is already alias-expanded, so
13843                // the re-lex must not expand again, and RCQUOTES must not
13844                // fold a `''` pair the first lex kept as written.
13845                let noalias_save = crate::ported::lex::noaliases();
13846                let rcquotes_save = isset(crate::ported::zsh_h::RCQUOTES);
13847                // A text the wordcode parser rejects falls back to the job
13848                // text silently: C never parses it, so no diagnostic or
13849                // errflag may escape.
13850                let noerrs_save = *crate::ported::utils::noerrs_lock().lock().unwrap();
13851                let errflag_save =
13852                    crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
13853                crate::ported::utils::set_noerrs(1);
13854                crate::ported::lex::set_noaliases(true);
13855                crate::ported::options::opt_state_set("rcquotes", false);
13856                let permtext = crate::ported::exec::parse_string(&cmd_text, 0)
13857                    .map(|p| crate::ported::text::getpermtext(Box::new(p), None, 0));
13858                crate::ported::options::opt_state_set("rcquotes", rcquotes_save);
13859                crate::ported::lex::set_noaliases(noalias_save);
13860                crate::ported::utils::set_noerrs(noerrs_save);
13861                crate::ported::utils::errflag
13862                    .store(errflag_save, std::sync::atomic::Ordering::Relaxed);
13863                let text = permtext.filter(|t| !t.is_empty()).unwrap_or(cmd_text);
13864                crate::ported::params::setsparam("ZSH_DEBUG_CMD", &text);
13865            }
13866            // c:1488/1636 — `exiting = donetrap;` … c:1493/1641 `donetrap = exiting;`
13867            let exiting = crate::ported::exec::DONETRAP.load(std::sync::atomic::Ordering::Relaxed);
13868            let ret = crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed); // c:1489
13869            let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGDEBUG);
13870            // c:1491-1492 — `if (!retflag) lastval = ret;`. A trap that
13871            // ran `return N` keeps the forced status; anything else
13872            // leaves the pre-trap `$?` alone.
13873            let retflag =
13874                crate::ported::builtin::RETFLAG.load(std::sync::atomic::Ordering::Relaxed) != 0;
13875            if !retflag {
13876                crate::ported::builtin::LASTVAL.store(ret, std::sync::atomic::Ordering::Relaxed);
13877                // c:1492
13878            }
13879            crate::ported::exec::noerrexit
13880                .store(oldnoerrexit, std::sync::atomic::Ordering::Relaxed); // c:1494
13881                                                                            // c:1499 — `donedebug = isset(ERREXIT) ? 2 : 1;`. The trap
13882                                                                            // setting ERREXIT is zsh's documented "skip this command"
13883                                                                            // signal (zshmisc(1), DEBUG trap).
13884            let donedebug2 = before && isset(crate::ported::zsh_h::ERREXIT); // c:1499
13885            crate::ported::options::opt_state_set("errexit", oerrexit_opt); // c:1500/1643
13886            crate::ported::exec::DONETRAP.store(exiting, std::sync::atomic::Ordering::Relaxed); // c:1493/1641
13887                                                                                                // c:Src/exec.c:1501-1502 — `if (pm) unsetparam_pm(pm, 0, 1);`
13888            if before {
13889                crate::ported::params::unsetparam("ZSH_DEBUG_CMD");
13890            }
13891            c.set(false);
13892            if retflag {
13893                // c:1443 — the enclosing list loop stops on retflag, so the
13894                // command never runs. Mirror the forced status into the VM's
13895                // own counter (the ported LASTVAL atomic is a separate store)
13896                // and report "skip" so the emit-side jump lands past the
13897                // statement, where the RETFLAG escape unwinds the function.
13898                let forced =
13899                    crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
13900                vm.last_status = forced;
13901                with_executor(|exec| exec.set_last_status(forced));
13902                return Value::Int(1);
13903            }
13904            if donedebug2 {
13905                // c:1511 — `if (donedebug != 2) execsimple(state);` and
13906                // c:1519-1529 — the compound form skips the whole sublist and
13907                // sets `donetrap = 1`.
13908                crate::ported::exec::DONETRAP.store(1, std::sync::atomic::Ordering::Relaxed); // c:1527
13909                return Value::Int(1);
13910            }
13911            Value::Int(0)
13912        })
13913    });
13914
13915    // Fatal-only abort check emitted between the pipes of an `&&` / `||`
13916    // chain, where the full errexit check is suppressed. Mirrors ONLY the
13917    // errflag arm of BUILTIN_ERREXIT_CHECK below: an errflag abandons the
13918    // list in zsh, and no connector can consume it.
13919    vm.register_builtin(BUILTIN_FATAL_ABORT_CHECK, |vm, _argc| {
13920        use std::sync::atomic::Ordering;
13921        // c:Src/exec.c:1390 — `while (wc_code(code) == WC_LIST && !breaks &&
13922        // !retflag && !errflag)`: the enclosing list loops test the WHOLE
13923        // errflag. A user interrupt sets ERRFLAG_INT and never ERRFLAG_ERROR
13924        // (signals.c:457), so masking here let the rest of the list run after
13925        // an interrupt:
13926        //   TRAPINT() { print T; return 1 }
13927        //   f() { print A; kill -INT $$; print C }; f; print B
13928        //   zsh: A T      zshrs: A T B
13929        //
13930        // The gate is also NOT conditional on the shell being
13931        // non-interactive. C's list loop is the same loop in both cases; an
13932        // interactive shell survives the abort because `loop(toplevel=1)`
13933        // resets the flag before it reads the next command line
13934        // (c:Src/init.c:139/150 `errflag = 0;`, ported at
13935        // ported/init.rs:2384/2394) — not because the list kept running. The
13936        // `isset(INTERACTIVE)` exemption that used to sit here was the reason
13937        // an interrupt was recorded and then ignored:
13938        //   kill -INT $$; print survived
13939        //   zsh: (nothing, exit 130)      zshrs: survived, exit 0
13940        // and it is why installing C's SIGINT handler (`intr()`,
13941        // c:Src/init.c:1442) was unsafe before this — the handler sets
13942        // ERRFLAG_INT and expects THIS gate to end the list.
13943        let errflag_set = crate::ported::utils::errflag.load(Ordering::Relaxed) != 0;
13944        if !errflag_set {
13945            return Value::Int(0);
13946        }
13947        // CONTINUE_ON_ERROR does not reach here: C's list loop
13948        // (c:Src/exec.c:1443) breaks on errflag whatever the option says,
13949        // and only zsh_main's outer do-while re-enters loop() for the NEXT
13950        // top-level line (c:Src/init.c:1960-1968, ported in
13951        // vm_helper.rs execode's toplevel arm).
13952        // Abort the chain with the failing command's own status intact —
13953        // a cond syntax error left lastval=2 (c:Src/exec.c:5216-5221), and
13954        // that 2 is what zsh exits with. Reading the executor's live
13955        // lastval (not forcing 1) is the same rule the full check uses.
13956        //
13957        // An INTERRUPT is the one case where the executor's cell is not the
13958        // live lastval. C has a single `lastval` global, and the SIGINT
13959        // handler writes it directly (`lastval = 128 + SIGINT`,
13960        // c:Src/signals.c:463) — as does a forced-return INT trap
13961        // (`lastval = new_trap_return`, c:Src/signals.c:1202). zshrs splits
13962        // that global in two (see BUILTIN_SUBLIST_FINISH): the handler and
13963        // `dotrapargs` write `builtin::LASTVAL`, while the executor's cell
13964        // holds whatever the interrupted command itself returned — 0 for the
13965        // `kill` that raised the signal. Reading the executor here therefore
13966        // threw the interrupt status away:
13967        //   <shell> -f -i -c 'kill -INT $$; print survived'
13968        //   zsh exit 130      zshrs exit 0
13969        // and the same for an INT trap that returns non-zero (zsh exit 1).
13970        // Take the handler's write for an interrupt, the executor's for
13971        // everything else.
13972        vm.last_status = if (crate::ported::utils::errflag.load(Ordering::Relaxed)
13973            & crate::ported::zsh_h::ERRFLAG_INT)
13974            != 0
13975        {
13976            crate::ported::builtin::LASTVAL.load(Ordering::Relaxed)
13977        } else {
13978            with_executor(|exec| exec.last_status())
13979        };
13980        Value::Int(1)
13981    });
13982    vm.register_builtin(BUILTIN_PRINT_EXIT_VALUE, |vm, argc| {
13983        // c:Src/exec.c:5498-5505 — the ANONYMOUS-FUNCTION report in
13984        // execfuncdef:
13985        //     execshfunc(shf, args);
13986        //     ret = lastval;
13987        //     if (isset(PRINTEXITVALUE) && isset(SHINSTDIN) && lastval) {
13988        //         fprintf(stderr, "zsh: exit %lld\n", lastval);
13989        // It has NO `!subsh` term, unlike execcmd_exec's site at
13990        // c:4308-4309. `argc == 1` marks that call site (the compiler
13991        // pushes a 1 before it); `argc == 0` is the per-command site.
13992        let anon_site = argc >= 1 && vm.pop().to_int() != 0;
13993        // c:Src/exec.c:4308-4316 — `if (isset(PRINTEXITVALUE) &&
13994        // isset(SHINSTDIN) && lastval && !subsh) fprintf(stderr,
13995        // "zsh: exit %lld\n", lastval);`
13996        //
13997        // SHINSTDIN keeps this to a shell reading its program from stdin
13998        // (`zsh -f < script`, the interactive shell) — `-c` and script-file
13999        // runs never report. `subsh` keeps it out of forked pipeline stages
14000        // and `(...)` subshells, which is why zsh prints nothing for
14001        // `false | true` or `(exit 3)`. A function BODY is silent for a
14002        // different reason: c:Src/exec.c:6037 `opts[PRINTEXITVALUE] = 0`
14003        // in doshfunc (ported at exec.rs), restored at c:6158.
14004        let lastval = vm.last_status; // c:4309 lastval
14005        if crate::ported::zsh_h::isset(crate::ported::zsh_h::PRINTEXITVALUE) // c:4308
14006            && crate::ported::zsh_h::isset(crate::ported::zsh_h::SHINSTDIN)  // c:4308
14007            && lastval != 0                                                  // c:4309
14008            && (anon_site
14009                || crate::ported::exec::subsh.load(std::sync::atomic::Ordering::Relaxed) == 0)
14010        // c:4309 (`!subsh`; absent at the c:5498 anon-function site)
14011        {
14012            eprintln!("zsh: exit {lastval}"); // c:4311/4313
14013            let _ = std::io::Write::flush(&mut std::io::stderr()); // c:4315 fflush(stderr)
14014        }
14015        Value::Status(0)
14016    });
14017    vm.register_builtin(BUILTIN_ERREXIT_CHECK, |vm, _argc| {
14018        // Returns Value::Int(1) when the caller should jump to the
14019        // current scope's return-patch landing (subshell-end / func-
14020        // end / chunk-end). Returns Value::Int(0) otherwise. Emit
14021        // side at `emit_errexit_check` pairs this with a JumpIfTrue
14022        // → return_patches pattern so the caller can short-circuit.
14023        //
14024        // Four triggers:
14025        //   1. RETFLAG set by a nested `return` / `exit` (eval,
14026        //      sourced file, called function). Unwind THIS scope so
14027        //      the flag propagates outward until something clears it.
14028        //   2. EXIT_PENDING set (mostly subshell-context exits). Same
14029        //      propagation logic.
14030        //   3. `set -e` + nonzero status — the classic errexit path.
14031        //   4. errflag set in non-interactive mode — readonly
14032        //      reassign, bad redirect, parse error mid-expansion etc.
14033        //      Aborts the script (c:Src/init.c loop()).
14034        use std::sync::atomic::Ordering;
14035        let retflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
14036        // c:Src/exec.c:6198-6201 — "If we are in an exit trap, finish it
14037        // first... we wouldn't set exit_pending if we were already in one."
14038        // C's list loop (c:1443) never consults exit_pending at all;
14039        // EXIT_PENDING is zshrs's own deferred-exit channel, and leaving it
14040        // armed while the EXIT trap body runs made every command after the
14041        // trap's FIRST one get skipped:
14042        //   h(){ echo a; echo b }; trap h EXIT; f(){ exit }; f
14043        //   zsh: a b      zshrs: a
14044        // `in_exit_trap` is the same counter C tests at c:6201.
14045        let exit_pending = if crate::ported::signals::in_exit_trap.load(Ordering::Relaxed) != 0 {
14046            0
14047        } else {
14048            crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed)
14049        };
14050        // c:Src/exec.c:1571-1603 — `sublist_done:` runs the ZERR trap for
14051        // the sublist that just failed. It is NOT gated on retflag: C only
14052        // consults retflag at the TOP of the list loop (c:1370 `while
14053        // (wc_code(code) == WC_LIST && !breaks && !retflag && !errflag)`),
14054        // which stops the NEXT sublist — the current one still completes
14055        // its sublist_done. So `return 5` fires the ERR trap on its way out.
14056        //
14057        // zshrs's escape short-circuit below returns before ever reaching
14058        // the ZERR fire, so a `return N` inside a try-list skipped the trap:
14059        //   f() { { return 5 } always { print fin } }; f
14060        // printed `fin / err=5` where zsh prints `err=5 / fin / err=5`.
14061        // (Plain `f() { return 5 }` matched by luck — the inner fire was
14062        // missing but the OUTER sublist fired instead, since doshfunc had
14063        // cleared retflag by then and DONETRAP was still 0.)
14064        //
14065        // `exit` is deliberately excluded: C's `exit` goes zexit() →
14066        // realexit(), leaving the process without ever reaching
14067        // sublist_done. Verified: `zsh -fc 'trap "print err" ERR; f(){ exit
14068        // 5 }; f'` prints nothing.
14069        if retflag != 0 && exit_pending == 0 {
14070            let last = vm.last_status;
14071            // c:1651 — `!donetrap` as it stood when this sublist finished.
14072            let donetrap_at_entry = crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
14073            // c:1598-1603 — same DONETRAP gate as the non-escape path below.
14074            if last != 0 && !donetrap_at_entry && zerr_sigtrapped() {
14075                // c:Src/signals.c:1085-1087 — `int obreaks = breaks; int
14076                // oretflag = retflag; int olastval = lastval;` and c:1220-1222
14077                // — `breaks += obreaks; retflag = oretflag;`. dotrapargs
14078                // brackets EVERY trap dispatch with this save/restore because
14079                // the trap body runs as a normal list and would otherwise
14080                // consume the caller's control-flow flags. That matters
14081                // exactly here: we are firing ZERR while retflag is SET, and a
14082                // FUNCTION-form trap (`TRAPZERR() { … }`) goes through
14083                // doshfunc, whose epilogue eats retflag outright
14084                // (c:Src/exec.c:6047-6052 `if (retflag) { retflag = 0; breaks
14085                // = funcsave->breaks; }`). Without the bracket the pending
14086                // `return 5` was swallowed by its own ERR trap and the
14087                // function ran on:
14088                //   TRAPZERR() { print z }; f() { { return 2 } always { : }
14089                //                             print after }; f
14090                // printed `after`, where zsh returns from f.
14091                //
14092                // zshrs's `dotrap` inlines the dispatch and does not carry
14093                // dotrapargs' save/restore, so the bracket lives at this call
14094                // site. lastval is restored too (c:1087 / c:1213 `lastval =
14095                // olastval`) — the trap body's own commands must not become
14096                // the caller's `$?`.
14097                let obreaks = crate::ported::builtin::BREAKS.load(Ordering::Relaxed); // c:1085
14098                let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed); // c:1086
14099                let olastval = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1087
14100                let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1601
14101                crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed); // c:1602
14102                crate::ported::builtin::BREAKS.store(obreaks, Ordering::Relaxed); // c:1220
14103                crate::ported::builtin::RETFLAG.store(oretflag, Ordering::Relaxed); // c:1222
14104                crate::ported::builtin::LASTVAL.store(olastval, Ordering::Relaxed);
14105                // c:1213
14106            }
14107            // c:1660-1680 — the same sublist_done then applies ERR_EXIT /
14108            // ERR_RETURN to the `return N` that ended it: `setopt errexit;
14109            // trap 'echo e' ZERR; return 1` inside a function exits there.
14110            if last != 0 && !donetrap_at_entry {
14111                let _ = errexit_tail(last);
14112            }
14113        }
14114        if retflag != 0 || exit_pending != 0 {
14115            if exit_pending != 0 {
14116                // c:Src/builtin.c zexit — the deferred exit carries its
14117                // status in EXIT_VAL; sync it into the VM counter so
14118                // the top-level unwind reports it as the script's exit
14119                // (run_chunk returns vm.last_status). Without this, a
14120                // POSIX-fatal `.` failure exited 127 (bin_dot's return)
14121                // instead of C's exit(1) at Src/exec.c:4383.
14122                vm.last_status = crate::ported::builtin::EXIT_VAL.load(Ordering::Relaxed) & 0xFF;
14123            }
14124            return Value::Int(1);
14125        }
14126        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
14127            & crate::ported::zsh_h::ERRFLAG_ERROR)
14128            != 0;
14129        // c:Src/init.c:1960-1968 — CONTINUE_ON_ERROR is decided by
14130        // zsh_main's outer do-while, which re-enters loop() for the next
14131        // top-level LINE; the current list (and every enclosing list,
14132        // loop and function body) still breaks on errflag here. The
14133        // re-entry is ported in vm_helper.rs execode's toplevel arm.
14134        if errflag_set && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE) {
14135            // c:Src/exec.c execlist — every enclosing list loop runs
14136            // `while (... && !errflag)`, so a set errflag breaks the
14137            // CURRENT scope and the check in the enclosing scope
14138            // breaks THAT one, all the way out. Leave errflag SET —
14139            // do NOT convert it to EXIT_PENDING: a process-exit
14140            // signal tunnels through the containment boundaries C
14141            // has, namely eval (Src/builtin.c:6221 `errflag &=
14142            // ~ERRFLAG_ERROR`), source (Src/init.c:1663 same), fork
14143            // boundaries (subshell/cmdsubst — child's errflag dies
14144            // with the child), and the interactive toplevel
14145            // (Src/init.c:139). Those boundaries clear errflag
14146            // themselves and execution continues past them; with
14147            // EXIT_PENDING armed here, `eval 'assoc=(odd)'; echo
14148            // after` aborted the whole script where zsh 5.9 prints
14149            // `after` (eval status 1). Bug #74's function case
14150            // (`f() { local -r x=5; x=10; }; f; echo after`) still
14151            // aborts: the function scope unwinds on THIS check, and
14152            // the caller's next ERREXIT_CHECK sees the still-set
14153            // errflag and unwinds too — exactly C's propagation.
14154            //
14155            // c:Src/init.c:234 — loop() BREAKS on errflag and
14156            // zsh_main exits with the UNTOUCHED lastval, NOT a
14157            // forced 1: `typeset -i x=3#8` (math error during the
14158            // assignment, before typeset sets a status) exits 0 in
14159            // zsh; a cond syntax error set lastval=2 (exec.c:5216-
14160            // 5221) and zsh exits 2; the readonly-reassign case
14161            // exits 1 because ITS lastval is 1. Sync the VM counter
14162            // from the executor's live lastval instead of
14163            // overwriting.
14164            vm.last_status = with_executor(|exec| exec.last_status());
14165            // c:Src/exec.c:1598-1603 — `sublist_done:` runs the ZERR trap
14166            // for the failed sublist BEFORE the enclosing list loop breaks
14167            // on errflag (`while (... && !errflag)` at c:1370). So an
14168            // errflag-setting command (readonly reassign, bad redirect)
14169            // must fire ZERR on its way out, exactly like the retflag
14170            // escape above and the non-escape fall-through below. Without
14171            // this the errflag early-return pre-empted the ZERR block
14172            // further down, so `TRAPZERR() { … }; typeset -r ro=1; ro=2`
14173            // aborted the script (correct) but never fired the trap. Same
14174            // DONETRAP gate + dotrapargs save/restore bracket
14175            // (c:signals.c:1085-1087 / 1213-1222) as the retflag branch:
14176            // a function-form TRAPZERR runs through doshfunc and would
14177            // otherwise consume the caller's breaks/retflag/lastval.
14178            let last = with_executor(|exec| exec.last_status());
14179            if last != 0 && crate::ported::exec::DONETRAP.load(Ordering::Relaxed) == 0 && zerr_sigtrapped() {
14180                let obreaks = crate::ported::builtin::BREAKS.load(Ordering::Relaxed); // c:1085
14181                let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed); // c:1086
14182                let olastval = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1087
14183                                                                                        // c:Src/signals.c:1101 — dotrapargs returns early if errflag
14184                                                                                        // is set, and c:1174/1205-1218 brackets the dispatch with
14185                                                                                        // `traperr = errflag` … restore. The failing assignment left
14186                                                                                        // errflag SET, so the trap body (`print zerr`) would itself
14187                                                                                        // bail on the first op. Clear errflag across the dispatch so
14188                                                                                        // the body runs, then restore it so the script still aborts.
14189                let oerrflag = crate::ported::utils::errflag.load(Ordering::Relaxed); // c:1174
14190                crate::ported::utils::errflag.store(0, Ordering::Relaxed);
14191                let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1601
14192                crate::ported::utils::errflag.store(oerrflag, Ordering::Relaxed); // c:1216
14193                crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed); // c:1602
14194                crate::ported::builtin::BREAKS.store(obreaks, Ordering::Relaxed); // c:1220
14195                crate::ported::builtin::RETFLAG.store(oretflag, Ordering::Relaxed); // c:1222
14196                crate::ported::builtin::LASTVAL.store(olastval, Ordering::Relaxed);
14197                // c:1213
14198            }
14199            return Value::Int(1);
14200        }
14201        let last = vm.last_status;
14202        if last == 0 {
14203            return Value::Int(0);
14204        }
14205        // c:Src/exec.c:1598 `if (!this_noerrexit && !donetrap &&
14206        // !this_donetrap)` — gate the ZERR trap fire on DONETRAP so
14207        // an inner sublist (e.g. `false` inside a function) that
14208        // already fired ZERR doesn't fire it AGAIN at the outer
14209        // sublist's post-command check (after the function
14210        // returned non-zero). Bug #303 in docs/BUGS.md. DONETRAP
14211        // is reset at top-level statement boundaries via
14212        // BUILTIN_DONETRAP_RESET (compile_list emit at
14213        // compile_zsh.rs).
14214        let already_done = crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
14215        // c:Src/exec.c:1652-1653 —
14216        //     if (sigtrapped[SIGZERR] && lastval &&
14217        //         !(noerrexit & NOERREXIT_EXIT)) {
14218        // The ZERR half of the check is gated on the SAME runtime bit as
14219        // the errexit half below. Without this an `&&`/`||` operand — or
14220        // anything it calls — still fired ZERR, so
14221        //   TRAPZERR(){ print E }; f(){ print f; false; }; f && t
14222        // printed E where zsh is silent (C03traps:14, E01options:18).
14223        let zerr_suppressed = (crate::ported::exec::noerrexit.load(Ordering::Relaxed)
14224            & crate::ported::zsh_h::NOERREXIT_EXIT)
14225            != 0; // c:1653
14226        // c:1652 — `sigtrapped[SIGZERR] &&`: with no ZERR trap the block
14227        // never runs, so `donetrap` stays 0 and cannot spare a caller's
14228        // errexit / errreturn below.
14229        if !already_done && !zerr_suppressed && zerr_sigtrapped() {
14230            // c:Src/signals.c:1245 dotrap(SIGZERR) — canonical ZERR
14231            // trap dispatch. Fires whenever a command exits
14232            // non-zero.
14233            let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
14234            // c:Src/exec.c:1654-1657 — `int eflag = errflag; errflag = 0;
14235            // dotrap(SIGZERR); errflag = eflag;`: an error raised INSIDE
14236            // the trap body (a `set -u` failure) ends the trap, not the
14237            // list — `set -u; trap 'print t; : $u; print x' ZERR; false;
14238            // print after` prints `after`. Without the restore the
14239            // trap's errflag aborted the enclosing list.
14240            let eflag = crate::ported::utils::errflag.load(Ordering::Relaxed); // c:1654
14241            crate::ported::utils::errflag.store(0, Ordering::Relaxed); // c:1655
14242            let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1656
14243            // c:Src/subst.c:3355-3366 — the one error the restore must not
14244            // drop: `${v?}` in a NON-interactive shell never gets here in C,
14245            // it has already left through `zexit(1)`. The subst port defers
14246            // that exit through ERRFLAG_HARD, so carry the bit across.
14247            let hard = if isset(crate::ported::zsh_h::INTERACTIVE) {
14248                0
14249            } else {
14250                crate::ported::utils::errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_HARD
14251            };
14252            crate::ported::utils::errflag.store(eflag | hard, Ordering::Relaxed); // c:1657
14253            // c:1602 — `donetrap = 1;` after firing.
14254            crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed);
14255            // c:Src/signals.c:1201-1203 — a trap body that ran `return N`
14256            // comes back with `lastval = new_trap_return` and `retflag =
14257            // 1`; C's enclosing `while (wc_code(code) == WC_LIST &&
14258            // !breaks && !retflag && !errflag)` (c:Src/exec.c:1443) then
14259            // abandons the rest of the list and the containing function
14260            // returns that status. zshrs's list loop is the VM, which
14261            // reads `vm.last_status` / the executor's counter rather than
14262            // the ported LASTVAL atomic — so mirror the forced status
14263            // across and report "abort" to the caller. Without this
14264            //     fn(){ trap 'print t; return 42' ZERR; false; print B }
14265            // ran `print B` and returned 0.
14266            if oretflag == 0 && crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) != 0 {
14267                let forced = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1201
14268                vm.last_status = forced;
14269                with_executor(|exec| exec.set_last_status(forced));
14270                return Value::Int(1); // c:1443 — list loop stops on retflag
14271            }
14272        }
14273        // c:Src/exec.c:1651 — `if (!this_noerrexit && !donetrap &&
14274        // !this_donetrap)` gates the errexit / errreturn half too, not only
14275        // ZERR: a ZERR trap that already fired inside a function called by
14276        // this sublist also spares the caller the top-level ERR_RETURN-as-
14277        // ERR_EXIT (`setopt errreturn; trap 'echo z' ZERR; f() { false };
14278        // f; echo s` prints `z` then `s`).
14279        if already_done {
14280            return Value::Int(0);
14281        }
14282        errexit_tail(last)
14283    });
14284
14285    // BUILTIN_ASSIGN_ONLY_STATUS — status of an assignment-only
14286    // simple command. c:Src/exec.c:3393-3396 (execcmd_exec, no
14287    // command word + varspc): `if (errflag) lastval = 1; else
14288    // lastval = cmdoutval;`; same shape at c:1322 (execsimple
14289    // WC_ASSIGN: `lv = (errflag ? errflag : cmdoutval)`) and
14290    // c:3977 (nullexec=2 redir variant). cmdoutval is the exit of
14291    // a `$()` that ran in an RHS (already in vm.last_status via
14292    // compile_assign's per-assign SetStatus), 0 otherwise. The
14293    // store goes to the canonical LASTVAL too — that IS C's single
14294    // `lastval` global; without it the errflag-abort path
14295    // (BUILTIN_ERREXIT_CHECK trigger 4) syncs vm.last_status from
14296    // a stale LASTVAL and `readonly r=1; r=2` exited 0, not 1.
14297    vm.register_builtin(BUILTIN_ASSIGN_ONLY_STATUS, |vm, _argc| {
14298        use std::sync::atomic::Ordering;
14299        let had_cmd_subst = vm.pop().to_int() != 0;
14300        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
14301            & crate::ported::zsh_h::ERRFLAG_ERROR)
14302            != 0;
14303        // c:Src/exec.c addvars — `if (!pm) { lastval = 1; if
14304        // (!cmdoutval) cmdoutval = 1; }` (assignment-failed cheat).
14305        let assign_failed = ASSIGN_FAILED_FLAG.swap(false, std::sync::atomic::Ordering::Relaxed);
14306        let _ = had_cmd_subst;
14307        if assign_failed && crate::ported::exec::cmdoutval.load(Ordering::Relaxed) == 0 {
14308            // c:Src/exec.c:2662-2663 — `if (!cmdoutval) cmdoutval = 1;`
14309            crate::ported::exec::cmdoutval.store(1, Ordering::Relaxed);
14310        }
14311        let status = if errflag_set || assign_failed {
14312            1 // c:Src/exec.c:3394 `lastval = 1` / addvars cmdoutval=1
14313        } else {
14314            // c:3396 `lastval = cmdoutval` — reset to 0 when the command
14315            // started (c:2796 / c:1368, BUILTIN_CMDOUTVAL_RESET), then set by
14316            // any `$(...)` that ran in it, RHS or subscript alike.
14317            crate::ported::exec::cmdoutval.load(Ordering::Relaxed)
14318        };
14319        with_executor(|exec| exec.set_last_status(status));
14320        // c:Src/jobs.c deletefilelist — a `=(cmd)` temp file is bound to the
14321        // JOB of the command that created it and unlinked when that command
14322        // completes (Src/exec.c:5588 for the shfunc case; the simple-command
14323        // job's filelist likewise). An assignment-only command like
14324        // `f==(cmd)` has no consuming builtin/exec, so the PsubFdGuard that
14325        // cleans consuming commands never fires — the temp leaked and a later
14326        // `$(<$f)` / `[[ -f $f ]]` still saw it, where zsh deletes it at the
14327        // end of the assignment (verified: even `f==(x) && cat $f` fails).
14328        // Clean here so the assignment command is the temp's job boundary.
14329        close_pending_psub_fds();
14330        Value::Status(status)
14331    });
14332
14333    // `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
14334    // Pops [name, op_byte, rhs] (rhs popped first). Returns the modified
14335    // value as Value::Str. Handles unset/empty distinction (`:-` etc.
14336    // treat empty same as unset, matching POSIX).
14337    // BUILTIN_PARAM_DEFAULT_FAMILY — `${var-x}` / `${var:-x}` / `${var=x}` /
14338    // `${var:=x}` / `${var?x}` / `${var:?x}` / `${var+x}` / `${var:+x}`.
14339    // PURE PASSTHRU: pop name + op + rhs, reconstruct the canonical
14340    // brace expression, hand to `subst::paramsubst` (C port of
14341    // `Src/subst.c::paramsubst`). All "missing vs empty" gating,
14342    // nounset suppression, default-evaluation, and elide-empty-words
14343    // semantics live inside paramsubst.
14344    vm.register_builtin(BUILTIN_PARAM_DEFAULT_FAMILY, |vm, argc| {
14345        // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
14346        // compile_zsh::emit_param_modifier (see the note there).
14347        let pf_flags = if argc as usize > 3 && vm.pop().to_int() != 0 {
14348            crate::ported::zsh_h::PREFORK_SINGLE
14349        } else {
14350            0
14351        };
14352        let rhs = vm.pop().to_str();
14353        let op = vm.pop().to_int() as u8;
14354        let name = vm.pop().to_str();
14355        // c:Src/params.c:428-430 — `IPDEF9("*", &pparams, …)` and
14356        // `IPDEF9("argv", &pparams, …)` are ONE parameter, and the only shape
14357        // discriminator in the read path is c:Src/params.c:2251
14358        // `isvarat = (t[0] == '@' && !t[1])`, which the name `argv` misses just
14359        // as `*` does. So `${argv:-…}` IS `${*:-…}`. The rebuilt body below
14360        // reaches `paramsubst`, whose "is the parameter set" probe for this
14361        // operator family tests only the literal `@` / `*` names — `argv` fell
14362        // through to an ordinary lookup, came back UNSET, and
14363        // `set -- x y; ${argv:-nope}` took the default where zsh takes the
14364        // positionals. Normalising the spelling reuses every already-correct
14365        // `$*` arm, the same way the compiler's `bare_target` does for `$argv`
14366        // / `${argv}`. Every other modifier family (`#`, `%`, `/`, `:off:len`)
14367        // already resolves `argv` and is left alone.
14368        //
14369        // NOT the assigning ops. c:Src/subst.c:3690-3697 routes `${name:=…}` /
14370        // `${name=…}` through `setsparam`/`setaparam` on the NAME, and zsh
14371        // rejects the `*` spelling there ("not an identifier: *"), while
14372        // `${argv=…}` assigns the positional list. `${+name}` is the same
14373        // story from the other side — zsh answers "bad substitution" for
14374        // `${+*}` but 0/1 for `${+argv}`. Those three keep the literal name.
14375        let name = if name == "argv" && !matches!(op, 1 | 5 | 8) {
14376            "*".to_string()
14377        } else {
14378            name
14379        };
14380        // op=8 is the `${+name}` set-test prefix form (distinct from the
14381        // `${name+rhs}` substitute-if-set suffix form which is op=7).
14382        // Per compile_zsh.rs::parse_param_modifier: the `+` is emitted as
14383        // a leading sigil and `rhs` is empty.
14384        let body = if op == 8 {
14385            format!("${{+{}}}", name)
14386        } else {
14387            let op_str = match op {
14388                0 => ":-",
14389                1 => ":=",
14390                2 => ":?",
14391                3 => ":+",
14392                4 => "-",
14393                5 => "=",
14394                6 => "?",
14395                7 => "+",
14396                _ => "-",
14397            };
14398            format!("${{{}{}{}}}", name, op_str, rhs)
14399        };
14400        paramsubst_to_value_status(vm, &body, pf_flags)
14401    });
14402
14403    // `${var:offset[:length]}` — substring. Pops [name, offset, length].
14404    // length == -1 means "rest of string". Negative offset counts from end.
14405    // BUILTIN_PARAM_SUBSTRING — `${var:offset:length}` literal-int form.
14406    // PURE PASSTHRU: reconstruct `${name:offset:length}` and route
14407    // through `subst::paramsubst`. Length sentinel `i64::MIN` =
14408    // "no length given" (omit the `:length` portion).
14409    //
14410    // c:Src/subst.c:1571,3781 — `${name:-N}` is the colon-default
14411    // operator, NOT a substring with negative offset. zsh's lexical
14412    // rule disambiguates via a literal space: `${name: -N}` (space
14413    // before `-`) is the substring form. The reconstructed body MUST
14414    // preserve that space when offset < 0; otherwise paramsubst's
14415    // `:-` dispatch fires on the synthesized `${name:-N}` body and
14416    // returns N as the unset-default instead of slicing the last N
14417    // chars. Length-form `${name:-N:M}` has the same trap.
14418    vm.register_builtin(BUILTIN_PARAM_SUBSTRING, |vm, argc| {
14419        // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
14420        // compile_zsh::emit_param_modifier (see the note there).
14421        let pf_flags = if argc as usize > 3 && vm.pop().to_int() != 0 {
14422            crate::ported::zsh_h::PREFORK_SINGLE
14423        } else {
14424            0
14425        };
14426        let length = vm.pop().to_int();
14427        let offset = vm.pop().to_int();
14428        let name = vm.pop().to_str();
14429        // !!! DASH-STRICT GATE !!! dash/ash have no `${var:offset:length}`
14430        // substring expansion (it is a "Bad substitution"); bash/ksh/sh do.
14431        if crate::dash_mode::dash_strict() {
14432            crate::ported::utils::zerr("bad substitution");
14433            crate::ported::utils::errflag.fetch_or(
14434                crate::ported::zsh_h::ERRFLAG_ERROR,
14435                std::sync::atomic::Ordering::Relaxed,
14436            );
14437            with_executor(|exec| exec.set_last_status(1));
14438            return Value::str("");
14439        }
14440        let off_sep = if offset < 0 { " " } else { "" };
14441        let body = if length == i64::MIN {
14442            format!("${{{}:{}{}}}", name, off_sep, offset)
14443        } else {
14444            format!("${{{}:{}{}:{}}}", name, off_sep, offset, length)
14445        };
14446        paramsubst_to_value_status(vm, &body, pf_flags)
14447    });
14448
14449    // BUILTIN_PARAM_SUBSTRING_EXPR — `${var:offset_expr[:length_expr]}` form.
14450    // PURE PASSTHRU: rebuild `${name:offset:length}` using the
14451    // expression text verbatim (paramsubst's offset/length
14452    // parser evaluates arith / param refs itself).
14453    //
14454    // c:Src/subst.c:1571,3781 — same `:-` disambiguation trap as
14455    // BUILTIN_PARAM_SUBSTRING. The expression text may itself start
14456    // with `-` (e.g. `${VAR:$((-1))}` arith resolves at the body-
14457    // assembly layer in some upstream paths, leaving `-1` in
14458    // off_expr). Insert a leading space when off_expr starts with
14459    // `-` so paramsubst's check_colon_subscript (subst.c:1571)
14460    // accepts the operand as a math expression instead of the
14461    // `:-` operator catching it.
14462    vm.register_builtin(BUILTIN_PARAM_SUBSTRING_EXPR, |vm, argc| {
14463        // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
14464        // compile_zsh::emit_param_modifier (see the note there).
14465        let pf_flags = if argc as usize > 4 && vm.pop().to_int() != 0 {
14466            crate::ported::zsh_h::PREFORK_SINGLE
14467        } else {
14468            0
14469        };
14470        let has_len = vm.pop().to_int() != 0;
14471        let len_expr = vm.pop().to_str();
14472        let off_expr = vm.pop().to_str();
14473        let name = vm.pop().to_str();
14474        let off_sep = if off_expr.starts_with('-') { " " } else { "" };
14475        let body = if has_len {
14476            format!("${{{}:{}{}:{}}}", name, off_sep, off_expr, len_expr)
14477        } else {
14478            format!("${{{}:{}{}}}", name, off_sep, off_expr)
14479        };
14480        paramsubst_to_value_status(vm, &body, pf_flags)
14481    });
14482
14483    // `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}`
14484    // Pops [name, pattern, op_byte]. op: 0=`#` short-prefix, 1=`##` long,
14485    // 2=`%` short-suffix, 3=`%%` long. Glob-pattern matching via the
14486    // existing glob_match_static helper.
14487    // BUILTIN_PARAM_STRIP — `${var#pat}` / `${var##pat}` / `${var%pat}` /
14488    // `${var%%pat}`. PURE PASSTHRU: reconstruct the brace expression
14489    // and route through `subst::paramsubst`. (M)/(S) flags arrive
14490    // through SUB_FLAGS (already inside paramsubst's scope), so we
14491    // just clear the bridge-side cached read.
14492    vm.register_builtin(BUILTIN_PARAM_STRIP, |vm, argc| {
14493        // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
14494        // compile_zsh::emit_param_modifier (see the note there).
14495        let pf_flags = if argc as usize > 4 && vm.pop().to_int() != 0 {
14496            crate::ported::zsh_h::PREFORK_SINGLE
14497        } else {
14498            0
14499        };
14500        let _dq_flag = vm.pop().to_int() != 0;
14501        let op = vm.pop().to_int() as u8;
14502        let pattern = vm.pop().to_str();
14503        let name = vm.pop().to_str();
14504        let op_str = match op {
14505            0 => "#",
14506            1 => "##",
14507            2 => "%",
14508            3 => "%%",
14509            _ => "#",
14510        };
14511        let body = format!("${{{}{}{}}}", name, op_str, pattern);
14512        paramsubst_to_value_status(vm, &body, pf_flags)
14513    });
14514
14515    // `$((expr))` — pops [expr_string], evaluates via MathEval which
14516    // honors integer-vs-float distinction (zsh-compatible). Returns
14517    // the result as Value::Str so it can be Concat'd into surrounding
14518    // word context.
14519    vm.register_builtin(BUILTIN_ARITH_LASTBASE, |vm, _argc| {
14520        // See [`BUILTIN_ARITH_LASTBASE`]: `lastbase = -1` at the head of an
14521        // evaluation, and the literal's own base where the compiler lexed one.
14522        let base = vm.pop();
14523        let base = match base {
14524            Value::Int(n) => n as i32,
14525            other => other.to_str().parse::<i32>().unwrap_or(-1),
14526        };
14527        crate::ported::math::set_lastbase(base);
14528        Value::Int(i64::from(base))
14529    });
14530    vm.register_builtin(BUILTIN_ARITH_EVAL, |vm, _argc| {
14531        // Pure path: evaluate expr, return string. errflag may be
14532        // set by arithsubst on math error; the caller decides
14533        // whether to clear it. For `(( ... ))` (math command) the
14534        // compile_arith path clears via BUILTIN_ARITH_CMD_FINISH;
14535        // for `$((... ))` (substitution inside another command)
14536        // errflag stays set so the surrounding command aborts —
14537        // matches c:Src/math.c "math errors propagate as errflag
14538        // through the containing word expansion".
14539        let expr = vm.pop().to_str();
14540        let result = crate::ported::subst::arithsubst(&expr, "", "");
14541        let _ = vm; // silence unused warning when no math error path mutates
14542        Value::str(result)
14543    });
14544
14545    // After-call hook used by compile_arith's `(( ... ))` path: when
14546    // arithsubst set errflag (math error), clear it and signal
14547    // status=2 in vm.last_status — matches zsh's c:exec.c arith-
14548    // failure: the math command exits 2 and the script continues.
14549    vm.register_builtin(BUILTIN_ARITH_CMD_FINISH, |vm, _argc| {
14550        use std::sync::atomic::Ordering;
14551        let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
14552        let err = live & crate::ported::zsh_h::ERRFLAG_ERROR;
14553        let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
14554        if err != 0 {
14555            // c:Src/subst.c:3344 — when `${var:?msg}` fires, errflag
14556            // is OR'd with ERRFLAG_HARD to signal a script-abort
14557            // error (vs a recoverable math error like `$((1/0))`).
14558            // Clear only the ERRFLAG_ERROR bit; preserve
14559            // ERRFLAG_HARD so the next ERREXIT_CHECK aborts the
14560            // script. Bug #193 in docs/BUGS.md.
14561            if hard != 0 {
14562                // Keep ERRFLAG_HARD AND ERRFLAG_ERROR set so the
14563                // script-abort gate downstream still fires.
14564                vm.last_status = 2;
14565                Value::Status(2)
14566            } else {
14567                crate::ported::utils::errflag
14568                    .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
14569                vm.last_status = 2;
14570                Value::Status(2)
14571            }
14572        } else {
14573            Value::Status(vm.last_status)
14574        }
14575    });
14576
14577    // `$(cmd)` — pops [cmd_string], routes through
14578    // run_command_substitution which performs an in-process pipe-capture.
14579    // Avoids the Op::CmdSubst sub-chunk word-emit bug
14580    // (`printf "a\nb"` produced "anb" via that path). Returns trimmed
14581    // output (trailing newlines stripped per POSIX cmd-sub semantics).
14582    vm.register_builtin(BUILTIN_CMD_SUBST_TEXT, |vm, _argc| {
14583        let cmd = vm.pop().to_str();
14584        // c:Src/exec.c:4727-4729 (getoutput) — `if (!isset(EXECOPT)) return
14585        // newlinklist();`: under NO_EXEC the substitution yields nothing and
14586        // runs nothing, `$(<file)` included (that test is ahead of c:4731).
14587        if !isset(crate::ported::zsh_h::EXECOPT) {
14588            return Value::str(String::new());
14589        }
14590        // The enclosing word's `BUILTIN_WORD_DEFER_EMPTIES` declaration
14591        // covers that word's own segments, not the words of a command run
14592        // inside it (`PRE$(f)${a}POST` — `f`'s own words are finished words
14593        // and owe c:183-186 where they stand). Suspend it for the call.
14594        let saved_defer = crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get());
14595        crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(0));
14596        // Inherit live $? into the inner shell so cmd-subst sees the
14597        // parent's most recent exit. Same rationale as the mode-3
14598        // backtick path above.
14599        let live_status = vm.last_status;
14600        let result = with_executor(|exec| {
14601            exec.set_last_status(live_status);
14602            exec.run_command_substitution(&cmd)
14603        });
14604        // Mirror run_command_substitution's exec.last_status side
14605        // effect into the VM's live counter so a containing
14606        // assignment's BUILTIN_SET_VAR — which reads vm.last_status
14607        // — sees the cmd-subst's exit. Without this, `a=$(false);
14608        // echo $?` reads stale 0 (vm.last_status was zeroed by
14609        // compile_assign's prelude SetStatus, and run_cmd_subst only
14610        // updated exec.last_status). Pull the value back through
14611        // exec since it owns the canonical post-subst record.
14612        let cs_status = with_executor(|exec| exec.last_status());
14613        vm.last_status = cs_status;
14614        // c:Src/exec.c — a command substitution running during a
14615        // command's word expansion makes its exit the status of an
14616        // otherwise-empty command (`$(exit 5)` → 5). Flag it so
14617        // BUILTIN_EXEC_DYNAMIC's null-command branch keeps `$?` instead
14618        // of resetting to 0.
14619        crate::ported::exec::use_cmdoutval.store(1, std::sync::atomic::Ordering::Relaxed);
14620        // Hand the enclosing word's declaration back — its remaining
14621        // segments are still part of that word.
14622        crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(saved_defer));
14623        Value::str(result)
14624    });
14625
14626    // Text-based word expansion. Pops [preserved_text, mode_byte].
14627    // mode_byte:
14628    //   0 = Default — expand_string + xpandbraces + expand_glob
14629    //   1 = DoubleQuoted — strip outer `"…"`, expand_string only
14630    //         (no brace, no glob — DQ semantics)
14631    //   2 = SingleQuoted — strip outer `'…'`, no expansion
14632    //         (kept for symmetry; Snull early-return covers most SQ)
14633    //   3 = AltBackquote — strip backticks, run as cmd-sub
14634    //   7 = RedirTarget — same as Default but glob gated on MULTIOS
14635    //         (c:Src/glob.c:2161-2167 xpandredir)
14636    //   8 = unquoted assignment VALUE — same as 6 plus PREFORK_SINGLE
14637    //         (c:Src/exec.c:2603 / :4239-4241)
14638    //   9 = singsub word — mode 0 plus PREFORK_SINGLE and nothing else
14639    //         (c:Src/subst.c:514-520 `prefork(&foo, PREFORK_SINGLE, NULL)`:
14640    //          the `case` WORD c:Src/loop.c:611, every `[[ … ]]` operand
14641    //          c:Src/cond.c:53). No PREFORK_ASSIGN — filesub's colon-walk
14642    //          is assignment-only (c:Src/subst.c:689).
14643    // Single result → Value::str; multi → Value::Array.
14644    vm.register_builtin(BUILTIN_EXPAND_TEXT, |vm, _argc| {
14645        let mode = vm.pop().to_int() as u8;
14646        let text = vm.pop().to_str();
14647        // Sync vm.last_status → exec.last_status so cmd-subst (mode 3)
14648        // and any nested $? reads inside singsub see the live `$?`
14649        // from the most recent VM op. Without this, cmd-subst inside
14650        // arg-eval saw a stale exec.last_status that was zeroed at
14651        // the start of the current statement. Direct port of zsh's
14652        // pre-cmdsubst lastval propagation per Src/exec.c:4770.
14653        let live_status = vm.last_status;
14654        with_executor(|exec| exec.set_last_status(live_status));
14655        let result_value = with_executor(|exec| match mode {
14656            // Mode 1 = DoubleQuoted (argument context).
14657            // Mode 5 = DoubleQuoted in scalar-assignment context.
14658            // Both share the same DQ unescape pre-processing; mode 5
14659            // additionally bumps `in_scalar_assign` so subst_port's
14660            // paramsubst sees ssub=true and suppresses split flags
14661            // `(f)` / `(s:STR:)` / `(0)` per Src/subst.c:1759 +
14662            // Src/exec.c::addvars line 2546 (the PREFORK_SINGLE bit
14663            // C zsh sets when prefork-ing the assignment RHS).
14664            1 | 5 => {
14665                // DoubleQuoted: strip outer `"…"` if present. In DQ
14666                // context, `\` escapes the DQ-special chars `$`, `` ` ``,
14667                // `"`, `\`. zsh's expand_string expects the lexer's
14668                // `\0X` literal-marker for an already-escaped char, so
14669                // we pre-process: `\$` → `\0$`, `\\` → `\0\`, etc. Then
14670                // expand_string handles the rest.
14671                let inner = if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
14672                    &text[1..text.len() - 1]
14673                } else {
14674                    text.as_str()
14675                };
14676                // The lexer's dquote_parse (Src/lex.c) already tokenized
14677                // DQ contents: `$` → Qstring (\u{e18c}), `\$`/`\\`/`\"`/
14678                // `` \` `` → Bnull (\u{e19f}) + literal. Stringsubst /
14679                // multsub recognize these markers natively. We pass
14680                // `inner` through verbatim — no re-tokenization needed.
14681                let prepped: String = inner.to_string();
14682                // Tell parameter-flag application that we're inside
14683                // double quotes — array-only flags ((o), (O), (n),
14684                // (i), (M), (u)) must be no-ops here per zsh.
14685                exec.in_dq_context += 1;
14686                if mode == 5 {
14687                    exec.in_scalar_assign += 1;
14688                }
14689                // Mode 1 = argv DQ word; mode 5 = scalar-assign RHS.
14690                // In C zsh, the corresponding prefork-on-list paths
14691                // are: argv → `prefork(argv_list, 0)` returns multi-
14692                // word LinkList (Src/exec.c::execcmd), assignment →
14693                // `prefork(rhs_list, PREFORK_SINGLE|PREFORK_ASSIGN)`
14694                // returns single-word (Src/exec.c::addvars line
14695                // 2546). zshrs's `multsub` (Src/subst.c:544) is the
14696                // multi-result variant; `singsub` (Src/subst.c:514)
14697                // asserts ≤1 node. Mode 5 keeps singsub; mode 1
14698                // switches to multsub so `"${(@)arr}"`/`"$@"`/
14699                // `"${arr[@]}"` in argv context emit multiple words
14700                // as the C path would.
14701                // c:Src/lex.c untokenize — the final argv pass C runs
14702                // on every expanded word (glob.c:1862 / exec.c) drops
14703                // the Nularg empty-word sentinel remnulargs left in
14704                // place and folds any remaining token chars. Without
14705                // it, quoted splits with empty pieces
14706                // ("${(s:|:)x}" on "|a|b|") leak Nularg into argv.
14707                let result_value = if mode == 5 {
14708                    let out = crate::ported::subst::singsub(&prepped);
14709                    if crate::ported::subst::PREFORK_STOPPED.with(|c| c.get()) {
14710                        // Stopped by an `(e)` NULL: the node keeps its quote
14711                        // tokens (no remnulargs), rendered by C's untokenize
14712                        // (c:Src/exec.c:2134): `v="pre${(e)a}post"` → `"pre`.
14713                        Value::str(crate::ported::lex::untokenize_ztokens(&out))
14714                    } else {
14715                        Value::str(crate::ported::lex::untokenize(&out))
14716                    }
14717                } else {
14718                    // c:Src/subst.c:3892-3895 — `if (isarr) l->list.flags |=
14719                    // LF_ARRAY; else l->list.flags &= ~LF_ARRAY;`. Clear the
14720                    // carrier BEFORE the expansion so a word that runs no
14721                    // paramsubst at all reads `false` rather than some earlier
14722                    // expansion's stamp — the same reset the unquoted arm does
14723                    // before its own multsub.
14724                    crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.set(false));
14725                    let (_first, nodes, _ms_ws, _ret) = crate::ported::subst::multsub(&prepped, 0);
14726                    // Read immediately: anything below can re-enter paramsubst
14727                    // and overwrite the cell. This is C's `isarr` for the
14728                    // OUTERMOST expansion of this word — c:4245 `if (isarr)`.
14729                    let seg_is_array = crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.get());
14730                    // c:Src/subst.c:655 — multsub returns Vec::new()
14731                    // for zero-word results (quoted array splat that
14732                    // resolved to empty array). Surface as
14733                    // Value::Array(vec![]) so the downstream array
14734                    // assignment / argv flattening sees ZERO args.
14735                    // Previous Rust port returned Value::str("") which
14736                    // surfaced as ONE empty arg. Bug #120 in
14737                    // docs/BUGS.md.
14738                    if crate::ported::subst::PREFORK_STOPPED.with(|c| c.get()) {
14739                        // An `(e)` NULL stopped prefork: every node already in
14740                        // the list stays (c:Src/subst.c:4383-4394 inserted the
14741                        // elements before the failing one, the first joined to
14742                        // the text before the `$`), unexpanded, with its quote
14743                        // tokens rendered by untokenize (c:Src/exec.c:2134).
14744                        stopped_nodes_value(nodes)
14745                    } else if nodes.is_empty() {
14746                        // c:Src/subst.c:4245 `if (isarr)` gates the array emit
14747                        // block, and c:4362-4365 `if (plan9) { uremnode(l, n);
14748                        // return n; }` deletes the WHOLE word from inside it —
14749                        // so whether a zero-word expansion removes its word is
14750                        // decided by THIS expansion's own `isarr`, never by the
14751                        // previous one's. `Value::Array(vec![])` cannot carry
14752                        // the bit, so record it here exactly as the unquoted
14753                        // arm does (`note_empty_is_scalar(!(seg_zero_words &&
14754                        // seg_is_array))`). Without the write the cell still
14755                        // held an unrelated earlier expansion's value, and a
14756                        // preceding empty-SCALAR read (`local x=$a[2]`, which
14757                        // leaves it `true`) resurrected a quoted empty-array
14758                        // word as one empty word under RC_EXPAND_PARAM:
14759                        // `e=(); r=( q "${e[@]:/A/B}" )` counted 2 where zsh
14760                        // counts 1. See EMPTY_EXPANSION_IS_SCALAR.
14761                        note_empty_is_scalar(!seg_is_array);
14762                        Value::array(Vec::new())
14763                    } else if nodes.len() == 1 {
14764                        Value::str(crate::ported::lex::untokenize(
14765                            &nodes.into_iter().next().unwrap(),
14766                        ))
14767                    } else {
14768                        Value::array(
14769                            nodes
14770                                .into_iter()
14771                                .map(|n| Value::str(crate::ported::lex::untokenize(&n)))
14772                                .collect(),
14773                        )
14774                    }
14775                };
14776                if mode == 5 {
14777                    exec.in_scalar_assign -= 1;
14778                }
14779                exec.in_dq_context -= 1;
14780                // c:Src/subst.c:326-327 → prefork c:142-147 — subst.rs's prefork
14781                // stopped on an `(e)` NULL: the word is what preceded the `$`
14782                // (c:1878). The lexer's opening Dnull stays in that node and
14783                // untokenizes to a literal `"` when the word itself opened a
14784                // double quote; this handler stripped those outer quotes above,
14785                // so put it back. Mode 5 also carries UNQUOTED scalar-assignment
14786                // words (`v=${(e)a}`), which have no quote to restore.
14787                // PREFORK_CUT tells the words that follow to stay unexpanded.
14788                if crate::ported::subst::PREFORK_STOPPED.with(|c| c.replace(false)) {
14789                    PREFORK_CUT.with(|c| c.set(true));
14790                    // The stopped nodes are rendered WITH their quote tokens
14791                    // (stopped_nodes_value, or ztokens for mode 5), so a lexer
14792                    // Dnull already shows as `"`. Only an ASCII `"` that this
14793                    // handler stripped above still has to be put back.
14794                    let opened_dq = text.starts_with('"');
14795                    let quote = if opened_dq { "\"" } else { "" };
14796                    // The quote belongs to the FIRST node, the one holding the
14797                    // text before the `$`; later nodes are whole elements.
14798                    return match result_value {
14799                        Value::Array(items) if !items.is_empty() => {
14800                            let mut words: Vec<Value> = items.iter().cloned().collect();
14801                            words[0] = Value::str(format!("{}{}", quote, words[0].to_str()));
14802                            Value::array(words)
14803                        }
14804                        other => Value::str(format!("{}{}", quote, other.to_str())),
14805                    };
14806                }
14807                result_value
14808            }
14809            2 => {
14810                // SingleQuoted: pure literal, strip outer `'…'`.
14811                let inner = if text.len() >= 2 && text.starts_with('\'') && text.ends_with('\'') {
14812                    &text[1..text.len() - 1]
14813                } else {
14814                    text.as_str()
14815                };
14816                Value::str(inner.to_string())
14817            }
14818            3 => {
14819                // Backquote command sub: strip outer backticks.
14820                // Word-split the result on IFS when the surrounding
14821                // word is unquoted — zsh: `print -l \`echo a b c\``
14822                // emits one arg per word. The $(…) path applies the
14823                // same split via BUILTIN_WORD_SPLIT after capture; do
14824                // the equivalent here for the `…` form.
14825                let inner = if text.len() >= 2 && text.starts_with('`') && text.ends_with('`') {
14826                    &text[1..text.len() - 1]
14827                } else {
14828                    text.as_str()
14829                };
14830                // Apply the live VM status before running the inner
14831                // shell so the inherited $? matches zsh's lastval
14832                // propagation.
14833                exec.set_last_status(live_status);
14834                // c:Src/exec.c:4727-4729 — getoutput returns an empty list
14835                // under NO_EXEC.
14836                let captured = if isset(crate::ported::zsh_h::EXECOPT) {
14837                    exec.run_command_substitution(inner)
14838                } else {
14839                    String::new()
14840                };
14841                let trimmed = captured.trim_end_matches('\n');
14842                if exec.in_dq_context > 0 {
14843                    Value::str(trimmed.to_string())
14844                } else {
14845                    let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
14846                    let parts: Vec<Value> = trimmed
14847                        .split(|c: char| ifs.contains(c))
14848                        .filter(|s| !s.is_empty())
14849                        .map(|s| Value::str(s.to_string()))
14850                        .collect();
14851                    if parts.is_empty() {
14852                        Value::str(String::new())
14853                    } else if parts.len() == 1 {
14854                        parts.into_iter().next().unwrap()
14855                    } else {
14856                        Value::array(parts)
14857                    }
14858                }
14859            }
14860            4 => {
14861                // HeredocBody: expand variables / command-subst / arith
14862                // but NOT glob or brace. Heredoc lines like `[42]` must
14863                // pass through verbatim — running them through the
14864                // default pipeline triggers NOMATCH on the literal.
14865                Value::str(crate::ported::subst::singsub(&text))
14866            }
14867            _ => {
14868                // Default (unquoted): the lexer's gettokstr already
14869                // tokenized backslash-escapes (`\$` → Bnull+$, etc).
14870                // Pass `text` through verbatim — multsub/stringsubst
14871                // recognize the markers natively. No bridge-side
14872                // re-tokenization needed.
14873                //
14874                // Mode 6 = unquoted RHS in scalar-assign context.
14875                // Pass PREFORK_ASSIGN so prefork's filesub colon-walk
14876                // fires per c:Src/exec.c:2546.
14877                let prepped: String = text.clone();
14878                if std::env::var("ZSHRS_TRACE_DEFP").is_ok() {
14879                    eprintln!(
14880                        "[TRACE_DEFP] text={:?} prepped={:?} mode={}",
14881                        text, prepped, mode
14882                    );
14883                }
14884                // Mode 8 = the unquoted VALUE of a `NAME=VALUE` assignment
14885                // (bare statement or typeset-family argument). C preforks
14886                // exactly that with `PREFORK_SINGLE|PREFORK_ASSIGN`
14887                // (c:Src/exec.c:2603 and c:Src/exec.c:4239-4241); the
14888                // PREFORK_SINGLE half is paramsubst's `ssub`
14889                // (c:Src/subst.c:1761), which gates off the forced split at
14890                // c:Src/subst.c:3913.
14891                // Mode 9 = a word C expands with `singsub` (c:Src/subst.c:520
14892                // `prefork(&foo, PREFORK_SINGLE, NULL)`) — the `case` WORD
14893                // (c:Src/loop.c:611) and every `[[ … ]]` operand
14894                // (c:Src/cond.c:53). PREFORK_SINGLE alone: it is paramsubst's
14895                // `ssub` (c:Src/subst.c:1761), which takes the c:4226
14896                // `if (isarr && ssub)` IFS[0] join BEFORE the c:4256 array-emit
14897                // block, so the c:4264 unique fold and the c:4301-4326 sort do
14898                // not run; it also turns off `xpandbraces` (c:Src/subst.c:170)
14899                // and c:3913's `force_split`. NO PREFORK_ASSIGN — a `case`
14900                // word / cond operand gets no `filesub` colon-walk.
14901                // Mode 12 = the same `singsub`, for a `[[ … == pat ]]` RHS or a
14902                // `case` arm. It differs from mode 9 only in what it hands
14903                // BACK: the word keeps its tokens (see the untokenize below),
14904                // because a pattern operand's guard still has to tell a
14905                // metacharacter `shtokenize` armed from a raw byte of a value.
14906                let pf_flags = if mode == 8 {
14907                    crate::ported::zsh_h::PREFORK_SINGLE | crate::ported::zsh_h::PREFORK_ASSIGN
14908                } else if mode == 9 || mode == 12 {
14909                    crate::ported::zsh_h::PREFORK_SINGLE
14910                } else if mode == 6 {
14911                    crate::ported::zsh_h::PREFORK_ASSIGN
14912                } else if mode == 13 {
14913                    // Mode 13 = a command argument under MAGIC_EQUAL_SUBST +
14914                    // KSH_TYPESET: c:Src/exec.c:3353-3355 preforks it with
14915                    // `esprefork = PREFORK_TYPESET`, whose c:Src/subst.c:103
14916                    // `asssub` stops word splitting after the first `=`.
14917                    crate::ported::zsh_h::PREFORK_TYPESET
14918                } else {
14919                    0
14920                };
14921                // c:Src/subst.c:544+ — `multsub(&prepped, 0)` is the
14922                // unquoted-argv equivalent of zsh's `prefork(list,
14923                // 0, NULL)` for a single-element list. Returns the
14924                // post-expansion node list (Vec<String>) so array-
14925                // shape results (e.g. `${a:e}`, `${a[@]}`,
14926                // `${(s::)str}`) splat into multiple argv words.
14927                // singsub() collapses to one string and discards the
14928                // splat — parity bug #28 (whole-array modifier).
14929                // c:Src/subst.c:3929-3932 — `if (isarr) l->list.flags |=
14930                // LF_ARRAY; else l->list.flags &= ~LF_ARRAY;`. C's paramsubst
14931                // holds the LinkList and stamps its `isarr` on it directly;
14932                // the Rust port hands the same bit back through the
14933                // `PARAMSUBST_LF_ARRAY` thread-local (subst.rs:20511) — set at
14934                // subst.rs:17796 (`isarr != 0 && !forced_split_to_one`) and
14935                // reset to false at the top of EVERY paramsubst
14936                // (subst.rs:3891 / subst.rs:17445).
14937                //
14938                // Clear it BEFORE multsub so a segment that runs no paramsubst
14939                // at all reads false instead of some earlier expansion's
14940                // value. `multsub`'s own `isarr` return cannot be used for
14941                // this: an unquoted `$(cmd)` / `` `cmd` `` sets LF_ARRAY
14942                // unconditionally (subst.rs:897 / :1159, c:Src/subst.c:285-286
14943                // `if (!qt) list->list.flags |= LF_ARRAY;` and c:331), so
14944                // `x$(true)y` would look array-shaped when zsh keeps that
14945                // word (verified: it is the single word `xy`).
14946                //
14947                // Every paramsubst re-initialises the cell on entry, so
14948                // clearing it here cannot disturb subst.rs's own readers
14949                // (stringsubst reads it immediately after each paramsubst
14950                // call, subst.rs:1094).
14951                crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.set(false));
14952                // Mode 10 (a deferred command argument): c:Src/subst.c:165-191
14953                // runs filesub in prefork's second pass, after stringsubst has
14954                // run over EVERY word, so `print =nosuchcmd $(cmd)` runs `cmd`
14955                // before `=nosuchcmd` fails. This handler expands one word, so
14956                // the word's filesub is left to BUILTIN_GLOBLIST's all-words
14957                // pass (the word comes back with its Equals/Tilde token).
14958                let saved_skip_filesub = crate::ported::subst::SKIP_FILESUB.with(|c| c.get());
14959                if mode == 10 {
14960                    crate::ported::subst::SKIP_FILESUB.with(|c| c.set(true));
14961                }
14962                let (_first, nodes, _ms_ws, _ret) =
14963                    crate::ported::subst::multsub(&prepped, pf_flags);
14964                crate::ported::subst::SKIP_FILESUB.with(|c| c.set(saved_skip_filesub));
14965                // c:Src/subst.c:326-327 → prefork c:142-147 — an `(e)` NULL ended
14966                // prefork: the word is the text before the `$` (c:1878), and the
14967                // brace / filesub / remnulargs passes after the substitution loop
14968                // never ran. PREFORK_CUT keeps the later words unexpanded.
14969                if crate::ported::subst::PREFORK_STOPPED.with(|c| c.replace(false)) {
14970                    PREFORK_CUT.with(|c| c.set(true));
14971                    // remnulargs (c:170) never ran either, so C's nodes still
14972                    // hold their quote tokens and untokenize (c:Src/exec.c:2134)
14973                    // renders them through ztokens. Every node before the cut
14974                    // stays: `(@e)` inserted the earlier elements
14975                    // (c:Src/subst.c:4383-4394), so `A${(@e)arr}B` with
14976                    // arr=(p x '$(') is `Ap` and `x`.
14977                    return stopped_nodes_value(nodes);
14978                }
14979                // Read immediately: brace expansion / filesub / glob below can
14980                // re-enter paramsubst and overwrite the cell. `seg_is_array` is
14981                // the array-ness of the OUTERMOST paramsubst in this segment —
14982                // C's c:4245 `if (isarr)` for the same expansion. Paired with
14983                // `seg_zero_words` it separates the two empty shapes for the
14984                // empty-result arm further down (c:4362 vs c:4464).
14985                let seg_is_array = crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.get());
14986                let seg_zero_words = nodes.is_empty();
14987                if std::env::var("ZSHRS_TRACE_MULTSUB").is_ok() {
14988                    eprintln!("[TRACE_MULTSUB] prepped={:?} nodes={:?}", prepped, nodes);
14989                }
14990                // c:Src/subst.c:166 — xpandbraces runs AFTER prefork's
14991                // substitution pass and BEFORE untokenize/glob. Per
14992                // word, scan for Inbrace TOKEN and expand. Words that
14993                // don't contain Inbrace TOKEN pass through unchanged.
14994                // Brace expansion is done here (inside the bridge
14995                // default arm) instead of via a post-EXPAND_TEXT
14996                // BRACE_EXPAND emit because untokenize (line below)
14997                // strips TOKEN bytes, after which the strict-TOKEN
14998                // xpandbraces gate would no longer match.
14999                let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
15000                // c:Src/options.c — `no_brace_expand` (negated
15001                // `braceexpand`) gates brace expansion entirely.
15002                // When off, `{a,b}` stays literal.
15003                // c:Src/subst.c:170 — `if (unset(IGNOREBRACES) && !(flags &
15004                // PREFORK_SINGLE))` guards the `xpandbraces` loop, so a word
15005                // preforked as a scalar (assignment VALUE, mode 8) is NEVER
15006                // brace-expanded: `local x={a,b}` stores the five literal
15007                // characters. This pass stands in for prefork's loop, so it
15008                // owes the same guard.
15009                let brace_expand = opt_state_get("braceexpand").unwrap_or(true)
15010                    && (pf_flags & crate::ported::zsh_h::PREFORK_SINGLE) == 0; // c:170
15011                let pre_brace: Vec<String> = if nodes.is_empty() {
15012                    vec![String::new()]
15013                } else {
15014                    nodes
15015                };
15016                let brace_expanded: Vec<String> = pre_brace
15017                    .into_iter()
15018                    .flat_map(|w| {
15019                        if brace_expand && w.contains('\u{e18f}') {
15020                            crate::ported::glob::xpandbraces(&w, brace_ccl)
15021                        } else {
15022                            vec![w]
15023                        }
15024                    })
15025                    .collect();
15026                // zsh stores the option as `glob` (default ON);
15027                // `setopt noglob` writes `glob=false`. Honor either
15028                // form so the dispatcher behaves the same as zsh.
15029                // Mode 7 = redirect-target word: glob only under
15030                // MULTIOS (c:Src/glob.c:2161-2167 xpandredir,
15031                // "Globbing is only done for multios.").
15032                let noglob = opt_state_get("noglob").unwrap_or(false)
15033                    || opt_state_get("GLOB").map(|v| !v).unwrap_or(false)
15034                    || !opt_state_get("glob").unwrap_or(true)
15035                    || (mode == 7 && !opt_state_get("multios").unwrap_or(true))
15036                    // Mode 9 = singsub. c:Src/subst.c:514-525 runs `prefork`
15037                    // and NOTHING else — `globlist` is never called, so a
15038                    // `case` word (c:Src/loop.c:610-612) and a `[[ … ]]`
15039                    // operand (c:Src/cond.c:53) are never filename-generated.
15040                    || mode == 9
15041                    // Mode 12 = the pattern-operand spelling of mode 9, so it
15042                    // owes the same "singsub never reaches globlist" rule.
15043                    || mode == 12
15044                    // Mode 11 = a segment of a larger word (see compile_zsh's
15045                    // text-expansion arm): c:Src/exec.c:3755-3757 globs the
15046                    // assembled word, so the segment itself is not globbed.
15047                    || mode == 11
15048                    // c:Src/exec.c:2603-2613 — a scalar `NAME=VALUE` is
15049                    // preforked PREFORK_SINGLE|PREFORK_ASSIGN (mode 8) and
15050                    // reaches globlist only under GLOB_ASSIGN; without it the
15051                    // value is never globbed. Missing this term, a value the
15052                    // compiler did not DQ-wrap globbed here: `x=q(` said "bad
15053                    // pattern: q(", `x=q(a)` "number expected", `x=q(|b)` "no
15054                    // matches found".
15055                    || (mode == 8 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBASSIGN));
15056                // c:Src/subst.c:168 — prefork's `if (*(char *)getdata(node))`
15057                // runs on the node AS IT STANDS, i.e. still carrying the quote
15058                // markers the lexer left and the `Nularg` that c:169's
15059                // `remnulargs` re-inserts for a node made of nothing else
15060                // (c:Src/glob.c:3683-3686). `untokenize` drops that sentinel
15061                // (c:Src/exec.c:2143), but only much later, once the node is
15062                // already a word. Testing emptiness AFTER the untokenize below
15063                // inverted that order and deleted the word C keeps:
15064                // `unset u; r=( q ${u:-""} )` counted 1 where zsh counts 2
15065                // (same for `${u:-''}`, `${u-""}`, `${1:-""}`, `${a[@]:+""}`).
15066                // Record C's answer here and let the empty-result arm consult
15067                // it.
15068                let nonempty_before_untokenize = brace_expanded.iter().any(|w| !w.is_empty());
15069                let parts: Vec<String> = brace_expanded
15070                    .into_iter()
15071                    .flat_map(|s| {
15072                        // The lexer leaves glob metacharacters in their
15073                        // META-encoded form: `*` → `\u{e187}`, `?` →
15074                        // `\u{e186}`, `[` → `\u{e191}`, etc. expand_string
15075                        // doesn't untokenize them, so the literal-char
15076                        // checks below (`s.contains('*')`) would miss
15077                        // every real glob and skip expand_glob — that
15078                        // bug let `echo *.toml` print the literal
15079                        // `*.toml` because the META `\u{e187}` never
15080                        // matched the literal `*`. Untokenize once so
15081                        // the metacharacter checks see the canonical
15082                        // form. zsh's pattern.c expects `*` etc. as
15083                        // bare chars at the glob layer.
15084                        // c:Src/pattern.c:4306 haswilds on the still-
15085                        // TOKENIZED word (pre-untokenize), matching C's
15086                        // zglob entry gate (Src/glob.c:1230) which runs
15087                        // on the lexer-tokenized string. haswilds
15088                        // matches ONLY token codes: source-level
15089                        // `*.toml` carries Star and fires; bare literal
15090                        // `[`/`*`/`?` from `$'...'` decode, `:-`
15091                        // default values, or nested-substitution
15092                        // results were never shtokenize'd (C
15093                        // subst.c:3231 sets globsubst=0 in the `:-`
15094                        // arm) and stay literal — bug #625. Plain
15095                        // multibyte text (`↔`) never matches a token
15096                        // codepoint — bug #627.
15097                        let is_glob_pre = !noglob && crate::ported::pattern::haswilds(&s);
15098                        // c:Src/glob.c:1230 — zglob receives the word in
15099                        // LEXER-TOKENIZED form and only untokenizes it when
15100                        // it declines to glob (c:1232) or falls back to the
15101                        // literal (c:1884). The token form is what makes a
15102                        // QUOTED metachar distinguishable from an active one:
15103                        // in `*(.e['[[ $REPLY == a* ]]'])` the body's `]`
15104                        // bytes are raw ASCII while the qualifier's real
15105                        // closer is `Outbrack`, which is exactly how
15106                        // checkglobqual (c:1163/1170, testing Outpar/Inpar)
15107                        // and get_strarg's tokenized delimiter half
15108                        // (Src/subst.c:1379-1390) find the true end. zshrs
15109                        // untokenized here, one line before the glob layer,
15110                        // so every quoted metachar became indistinguishable
15111                        // from an active one and the qualifier parser closed
15112                        // on the first quoted `]`. Keep the tokenized word
15113                        // for the glob call; the untokenized form still
15114                        // drives the non-glob arms below.
15115                        let s_tok = s.clone();
15116                        // c:36 `nulstring` — a segment (mode 11) of a word
15117                        // whose end-of-word drop is still to run keeps the
15118                        // Nularg that marks a split's empty field, as
15119                        // `paramsubst_to_value_pf` does; untokenize would fold
15120                        // it to "", which that drop cannot tell from a truly
15121                        // empty node. The drop removes the marker (c:170).
15122                        let nul = crate::ported::zsh_h::Nularg;
15123                        let s = if mode == 11
15124                            && s.contains(nul)
15125                            && crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get()) > 0
15126                        {
15127                            s.split(nul)
15128                                .map(crate::lex::untokenize)
15129                                .collect::<Vec<_>>()
15130                                .join(&nul.to_string())
15131                        } else if mode == 12 {
15132                            // c:Src/glob.c:3640-3645 — `shtokenize` says "this
15133                            // metacharacter is ACTIVE" by rewriting it to a
15134                            // TOKEN and says "this one is data" by leaving the
15135                            // raw byte, and C carries both spellings into
15136                            // `patcompile` unchanged. Mode 12 is a `[[ … ]]` /
15137                            // `case` PATTERN operand, the one word where that
15138                            // distinction still has a consumer: untokenizing
15139                            // here collapses the two, and the guard that runs
15140                            // next can then only escape ALL of the word's metas
15141                            // or none of them. c:Src/subst.c:1551-1552 puts
15142                            // both halves in one string — the `(~j)` separator
15143                            // is tokenized, the elements it joins are not — so
15144                            // neither answer is right. Hand the word on
15145                            // tokenized; BUILTIN_GLOB_SUBST_GUARD /
15146                            // BUILTIN_PAT_DATA_BACKSLASH run the untokenize
15147                            // once they have escaped the raw half.
15148                            s
15149                        } else {
15150                            crate::lex::untokenize(&s)
15151                        };
15152                        // c:Src/exec.c:2603-2613 — only an assignment VALUE
15153                        // skips globlist without GLOB_ASSIGN, and that value
15154                        // arrives here in mode 8, which `noglob` above already
15155                        // covers. The word's text says nothing about its role:
15156                        // a text-shape test (`[A-Za-z_][A-Za-z0-9_]*=`) that
15157                        // used to skip the glob here also kept `print a=zzq*`,
15158                        // `set -- a=zzq*`, `for w in a=zzq*`, `> a=zzq*` and
15159                        // the array element `x=(a=zzq*)` from globbing, where
15160                        // zsh reports "no matches found".
15161                        // Glob-trigger decision: pre-untokenize
15162                        // haswilds_tokens_only result (computed above
15163                        // before the untokenize that collapses META
15164                        // tokens to their ASCII forms). The TOKEN-only
15165                        // gate matches C `Src/pattern.c:4306-4376`
15166                        // exactly — only Inbrack/Star/Quest/Inpar/Bar/
15167                        // Inang/Pound/Hat token codes count as wild,
15168                        // not their literal ASCII counterparts. Source-
15169                        // level `*.toml` carries Star token so globs;
15170                        // `$'…'`-decoded `[abc]` carries bare `[` so
15171                        // stays literal. Bug #625.
15172                        // c:Src/subst.c:677-678 — `filesub`'s own precondition
15173                        // for the PREFORK_TYPESET arm is `(*namptr)[1] &&
15174                        // strchr(*namptr + 1, Equals)`: an `=` anywhere but
15175                        // position 0. There is NO identifier test. Gating the
15176                        // arm below on an assignment-shape test — which demanded a
15177                        // leading `[A-Za-z_][A-Za-z0-9_]*` — therefore lost every
15178                        // word whose `=` is not preceded by a bare identifier:
15179                        //   setopt magicequalsubst; print -r -- x:y=~/z
15180                        //   zsh: x:y=/home/u/z      zshrs: x:y=~/z
15181                        // Same for `ME:a=~/x`, `1abc=~/x` and `-o=~/x`, the last
15182                        // of which is the common `--prefix=~/dir` shape.
15183                        // c:Src/subst.c:678 — filesub's own gate is
15184                        // `strchr(*namptr + 1, Equals)`: the Equals TOKEN,
15185                        // which the lexer emits ONLY for an unquoted `=`.
15186                        // Test the still-TOKENIZED word so a quoted `=` does
15187                        // not arm the arm below.
15188                        let has_nonleading_equals = s_tok
15189                            .chars()
15190                            .skip(1)
15191                            .any(|c| c == crate::ported::zsh_h::Equals);
15192                        // c:Src/subst.c:180 `filesub(&cptr, flags & …)` acts on a
15193                        // leading Tilde / Equals token; mode 10 deferred it above.
15194                        let filesub_deferred = mode == 10
15195                            && (s_tok.starts_with(crate::ported::zsh_h::Tilde)
15196                                || s_tok.starts_with(crate::ported::zsh_h::Equals));
15197                        if (is_glob_pre || filesub_deferred) && mode == 10 {
15198                            // Mode 10: the command's BUILTIN_GLOBLIST runs this
15199                            // word's filesub and glob after every argument has
15200                            // been expanded (c:Src/subst.c:165-191, then
15201                            // c:Src/exec.c:3755-3757).
15202                            vec![s_tok]
15203                        } else if is_glob_pre {
15204                            // c:Src/exec.c:3755-3762 — an error raised here is a
15205                            // globlist error (`lastval = 1`); see GLOB_WORD_ERRFLAG.
15206                            let ef = || {
15207                                crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
15208                                    & crate::ported::zsh_h::ERRFLAG_ERROR
15209                            };
15210                            let before = ef();
15211                            // c:Src/glob.c:2164-2166 — a redirect target
15212                            // (mode 7) is globbed with `in_expandredir` set,
15213                            // so a NULL_GLOB no-match is `redirection failed
15214                            // (no match)` (c:1888-1894), not a dropped word.
15215                            let redir = (mode == 7) as i32;
15216                            crate::ported::glob::IN_EXPANDREDIR.store(redir, std::sync::atomic::Ordering::SeqCst);
15217                            let globbed = exec.expand_glob(&s_tok);
15218                            crate::ported::glob::IN_EXPANDREDIR.store(0, std::sync::atomic::Ordering::SeqCst);
15219                            if before == 0 && ef() != 0 {
15220                                GLOB_WORD_ERRFLAG.with(|c| c.set(true));
15221                            }
15222                            globbed
15223                        } else if has_nonleading_equals
15224                            && crate::ported::zsh_h::isset(crate::ported::zsh_h::MAGICEQUALSUBST)
15225                        {
15226                            // c:Src/exec.c:3353 — when MAGIC_EQUAL_SUBST is set
15227                            // on a non-typeset command, esprefork = PREFORK_TYPESET,
15228                            // so every arg runs through
15229                            // filesub(PREFORK_TYPESET): the `~`/`=` after the
15230                            // first `=` (and after each `:`) undergo filename
15231                            // expansion. `print foo=~/bar` → `foo=$HOME/bar`.
15232                            // filesub (subst.c:667) keys on the Tilde/Equals
15233                            // TOKENS, not the literal chars, because that is
15234                            // exactly what tells a quoted `~`/`=` from a live
15235                            // one. This arm used to `untokenize` the word and
15236                            // then `shtokenize` it back, which re-marks EVERY
15237                            // literal `~`/`=` as a token — including the ones
15238                            // that came out of quotes. With MAGIC_EQUAL_SUBST
15239                            // set, `print -r -- --a='b:=c'` then did `=`
15240                            // filename expansion on the quoted `=c` and failed
15241                            // with `c not found` (zsh prints `--a=b:=c`), and
15242                            // `A=( --height='${X:=75%}' )` — fzf-tab.plugin.zsh
15243                            // sh:117-128 — errored `75%} not found` at plugin
15244                            // load, aborting the rest of the zinit turbo
15245                            // `atload` chain. Hand filesub the word C hands it:
15246                            // the one the lexer produced.
15247                            let exp = crate::ported::subst::filesub(
15248                                &s_tok,
15249                                crate::ported::zsh_h::PREFORK_TYPESET,
15250                            );
15251                            vec![crate::lex::untokenize(&exp).to_string()]
15252                        } else {
15253                            vec![s]
15254                        }
15255                    })
15256                    .collect();
15257                if parts.len() == 1 {
15258                    let only = parts.into_iter().next().unwrap_or_default();
15259                    // Empty unquoted expansion → drop the arg entirely
15260                    // (zsh "remove empty unquoted words" rule). Returning
15261                    // an empty Value::Array makes pop_args contribute zero
15262                    // items. Direct port of subst.c's empty-elide pass at
15263                    // the end of multsub which removes empty linknodes
15264                    // from unquoted contexts. Quoted DQ/SQ paths (modes
15265                    // 1/2/5) take separate arms above and always emit
15266                    // Value::Str so the empty arg survives.
15267                    //
15268                    // c:Src/subst.c:4437 + 1650-1656 — a word CONTAINING a
15269                    // quoted span never drops: `x"${v[-1]}"y` (v empty)
15270                    // is the scalar "xy", and a standalone `"${v[-1]}"`
15271                    // is ONE empty arg. The lexer marks DQ/SQ spans with
15272                    // Dnull(\u{e19e})/Snull(\u{e19d})/Qstring(\u{e18c})/
15273                    // Bnull(\u{e19f}); their presence in the SOURCE word
15274                    // means qt semantics apply. Without this gate, zpwr's
15275                    // global `setopt rc_expand_param` turned autopair's
15276                    // `local lchar="${LBUFFER[-1]}"` (empty prompt +
15277                    // backspace) into an ARGLESS `local` — the full
15278                    // parameter-table dump the user saw per keystroke.
15279                    if only.is_empty() && !nonempty_before_untokenize {
15280                        // A quote span that WRAPS the expansion keeps the
15281                        // empty arg (`"${v[-1]}"` → one empty arg). But a
15282                        // quote INSIDE the `${…}` braces — e.g. the alternate
15283                        // of `${x:+'q'}` or `${x:-'d'}` — does NOT: when that
15284                        // branch isn't taken the result is a plain unquoted
15285                        // empty and must ELIDE, matching zsh (`a=(A ${x:+'q'}
15286                        // C)` → 2 elements, not 3). So only count quote
15287                        // markers at brace-depth 0 (outside `${…}`). Inbrace
15288                        // = \u{e18f}, Outbrace = \u{e190}.
15289                        let mut depth = 0i32;
15290                        let mut word_has_quoted_span = false;
15291                        for c in text.chars() {
15292                            match c {
15293                                '\u{e18f}' => depth += 1,
15294                                '\u{e190}' => depth -= 1,
15295                                '\u{e19e}' | '\u{e19d}' | '\u{e18c}' | '\u{e19f}' | '"' | '\''
15296                                    if depth <= 0 =>
15297                                {
15298                                    word_has_quoted_span = true;
15299                                    break;
15300                                }
15301                                _ => {}
15302                            }
15303                        }
15304                        if word_has_quoted_span {
15305                            // Returns a SCALAR Value, so the empty-Array
15306                            // shape bit does not describe it — leave the
15307                            // cell alone. Overwriting it here would let a
15308                            // trailing quoted-empty segment resurrect a word
15309                            // an EARLIER empty array already deleted:
15310                            // `setopt rcexpandparam; a=(); x${a}"${P}"y` is
15311                            // ZERO words in zsh, and concat_plan9's
15312                            // `(Array(empty), scalar)` arm returns the scalar
15313                            // when the bit says "scalar".
15314                            Value::str(String::new())
15315                        } else {
15316                            // The empty `Value::Array` below stands for TWO
15317                            // different C shapes, and under RC_EXPAND_PARAM
15318                            // they behave OPPOSITELY:
15319                            //
15320                            //   c:Src/subst.c:4362-4365 (plan9, empty ARRAY)
15321                            //     if (plan9) { uremnode(l, n); return n; }
15322                            //   → the whole word is deleted:
15323                            //     `a=(); x${a}y` and `x${${a}}y` are 0 words.
15324                            //
15325                            //   c:Src/subst.c:4438-4467 (scalar arm, empty
15326                            //   SCALAR) — c:4464
15327                            //     *str = strcatsub(&y, ostr, aptr, x, xlen,
15328                            //                      fstr, globsubst, copied);
15329                            //   then c:4467 `setdata(n, (void *) y);`
15330                            //   → the surrounding text survives:
15331                            //     `unset P; x${P}y` and `x${${P}}y` are the
15332                            //     single word `xy`.
15333                            //
15334                            // Nesting is NOT the discriminator — shape is.
15335                            // The word is deleted only when the expansion was
15336                            // ARRAY-shaped (c:4245 `if (isarr)`) AND produced
15337                            // zero words, i.e. C's `while ((x = *aval++))`
15338                            // loop at c:4327 never ran and left `plan9`
15339                            // non-zero at c:4362. Anything else empty — an
15340                            // unset/empty scalar, a nested subexp that
15341                            // resolved to a scalar, a command substitution
15342                            // with no output — takes c:4438's scalar arm and
15343                            // keeps the word.
15344                            note_empty_is_scalar(!(seg_zero_words && seg_is_array));
15345                            Value::array(Vec::new())
15346                        }
15347                    } else {
15348                        Value::str(only)
15349                    }
15350                } else {
15351                    Value::array(parts.into_iter().map(Value::str).collect())
15352                }
15353            }
15354        });
15355        // Pull any inner cmd-subst (`` `cmd` `` via mode 3 or via
15356        // mode 0/6 multsub → getoutput, `$(cmd)` via the default
15357        // arm's multsub path, nested `$()`s reached through
15358        // stringsubst) back into vm.last_status so a containing
15359        // assignment's BUILTIN_SET_VAR — which reads vm.last_status —
15360        // sees the cmd-subst's exit. Without this, backtick
15361        // assignments (`a=\`false\`; echo $?`) reported 0 because the
15362        // ported LASTVAL update never reached the VM-side counter.
15363        let cs_status = with_executor(|exec| exec.last_status());
15364        vm.last_status = cs_status;
15365        result_value
15366    });
15367
15368    // `${#name}` — pops [name]. Returns the value's element count for
15369    // arrays (indexed and assoc) or character length for scalars.
15370    // BUILTIN_PARAM_LENGTH — `${#name}`. PURE PASSTHRU.
15371    vm.register_builtin(BUILTIN_PARAM_LENGTH, |vm, _argc| {
15372        let name = vm.pop().to_str();
15373        // PARAM_LENGTH's empty-result semantics differ from
15374        // paramsubst_to_value: 0 nodes → "0" (numeric length), not
15375        // empty array. paramsubst on `${#X}` always returns at least
15376        // one node in practice (the length string); the empty case
15377        // is defensive.
15378        let mut ret_flags: i32 = 0;
15379        let (_full, _pos, nodes) = crate::ported::subst::paramsubst(
15380            &format!("${{#{}}}", name),
15381            0,
15382            false,
15383            0i32,
15384            &mut ret_flags,
15385        );
15386        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
15387            with_executor(|exec| exec.set_last_status(1));
15388        }
15389        if nodes.is_empty() {
15390            Value::str("0")
15391        } else {
15392            nodes_to_value(nodes)
15393        }
15394    });
15395
15396    // `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
15397    // `${var/%pat/repl}` — Pops [name, pattern, replacement, op_byte].
15398    // op: 0=first, 1=all, 2=anchor-prefix (`/#`), 3=anchor-suffix (`/%`).
15399    // BUILTIN_PARAM_REPLACE — `${var/pat/repl}` / `${var//pat/repl}` /
15400    // `${var/#pat/repl}` / `${var/%pat/repl}`. PURE PASSTHRU.
15401    vm.register_builtin(BUILTIN_PARAM_REPLACE, |vm, argc| {
15402        // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
15403        // compile_zsh::emit_param_modifier (see the note there).
15404        let pf_flags = if argc as usize > 5 && vm.pop().to_int() != 0 {
15405            crate::ported::zsh_h::PREFORK_SINGLE
15406        } else {
15407            0
15408        };
15409        let dq_flag = vm.pop().to_int() != 0;
15410        let op = vm.pop().to_int() as u8;
15411        let repl = vm.pop().to_str();
15412        let pattern = vm.pop().to_str();
15413        let name = vm.pop().to_str();
15414        // !!! DASH-STRICT GATE !!! dash / ash have no `${var/pat/repl}`
15415        // pattern-replacement expansion — it is a "Bad substitution" error
15416        // (bash/ksh/POSIX-sh support it, so those modes fall through). Raise
15417        // the canonical zsh diagnostic + exit 1 to match /bin/dash's failure.
15418        if crate::dash_mode::dash_strict() {
15419            crate::ported::utils::zerr("bad substitution");
15420            crate::ported::utils::errflag.fetch_or(
15421                crate::ported::zsh_h::ERRFLAG_ERROR,
15422                std::sync::atomic::Ordering::Relaxed,
15423            );
15424            with_executor(|exec| exec.set_last_status(1));
15425            return Value::str("");
15426        }
15427        // DQ context: C's lexer marks every `$` inside double quotes
15428        // as the Qstring token (Src/lex.c dquote_parse) and keeps `'`
15429        // a plain char — so a DQ replacement's `$'…'` is LITERAL in
15430        // C (Src/subst.c:301 decodes only the tokenized Snull form;
15431        // `"${a/X/$'\0'}"` keeps the five chars `$'\0'`). The body
15432        // rebuilt below re-enters stringsubst as raw text, which
15433        // would mis-decode `$'…'` as ANSI-C; stamp the Qstring
15434        // marker on the repl's `$` so stringsubst sees the same DQ
15435        // signal C's tokens carry. The PATTERN side keeps decoding
15436        // (matches observed zsh: the pattern's `$'\0'` matches a
15437        // real NUL while the repl's stays literal).
15438        let repl = if dq_flag {
15439            repl.replace('$', "\u{e18c}")
15440        } else {
15441            repl
15442        };
15443        // op encoding: 0 = first `/`, 1 = all `//`, 2 = anchor-prefix
15444        // `/#`, 3 = anchor-suffix `/%`. The brace form distinguishes
15445        // first-vs-all by single vs doubled slash, and anchored by
15446        // a `#` or `%` immediately after the slash(es).
15447        let body = match op {
15448            0 => format!("${{{}/{}/{}}}", name, pattern, repl),
15449            1 => format!("${{{}//{}/{}}}", name, pattern, repl),
15450            2 => format!("${{{}/#{}/{}}}", name, pattern, repl),
15451            3 => format!("${{{}/%{}/{}}}", name, pattern, repl),
15452            _ => format!("${{{}/{}/{}}}", name, pattern, repl),
15453        };
15454        // c:Src/subst.c:1625 — paramsubst's qt flag. The compiler
15455        // threads the word's DQ context onto the stack; dropping it
15456        // (the old `let _dq_flag`) ran the rebuilt body with qt=false
15457        // whenever the opcode fired outside an EXPAND_TEXT scope, so
15458        // DQ-only semantics inside the replacement (e.g. `$'` staying
15459        // literal per Src/subst.c:301 — `"${a/x/$'\t'q}"`) were lost.
15460        // Bump in_dq_context exactly like EXPAND_TEXT mode 1 so
15461        // paramsubst_to_value's qt probe sees the right context.
15462        if dq_flag {
15463            with_executor(|exec| exec.in_dq_context += 1);
15464        }
15465        let ret = paramsubst_to_value_status(vm, &body, pf_flags);
15466        if dq_flag {
15467            with_executor(|exec| exec.in_dq_context -= 1);
15468        }
15469        ret
15470    });
15471
15472    vm.register_builtin(BUILTIN_REGISTER_COMPILED_FN, |vm, argc| {
15473        let args = pop_args(vm, argc);
15474        // c:Src/exec.c:5389-5394 — `if (htok && names) { execsubst(names);
15475        // if (errflag) { state->pc = end; return 1; } }`: the name words are
15476        // prefork-expanded and globbed (c:2741-2746), so a NOMATCH defines
15477        // nothing and fails the definition.
15478        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
15479            & crate::ported::utils::ERRFLAG_ERROR
15480            != 0
15481        {
15482            return Value::Status(1); // c:5393
15483        }
15484        // c:5399 — `while ((s = (char *) ugetnode(names)))`: one function per
15485        // name the expansion produced (`function a* { … }` defines one per
15486        // matching file). The names lead the argument list; the five
15487        // definition fields follow.
15488        let nnames = args.len().saturating_sub(5);
15489        let (names, fields) = args.split_at(nnames);
15490        let mut status = Value::Status(0);
15491        for name in names {
15492            let mut one = Vec::with_capacity(fields.len() + 1);
15493            one.push(name.clone());
15494            one.extend_from_slice(fields);
15495            status = register_compiled_fn(vm, one);
15496        }
15497        status
15498    });
15499    fn register_compiled_fn(vm: &mut fusevm::VM, args: Vec<String>) -> Value {
15500        let mut iter = args.into_iter();
15501        let name = iter.next().unwrap_or_default();
15502        let body_b64 = iter.next().unwrap_or_default();
15503        let body_source = iter.next().unwrap_or_default();
15504        let line_base_str = iter.next().unwrap_or_default();
15505        let line_base: i64 = line_base_str.parse().unwrap_or(0);
15506        // c:Src/exec.c:5384-5388 — `shf->lineno = (funcstack && (tp == FS_FUNC ||
15507        // tp == FS_EVAL)) ? funcstack->flineno + lineno : lineno;`. Inside an
15508        // `eval` the compiled line is relative to the eval text, so add the
15509        // eval frame's file offset. (A function body is compiled with file
15510        // lines already, so the FS_FUNC arm needs no adjustment here.)
15511        let line_base: i64 = match crate::ported::modules::parameter::FUNCSTACK
15512            .lock()
15513            .ok()
15514            .and_then(|stk| stk.last().map(|top| (top.tp, top.flineno)))
15515        {
15516            Some((tp, flineno)) if tp == crate::ported::zsh_h::FS_EVAL && line_base >= 0 => {
15517                flineno + line_base // c:5387
15518            }
15519            _ => line_base,
15520        };
15521        // c:Src/exec.c:5382 `do_tracing = *state->pc++;` — the `-T` of
15522        // `function -T name { … }`, carried across from compile_funcdef.
15523        let do_tracing = iter.next().map(|s| s == "1").unwrap_or(false); // c:5382
15524                                                                         // c:Src/exec.c:5451-5456 — `shf->redir = <redir_prog>`: the rendered
15525                                                                         // text of the definition's trailing redirections (empty when there
15526                                                                         // were none). See `shfunc::redir_text`.
15527        let redir_text = iter.next().unwrap_or_default(); // c:5453
15528                                                          // c:5387 — `tracing_flags = do_tracing ? PM_TAGGED_LOCAL : 0;`
15529        let tracing_flags: u32 = if do_tracing {
15530            crate::ported::zsh_h::PM_TAGGED_LOCAL
15531        } else {
15532            0
15533        }; // c:5387
15534        // `#<idx>` — the body lives in the ENCLOSING chunk's `sub_chunks` and
15535        // only its index travelled through the constant pool (see
15536        // compile_zsh.rs `compile_funcdef`). `vm.chunk` is the chunk currently
15537        // executing this CallBuiltin, i.e. the one that owns the sub-chunk:
15538        // at top level the script chunk, and inside a function body the body
15539        // chunk that `vm_pool::acquire` (vm_helper.rs:4098) is running.
15540        //
15541        // Anything else is the historical `base64(bincode(chunk))` payload —
15542        // still accepted so a chunk restored from a bytecode cache written by
15543        // an older binary keeps working. `#` is outside the base64 alphabet so
15544        // the two forms cannot be confused.
15545        let sub_chunk = body_b64
15546            .strip_prefix('#')
15547            .and_then(|n| n.parse::<usize>().ok())
15548            .map(|idx| vm.chunk.sub_chunks.get(idx).cloned().ok_or(()));
15549        let decoded = match sub_chunk {
15550            Some(Ok(chunk)) => Ok(chunk),
15551            Some(Err(())) => Err(()),
15552            None => {
15553                let bytes = base64_decode(&body_b64);
15554                bincode::deserialize::<fusevm::Chunk>(&bytes).map_err(|_| ())
15555            }
15556        };
15557        let status = match decoded {
15558            Ok(chunk) => with_executor(|exec| {
15559                // c:Src/exec.c:5383 — `shf->filename =
15560                // ztrdup(scriptfilename);` — the function's
15561                // definition-file is read from the canonical
15562                // file-scope `scriptfilename` global at compile
15563                // time, NOT from a per-executor struct field.
15564                // exec.scriptfilename is seeded once at
15565                // bins/zshrs.rs:1717 to the bin basename ("zsh")
15566                // and never updates on source/dot, so reading from
15567                // it left every user function's def_file as "zsh".
15568                // Route through scriptfilename_get() so source /
15569                // dot's set_scriptfilename calls propagate.
15570                let def_file = crate::ported::utils::scriptfilename_get()
15571                    .or_else(|| exec.scriptfilename.clone());
15572                let def_file_for_prov = def_file.clone();
15573                if !body_source.is_empty() {
15574                    exec.function_source
15575                        .insert(name.clone(), body_source.clone());
15576                }
15577                exec.function_line_base.insert(name.clone(), line_base);
15578                exec.function_def_file.insert(name.clone(), def_file);
15579                // PFA-SMR aspect: every `name() {}` / `function name { }`
15580                // funnels through here at compile time. Emit one record
15581                // with the function name + raw body source.
15582                #[cfg(feature = "recorder")]
15583                if crate::recorder::is_enabled() {
15584                    let ctx = exec.recorder_ctx();
15585                    let body = if body_source.is_empty() {
15586                        None
15587                    } else {
15588                        Some(body_source.as_str())
15589                    };
15590                    crate::recorder::emit_function(&name, body, ctx);
15591                }
15592                // c:Src/exec.c:5516-5531 — `TRAP<SIG>() { ... }` is the
15593                // function-named trap install. zsh detects the `TRAP`
15594                // prefix at func-def time and calls
15595                // `settrap(signum, NULL, ZSIG_FUNC)` so the next
15596                // dispatch of that signal routes to the named shfunc.
15597                // Bug #157 in docs/BUGS.md — fusevm_bridge's funcdef
15598                // opcode skipped this dispatch entirely, so TRAPEXIT /
15599                // TRAPUSR1 / TRAPZERR / TRAPDEBUG never fired.
15600                //
15601                // ORDER MATTERS. C runs settrap at c:5518 and
15602                // `shfunctab->addnode` only at c:5539, and dosavetrap
15603                // says why (c:634-637): "Get the old function: this
15604                // assumes we haven't added the new one yet." Running
15605                // the install first made settrap→unsettrap→removetrap→
15606                // dosavetrap snapshot the NEW body, so endtrapscope
15607                // "restored" the inner definition and a nested
15608                // `TRAPEXIT() { … }` permanently clobbered the outer one.
15609                // BUGS.md #1114 — the TRAPxxx() FUNCTION-trap form is zsh-only.
15610                // bash, ksh and dash have no such concept: there `TRAPINT` is an
15611                // ordinary function whose name merely begins with TRAP, and SIGINT
15612                // keeps its default disposition. Recognising it in a drop-in mode
15613                // also SILENTLY DESTROYS an already-installed list trap for that
15614                // signal, since the two forms are alternatives in zsh.
15615                // posix_faithful() is raised only for a bare drop-in flag, so
15616                // --zsh, native zshrs and `emulate sh` are untouched.
15617                if !crate::extensions::dash_mode::posix_faithful() && name.len() > 4 && name.starts_with("TRAP") {
15618                    if let Some(sn) = crate::ported::jobs::getsigidx(&name[4..]) {
15619                        let _ = crate::ported::signals::settrap(
15620                            sn,
15621                            None,
15622                            crate::ported::zsh_h::ZSIG_FUNC as i32,
15623                        );
15624                        // c:5530 — `removetrapnode(signum);` "Remove the
15625                        // old node explicitly in case it has an
15626                        // alternative name". NOT mirrored here: zshrs's
15627                        // `jobs::removetrapnode` routes through
15628                        // `hashtable::removeshfuncnode`, which calls back
15629                        // into `unsettrap` (C explicitly avoids that path
15630                        // — see the comment at Src/signals.c:836-838) and
15631                        // would tear down the trap `settrap` just armed.
15632                        // The `tab.add` below overwrites the canonical
15633                        // `TRAP<SIG>` node anyway; only the alt-name case
15634                        // (TRAPCLD vs TRAPCHLD) is left unhandled.
15635                    }
15636                }
15637                // Mirror into canonical shfunctab so scanfunctions /
15638                // ${(k)functions} / functions builtin see user defs.
15639                // C: exec.c:funcdef → shfunctab->addnode(ztrdup(name),shf).
15640                if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
15641                    let mut shf = crate::ported::hashtable::shfunc_with_body(&name, &body_source);
15642                    // c:Src/exec.c:5453/5455 — `shf->redir = …`. Stored as
15643                    // rendered text (see `shfunc::redir_text`); `None` is C's
15644                    // `shf->redir == NULL`.
15645                    shf.redir_text = if redir_text.is_empty() {
15646                        None
15647                    } else {
15648                        Some(redir_text.clone())
15649                    };
15650                    // c:Src/exec.c:5437 — `shf->node.flags = tracing_flags;`
15651                    shf.node.flags |= tracing_flags as i32; // c:5437
15652                                                            // c:5532-5538 — /* Is this function traced and redefining
15653                                                            //                  itself? */
15654                                                            //     if (funcstack && funcstack->tp == FS_FUNC &&
15655                                                            //             !strcmp(s, funcstack->name)) {
15656                                                            //         Shfunc old = ((Shfunc)shfunctab->getnode(shfunctab, s));
15657                                                            //         if (old)
15658                                                            //             shf->node.flags |= old->node.flags &
15659                                                            //                                (PM_TAGGED|PM_TAGGED_LOCAL);
15660                                                            //     }
15661                                                            // The ported walker does this at exec.rs:7387, but fusevm —
15662                                                            // not that walker — is what registers a `name() { … }`, so a
15663                                                            // `functions -T f`-tagged function that redefined itself
15664                                                            // came back untraced (E02xtrace:6).
15665                    if let Ok(stk) = crate::ported::modules::parameter::FUNCSTACK.lock() {
15666                        if let Some(top) = stk.last() {
15667                            // c:5533
15668                            if top.tp == crate::ported::zsh_h::FS_FUNC && top.name == name {
15669                                // c:5535 — `Shfunc old = shfunctab->getnode(shfunctab, s);`
15670                                // Read through the write guard already held
15671                                // above: `shfunctab_lock()` is a std RwLock and
15672                                // is NOT reentrant, so re-acquiring it here
15673                                // self-deadlocked on exactly the
15674                                // self-redefinition case (C04funcdef:30 hung).
15675                                if let Some(old) = tab.get(&name) {
15676                                    // c:5537
15677                                    shf.node.flags |= old.node.flags
15678                                        & (crate::ported::zsh_h::PM_TAGGED as i32
15679                                            | crate::ported::zsh_h::PM_TAGGED_LOCAL as i32);
15680                                }
15681                            }
15682                        }
15683                    }
15684                    // `shfunc_with_body` stamps the AMBIENT `scriptfilename`
15685                    // (c:Src/exec.c:5383). That is right for an ordinary
15686                    // definition but wrong twice over for an autoloaded one:
15687                    //
15688                    //  * c:Src/exec.c:5622-5630 — while an autoload file's text
15689                    //    runs, C sets `scriptfilename = getshfuncfile(shf)`, so a
15690                    //    `name() { … }` INSIDE it records the fpath file. zshrs
15691                    //    runs that body on the normal VM path with the CALLER's
15692                    //    scriptfilename still in place, so a ksh-style autoload
15693                    //    file — one that defines the function and then calls it —
15694                    //    got attributed to whoever triggered the load.
15695                    //  * the function is then re-registered UNCHANGED when its
15696                    //    chunk is compiled at call time, and that second stamp
15697                    //    would overwrite the first even if the first were right.
15698                    //
15699                    // Result before this: `whence -v f` said "from ./caller.zsh"
15700                    // where zsh says "from dir/f", and `funcsourcetrace[1]`
15701                    // pointed at the caller — which compsys reads directly
15702                    // (`_git` locates git-completion.bash through
15703                    // `"$(dirname ${funcsourcetrace[1]%:*})"`).
15704                    if let Some(f) = crate::vm_helper::autoload_def_file(&name) {
15705                        shf.filename = Some(f); // c:5625 getshfuncfile(shf)
15706                    } else if let Some(prev) = tab.get(&name) {
15707                        // Re-registration of an unchanged body relabels nothing.
15708                        if prev.body.as_deref() == Some(body_source.as_str()) {
15709                            shf.filename = prev.filename.clone();
15710                            shf.node.flags |=
15711                                prev.node.flags & crate::ported::zsh_h::PM_LOADDIR as i32;
15712                        }
15713                    }
15714                    // c:Src/exec.c:5409 — `shf->lineno = lineno;`. Use
15715                    // the same max(1, line_base) clamp as the synth_shf
15716                    // in vm_helper::dispatch_function_call. Bug #396.
15717                    shf.lineno = std::cmp::max(1, line_base);
15718                    // c:Src/exec.c:5402 — `shfunc_set_sticky(shf);`
15719                    // stamps the definition-time `sticky` emulation
15720                    // snapshot onto the function so a later call can
15721                    // re-enter that emulation (doshfunc c:5978). The
15722                    // ported execfuncdef does this at exec.rs:7141, but
15723                    // fusevm — not that walker — is what actually
15724                    // registers a `name() { … }`, so `emulate sh -c
15725                    // 'f() { … }'` produced a function with no sticky
15726                    // emulation at all (B07emulate.ztst:6,7,8,12,13,14).
15727                    crate::ported::exec::shfunc_set_sticky(&mut shf);
15728                    // zshrs re-runs this registration when a function's
15729                    // chunk is (re)compiled at call time — see the
15730                    // filename note above. That second pass happens with
15731                    // the AMBIENT sticky (normally none), which would
15732                    // erase the definition-time stamp. An unchanged body
15733                    // is not a redefinition, so keep what it had.
15734                    if shf.sticky.is_none() {
15735                        if let Some(prev) = tab.get(&name) {
15736                            if prev.body.as_deref() == Some(body_source.as_str()) {
15737                                shf.sticky = prev
15738                                    .sticky
15739                                    .as_deref()
15740                                    .map(|b| crate::ported::exec::sticky_emulation_dup(b, 0));
15741                            }
15742                        }
15743                    }
15744                    // docs/BUGS.md #1105 — C settles every lexer-time
15745                    // decision when the definition runs (c:Src/exec.c:5389
15746                    // `shf->funcdef = dupeprog(…)`) and prints that wordcode
15747                    // back unchanged (c:Src/hashtable.c:954). zshrs stores
15748                    // the raw source and re-lexes it to print, so the one
15749                    // lexer-time option that survives into a deparse —
15750                    // RCQUOTES, c:Src/lex.c:1328 — has to be recorded here
15751                    // or a later `setopt rcquotes` rewrites the listing of a
15752                    // function defined long before it. Skipped for the
15753                    // re-registration of an unchanged body (the same test
15754                    // the filename and sticky stamps above use): that second
15755                    // pass runs at CALL time, whose option state is not the
15756                    // definition's.
15757                    if tab
15758                        .get(&name)
15759                        .is_none_or(|prev| prev.body.as_deref() != Some(body_source.as_str()))
15760                    {
15761                        crate::vm_helper::funcdef_note_rcquotes(
15762                            &name,
15763                            &body_source,
15764                            crate::ported::zsh_h::isset(crate::ported::zsh_h::RCQUOTES),
15765                        );
15766                    }
15767                    tab.add(shf);
15768                }
15769                // Lineage tap: this is where a `name() { … }` actually
15770                // lands in the VM path — `execfuncdef`'s shfunctab
15771                // install only runs for the interpreter path. The first
15772                // definition is the function's origin; a later one is a
15773                // `redefine` op on the same chain.
15774                if crate::provenance::active() {
15775                    crate::provenance::on_func_define(
15776                        &name,
15777                        Some(body_source.as_str()),
15778                        def_file_for_prov.as_deref(),
15779                        std::cmp::max(1, line_base),
15780                    );
15781                }
15782                exec.functions_compiled.insert(name, chunk);
15783                0
15784            }),
15785            Err(_) => 1,
15786        };
15787        Value::Status(status)
15788    }
15789
15790    // Wire the ShellHost so direct shell ops (Op::Glob, Op::TildeExpand,
15791    // Op::ExpandParam, Op::CmdSubst, Op::CallFunction, etc.) route through
15792    // ZshrsHost back into the executor.
15793    vm.set_shell_host(Box::new(ZshrsHost));
15794}
15795
15796impl ZshrsHost {
15797    /// True iff `c` can be a `(j:…:)` / `(s:…:)` delimiter — non-alphanumeric,
15798    /// non-underscore. Restricting to punctuation avoids `(jL)` consuming `L`
15799    /// as a delim instead of as the next flag.
15800    fn is_zsh_flag_delim(c: char) -> bool {
15801        !c.is_ascii_alphanumeric() && c != '_'
15802    }
15803}
15804
15805/// Shared `${name[idx]}` subscript dispatch for BUILTIN_ARRAY_INDEX
15806/// and the KSHARRAYS-unset arm of BUILTIN_ARRAY_INDEX_UNBRACED.
15807///
15808/// c:Src/subst.c subscript parsing — when paramsubst re-parses the
15809/// synthesized `${name[idx]}` body, characters like `'` `"` `\` `$`
15810/// etc. are LEXER-active inside the `[…]` and get reinterpreted
15811/// (quote-strip, paramsubst recursion, …). For PRE-EVALUATED key
15812/// strings (the dynamic-key fast path at compile_zsh.rs:3234 already
15813/// expanded `$k` via EXPAND_TEXT), the idx is a literal string that
15814/// must match the stored key byte-for-byte — no further
15815/// reinterpretation. Direct assoc lookup bypasses the lexer for this
15816/// case, avoiding the quote-strip bug where `h[a'b]` failed to
15817/// resolve because paramsubst's subscript lexer treated the `'` as a
15818/// quote. Bug #338. Only fires for simple assoc-name + non-flag idx
15819/// (no outer-flag sentinels, no `(…)` flag prefix on idx, no splat
15820/// operator). Other paths (slice, splat, flag-based search,
15821/// magic-assoc) still flow through paramsubst.
15822///
15823/// `ssub` is c:Src/subst.c:1761 `(pf_flags & PREFORK_SINGLE)` — see the
15824/// `BUILTIN_ARRAY_INDEX` argc contract. It only reaches the textual
15825/// `paramsubst` rebuild at the bottom: every direct-lookup shortcut above it
15826/// resolves a single assoc value, which c:4226's join leaves alone.
15827/// Spell an ALREADY-EXPANDED subscript so a flat `${name[…]}` rebuild reads it
15828/// back as the key/index it is.
15829///
15830/// c:Src/params.c:2047-2054 — `getindex` decides the whole-parameter splat on
15831/// the RAW bracket text, before `getarg` runs `parsestr`/`singsub` on it:
15832///
15833/// ```c
15834///     s = *pptr + 1;
15835///     if ((s[0] == '*' || s[0] == '@') && s + 1 == tbrack) {
15836///         ...  v->start = 0; v->end = -1;  s += 2;
15837///     } else {
15838///         start = getarg(&s, &inv, v, 0, &we, ...);
15839///     }
15840/// ```
15841///
15842/// So in C a subscript that merely EXPANDS to `@` or `*` can never be the
15843/// splat — the text at `s[0]` is still `$k`. zshrs expands the subscript in the
15844/// VM and then rebuilds the flat `${name[VALUE]}` string for `paramsubst`,
15845/// which puts that `@` exactly where c:2048 reads it as the splat: `k='@';
15846/// ${h[$k]}` enumerated the whole hash instead of reading the key `@`.
15847///
15848/// Every compiler route that reaches a rebuild rejects a SOURCE-literal
15849/// `[@]`/`[*]` first (`braced_subscript_ref`, `braced_subscript_dynamic_ref`
15850/// and `parse_zsh_flag_subscript` all bail on `key == "@" || key == "*"`), so
15851/// an `@`/`*` seen here is always an expansion result. Re-spell it with C's
15852/// exact-match subscript group (c:Src/params.c:1449-1450 `(e)`), which sidesteps
15853/// c:2048 — `s[0]` is `(` — and then keys on the literal text. `${h[(e)@]}` is
15854/// byte-identical to `k='@'; ${h[$k]}` on zsh 5.9 for every shape measured:
15855/// hash key (`AT`), array/scalar (`bad math expression`), `(k)` (`@`),
15856/// `${#…}` (`2`) and `${+…}` (`1`).
15857fn expanded_subscript_text(idx: &str) -> std::borrow::Cow<'_, str> {
15858    if idx == "@" || idx == "*" {
15859        std::borrow::Cow::Owned(format!("(e){}", idx)) // c:1449-1450
15860    } else {
15861        std::borrow::Cow::Borrowed(idx)
15862    }
15863}
15864
15865/// The value of one assoc element read by `array_index_lookup`. A MISSING
15866/// key reads as the empty string (c:Src/params.c:1606-1610 creates a
15867/// PM_UNSET node), and an unquoted empty word is then deleted by prefork
15868/// (c:Src/subst.c:183-186) — `print -l -- $h[missing] x` is ONE word. Returning
15869/// `Value::str("")` directly skipped that drop, so a missing key became an
15870/// empty argument; `nodes_to_value` is where the bridge applies it.
15871fn assoc_key_value(v: Option<String>) -> Value {
15872    match v {
15873        Some(s) if !s.is_empty() => Value::str(s),
15874        _ => nodes_to_value(vec![String::new()]),
15875    }
15876}
15877
15878/// `assoc_key_value` for a key read that answered from the plain hash
15879/// store. c:Src/params.c:1597-1606 hands back no node for a missing key, so
15880/// c:Src/subst.c:2804-2807 sets `vunset`, and with NO_UNSET the reference
15881/// is an error (c:3608-3613): `zerr("%s: parameter not set", idbeg)`. The
15882/// fast paths below answer before paramsubst runs, so they owe that check.
15883/// The alias views (getpmalias, c:Src/Modules/parameter.c) return an
15884/// unset-flagged node that zsh does not report, so `direct_assoc_key_get`'s
15885/// alias arms keep plain `assoc_key_value`.
15886fn hash_key_value(name: &str, idx: &str, v: Option<String>) -> Value {
15887    if v.is_none() && !crate::ported::zsh_h::isset(crate::ported::zsh_h::UNSET) {
15888        crate::ported::utils::zerr(&format!("{}[{}]: parameter not set", name, idx)); // c:3612
15889        crate::ported::utils::errflag
15890            .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, std::sync::atomic::Ordering::Relaxed);
15891        with_executor(|exec| exec.set_last_status(1));
15892        return Value::str(String::new());
15893    }
15894    assoc_key_value(v)
15895}
15896
15897/// One element of a plain indexed array by integer subscript, without the
15898/// textual `${name[idx]}` rebuild — which clones the WHOLE array on every
15899/// read, making a loop over an N-element array quadratic (8000 elements:
15900/// 2.7s against zsh's 0.017s; zpwr's zshRegenSearchableEnv.zsh indexes
15901/// `$lines[i]` over every autoload file and never finished).
15902///
15903/// `Some(elem)` only for the unambiguous case — a non-special `PM_ARRAY` in
15904/// the live paramtab, a nonzero integer subscript (a literal, or a bare
15905/// parameter holding one), KSH_ARRAYS off, and a non-empty element. Anything
15906/// else (flags, ranges, magic arrays, namerefs, empty elements whose word
15907/// elision depends on quoting) returns `None` and keeps the general path.
15908/// zshrs-original — no C counterpart (c:Src/params.c:getarg is O(1)).
15909fn array_elem_fast(name: &str, idx: &str) -> Option<String> {
15910    use crate::ported::zsh_h::{PM_ARRAY, PM_SPECIAL};
15911    if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
15912        return None;
15913    }
15914    let n: i64 = match idx.parse() {
15915        Ok(n) => n,
15916        Err(_) => {
15917            // A bare identifier holding an integer (`$a[i]`).
15918            let bare = idx
15919                .chars()
15920                .next()
15921                .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
15922                && idx.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
15923            if !bare {
15924                return None;
15925            }
15926            crate::ported::params::getsparam(idx)?.trim().parse().ok()?
15927        }
15928    };
15929    if n == 0 || name.is_empty() || matches!(name, "argv" | "@" | "*") {
15930        return None;
15931    }
15932    if crate::ported::params::is_nameref(name) {
15933        return None;
15934    }
15935    let tab = crate::ported::params::paramtab().read().ok()?;
15936    let pm = tab.get(name)?;
15937    let visible = crate::ported::modules::param_private::getprivatenode(&**pm as *const _);
15938    if visible.is_null() || !std::ptr::eq(visible, &**pm as *const _) {
15939        return None;
15940    }
15941    let flags = pm.node.flags as u32;
15942    if crate::ported::zsh_h::PM_TYPE(flags) != PM_ARRAY || flags & PM_SPECIAL != 0 {
15943        return None;
15944    }
15945    let arr = pm.u_arr.as_ref()?;
15946    let at = if n > 0 {
15947        usize::try_from(n - 1).ok()?
15948    } else {
15949        arr.len().checked_sub(usize::try_from(-n).ok()?)?
15950    };
15951    let elem = arr.get(at)?;
15952    (!elem.is_empty()).then(|| elem.clone())
15953}
15954
15955fn array_index_lookup(name: &str, idx: &str, ssub: bool) -> Value {
15956    let idx_is_simple = !idx.starts_with('(') && idx != "@" && idx != "*" && !idx.contains(',');
15957    if idx_is_simple {
15958        if let Some(elem) = array_elem_fast(name, idx) {
15959            return Value::str(elem);
15960        }
15961        // assoc_key_hit: single-lock O(1) probe — exec.assoc() clones
15962        // the WHOLE map per lookup (O(n), quadratic in shell loops).
15963        // When `name` IS an assoc, exact-key semantics apply to EVERY
15964        // plain key: hit → value, miss → empty (C `${assoc[missing]}`).
15965        // Never fall through to the textual `${name[key]}` rebuild —
15966        // keys carrying `{`/`}`/`[`/`]` (zsh-autopair probes
15967        // `${AUTOPAIR_LBOUNDS[$pair]}` with pair='{') re-parse as
15968        // broken syntax there ("failed to compile regex: repetition
15969        // quantifier…" + a `}` appended per keystroke).
15970        if let Some((_, v)) = crate::vm_helper::assoc_key_hit(name, idx) {
15971            return hash_key_value(name, idx, v);
15972        }
15973    }
15974    // c:Src/params.c:1449-1450 getindex — a leading `(e)`/`(E)` flag
15975    // group makes the subscript LITERAL (group consumed, exact key).
15976    // The textual rebuild below re-parses a FLAT `${name[(e)KEY]}`
15977    // string, so a `]` / `}` that arrived via `$key` expansion
15978    // terminates the subscript / brace early — "bad substitution" or
15979    // spilled-junk values (zpwr expandstats iterates alias keys
15980    // containing brackets). C never re-parses: getarg scans the
15981    // TOKENIZED source where expanded data brackets are inert. Do the
15982    // exact-match lookup directly against the assoc (plain or magic
15983    // alias tables); search groups ((r)/(i)/(k)/…) and other targets
15984    // keep the textual path.
15985    if let Some(rest) = idx.strip_prefix('(') {
15986        if let Some(close) = rest.find(')') {
15987            let grp = &rest[..close];
15988            if !grp.is_empty() && grp.chars().all(|ch| ch == 'e' || ch == 'E') {
15989                let key = &rest[close + 1..];
15990                if let Some(hit) = direct_assoc_key_get(name, key) {
15991                    return if is_alias_view(name) { assoc_key_value(hit) } else { hash_key_value(name, idx, hit) };
15992                }
15993            }
15994        }
15995    }
15996    // Plain assoc key that the flat rebuild would mangle (`]` closes
15997    // the subscript, `}` closes the brace): direct lookup. On a miss
15998    // return empty — the textual fallback cannot represent the key.
15999    if (idx.contains(']') || idx.contains('}')) && !idx.starts_with('(') {
16000        if let Some(hit) = direct_assoc_key_get(name, idx) {
16001            return if is_alias_view(name) { assoc_key_value(hit) } else { hash_key_value(name, idx, hit) };
16002        }
16003    }
16004    let body = format!("${{{}[{}]}}", name, expanded_subscript_text(idx));
16005    // The key here is already expanded; C validated only the raw text
16006    // (c:Src/params.c:2029-2045). See subst::SUBSCRIPT_PREEXPANDED.
16007    crate::ported::subst::SUBSCRIPT_PREEXPANDED.with(|c| c.set(true));
16008    // c:Src/subst.c:4226 — `if (isarr && ssub) { val = sepjoin(aval, NULL, 1);
16009    // isarr = 0; }`. `PREFORK_SINGLE` is the whole of `ssub` (c:1761), and the
16010    // ported `paramsubst` already carries that gate (`ssub_c4226` /
16011    // `ssub_join_c3903` in `src/ported/subst.rs`); this fast path just had no
16012    // way to say it.
16013    let v = paramsubst_to_value_pf(
16014        &body,
16015        if ssub {
16016            crate::ported::zsh_h::PREFORK_SINGLE
16017        } else {
16018            0
16019        },
16020    );
16021    // A body that never reached the subscript check must not leave the
16022    // carrier armed for the next, unrelated subscript.
16023    crate::ported::subst::SUBSCRIPT_PREEXPANDED.with(|c| c.set(false));
16024    v
16025}
16026
16027/// The `zsh/parameter` alias views `direct_assoc_key_get` serves itself.
16028fn is_alias_view(name: &str) -> bool {
16029    matches!(
16030        name,
16031        "aliases" | "galiases" | "saliases" | "dis_aliases" | "dis_galiases" | "dis_saliases"
16032    )
16033}
16034
16035/// Exact-key read against an assoc-like target WITHOUT the textual
16036/// `${name[key]}` reparse (see array_index_lookup — expanded `]`/`}`
16037/// in keys break the flat form). `Some(hit)` when `name` is a target
16038/// this helper understands (plain assoc, or the alias magic assocs of
16039/// zsh/parameter — Src/Modules/parameter.c getpmalias family);
16040/// `None` = not direct-capable, caller keeps the textual path.
16041fn direct_assoc_key_get(name: &str, key: &str) -> Option<Option<String>> {
16042    use crate::ported::zsh_h::{ALIAS_GLOBAL, DISABLED};
16043    // c:Src/Modules/parameter.c:1247+ getpmalias / getpmgalias /
16044    // getpmsalias — each view filters its table by flags.
16045    let alias_view = |global: bool, suffix: bool, disabled: bool| -> Option<String> {
16046        let tab = if suffix {
16047            crate::ported::hashtable::sufaliastab_lock()
16048        } else {
16049            crate::ported::hashtable::aliastab_lock()
16050        };
16051        tab.read().ok().and_then(|t| {
16052            t.iter().find_map(|(k, a)| {
16053                let f = a.node.flags as u32;
16054                if k == key
16055                    && ((f & ALIAS_GLOBAL as u32 != 0) == global || suffix)
16056                    && ((f & DISABLED as u32 != 0) == disabled)
16057                {
16058                    Some(a.text.clone())
16059                } else {
16060                    None
16061                }
16062            })
16063        })
16064    };
16065    match name {
16066        "aliases" => Some(alias_view(false, false, false)),
16067        "galiases" => Some(alias_view(true, false, false)),
16068        "saliases" => Some(alias_view(false, true, false)),
16069        "dis_aliases" => Some(alias_view(false, false, true)),
16070        "dis_galiases" => Some(alias_view(true, false, true)),
16071        "dis_saliases" => Some(alias_view(false, true, true)),
16072        _ => {
16073            // Single-lock O(1) probe (see assoc_key_hit) — the previous
16074            // double exec.assoc() cloned the whole map twice per lookup.
16075            crate::vm_helper::assoc_key_hit(name, key).map(|(_, v)| v)
16076        }
16077    }
16078}
16079
16080/// KSHARRAYS bare-`$name` expansion words for the unbraced
16081/// no-subscript form (BUILTIN_ARRAY_INDEX_UNBRACED's KSHARRAYS arm).
16082///
16083/// - `@` / `*` stay the full positional list (the c:Src/params.c:
16084///   2293-2296 first-element collapse is gated on
16085///   `itype_end(t, IIDENT, 1) != t` — an identifier-shaped name —
16086///   which `@`/`*` are not). The literal `[idx]` then joins the LAST
16087///   word, matching zsh 5.9: `setopt ksharrays; set -- p q;
16088///   print -- $@[0]` → `zsh:1: no matches found: q[0]`.
16089/// - Identifier-named arrays collapse to the FIRST element
16090///   (c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`).
16091/// - Assocs collapse to the first value in scan order; the `options`
16092///   magic assoc's scan order is `OPTIONTAB` bucket order (first key
16093///   `posixargzero`), matching zsh 5.9: `emulate sh -L;
16094///   print $options[posixargzero]` → `off[posixargzero]`.
16095/// - Scalars / unset names expand to their value / empty (zsh 5.9:
16096///   `setopt ksharrays; print -- $unsetvar[0]` →
16097///   `zsh:1: no matches found: [0]`).
16098fn ksharrays_bare_words(name: &str) -> Vec<String> {
16099    if name == "@" || name == "*" {
16100        return with_executor(|exec| exec.pparams());
16101    }
16102    // Magic special-parameter lookups first — mirrors the
16103    // BUILTIN_GET_VAR precedence (partab before executor tables).
16104    if let Some(vals) = crate::vm_helper::partab_array_get(name) {
16105        return vec![vals.into_iter().next().unwrap_or_default()];
16106    }
16107    if let Some(keys) = crate::vm_helper::partab_scan_keys(name) {
16108        // c:Src/params.c:2342-2350 — under KSH emulation a bare special
16109        // hash is `${hash[0]}`, same arm as the plain assoc below.
16110        if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
16111            return vec![crate::vm_helper::partab_get(name, "0").unwrap_or_default()];
16112        }
16113        let v = keys
16114            .first()
16115            .and_then(|k| crate::vm_helper::partab_get(name, k))
16116            .unwrap_or_default();
16117        return vec![v];
16118    }
16119    let arr_or_assoc = with_executor(|exec| {
16120        if let Some(arr) = exec.array(name) {
16121            // c:Src/params.c:2293-2296 — first element only.
16122            return Some(arr.first().cloned().unwrap_or_default());
16123        }
16124        if let Some(map) = exec.assoc(name) {
16125            // c:Src/params.c:2351-2358 — under KSH EMULATION a bare
16126            // `$assoc` is `${assoc[0]}` (KEY-"0" lookup), EMPTY unless the
16127            // hash has a key "0": `emulate -L ksh; typeset -A h=(a 1 b 2);
16128            // print $h` is empty. Every other mode (`setopt ksharrays`,
16129            // `emulate sh`) falls through to the first bucket value below.
16130            if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
16131                return Some(map.get("0").cloned().unwrap_or_default());
16132            }
16133            // Mirrors BUILTIN_GET_VAR's bare-assoc ordering
16134            // (sorted keys, first value).
16135            let mut keys: Vec<&String> = map.keys().collect();
16136            keys.sort();
16137            return Some(
16138                keys.first()
16139                    .and_then(|k| map.get(*k).cloned())
16140                    .unwrap_or_default(),
16141            );
16142        }
16143        None
16144    });
16145    if let Some(v) = arr_or_assoc {
16146        return vec![v];
16147    }
16148    vec![with_executor(|exec| exec.get_variable(name))]
16149}
16150
16151/// Run `body` through `crate::ported::subst::paramsubst` and convert
16152/// the resulting node list into a fusevm `Value`. Centralises the
16153/// pattern duplicated across ~10 BUILTIN_* handlers:
16154///   - build a `${...}` body string from opcode operands
16155///   - paramsubst the body
16156///   - propagate errflag to `exec.last_status`
16157///   - delegate the LinkList → Value conversion to `nodes_to_value`
16158///
16159/// **Extension** — Rust-only helper. No direct C analog because C
16160/// zsh uses LinkList everywhere; the conversion happens at the
16161/// boundary back into the VM's stack.
16162fn paramsubst_to_value(body: &str) -> Value {
16163    paramsubst_to_value_pf(body, 0)
16164}
16165
16166/// `paramsubst_to_value` with an explicit `pf_flags` (`PREFORK_*`) set.
16167///
16168/// c:Src/subst.c:1627 — `paramsubst(l, n, str, qt, pf_flags, ret_flags)`.
16169/// The only caller that needs a non-zero set today is the `${(flags)NAME}`
16170/// fast path when the word is a scalar-assignment VALUE: C preforks that with
16171/// `PREFORK_SINGLE|PREFORK_ASSIGN` (c:Src/exec.c:2603 for `x=…`,
16172/// c:Src/exec.c:4239-4241 for the typeset-family `NAME=…` argument), and
16173/// `PREFORK_SINGLE` is the `ssub` that turns off c:3913's forced split.
16174fn paramsubst_to_value_pf(body: &str, pf_flags: i32) -> Value {
16175    // c:Src/subst.c:1625 paramsubst's `qt` flag is the C signal that
16176    // the current expansion is inside `"…"`. The fast-path bridges
16177    // (BUILTIN_PARAM_*, BUILTIN_BRIDGE_BRACE_ARRAY) used to hardcode
16178    // qt=false, which silently broke DQ-only semantics inside
16179    // `${arr:^other}` / `${arr:^^other}` (Src/subst.c:3456-3520).
16180    // The executor's `in_dq_context` counter is bumped by EXPAND_TEXT
16181    // mode 1 / mode 5 before the bridge fires, so reading it here
16182    // propagates the DQ flag without changing every bridge call site.
16183    let qt = with_executor(|exec| exec.in_dq_context > 0);
16184    // c:Src/subst.c:318-324 — this is the bridge's stand-in for stringsubst's
16185    // paramsubst call, and that call site is where SH_WORD_SPLIT enters:
16186    //     if ((isset(SHWORDSPLIT) && !(pf_flags & PREFORK_NOSHWORDSPLIT)) ||
16187    //         (pf_flags & PREFORK_SPLIT))
16188    //         pf_flags |= PREFORK_SHWORDSPLIT;
16189    //     node = paramsubst(list, node, &str, qt,
16190    //         pf_flags & (PREFORK_SINGLE|PREFORK_SHWORDSPLIT|PREFORK_SUBEXP), …);
16191    // paramsubst's `spbreak` (c:1707) reads only that flag, never the option.
16192    // Passing the callers' bare flags left every compiled `${…}` fast path
16193    // unsplit: `setopt shwordsplit; s='a o b'; print -l ${s/o/x}` printed one
16194    // word where zsh prints `a` `x` `b` (same for `${s:-x}`, `${s#a}`,
16195    // `${s:1}`, `${(U)s}`, `${${s}/o/ }`). A scalar-assignment value still
16196    // passes PREFORK_SINGLE, which keeps `spbreak` off there.
16197    let pf_flags = {
16198        use crate::ported::zsh_h::{
16199            PREFORK_NOSHWORDSPLIT, PREFORK_SHWORDSPLIT, PREFORK_SINGLE, PREFORK_SPLIT,
16200            PREFORK_SUBEXP, SHWORDSPLIT,
16201        };
16202        let mut f = pf_flags;
16203        if (crate::ported::zsh_h::isset(SHWORDSPLIT) && f & PREFORK_NOSHWORDSPLIT == 0)
16204            || f & PREFORK_SPLIT != 0
16205        {
16206            f |= PREFORK_SHWORDSPLIT; // c:321
16207        }
16208        f & (PREFORK_SINGLE | PREFORK_SHWORDSPLIT | PREFORK_SUBEXP) // c:324-325
16209    };
16210    let mut ret_flags: i32 = 0;
16211    // c:Src/subst.c:183-186 — `body` is the `${…}` BODY, not the word: the
16212    // caller's literals are attached by its own concat opcodes afterwards.
16213    // `PARAMSUBST_AFFIXES_DEFERRED` (set by `BUILTIN_WORD_DEFER_EMPTIES`)
16214    // says the caller will also run the end-of-word empty-node removal, so
16215    // `paramsubst` must not run it early — see that declaration in
16216    // `src/ported/subst.rs`.
16217    //
16218    // The declaration belongs to the OUTERMOST expansion of the word only.
16219    // A bridge expansion re-entered from inside this one is its own
16220    // finished word with nobody left to attach anything to it, so it keeps
16221    // the removal — `IN_BRIDGE_PARAMSUBST` is that re-entry guard (the
16222    // ported `IN_PARAMSUBST_NEST` covers only the `stringsubst` route).
16223    // Both cells are restored afterwards so the word's remaining segments
16224    // still defer (`A${a%z}B${b%z}C`).
16225    let reentered = IN_BRIDGE_PARAMSUBST.with(|c| c.replace(true));
16226    let saved_defer = crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get());
16227    if reentered {
16228        crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(0));
16229    }
16230    let (_full, _pos, nodes) =
16231        crate::ported::subst::paramsubst(body, 0, qt, pf_flags, &mut ret_flags);
16232    let null_return = take_paramsubst_null();
16233    crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(saved_defer));
16234    IN_BRIDGE_PARAMSUBST.with(|c| c.set(reentered));
16235    if null_return {
16236        // c:Src/subst.c:1878/326-327 — the word ends at this `$`. For a scalar
16237        // nothing of this expansion is kept. For an array, c:4383-4394 had
16238        // already inserted the elements evaluated before the failing one, so
16239        // those survive as words (`arr=(one '$(' two); ${(e)arr}` is `one`).
16240        // See BUILTIN_PREFORK_CUT_CHECK.
16241        let kept: Vec<String> = nodes
16242            .into_iter()
16243            .filter(|n| !n.is_empty())
16244            .map(|n| crate::ported::lex::untokenize(&n).to_string())
16245            .collect();
16246        return if kept.is_empty() {
16247            Value::str(String::new())
16248        } else {
16249            nodes_to_value(kept)
16250        };
16251    }
16252    // c:Src/exec.c:3523-3525 — an expansion error aborts the command with
16253    // `if (errflag) { if (!lastval) lastval = 1; …`: a non-zero status from
16254    // the previous command survives (`nosuchcmd; print ${.bad}` exits 127).
16255    if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
16256        with_executor(|exec| {
16257            if exec.last_status() == 0 {
16258                exec.set_last_status(1);
16259            }
16260        });
16261    }
16262    // c:Src/lex.c untokenize — the final argv pass C runs on every
16263    // expanded word (glob.c:1862 / exec.c) DROPS the Nularg
16264    // empty-word sentinel (c:2089 `if (c != Nularg)`) that
16265    // remnulargs faithfully leaves in place (glob.c:3673 re-adds it
16266    // for all-empty results). These fast-path bridges are terminal —
16267    // their output lands directly in argv slots — so apply it here.
16268    // Without it, quoted splits with empty pieces ("${(s:|:)x}" on
16269    // "|a|b|") leak Nularg into argv.
16270    // c:Src/subst.c:178-181 — `prefork` runs `filesub` on the word AFTER
16271    // `stringsubst` and BEFORE anything untokenizes it, which is what makes a
16272    // `~` inside an expansion body expand: `filesubstr` (c:741) matches the
16273    // lexer's `Tilde` TOKEN, never an ASCII `~`. These bridges are terminal
16274    // and the untokenize below folds that token back to `~`, so the filesub
16275    // the word path runs afterwards had nothing left to act on and
16276    // `${XDG_CACHE_HOME:-~/.cache}` came back literal (powerlevel10k's dump
16277    // path, powerlevel10k.zsh-theme:62). Run C's order here: filesub first,
16278    // untokenize second.
16279    //
16280    // Gated exactly like the prefork site (subst.rs:396): not under
16281    // `SHFILEEXPANSION` (whose file expansion runs BEFORE substitution, so an
16282    // expansion result is never tilde-expanded), not inside `"…"` (`qt`), and
16283    // not while `SKIP_FILESUB` marks a `${var/pat/repl}` pattern context where
16284    // a literal `~` must survive.
16285    // The word this expansion belongs to runs its own end-of-word drop.
16286    // A lone node that is NOTHING BUT the c:36 `nulstring` marker is an
16287    // empty FIELD a split produced, not an empty word. C drops the marker
16288    // in its final untokenize pass (c:Src/lex.c:2089), but prefork's
16289    // node-removal decision at c:183 was made before that, with the marker
16290    // still in place, so the word survives as "". Folding it here instead
16291    // hands `nodes_to_value` a node it cannot tell from a genuine empty,
16292    // and its single-node drop deletes it — see the trace there. `IFS=:;
16293    // v=":"; set -- $v` under `--dash` reported `$#` as 0 where dash, bash
16294    // and ksh all report 1.
16295    // Only outside `"…"`. A quoted expansion keeps its empty string on its
16296    // own (`nodes_to_value`'s `in_dq_context` arm), so there is no drop to
16297    // protect it from — and the paths a quoted word takes do not all run
16298    // remnulargs afterwards, so keeping the marker there put a literal
16299    // Nularg in the output: `b=(1 2 3 4 5); c=(2 4 6);
16300    // print -r -- "[${(j: :)b:*c}]"` printed `[¡]` instead of `[]`.
16301    let lone_marker = !qt
16302        && nodes.len() == 1
16303        && nodes[0].chars().eq(std::iter::once(crate::ported::zsh_h::Nularg));
16304    let keep_nulargs = (saved_defer > 0 && !reentered) || lone_marker;
16305    let do_filesub = !qt
16306        && !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHFILEEXPANSION)
16307        && !crate::ported::subst::SKIP_FILESUB.with(|c| c.get());
16308    let nodes: Vec<String> = nodes
16309        .into_iter()
16310        .map(|n| {
16311            let n = if do_filesub {
16312                crate::ported::subst::filesub(
16313                    &n,
16314                    pf_flags
16315                        & (crate::ported::zsh_h::PREFORK_TYPESET
16316                            | crate::ported::zsh_h::PREFORK_ASSIGN),
16317                )
16318            } else {
16319                n
16320            };
16321            // c:36 `nulstring` survives for the word's end-of-word drop (see
16322            // BUILTIN_WORD_ELIDE_EMPTY / BUILTIN_ARRAY_DROP_EMPTY): untokenize
16323            // would fold it to "", which that drop cannot tell from a real
16324            // empty node.
16325            if keep_nulargs && n.contains(crate::ported::zsh_h::Nularg) {
16326                n.split(crate::ported::zsh_h::Nularg)
16327                    .map(crate::ported::lex::untokenize)
16328                    .collect::<Vec<_>>()
16329                    .join(&crate::ported::zsh_h::Nularg.to_string())
16330            } else {
16331                crate::ported::lex::untokenize(&n)
16332            }
16333        })
16334        .collect();
16335    let value = nodes_to_value(nodes);
16336    // Provenance: this is the funnel every `${...}` bytecode fast path
16337    // reaches, so it is where a tracked parameter's chain is handed to
16338    // the value the expansion produced.
16339    if crate::provenance::active() {
16340        if let Some(name) = prov_subst_name(body) {
16341            crate::provenance::on_param_read(&name, &value);
16342        }
16343    }
16344    value
16345}
16346
16347/// !!! RUST-ONLY ADAPTER — the dual `$?` store !!!
16348///
16349/// `paramsubst_to_value_pf` for the compiled `${…}` fast-path builtins, whose
16350/// body can run a command substitution (`${y:-$(exit 4)}`, `${y#$(…)}`).
16351/// C has ONE `lastval`: getoutput's `lastval = cmdoutval` (c:Src/exec.c:4775)
16352/// is what a later `$?` in the same command reads, so
16353/// `print ${y:-$(exit 4)} $?` prints `4`. zshrs also keeps `vm.last_status`,
16354/// which `BUILTIN_GET_VAR` treats as authoritative, so the inner status has
16355/// to be handed back to it — the same two-way mirror `BUILTIN_CMD_SUBST_TEXT`
16356/// and `BUILTIN_EXPAND_TEXT` do around their own cmd-subst runs.
16357fn paramsubst_to_value_status(vm: &mut fusevm::VM, body: &str, pf_flags: i32) -> Value {
16358    // The substitution sees the live `$?` (`false; print ${y:-$(print $?)}`).
16359    let live_status = vm.last_status;
16360    with_executor(|exec| exec.set_last_status(live_status));
16361    let v = paramsubst_to_value_pf(body, pf_flags);
16362    vm.last_status = with_executor(|exec| exec.last_status()); // c:4775
16363    v
16364}
16365
16366/// Parameter name inside a `${...}` expansion body, for the provenance
16367/// tap in `paramsubst_to_value_pf`. Skips a leading `(flags)` group and
16368/// the `#`/`+`/`^`/`=`/`~` prefix sigils, then takes the identifier —
16369/// `${(k)assoc[key]}` yields `assoc`, `${#F}` yields `F`. Returns `None`
16370/// for the forms with no single named source (`$(...)`, `${(%)...}`).
16371fn prov_subst_name(body: &str) -> Option<String> {
16372    let mut rest = body.strip_prefix("${").or_else(|| body.strip_prefix('$'))?;
16373    rest = rest.trim_end_matches('}');
16374    // Skip one leading `(flags)` group.
16375    if let Some(after) = rest.strip_prefix('(') {
16376        rest = after.split_once(')').map(|(_, r)| r)?;
16377    }
16378    rest = rest.trim_start_matches(['#', '+', '^', '=', '~']);
16379    let name: String = rest
16380        .chars()
16381        .take_while(|c| c.is_alphanumeric() || *c == '_')
16382        .collect();
16383    if name.is_empty() {
16384        None
16385    } else {
16386        Some(name)
16387    }
16388}
16389
16390/// Wrap a `Vec<String>` (e.g. paramsubst nodes, multsub parts,
16391/// xpandbraces output) into a fusevm `Value`: 0 → empty Array, 1 →
16392/// Str, >1 → Array. Same unwrap idiom every handler that calls a
16393/// canonical Vec-returning fn does.
16394/// c:Src/subst.c:1663 — `int plan9 = isset(RCEXPANDPARAM);`
16395///
16396/// zsh calls the RC_EXPAND_PARAM word shape "plan9" after the rc(1)
16397/// shell it comes from. The option is read fresh on every expansion
16398/// (`setopt` mid-script changes the very next word), so the concat
16399/// builtins must consult it at RUNTIME, not bake it in at compile time.
16400fn plan9_active() -> bool {
16401    with_executor(|_exec| opt_state_get("rcexpandparam").unwrap_or(false))
16402}
16403
16404thread_local! {
16405    /// The `isarr` bit that `Value` cannot carry.
16406    ///
16407    /// c:Src/subst.c:4245 `if (isarr)` gates the whole array emit block,
16408    /// so plan9's word-removal rule (c:4362 `uremnode`) only ever applies
16409    /// to an ARRAY-valued expansion. An empty SCALAR has `isarr == 0`,
16410    /// takes the c:4437 scalar branch, and leaves the surrounding text
16411    /// intact — `setopt rcexpandparam; v=; print -rl -- x$v y` prints `x`
16412    /// and `y`, while the same line with an empty ARRAY prints only `y`.
16413    ///
16414    /// zshrs collapses BOTH shapes to `Value::Array(vec![])`: a real empty
16415    /// array, and an unquoted empty scalar (collapsed so a standalone `$v`
16416    /// contributes zero argv words, mirroring prefork's `uremnode` at
16417    /// c:Src/subst.c:184-187). The two are indistinguishable by the time
16418    /// they reach a concat builtin, so the expansion builtins record here
16419    /// whether the empty Array they just produced came from a scalar.
16420    ///
16421    /// Sticky until the next expansion overwrites it — a word folds its
16422    /// segments left-associatively (`concat(concat(x, $e), y)`), so the
16423    /// propagating second concat must still see the first one's bit.
16424    /// Consequence: only a builtin that RETURNS an empty `Value::Array` may
16425    /// write the cell. A builtin returning an empty SCALAR `Value` must leave
16426    /// it alone, or it resurrects a word an earlier empty array deleted
16427    /// (`a=(); x${a}"${P}"y` is zero words in zsh) — see the
16428    /// `word_has_quoted_span` arm of `BUILTIN_EXPAND_TEXT`.
16429    static EMPTY_EXPANSION_IS_SCALAR: std::cell::Cell<bool> =
16430        const { std::cell::Cell::new(false) };
16431}
16432
16433/// Record whether the empty expansion just produced was a scalar (`true`)
16434/// or a genuine array (`false`). See `EMPTY_EXPANSION_IS_SCALAR`.
16435fn note_empty_is_scalar(is_scalar: bool) {
16436    EMPTY_EXPANSION_IS_SCALAR.with(|c| c.set(is_scalar));
16437}
16438
16439/// True when the empty `Value::Array` about to be concatenated stands for
16440/// an empty SCALAR (c:4438-4467), not an empty array (c:4362).
16441fn empty_is_scalar() -> bool {
16442    EMPTY_EXPANSION_IS_SCALAR.with(|c| c.get())
16443}
16444
16445thread_local! {
16446    /// The other `isarr` bit `Value` cannot carry: whether an empty element
16447    /// of the array now on the stack is ELIDABLE.
16448    ///
16449    /// c:Src/subst.c:36 `char nulstring[] = {Nularg, '\0'};` — zsh does not
16450    /// represent a split-produced empty field as the empty string. Every
16451    /// place that manufactures one (the SH_WORD_SPLIT scalar split at
16452    /// c:3919 `sepsplit`, command substitution's split, the quoted-array
16453    /// retention at c:4354 `if (qt && !*y && isarr != 2) y =
16454    /// dupstring(nulstring)`) stores `nulstring`, whose node text is
16455    /// NON-empty — so prefork's node-removal test at c:183
16456    /// `if (*(char *)getdata(node))` KEEPS it, and `remnulargs` (c:170)
16457    /// turns it back into "" afterwards. Only an empty element that came
16458    /// from a genuine ARRAY read is stored as a true empty string, and only
16459    /// that one reaches c:186 `uremnode`.
16460    ///
16461    /// zshrs renders both shapes as `String::new()`, so the marker C tests
16462    /// on is gone by the time a word is assembled. This cell carries it
16463    /// instead: `get_var_impl` sets it on the unquoted array/assoc read
16464    /// paths (C's `isarr` != 2 array reference), and `BUILTIN_WORD_ELIDE_EMPTY`
16465    /// consumes it. Without the distinction the elide either dropped
16466    /// split fields zsh keeps (`setopt shwordsplit; IFS=:; s='a::b';
16467    /// print -rl -- ${s}` is `a` `` `b`; `IFS=:; print -rl -- $(printf
16468    /// 'a::b')` likewise) or kept array empties zsh drops.
16469    ///
16470    /// Sticky across a word's segment folds, cleared when an end-of-word
16471    /// drop builtin consumes it.
16472    static ARRAY_EMPTIES_ELIDABLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
16473}
16474
16475thread_local! {
16476    /// The opposite bit: this word's array came out of a QUOTED splat whose
16477    /// empty elements C stores as `nulstring`, so the end-of-word drop must
16478    /// KEEP them.
16479    ///
16480    /// c:Src/subst.c:4387, :4404, :4426 — every node the array emit block
16481    /// produces runs `if (qt && !*y && isarr != 2) y = dupstring(nulstring)`.
16482    /// `nulstring` (c:36 `{Nularg, '\0'}`) is NON-empty, so c:183's
16483    /// `if (*(char *)getdata(node))` keeps the node and c:186's `uremnode`
16484    /// never sees it; `remnulargs` (c:170) turns it back into `""`.
16485    ///
16486    /// The rule has a RUN-time cut-off that no compile-time gate can express:
16487    /// c:4261 `if ((!aval[0] || !aval[1]) && !plan9)` claims the empty and
16488    /// single-element cases FIRST and strips the surrounding `Dnull` markers
16489    /// (c:4272-4274 `*--aptr = '\0', fstr++`), so those nodes really are empty
16490    /// and c:186 deletes them. That is why `a=(); b=(); "${a[@]}""${b[@]}"` is
16491    /// ZERO words while `a=(x '' y); PRE"${a[@]}"POST` is three. Hence the bit
16492    /// is set by the READ (which knows the element count) and only when the
16493    /// compile site marked the splat quoted (`BUILTIN_ARRAY_ALL` argc 4, or
16494    /// `BUILTIN_GET_VAR_DQ` on the `@` positional).
16495    ///
16496    /// Word-scoped: cleared when a word opens (`BUILTIN_WORD_DEFER_EMPTIES`
16497    /// argc 0), cleared by any UNQUOTED array read
16498    /// (`note_array_empties_elidable`, its exact complement), and consumed by
16499    /// the end-of-word drop.
16500    ///
16501    /// Known residue: the bit is per-WORD, while C's `nulstring` is per-NODE,
16502    /// so a word mixing a quoted and an unquoted splat
16503    /// (`PRE"${a[@]}"${b[@]}POST`) keeps both sets of empties where C keeps
16504    /// only the quoted one.
16505    static QUOTED_SPLICE_KEEPS_EMPTIES: std::cell::Cell<bool> =
16506        const { std::cell::Cell::new(false) };
16507}
16508
16509/// Record that the value just read is a genuine ARRAY reference, so an empty
16510/// element of it is a true empty word. See `ARRAY_EMPTIES_ELIDABLE`.
16511fn note_array_empties_elidable() {
16512    ARRAY_EMPTIES_ELIDABLE.with(|c| c.set(true));
16513    // c:4387's `qt` half is false for this read, so nothing it produced is
16514    // `nulstring`. The two bits are complements; do not let a previous word's
16515    // quoted splat keep this one's empties.
16516    QUOTED_SPLICE_KEEPS_EMPTIES.with(|c| c.set(false));
16517}
16518
16519/// Read and clear the elidable-empties bit. See `ARRAY_EMPTIES_ELIDABLE`.
16520fn take_array_empties_elidable() -> bool {
16521    ARRAY_EMPTIES_ELIDABLE.with(|c| c.replace(false))
16522}
16523
16524/// c:Src/subst.c:4387 — record that a QUOTED splat of `n` elements was read.
16525/// c:4261's cut-off means the `nulstring` rewrite is only reachable for
16526/// `n >= 2`; below that the emit block strips the quotes and the node is a
16527/// true empty. See `QUOTED_SPLICE_KEEPS_EMPTIES`.
16528fn note_quoted_splice_elems(n: usize) {
16529    QUOTED_SPLICE_KEEPS_EMPTIES.with(|c| c.set(n >= 2));
16530}
16531
16532/// Read and clear the quoted-splat bit. See `QUOTED_SPLICE_KEEPS_EMPTIES`.
16533fn take_quoted_splice_keeps_empties() -> bool {
16534    QUOTED_SPLICE_KEEPS_EMPTIES.with(|c| c.replace(false))
16535}
16536
16537/// Declare that the word now being assembled attaches its own literals, so
16538/// `paramsubst` must leave c:183-186's empty-node removal to this word's
16539/// end-of-word drop. See `BUILTIN_WORD_DEFER_EMPTIES` and, in
16540/// `src/ported/subst.rs`, `PARAMSUBST_AFFIXES_DEFERRED`.
16541fn open_deferred_affix_word() {
16542    crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(c.get() + 1));
16543    // c:4387's `qt`/`nulstring` bit belongs to the word being assembled, so a
16544    // word that opens starts from a clean one — a preceding whole-word
16545    // `"${a[@]}"` emits no end-of-word drop and would otherwise leave it set.
16546    QUOTED_SPLICE_KEEPS_EMPTIES.with(|c| c.set(false));
16547}
16548
16549/// End the declaration `open_deferred_affix_word` made — the marker's
16550/// closing half (argc 1), emitted by `compile_zsh` immediately after this
16551/// word's drop. Pairing it with the OPEN rather than with the drop itself
16552/// matters: `BUILTIN_ARRAY_DROP_EMPTY` is also emitted for a bare splice
16553/// segment that opened nothing (`P${b[@]}${a%z}Q` emits one for `${b[@]}`),
16554/// and letting that close the enclosing word ended the declaration before
16555/// `${a%z}` was reached.
16556///
16557/// The `PARAMSUBST_EMPTIES_DEFERRED` report is cleared only when the
16558/// OUTERMOST word closes: the drop that consumes it is that word's.
16559fn close_deferred_affix_word() {
16560    let depth = crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| {
16561        c.set((c.get() - 1).max(0));
16562        c.get()
16563    });
16564    if depth == 0 {
16565        crate::ported::subst::PARAMSUBST_EMPTIES_DEFERRED.with(|c| c.set(false));
16566    }
16567}
16568
16569thread_local! {
16570    /// True while `paramsubst_to_value_pf` is inside `paramsubst`.
16571    ///
16572    /// The deferral `BUILTIN_WORD_DEFER_EMPTIES` declares is a property of
16573    /// the WORD the compiler is assembling, so it applies to that word's own
16574    /// segment expansions and to nothing they in turn expand. A bridge
16575    /// expansion re-entered from inside one (a nested body the compiler
16576    /// routed through its own opcodes, or anything the ported code drives
16577    /// back through this funnel) is a finished word already, so c:183-186's
16578    /// removal stays where it is for that one.
16579    static IN_BRIDGE_PARAMSUBST: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
16580}
16581
16582/// Re-assert `was_scalar` when `v` is an empty result.
16583///
16584/// For a pass-through stage — one that rewrites word TEXT but cannot turn a
16585/// scalar into an array or vice versa — the shape bit belongs to whichever
16586/// expansion produced the value, not to the stage. Such stages usually end in
16587/// `nodes_to_value`, which records "array" for an empty result, so without
16588/// this the producer's bit is lost before `concat_plan9` reads it.
16589/// Non-empty results carry their shape in the `Value` itself and are
16590/// returned untouched.
16591fn restore_empty_shape(v: Value, was_scalar: bool) -> Value {
16592    if matches!(&v, Value::Array(a) if a.is_empty()) {
16593        note_empty_is_scalar(was_scalar);
16594    }
16595    v
16596}
16597
16598/// `concat_splice` with the provenance tap applied to the result. Every
16599/// concat bytecode op routes through this (or `concat_plan9_prov`), so a
16600/// word built out of a tracked parameter keeps that parameter's lineage
16601/// even though the concatenated bytes are a fresh allocation.
16602fn concat_splice_prov(lhs: Value, rhs: Value) -> Value {
16603    if !crate::provenance::active() {
16604        return concat_splice(lhs, rhs);
16605    }
16606    let (l, r) = (lhs.clone(), rhs.clone());
16607    let out = concat_splice(lhs, rhs);
16608    crate::provenance::on_concat(&l, &r, &out);
16609    out
16610}
16611
16612/// `concat_plan9` with the provenance tap. See `concat_splice_prov`.
16613fn concat_plan9_prov(lhs: Value, rhs: Value) -> Value {
16614    if !crate::provenance::active() {
16615        return concat_plan9(lhs, rhs);
16616    }
16617    let (l, r) = (lhs.clone(), rhs.clone());
16618    let out = concat_plan9(lhs, rhs);
16619    crate::provenance::on_concat(&l, &r, &out);
16620    out
16621}
16622
16623/// c:Src/subst.c:4366-4437 — the NON-plan9 arm of paramsubst's array
16624/// emit block: "simply join the first and last values."
16625///
16626/// The word prefix (`ostr..aptr`) is concatenated onto element 0
16627/// (c:4386 `strcatsub(&y, ostr, aptr, x, xlen, NULL, …)`), the interior
16628/// elements are emitted bare (c:4393-4412), and the word suffix (`fstr`)
16629/// is concatenated onto the final element (c:4414-4429). Applied left-
16630/// associatively across a word's segments this reproduces zsh's
16631/// `pre${arr}post` → `prep` / `q` / `rpost`.
16632///
16633/// An EMPTY array never reaches this arm in C: c:4261
16634/// `if ((!aval[0] || !aval[1]) && !plan9)` collapses it to the scalar ""
16635/// first, so the surrounding text survives as one word (`x$e y` → `x`).
16636/// That is what the empty-Array arms below reproduce.
16637fn concat_splice(lhs: Value, rhs: Value) -> Value {
16638    match (lhs, rhs) {
16639        (Value::Array(la), Value::Array(ra)) => {
16640            if la.is_empty() {
16641                return Value::Array(ra);
16642            }
16643            if ra.is_empty() {
16644                return Value::Array(la);
16645            }
16646            // Last of la merges with first of ra; rest unchanged.
16647            let mut la = la.to_vec();
16648            let last_l = la.pop().unwrap();
16649            let mut ra_iter = ra.iter().cloned();
16650            let first_r = ra_iter.next().unwrap();
16651            let l_s = last_l.as_str_cow();
16652            let r_s = first_r.as_str_cow();
16653            let mut merged = String::with_capacity(l_s.len() + r_s.len());
16654            merged.push_str(&l_s);
16655            merged.push_str(&r_s);
16656            la.push(Value::str(merged));
16657            la.extend(ra_iter);
16658            Value::array(la)
16659        }
16660        (Value::Array(la), rhs_scalar) => {
16661            // c:4261 — empty array + empty surrounding text is zero
16662            // words, not one empty word. Bug #120 in docs/BUGS.md:
16663            // `b=("${a[@]:0:-1}")` gave len=1 instead of zsh's len=0.
16664            let rhs_s = rhs_scalar.as_str_cow();
16665            if la.is_empty() {
16666                if rhs_s.is_empty() {
16667                    return Value::array(Vec::new());
16668                }
16669                return Value::str(rhs_s.to_string());
16670            }
16671            let mut la = la.to_vec();
16672            let last = la.pop().unwrap();
16673            let l_s = last.as_str_cow();
16674            let mut s = String::with_capacity(l_s.len() + rhs_s.len());
16675            s.push_str(&l_s);
16676            s.push_str(&rhs_s);
16677            la.push(Value::str(s));
16678            Value::array(la)
16679        }
16680        (lhs_scalar, Value::Array(ra)) => {
16681            let lhs_s = lhs_scalar.as_str_cow();
16682            if ra.is_empty() {
16683                // Symmetric c:4261 empty-array rule; see the arm above.
16684                if lhs_s.is_empty() {
16685                    return Value::array(Vec::new());
16686                }
16687                return Value::str(lhs_s.to_string());
16688            }
16689            let mut ra = ra.to_vec();
16690            let first = ra.remove(0);
16691            let r_s = first.as_str_cow();
16692            let mut s = String::with_capacity(lhs_s.len() + r_s.len());
16693            s.push_str(&lhs_s);
16694            s.push_str(&r_s);
16695            let mut out = Vec::with_capacity(ra.len() + 1);
16696            out.push(Value::str(s));
16697            out.extend(ra);
16698            Value::array(out)
16699        }
16700        (lhs_s, rhs_s) => {
16701            let l = lhs_s.as_str_cow();
16702            let r = rhs_s.as_str_cow();
16703            let mut s = String::with_capacity(l.len() + r.len());
16704            s.push_str(&l);
16705            s.push_str(&r);
16706            Value::str(s)
16707        }
16708    }
16709}
16710
16711/// c:Src/subst.c:4316-4365 — the plan9 (RC_EXPAND_PARAM) arm of
16712/// paramsubst's array emit block.
16713///
16714/// Every element gets the FULL word prefix and suffix
16715/// (c:4341 `strcatsub(&y, ostr, aptr, x, xlen, y + 1, …)` inside the
16716/// per-element loop), giving the cross product with the surrounding
16717/// text: `pre${arr}post` → `preppost` / `preqpost` / `prerpost`.
16718///
16719/// An EMPTY array removes the WHOLE word: the c:4327
16720/// `while ((x = *aval++))` loop body never runs, so `plan9` is still
16721/// non-zero at c:4362 and the node is deleted —
16722/// `if (plan9) { uremnode(l, n); return n; }` (c:4362-4365). `e=();
16723/// setopt RC_EXPAND_PARAM; print -rl -- x$e y` prints only `y`. This is
16724/// the opposite of the non-plan9 rule at c:4261, which keeps `x`.
16725fn concat_plan9(lhs: Value, rhs: Value) -> Value {
16726    // c:4245 `if (isarr)` — an empty SCALAR never enters the array emit
16727    // block, so it contributes "" and the word survives (c:4437). Only a
16728    // real empty ARRAY reaches c:4362's `uremnode`. `Value` cannot tell
16729    // the two apart; EMPTY_EXPANSION_IS_SCALAR carries the missing bit.
16730    let scalar_empty = empty_is_scalar();
16731    match (lhs, rhs) {
16732        // c:4362-4365 — an empty array on either side deletes the word.
16733        // Propagated as an empty Array so a later concat in the same word
16734        // (`x${e[@]}y` folds twice) keeps the word deleted; pop_args
16735        // splats an empty Array into zero argv words.
16736        (Value::Array(la), rhs_v) if la.is_empty() => {
16737            if scalar_empty {
16738                // Empty scalar prefix: "" + rhs (c:4437 strcatsub).
16739                return match rhs_v {
16740                    Value::Array(ra) if ra.is_empty() => Value::array(Vec::new()),
16741                    other => other,
16742                };
16743            }
16744            Value::array(Vec::new())
16745        }
16746        (lhs_v, Value::Array(ra)) if ra.is_empty() => {
16747            if scalar_empty {
16748                // Empty scalar suffix: lhs + "" (c:4437 strcatsub).
16749                return lhs_v;
16750            }
16751            Value::array(Vec::new())
16752        }
16753        (Value::Array(la), Value::Array(ra)) => {
16754            let mut out = Vec::with_capacity(la.len() * ra.len());
16755            for a in la.iter() {
16756                let a_s = a.as_str_cow();
16757                for b in ra.iter() {
16758                    let b_s = b.as_str_cow();
16759                    let mut s = String::with_capacity(a_s.len() + b_s.len());
16760                    s.push_str(&a_s);
16761                    s.push_str(&b_s);
16762                    out.push(Value::str(s));
16763                }
16764            }
16765            Value::array(out)
16766        }
16767        (Value::Array(la), rhs_scalar) => {
16768            let r = rhs_scalar.as_str_cow();
16769            let out: Vec<Value> = la
16770                .iter()
16771                .map(|a| {
16772                    let a_s = a.as_str_cow();
16773                    let mut s = String::with_capacity(a_s.len() + r.len());
16774                    s.push_str(&a_s);
16775                    s.push_str(&r);
16776                    Value::str(s)
16777                })
16778                .collect();
16779            Value::array(out)
16780        }
16781        (lhs_scalar, Value::Array(ra)) => {
16782            let l = lhs_scalar.as_str_cow();
16783            let out: Vec<Value> = ra
16784                .iter()
16785                .map(|b| {
16786                    let b_s = b.as_str_cow();
16787                    let mut s = String::with_capacity(l.len() + b_s.len());
16788                    s.push_str(&l);
16789                    s.push_str(&b_s);
16790                    Value::str(s)
16791                })
16792                .collect();
16793            Value::array(out)
16794        }
16795        (lhs_s, rhs_s) => {
16796            // Both scalar: nothing to distribute (c:4444 scalar branch).
16797            let l = lhs_s.as_str_cow();
16798            let r = rhs_s.as_str_cow();
16799            let mut s = String::with_capacity(l.len() + r.len());
16800            s.push_str(&l);
16801            s.push_str(&r);
16802            Value::str(s)
16803        }
16804    }
16805}
16806
16807/// Flatten one word-segment `Value` into its element strings: an Array splats
16808/// to its items, a scalar is a single element.
16809fn word_seg_elems(v: &Value) -> Vec<String> {
16810    match v {
16811        Value::Array(items) => items.iter().map(|i| i.as_str_cow().into_owned()).collect(),
16812        other => vec![other.as_str_cow().into_owned()],
16813    }
16814}
16815
16816/// Assemble a DQ word from its segments, mixing plan9 (`^`, cross-product) and
16817/// non-plan9 (splice) segments in one pass — see BUILTIN_WORD_ASSEMBLE_PLAN9.
16818///
16819/// c:Src/subst.c:4316-4437 — zsh threads a "growing edge" (`aptr`/`fstr`)
16820/// through the whole word: an element stays active until a splice freezes all
16821/// but the last. `active_lo` is the index where that active tail begins.
16822///   * plan9 segment  → every active element crosses with EVERY new element;
16823///     all results stay active (c:4316-4350 cartesian). An empty plan9 array
16824///     deletes the word (c:4362 `uremnode`).
16825///   * splice segment → every active element takes the FIRST new element, the
16826///     remaining new elements append as fresh words; the last becomes the new
16827///     growing edge (c:4366-4437 first/last join). A single-element splice keeps
16828///     the whole active tail active (nothing frozen). An empty splice array
16829///     contributes nothing and leaves the word intact.
16830fn word_assemble_plan9(segments: &[Value], plan9_flags: &[bool]) -> Value {
16831    let mut words: Vec<String> = Vec::new();
16832    let mut active_lo: usize = 0;
16833    let mut started = false;
16834    for (i, seg) in segments.iter().enumerate() {
16835        let plan9 = plan9_flags.get(i).copied().unwrap_or(false);
16836        let elems = word_seg_elems(seg);
16837        if plan9 && elems.is_empty() {
16838            // c:4362-4365 — plan9 empty array deletes the whole word.
16839            return Value::array(Vec::new());
16840        }
16841        if !started {
16842            started = true;
16843            // c:Src/subst.c:4261 — `if ((!aval[0] || !aval[1]) && !plan9)`.
16844            // A NON-plan9 EMPTY expansion (empty array, or an empty scalar
16845            // that zshrs collapsed to the same empty `Value::Array`) is
16846            // folded into the word text as the empty string and the node
16847            // SURVIVES (c:4268-4274 `strcatsub` of prefix + "" + suffix).
16848            // Only the plan9 arm deletes the word, and that is the
16849            // `uremnode` case already returned above (c:4362-4365).
16850            //
16851            // Seeding `words` with that single empty element is what keeps a
16852            // growing edge alive for the segments that follow. Leaving
16853            // `words` empty instead made `words[active_lo..]` an empty slice
16854            // forever, so every later segment cross-multiplied against
16855            // nothing and the whole word vanished: `n=""; a=(x y z);
16856            // print -rl -- $n${^a}` printed nothing where zsh prints
16857            // `x`/`y`/`z`, and `$n$a${^a}` dropped the leading `x`. Only a
16858            // word whose FIRST segment was the empty one was affected —
16859            // `pre$n${^a}` already started from the literal and hit the
16860            // "contributes nothing" `continue` below.
16861            words = if elems.is_empty() {
16862                vec![String::new()]
16863            } else {
16864                elems
16865            };
16866            // plan9 → the whole first array is the growing edge; splice/scalar
16867            // → only its last element grows, earlier ones are finalized words.
16868            active_lo = if plan9 {
16869                0
16870            } else {
16871                words.len().saturating_sub(1)
16872            };
16873            continue;
16874        }
16875        if plan9 {
16876            let mut new_active = Vec::with_capacity(words[active_lo..].len() * elems.len());
16877            for a in &words[active_lo..] {
16878                for r in &elems {
16879                    new_active.push(format!("{a}{r}"));
16880                }
16881            }
16882            let frozen_len = active_lo;
16883            words.truncate(frozen_len);
16884            words.extend(new_active);
16885            active_lo = frozen_len; // all cross-products stay active
16886        } else {
16887            if elems.is_empty() {
16888                // Non-plan9 empty array contributes nothing; word survives.
16889                continue;
16890            }
16891            let frozen_len = active_lo;
16892            let r0 = &elems[0];
16893            let head: Vec<String> = words[active_lo..]
16894                .iter()
16895                .map(|a| format!("{a}{r0}"))
16896                .collect();
16897            words.truncate(frozen_len);
16898            words.extend(head);
16899            words.extend(elems[1..].iter().cloned());
16900            active_lo = if elems.len() == 1 {
16901                frozen_len // single-element splice: head stays the growing edge
16902            } else {
16903                words.len() - 1 // multi: only the last appended word grows
16904            };
16905        }
16906    }
16907    match words.len() {
16908        0 => Value::array(Vec::new()),
16909        1 => Value::str(words.pop().unwrap()),
16910        _ => Value::array(words.into_iter().map(Value::str).collect()),
16911    }
16912}
16913
16914fn nodes_to_value(nodes: Vec<String>) -> Value {
16915    // c:Src/glob.c:3649 remnulargs — strip the Nularg (`\u{e1a1}`)
16916    //   sentinel and other INULL bytes that paramsubst's splat block
16917    //   emits for empty array elements (so prefork's empty-node-delete
16918    //   pass doesn't drop them). Downstream consumers (cond `-z`/`-n`,
16919    //   command args, etc.) must see the post-remnulargs strings. Bug
16920    //   #185 in docs/BUGS.md: `[[ -z "${b[@]}" ]]` for b=("") returned
16921    //   false because the leftover `\u{e1a1}` had StringLen=1.
16922    //
16923    // ORDER MATTERS, and the single-node drop below is the second half of
16924    // it. C tests a node's emptiness at c:183 BEFORE remnulargs runs at
16925    // c:170, and `nulstring` is one byte long, so a node holding only the
16926    // marker is NON-empty at that test. The drop has ALREADY happened by
16927    // the time the marker becomes "", and nothing removes the node twice.
16928    //
16929    // Traced for `IFS=:; v=":"; set -- $v` under `--dash`:
16930    //   spacesplit(":")  -> ["<nul>", ""]   leading marker, trailing empty
16931    //   paramsubst c:183 -> ["<nul>"]       the real empty goes, marker stays
16932    //   caller's fold    -> [""]            untokenize folds the marker
16933    //   here, unguarded  -> []              dropped a second time
16934    // `$#` was 0 where dash, bash and ksh all report 1. Two or more nodes
16935    // never reach the single-node drop, which is why `::` was already
16936    // right and only the lone separator was wrong.
16937    let node_was_marked: bool = nodes.len() == 1 && !nodes[0].is_empty();
16938    let stripped: Vec<String> = nodes
16939        .into_iter()
16940        .map(|mut s| {
16941            crate::ported::glob::remnulargs(&mut s);
16942            s
16943        })
16944        .collect();
16945    if stripped.is_empty() {
16946        // Zero nodes = an ARRAY-shaped expansion that produced no words
16947        // (empty array splat, empty slice). c:4245 `if (isarr)` holds, so
16948        // plan9 deletes the surrounding word (c:4362).
16949        note_empty_is_scalar(false);
16950        Value::array(Vec::new())
16951    } else if stripped.len() == 1 {
16952        let only = stripped.into_iter().next().unwrap();
16953        // c:Src/subst.c:183-186 — `else if (!(flags & PREFORK_SINGLE)
16954        // && !(*ret_flags & PREFORK_KEY_VALUE) && !keep)
16955        //   uremnode(list, node);`
16956        // C zsh's prefork removes empty linknodes from the result
16957        // list when in non-SINGLE (argv-context) mode. The ported
16958        // prefork at subst.rs:388-396 honors the same delete-empty
16959        // pass, but some paramsubst paths land here with a single-
16960        // empty-string Vec instead of an empty Vec (paramsubst's
16961        // slice / substring / parameter-flag branches allocate a
16962        // result before checking emptiness). Mirror the prefork
16963        // drop at this layer: single-empty under !in_dq_context
16964        // collapses to Value::Array(empty), and pop_args (line 6243)
16965        // splats the empty Array → zero argv words. DQ context
16966        // (in_dq_context > 0) keeps the empty string so
16967        // `echo "${UNSET}"` still produces an empty arg per zsh's
16968        // quoting rules (c:Src/subst.c:1650-1656 isarr comment).
16969        if only.is_empty() && !node_was_marked {
16970            let in_dq = with_executor(|exec| exec.in_dq_context > 0);
16971            if !in_dq {
16972                // One empty node = a SCALAR-shaped empty result (c:4437),
16973                // not an empty array — see EMPTY_EXPANSION_IS_SCALAR.
16974                note_empty_is_scalar(true);
16975                return Value::array(Vec::new());
16976            }
16977        }
16978        Value::str(only)
16979    } else {
16980        Value::array(stripped.into_iter().map(Value::str).collect())
16981    }
16982}
16983
16984fn pop_args(vm: &mut fusevm::VM, argc: u8) -> Vec<String> {
16985    let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
16986    for _ in 0..argc {
16987        popped.push(vm.pop());
16988    }
16989    popped.reverse();
16990    let mut args: Vec<String> = Vec::with_capacity(popped.len());
16991    for v in popped {
16992        match v {
16993            Value::Array(items) => {
16994                for item in items.iter() {
16995                    args.push(item.to_str());
16996                }
16997            }
16998            other => args.push(other.to_str()),
16999        }
17000    }
17001    // `expand_glob` set the glob-failed cell when a no-match glob
17002    // triggered nomatch (c:Src/glob.c:1877). Signal the failure via
17003    // last_status + the per-command glob_failed cell; the dispatcher
17004    // (`host_exec_external`) consumes + clears it and returns status 1
17005    // without running the command body.
17006    if with_executor(|exec| exec.current_command_glob_failed.get()) {
17007        with_executor(|exec| exec.set_last_status(1));
17008    }
17009    // c:Src/exec.c:2709 setunderscore / c:Src/params.c:252 underscore_gsu
17010    // — `$_` has exactly ONE store in zsh, the `zunderscore` global
17011    // (`Src/init.c:49`), read back through `underscoregetfn`. It is
17012    // never written into the parameter table: the `_` Param created by
17013    // `IPDEF2("_", underscore_gsu, PM_DONTIMPORT)` (c:Src/params.c:326)
17014    // carries `nullstrsetfn` as its setfn, so even `_=x` stores nothing.
17015    //
17016    // The deferred `pending_underscore` → `set_scalar("_")` promotion
17017    // that used to live here was a second, contradictory store: it wrote
17018    // the paramtab node, which (a) CLEARED the PM_UNSET that `unset _`
17019    // had just set — resurrecting the parameter, so `${+_}` flipped back
17020    // to 1 and `$_` kept reading a value where zsh reports empty — and
17021    // (b) shadowed the canonical zunderscore value. Every dispatch path
17022    // now calls `set_zunderscore` (the `setunderscore` equivalent) just
17023    // before running its command, which is where C sets it
17024    // (execcmd_exec, c:3545-3547), so the deferral is unnecessary as
17025    // well: argument expansion has already happened by then, exactly as
17026    // in C.
17027    args
17028}
17029
17030/// zsh dispatch order is alias → function → builtin → external. The
17031/// compiler emits direct CallBuiltin ops for known builtin names for
17032/// perf, which silently skips a user function that shadows the same
17033/// name (e.g. `echo() { ... }; echo hi` would run the C builtin
17034/// without this check). Returns Some(status) when the call is routed
17035/// to the user function; the builtin handler should fall through to
17036/// its native impl when None.
17037/// Fork+exec a system binary by name. Used by `reg_overridable!` as
17038/// the fall-through path when `[builtins].coreutils_shadows = off`
17039/// (the default) — runs the canonical `/bin/X` instead of zshrs's
17040/// in-process shadow so old scripts hit zero behavioral divergence.
17041///
17042/// Inherits stdin/stdout/stderr from the parent so pipelines work
17043/// transparently. Resolves the binary via PATH; mirrors what zsh's
17044/// own external-command dispatch would do. Returns the child's exit
17045/// status (or 127 if PATH lookup fails — the standard "command not
17046/// found" code).
17047/// RAII guard that queues signals for the lifetime of a synchronous
17048/// foreground `waitpid` (via `std::process::Command::status`/`wait`).
17049///
17050/// zshrs installs a process-wide SIGCHLD handler (`zhandler` →
17051/// `wait_for_processes` → `waitpid(-1, WNOHANG)`) that reaps EVERY
17052/// exited child to drive the job table. `std::process::Command` does
17053/// its own targeted `waitpid(pid)`; when the reaper fires on any
17054/// thread between the fork and that wait, it reaps the child first and
17055/// `Command::status()` fails with ECHILD ("No child processes (os
17056/// error 10)"). This surfaced as `zshrs: hostname: No child processes
17057/// (os error 10)` when a coreutils shadow (`coreutils_shadows = off`
17058/// default) fork-execs `/usr/bin/hostname` while a background prewarm
17059/// child exits at the same instant.
17060///
17061/// Holding this guard bumps `queueing_enabled` (a global SeqCst atomic
17062/// that `zhandler` honors on every thread), so a SIGCHLD arriving
17063/// during the wait is pushed onto the deferred queue instead of being
17064/// reaped — `Command::status()` reaps its own child and reads the real
17065/// status. On drop, `unqueue_signals()` drains the queue, so any
17066/// genuine background children that exited meanwhile still get reaped
17067/// and routed to the job table. This is the same queue_signals /
17068/// unqueue_signals fencing zsh uses around its own foreground waits
17069/// (Src/exec.c). Panic-safe via `Drop`.
17070/// Apply `entersubsh`'s trap reset to the CURRENT process state.
17071///
17072/// !!! WARNING: RUST-ONLY HELPER !!!
17073/// C does this inline inside `entersubsh` (c:Src/exec.c:1088-1092), which
17074/// every subshell — `( … )`, `$( … )` AND each forked pipeline stage —
17075/// funnels through. zshrs enters a subshell context in three separate
17076/// places (the in-process `subshell_begin`, the in-process
17077/// `run_command_substitution`, and the pipeline stage fork), so the reset
17078/// lives here to keep them from drifting apart.
17079///
17080/// ```c
17081/// if (!(flags & ESUB_KEEPTRAP))
17082///     for (sig = 0; sig <= SIGCOUNT; sig++)
17083///         if (!(sigtrapped[sig] & ZSIG_FUNC) &&
17084///             !(isset(POSIXTRAPS) && (sigtrapped[sig] & ZSIG_IGNORED)))
17085///             unsettrap(sig);
17086/// ```
17087///
17088/// `unsettrap` clears BOTH the body and the sigtrapped flags, so both
17089/// stores are reset here. Function-form traps (ZSIG_FUNC, kept in
17090/// shfunctab as TRAPxxx) survive by construction; under POSIX_TRAPS an
17091/// IGNORED trap survives too. The loop bound stops below the pseudo
17092/// signals (c:Src/signals.h:34-35), so ERR/ZERR and DEBUG survive while
17093/// SIGEXIT (sig 0) is cleared.
17094pub(crate) fn entersubsh_reset_traps() {
17095    let posixtraps = crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXTRAPS);
17096    if let Ok(mut tbl) = crate::ported::builtin::traps_table().lock() {
17097        tbl.retain(|name, body| {
17098            // Above SIGCOUNT — outside c:1088's loop entirely.
17099            if name == "ERR" || name == "ZERR" || name == "DEBUG" {
17100                return true;
17101            }
17102            // c:1090-1092 — otherwise keep ONLY (POSIXTRAPS && ignored).
17103            posixtraps && body.is_empty()
17104        });
17105    }
17106    if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
17107        let count = crate::ported::signals_h::SIGCOUNT as usize;
17108        for sig in 0..st.len().min(count + 1) {
17109            let state = st[sig];
17110            if state == 0 {
17111                continue;
17112            }
17113            if (state & crate::ported::zsh_h::ZSIG_FUNC) != 0 {
17114                continue; // c:1090
17115            }
17116            if posixtraps && (state & crate::ported::zsh_h::ZSIG_IGNORED) != 0 {
17117                continue; // c:1091
17118            }
17119            st[sig] = 0; // c:1092 unsettrap(sig)
17120        }
17121    }
17122}
17123
17124// ---------------------------------------------------------------------------
17125// In-process subshell: signal delivery belongs to the PARENT.
17126// ---------------------------------------------------------------------------
17127
17128/// Depth of nested in-process `( … )` subshells, for the signal
17129/// bookkeeping below.
17130///
17131/// !!! WARNING: RUST-ONLY HELPER !!!
17132/// C has no counter because it forks: `entersubsh` (`Src/exec.c:1123`)
17133/// runs in a fresh process whose signal state is a private copy. This
17134/// counter exists only so `subshell_signal_enter` / `_leave` can tell
17135/// the OUTERMOST boundary — the one where `$$` stops naming the shell
17136/// that is running the body — from a nested one.
17137static SUBSH_SIGNAL_DEPTH: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
17138
17139/// The top-level shell's `sigtrapped[]` while an in-process subshell
17140/// runs; `None` when no subshell is active.
17141///
17142/// !!! WARNING: RUST-ONLY HELPER !!!
17143/// This is C's `sigtrapped[]` (`Src/signals.c:39`) as the PARENT still
17144/// sees it — in C that array simply stays put in the parent process
17145/// while the forked child mutates its own copy. zshrs has one process
17146/// and one array, so the parent's view has to be kept beside it.
17147static SUBSH_PARENT_SIGTRAPPED: std::sync::Mutex<Option<Vec<i32>>> = std::sync::Mutex::new(None);
17148
17149/// Signals delivered to the process while an in-process subshell was
17150/// running, which the PARENT traps. Replayed at the outermost
17151/// `subshell_end`.
17152///
17153/// !!! WARNING: RUST-ONLY HELPER !!!
17154/// The C analogue is `trap_queue[]` (`Src/signals.c:92`): the parent
17155/// sets `trap_queueing_enabled` in `zwaitjob` (`Src/jobs.c:1688`
17156/// `queue_traps(wait_cmd)`) for as long as the child runs, so a trap
17157/// that arrives meanwhile is deferred and dispatched by
17158/// `unqueue_traps()` (`Src/jobs.c:1751` → `Src/signals.c:1041-1047`)
17159/// once the child is reaped. A separate queue is needed here because
17160/// zshrs's `trap_queue` gate (`handletrap`, c:974) tests the LIVE
17161/// `sigtrapped[]`, which the in-process subshell has already cleared.
17162static SUBSH_DEFERRED_SIGS: std::sync::Mutex<Vec<i32>> = std::sync::Mutex::new(Vec::new());
17163
17164/// Called from `zhandler` (`src/ported/signals.rs`, at C's c:410
17165/// queueing check) before any trap dispatch. Returns 1 when the
17166/// delivery has been recorded for the parent and the handler must
17167/// return without acting.
17168///
17169/// !!! WARNING: RUST-ONLY HELPER !!!
17170/// C needs nothing here. `( … )` forks (`Src/exec.c:2922`
17171/// `entersubsh(flags, &esret)`), so `kill -USR1 $$` inside the
17172/// subshell names the PARENT process: the child's handlers are never
17173/// entered, and the parent — which `entersubsh` never touched —
17174/// runs its own trap once `zwaitjob` drains the trap queue. zshrs runs
17175/// the body in the same process, so the one set of handlers has to
17176/// answer for both roles. Recording the delivery and replaying it
17177/// against the parent's restored table at `subshell_end` reproduces
17178/// both the disposition zsh picks and the point at which it runs.
17179///
17180/// SIGCHLD and SIGWINCH are never deferred, matching the two signals C
17181/// itself excludes from the `settrap` / `removetrap` disposition
17182/// switches (`Src/signals.c:714-718`, `Src/signals.c:810-814`): job
17183/// reaping and resize redisplay have to stay live inside the body.
17184///
17185/// When the parent has NO trap for the signal this returns 0 and the
17186/// handler proceeds normally. That is the one leg an in-process
17187/// subshell cannot reproduce: C's parent would take the DEFAULT action
17188/// (`( trap 'echo IN' USR1; kill -USR1 $$ )` kills zsh outright), which
17189/// here would have to kill the shell out from under a body that C
17190/// leaves running.
17191pub fn subshell_defer_signal(sig: libc::c_int) -> i32 {
17192    if sig == libc::SIGCHLD || sig == libc::SIGWINCH {
17193        return 0;
17194    }
17195    let idx = crate::ported::signals_h::SIGIDX(sig) as usize;
17196    // `try_lock`, not `lock`: this runs inside a signal handler, so a
17197    // delivery that lands while the SAME thread is mid-`subshell_signal_enter`
17198    // would self-deadlock on a blocking acquire. A miss degrades to normal
17199    // handling, which is what happened before this hook existed.
17200    let parent = match SUBSH_PARENT_SIGTRAPPED.try_lock() {
17201        Ok(g) => match g.as_ref() {
17202            Some(v) => v.get(idx).copied().unwrap_or(0),
17203            None => return 0,
17204        },
17205        Err(_) => return 0,
17206    };
17207    if parent == 0 {
17208        return 0;
17209    }
17210    if (parent & crate::ported::zsh_h::ZSIG_IGNORED) != 0 {
17211        // The parent ignores it; C's parent process would drop it on
17212        // the floor (`Src/signals.c:713-719` installed SIG_IGN).
17213        return 1;
17214    }
17215    match SUBSH_DEFERRED_SIGS.try_lock() {
17216        Ok(mut q) => q.push(sig),
17217        // Could not record it; fall through to normal handling rather
17218        // than swallow the delivery outright.
17219        Err(_) => return 0,
17220    }
17221    1
17222}
17223
17224/// Arm parent-side signal delivery for the duration of an in-process
17225/// `( … )` or `$( … )`. Must run BEFORE `entersubsh_reset_traps` clears
17226/// the table.
17227///
17228/// !!! WARNING: RUST-ONLY HELPER !!!
17229/// See `subshell_defer_signal`. Only the OUTERMOST subshell records the
17230/// snapshot: `$$` names the top-level shell at every nesting level, so
17231/// a nested `( ( … ) )` still routes deliveries to the same table.
17232pub(crate) fn subshell_signal_enter() {
17233    if SUBSH_SIGNAL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
17234        let snap = crate::ported::signals::sigtrapped
17235            .lock()
17236            .map(|g| g.clone())
17237            .unwrap_or_default();
17238        if let Ok(mut p) = SUBSH_PARENT_SIGTRAPPED.lock() {
17239            *p = Some(snap);
17240        }
17241        if let Ok(mut q) = SUBSH_DEFERRED_SIGS.lock() {
17242            q.clear();
17243        }
17244    }
17245}
17246
17247/// Disarm parent-side delivery and dispatch whatever arrived. Must run
17248/// AFTER `subshell_end` has put the parent's `sigtrapped[]` and
17249/// `traps_table` back.
17250///
17251/// !!! WARNING: RUST-ONLY HELPER !!!
17252/// The dispatch itself is C's `unqueue_traps()` tail
17253/// (`Src/signals.c:1043-1047`): `while (trap_queue_front !=
17254/// trap_queue_rear) handletrap(trap_queue[…])`, run at the point
17255/// `zwaitjob` reaches after the child is reaped (`Src/jobs.c:1751`).
17256pub(crate) fn subshell_signal_leave() {
17257    if SUBSH_SIGNAL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::SeqCst) != 1 {
17258        return;
17259    }
17260    if let Ok(mut p) = SUBSH_PARENT_SIGTRAPPED.lock() {
17261        *p = None;
17262    }
17263    let pending: Vec<i32> = match SUBSH_DEFERRED_SIGS.lock() {
17264        Ok(mut q) => std::mem::take(&mut *q),
17265        Err(_) => return,
17266    };
17267    for sig in pending {
17268        // c:Src/signals.c:1046 — `(void) handletrap(trap_queue[…]);`
17269        let _ = crate::ported::signals::handletrap(sig);
17270    }
17271}
17272
17273/// Put the process's signal DISPOSITIONS back the way the parent had
17274/// them, for every signal whose trap state the subshell changed.
17275///
17276/// !!! WARNING: RUST-ONLY HELPER !!!
17277/// C gets this from the fork: `trap 'echo IN' USR1` inside `( … )`
17278/// calls `settrap` → `install_handler` (`Src/signals.c:724-732`) in the
17279/// CHILD, so the parent's `SIG_DFL` survives untouched and a later
17280/// `kill -USR1 $$` still kills the shell. zshrs shares the process, so
17281/// the child's `sigaction` leaked out and swallowed the signal. The
17282/// branch order below is `removetrap`'s tail verbatim
17283/// (`Src/signals.c:801-815`) for the "parent had no trap" case, and
17284/// `settrap`'s two installs (`Src/signals.c:713-719` ignored,
17285/// `Src/signals.c:724-732` trapped) for the others.
17286pub(crate) fn subshell_restore_signal_dispositions(parent: &[i32], child: &[i32]) {
17287    let count = crate::ported::signals_h::SIGCOUNT;
17288    for sig in 1..=count {
17289        let i = sig as usize;
17290        let p = parent.get(i).copied().unwrap_or(0);
17291        let c = child.get(i).copied().unwrap_or(0);
17292        if p == c {
17293            continue;
17294        }
17295        // c:714-718 / c:810-814 — C leaves these two alone in both
17296        // the install and the reset switch.
17297        if sig == libc::SIGWINCH || sig == libc::SIGCHLD {
17298            continue;
17299        }
17300        if (p & crate::ported::zsh_h::ZSIG_IGNORED) != 0 {
17301            crate::ported::signals_h::signal_ignore(sig); // c:719
17302        } else if p != 0 {
17303            crate::ported::signals::install_handler(sig); // c:732
17304        } else if sig == libc::SIGINT && crate::ported::signals::is_interact() {
17305            crate::ported::signals::intr(); // c:804
17306            crate::ported::signals::noholdintr(); // c:805
17307        } else if sig == libc::SIGHUP {
17308            crate::ported::signals::install_handler(sig); // c:807
17309        } else if sig == libc::SIGPIPE
17310            && crate::ported::signals::is_interact()
17311            && crate::ported::exec::FORKLEVEL.load(std::sync::atomic::Ordering::Relaxed) == 0
17312        {
17313            crate::ported::signals::install_handler(sig); // c:809
17314        } else {
17315            crate::ported::signals_h::signal_default(sig); // c:815
17316        }
17317    }
17318}
17319
17320/// `waitpid(pid, &status, 0)` that retries on `EINTR`.
17321///
17322/// !!! WARNING: RUST-ONLY HELPER !!!
17323/// No C counterpart — C zsh reaches the same place by blocking signals
17324/// around its foreground waits (`queue_signals` / `unqueue_signals`
17325/// fencing in `Src/exec.c`), so its `waitpid` is never interrupted in
17326/// the first place.
17327///
17328/// zshrs installs a process-wide SIGCHLD handler (`zhandler` →
17329/// `wait_for_processes`). When it fires while the shell is blocked in
17330/// this wait, `waitpid` returns -1/`EINTR` WITHOUT touching `status`.
17331/// The pipeline reap loop ignored the return value and read the
17332/// still-zero `status`, so `WIFEXITED(0)` was true and every FORKED
17333/// stage reported exit 0: `false | true` published `$pipestatus` as
17334/// `0 0` instead of zsh's `1 0`, and `setopt pipefail` had no non-zero
17335/// entry left to promote (c:Src/jobs.c:434-435 `if (jpipestats[i])
17336/// pipefail = jpipestats[i];`, applied at c:451-454).
17337///
17338/// The handler can also win outright: if the child exits while the last
17339/// pipeline stage is still running IN THIS PROCESS, SIGCHLD is delivered
17340/// and `wait_for_processes` reaps it long before this loop is reached,
17341/// so `waitpid` here fails with `ECHILD` and the stage's status is gone.
17342/// That window is as wide as the last stage's whole run — `(exit 5) |
17343/// (sleep 0.5; exit 3)` published `$pipestatus` as `0 3` where zsh says
17344/// `5 3`. The reaper publishes every status it takes
17345/// (`extensions/reaped_status.rs`), which is what C gets for free by
17346/// having only one collector, so claim it back here.
17347///
17348/// Returns the raw wait status, or `None` if the child could not be
17349/// reaped and the reaper has no record of it either.
17350fn waitpid_eintr(pid: libc::pid_t) -> Option<i32> {
17351    loop {
17352        let mut status: i32 = 0;
17353        let rc = unsafe { libc::waitpid(pid, &mut status, 0) };
17354        if rc >= 0 {
17355            return Some(status);
17356        }
17357        let err = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
17358        if err != libc::EINTR {
17359            // The SIGCHLD reaper collected this child first; take the
17360            // status it recorded instead of reporting a clean exit.
17361            return crate::reaped_status::take(pid);
17362        }
17363    }
17364}
17365
17366/// [`waitpid_eintr`] that also returns the child's resource usage.
17367///
17368/// c:Src/signals.c:279 — the SIGCHLD reaper collects with `wait3(&status,
17369/// WAITFLAGS, &ru)` and stores it on the process, `pn->ti = ru` (c:352),
17370/// which is where `dumptime`'s per-process user/system times come from. A
17371/// child the reaper got to first is claimed back from `reaped_status` with
17372/// the times it recorded.
17373fn wait4_eintr(pid: libc::pid_t) -> Option<(i32, libc::rusage)> {
17374    loop {
17375        let mut status: i32 = 0;
17376        let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
17377        let rc = unsafe { libc::wait4(pid, &mut status, 0, &mut ru) };
17378        if rc >= 0 {
17379            return Some((status, ru));
17380        }
17381        let err = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
17382        if err != libc::EINTR {
17383            return crate::reaped_status::take_rusage(pid).map(|(st, utime, stime)| {
17384                let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
17385                ru.ru_utime = usec_timeval(utime);
17386                ru.ru_stime = usec_timeval(stime);
17387                (st, ru)
17388            });
17389        }
17390    }
17391}
17392
17393/// The processes of a `time`d multi-stage pipeline, collected for
17394/// `dumptime` (c:Src/jobs.c:1029-1037).
17395///
17396/// !!! WARNING: RUST-ONLY SHAPE — C KEEPS THESE ON THE JOB'S PROCS !!!
17397/// In C every forked stage is `addproc`ed onto `jobtab[thisjob]` with its
17398/// text (c:Src/exec.c:2907) and the timed job is dumped when it finishes.
17399/// zshrs's foreground pipeline has no job-table entry, so BUILTIN_TIME_SUBLIST
17400/// hands the per-stage texts to BUILTIN_RUN_PIPELINE through
17401/// [`TIMED_PIPELINE`] and reads the filled-in procs back.
17402pub(crate) struct TimedPipeline {
17403    /// One job text per stage, in pipeline order.
17404    texts: Vec<String>,
17405    /// The in-shell last stage is a process of the job (c:Src/exec.c:3690
17406    /// `!is_cursh` — an external command or a subshell).
17407    last_forks: bool,
17408    /// Filled by the pipeline driver.
17409    procs: Vec<crate::ported::zsh_h::process>,
17410}
17411
17412thread_local! {
17413    /// Set by BUILTIN_TIME_SUBLIST around a timed pipeline's body; TAKEN by
17414    /// the first BUILTIN_RUN_PIPELINE that runs, so a pipeline nested inside
17415    /// one of its stages is not mistaken for it.
17416    static TIMED_PIPELINE: std::cell::RefCell<Option<TimedPipeline>> =
17417        const { std::cell::RefCell::new(None) };
17418}
17419
17420/// c:Src/exec.c:3690 — `is_cursh = (is_builtin || is_shfunc || nullexec ||
17421/// type >= WC_CURSH)` for the compiler's hint (compile_zsh.rs
17422/// `command_cursh_hint`): 1 = current shell, 0 = forks, 2 = the command word
17423/// decides at run time.
17424fn time_body_is_cursh(hint: i64, name: &str) -> bool {
17425    match hint {
17426        1 => true,
17427        2 => {
17428            // c:3488-3491 — shfunctab is consulted BEFORE builtintab.
17429            let is_shfunc = crate::ported::hashtable::shfunctab_lock()
17430                .read()
17431                .map(|t| t.get(name).is_some())
17432                .unwrap_or(false);
17433            is_shfunc || crate::ported::builtin::createbuiltintable().contains_key(name)
17434        }
17435        _ => false,
17436    }
17437}
17438
17439/// `a - b` for rusage times — c:Src/jobs.c `dtime_tv`.
17440fn timeval_sub(a: libc::timeval, b: libc::timeval) -> libc::timeval {
17441    let mut sec = a.tv_sec - b.tv_sec;
17442    let mut usec = a.tv_usec as i64 - b.tv_usec as i64;
17443    if usec < 0 {
17444        sec -= 1;
17445        usec += 1_000_000;
17446    }
17447    libc::timeval {
17448        tv_sec: sec,
17449        tv_usec: usec as libc::suseconds_t,
17450    }
17451}
17452
17453/// Microseconds as a `timeval`.
17454fn usec_timeval(us: u64) -> libc::timeval {
17455    libc::timeval {
17456        tv_sec: (us / 1_000_000) as libc::time_t,
17457        tv_usec: (us % 1_000_000) as libc::suseconds_t,
17458    }
17459}
17460
17461/// A `process` entry for [`TimedPipeline`]: C's addproc fields (c:Src/jobs.c
17462/// 1473-1486) plus the wait's status and rusage (update_process).
17463fn timed_proc(
17464    pid: libc::pid_t,
17465    text: &str,
17466    status: i32,
17467    ru: &libc::rusage,
17468    bgtime: std::time::Instant,
17469) -> crate::ported::zsh_h::process {
17470    crate::ported::zsh_h::process {
17471        pid,
17472        text: text.to_string(),
17473        status,
17474        ti: crate::ported::zsh_h::timeinfo::from_rusage(ru),
17475        bgtime: Some(bgtime),
17476        endtime: Some(std::time::Instant::now()),
17477    }
17478}
17479
17480/// The level bookkeeping `entersubsh` does in a freshly forked child.
17481///
17482/// c:Src/exec.c:1221 — `forklevel = locallevel;`. `bin_break`'s `exit` arm
17483/// (c:Src/builtin.c:5870-5894) only defers the exit while `locallevel >
17484/// forklevel`; in a forked child the function it was called from is the
17485/// child's outermost level, so `exit N` ends the child with status N.
17486///
17487/// !!! WARNING: RUST-ONLY LINE — SUBSHELL_DEPTH HAS NO C COUNTERPART !!!
17488/// `SUBSHELL_DEPTH` counts the in-process `( … )` bodies this process is
17489/// inside, and `zexit` defers to EXIT_PENDING while it is non-zero because
17490/// exiting would take the parent shell with it. After a fork that reason is
17491/// gone: the child is its own process, as C's always is. Left at its
17492/// inherited value, `exit 2` in a pipeline stage or background job forked
17493/// from inside a function or `( … )` only unwound the child's VM, which then
17494/// exited 0 — `(setopt pipefail; false | exit 2 | true; print $?)` printed
17495/// 1 where zsh prints 2.
17496thread_local! {
17497    /// c:Src/exec.c:3063 — `last1 = forked = 1;`: execcmd_exec has already
17498    /// forked for the command it is about to run (a non-last pipeline stage,
17499    /// an `&` job, a coproc, the sh-emulation last stage).
17500    ///
17501    /// !!! WARNING: RUST-ONLY FLAG — C KEEPS `forked` AS AN execcmd_exec LOCAL !!!
17502    /// zshrs forks in the pipeline/bg/coproc drivers, one level above the
17503    /// stage chunk, so the fact crosses into the chunk through this flag. The
17504    /// chunk's leading BUILTIN_EXECCMD_FORKED_LEVEL consumes it.
17505    static EXECCMD_FORKED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
17506    /// The VM (by address) whose chunk head consumed EXECCMD_FORKED for a
17507    /// non-subshell command. BUILTIN_EXEC_FORKED_SIMPLE honours it only from
17508    /// that same VM, so a `$( … )` or function body run while the forked
17509    /// command's words expand — each on its own VM — never execs in its place.
17510    ///
17511    /// !!! WARNING: RUST-ONLY FLAG — C KEEPS `forked` AS AN execcmd_exec LOCAL !!!
17512    static FORKED_SIMPLE_VM: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
17513    /// The VM whose chunk head consumed EXECCMD_FORKED (subshell or not).
17514    /// BUILTIN_FORKED_REDIRS honours it only from that same VM.
17515    ///
17516    /// !!! WARNING: RUST-ONLY FLAG — C KEEPS `forked` AS AN execcmd_exec LOCAL !!!
17517    static FORKED_REDIR_VM: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
17518    /// Depth of the redirect scope holding the forked command's OWN
17519    /// redirections (0: none). c:Src/exec.c:2478 — addfd saves the original
17520    /// fd only `if (!forked && save[fd1] == -2)`, so those redirections park
17521    /// nothing: the child exits after the command and never restores.
17522    ///
17523    /// !!! WARNING: RUST-ONLY FLAG — C PASSES `forked` TO EVERY addfd CALL !!!
17524    static FORKED_REDIR_SCOPE: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
17525}
17526
17527/// c:Src/exec.c:2478 — `if (!forked && save[fd1] == -2)`: true when the
17528/// redirect scope at `depth` (the innermost open one) belongs to the command
17529/// this child was forked for, whose redirections save no original fd. A
17530/// saved dup there outlived the command in the child: `{ sleep 2 } >/dev/null
17531/// &` kept the caller's stdout open for two seconds, so `$( … & )` and a
17532/// pipe reader waited for it.
17533fn redir_scope_forked(depth: usize) -> bool {
17534    depth != 0 && FORKED_REDIR_SCOPE.with(|c| c.get()) == depth
17535}
17536
17537/// c:Src/exec.c:2440 — `fdtable[fdN] |= FDT_SAVED_MASK;` on the copy
17538/// movefd made FDT_INTERNAL (c:Src/utils.c:2006). The mark is how a forked
17539/// child's entersubsh finds the copies it will never restore and closes them
17540/// (c:1214-1217). Unmarked, a job forked inside `{ … } 2>/dev/null` kept the
17541/// caller's stderr open until it finished. The restore's zclose clears it.
17542pub(crate) fn mark_saved_fd(saved: i32) {
17543    if saved >= 0 {
17544        crate::ported::utils::fdtable_set(
17545            saved,
17546            crate::ported::zsh_h::FDT_INTERNAL | crate::ported::zsh_h::FDT_SAVED_MASK,
17547        );
17548    }
17549}
17550
17551/// Head of a stage / `&` / coproc chunk: account for execcmd_fork's
17552/// `entersubsh` (c:Src/exec.c:1200 `zsh_subshell++;`) when the driver forked.
17553///
17554/// C forks once per such command. For `( … )` that one fork IS the subshell:
17555/// with `forked` already set, the `if (!forked)` block (c:3715-3752) is
17556/// skipped and the body runs in this child without a second fork, so
17557/// `(print $ZSH_SUBSHELL) | cat` prints 1. zshrs's `( … )` always bumps in
17558/// subshell_begin, so for a subshell the fork's bump is left to it; any other
17559/// command takes it here. Without a fork (the in-shell last stage) nothing
17560/// happens. The bump is never undone: the child exits.
17561fn execcmd_forked_level(is_subsh: bool, vm_addr: usize) {
17562    if !EXECCMD_FORKED.with(|f| f.replace(false)) {
17563        return;
17564    }
17565    // The chunk's top command is the one forked for; see BUILTIN_FORKED_REDIRS.
17566    FORKED_REDIR_VM.with(|f| f.set(vm_addr));
17567    // c:Src/exec.c:1245-1246 — `if (!job_control_ok) opts[MONITOR] = 0;` in
17568    // entersubsh. job_control_ok (c:1133) needs ESUB_JOB_CONTROL, which
17569    // execcmd_fork passes only for a non-async `( … )` (c:2919-2920), and
17570    // POSIX_JOBS. Without this a pipeline stage kept MONITOR and ran
17571    // update_job's foreground tail for its own children:
17572    // `{ sh -c 'kill -INT $$'; echo in } | cat` lost `in`.
17573    let job_control_ok = is_subsh && crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXJOBS);
17574    if !job_control_ok && crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) {
17575        crate::ported::options::dosetopt(crate::ported::zsh_h::MONITOR, 0, 0);
17576    }
17577    // The chunk's own top command is the one this child was forked for; see
17578    // BUILTIN_EXEC_FORKED_SIMPLE. A `( … )` is too: its body runs as
17579    // `execlist(state, 0, 1)` (c:Src/exec.c:4379), so the body's last command
17580    // takes the fake exec in this child (compile_zsh exiting_tail_list).
17581    FORKED_SIMPLE_VM.with(|f| f.set(vm_addr));
17582    if !is_subsh {
17583        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
17584            if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
17585                pm.u_val += 1; // c:1200
17586                pm.u_str = Some(pm.u_val.to_string());
17587                pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
17588            }
17589        }
17590    }
17591}
17592
17593fn forked_child_subsh_levels() {
17594    use std::sync::atomic::Ordering;
17595    crate::ported::exec::FORKLEVEL.store(
17596        crate::ported::params::locallevel.load(Ordering::Relaxed),
17597        Ordering::Relaxed,
17598    ); // c:1221
17599    // c:Src/exec.c:1263-1264 (55061) — `ancestor_loops += loops; loops = 0;`
17600    crate::ported::builtin::ANCESTOR_LOOPS.fetch_add(
17601        crate::ported::builtin::LOOPS.swap(0, Ordering::Relaxed),
17602        Ordering::Relaxed,
17603    );
17604    crate::ported::builtin::SUBSHELL_DEPTH.store(0, Ordering::Relaxed);
17605    // !!! RUST-ONLY: a forked child is no longer inside the in-process
17606    // `$( … )` it was forked from; its signal traps print to its own fd 1,
17607    // as in C, and the parent's saved fds are gone (mark_saved_fd).
17608    CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().clear());
17609}
17610
17611/// Holds SIGCHLD blocked across the span in which the VM forks children.
17612///
17613/// C: `execpline` opens that span with `child_block()` (c:Src/exec.c:1748),
17614/// before any stage is forked, and closes it with `child_unblock()` only
17615/// after the job is registered (c:1815, the async arm) or after the wait
17616/// (c:2017). Its comment says why: "We need to block SIGCHLD in case the
17617/// process we are spawning terminates before the job table is set up to
17618/// handle it" (c:2339-2343). `getoutputfile` does the same around its own
17619/// fork (c:4999/c:5030).
17620///
17621/// zshrs has a second, harder reason. The reaper allocates
17622/// (`wait_for_processes` builds a `Vec`), and libmalloc holds its fork
17623/// lock inside `fork()`. A SIGCHLD from an earlier stage delivered there
17624/// re-enters malloc on that lock and the process dies with SIGKILL
17625/// ("BUG IN CLIENT OF LIBPLATFORM: Trying to recursively lock an
17626/// os_unfair_lock", faulting stack `fork → _sigtramp → zhandler →
17627/// wait_for_processes → Vec::push → _xzm_fork_lock_wait`). With the
17628/// reaper armed and no block, `repeat 300 { true | false | true }; print
17629/// ok` was killed before printing on every run.
17630///
17631/// !!! WARNING: RUST-ONLY SHAPE — C CALLS child_block/child_unblock !!!
17632/// This guard restores the PRIOR state on drop instead of unconditionally
17633/// unblocking. C's execpline recursion never forks a pipeline from inside
17634/// another pipeline's still-blocked span in the same process, but the VM
17635/// does: the last stage of a pipeline runs in the parent, and a nested
17636/// pipeline or process substitution there would otherwise lift the outer
17637/// block before the outer wait. So SIGCHLD is unblocked on drop only if it
17638/// was unblocked on entry. A forked child never drops the guard (it leaves
17639/// through `exit`/`_exit`), so it inherits the blocked mask, as C's child
17640/// of `zfork` does until `execute` unblocks before `execve` (c:793).
17641#[cfg(unix)]
17642pub(crate) struct ChildBlockSpan {
17643    was_blocked: bool,
17644}
17645
17646#[cfg(unix)]
17647impl ChildBlockSpan {
17648    pub(crate) fn enter() -> Self {
17649        let mask = crate::ported::signals::signal_mask(libc::SIGCHLD);
17650        let old = crate::ported::signals::signal_block(&mask); // c:Src/signals.h:52
17651        let was_blocked = unsafe { libc::sigismember(&old, libc::SIGCHLD) } == 1;
17652        ChildBlockSpan { was_blocked }
17653    }
17654}
17655
17656#[cfg(unix)]
17657impl Drop for ChildBlockSpan {
17658    fn drop(&mut self) {
17659        if !self.was_blocked {
17660            crate::ported::signals_h::child_unblock(); // c:Src/signals.h:53
17661        }
17662    }
17663}
17664
17665pub(crate) struct ForegroundWaitGuard {
17666    /// c:Src/jobs.c:1638 — `int first = 1, q = queue_signal_level();`
17667    q: i32,
17668}
17669
17670impl ForegroundWaitGuard {
17671    #[inline]
17672    pub(crate) fn enter() -> Self {
17673        // c:Src/jobs.c:1638-1642 — `waitforpid`'s prologue verbatim:
17674        //     int first = 1, q = queue_signal_level();
17675        //     dont_queue_signals();
17676        //     child_block();          /* unblocked in signal_suspend() */
17677        // `zwaitjob` opens the same window at c:1684-1689.  Both then block
17678        // in `signal_suspend(SIGCHLD, wait_cmd)` (c:1658 / c:1710), and that
17679        // call's sigsuspend mask is EMPTY — c:Src/signals.c:216-232 does
17680        // `sigemptyset(&set)` and adds at most a conditional SIGINT.  So a
17681        // foreground wait is a delivery point for every other signal,
17682        // SIGWINCH included: the shell's standing `winch_block()`
17683        // (c:Src/init.c:1458, re-armed by preprompt at c:Src/utils.c:1541)
17684        // is lifted for exactly as long as the shell waits.  It is the one
17685        // window C reliably opens in the middle of a command, which is why
17686        // a resize during a completion reaches `$COLUMNS` while the
17687        // completion is still running.
17688        //
17689        // This guard used to be `queue_signals()` alone.  That kept the
17690        // SIGCHLD reaper off the child by QUEUEING the delivery in
17691        // userspace rather than blocking it, and left SIGWINCH blocked for
17692        // the whole of a completion no matter how many children it forked
17693        // — a queued SIGWINCH is not dispatched until the queue level
17694        // reaches zero, which for a completion is after the widget has
17695        // returned.  Measured with a completer that read `$COLUMNS`, ran
17696        // `sleep 1` and read it again, across a 24x80 -> 24x60 resize: zsh
17697        // reported 80 then 60, zshrs 80 then 80.
17698        //
17699        // The visible cost was the prompt row.  `asklist`'s `trashzle`
17700        // (c:Src/Zle/compresult.c:1922 → c:Src/Zle/zle_main.c:2071-2095)
17701        // redraws the command line only when the handler has already raised
17702        // `resetneeded`/`winchanged` (c:Src/utils.c:1956-1958), so a
17703        // row-shrink during `git <TAB>` had zsh repainting `READY% git`
17704        // above the query and zshrs repainting nothing at all.
17705        //
17706        // `child_block` gives the same protection the queueing did and
17707        // gives it the way C does: the reaper cannot take the child out
17708        // from under `Child::wait` while SIGCHLD is blocked at the OS
17709        // level, and SIGWINCH is free to arrive.
17710        let q = crate::ported::signals_h::queue_signal_level(); // c:1638
17711        crate::ported::signals_h::dont_queue_signals(); // c:1641
17712        crate::ported::signals_h::child_block(); // c:1642
17713                                                 // c:1643 — `queue_traps(wait_cmd);`.  Signals are dispatched during
17714                                                 // the wait but user TRAPS are held until the child is reaped; the
17715                                                 // old `queue_signals` deferred both together, so this keeps the
17716                                                 // trap half of that behaviour where C keeps it.  `wait_cmd` is 0:
17717                                                 // this is not the `wait` builtin.
17718        crate::ported::signals::queue_traps(0); // c:1643
17719        crate::ported::signals_h::winch_unblock(); // c:Src/signals.c:232
17720        ForegroundWaitGuard { q }
17721    }
17722}
17723
17724impl Drop for ForegroundWaitGuard {
17725    #[inline]
17726    fn drop(&mut self) {
17727        // c:Src/jobs.c:1667-1669 — `unqueue_traps(); child_unblock();
17728        // restore_queue_signals(q);`.  The `winch_block` ahead of them puts
17729        // back the standing mask that c:Src/utils.c:1541 leaves in place
17730        // between prompts.
17731        crate::ported::signals_h::winch_block(); // c:Src/utils.c:1541
17732        crate::ported::signals::unqueue_traps(); // c:1667
17733        crate::ported::signals_h::child_unblock(); // c:1668
17734        crate::ported::signals_h::restore_queue_signals(self.q); // c:1669
17735    }
17736}
17737
17738/// `Command::status()` plus the reaper-stole-it recovery that the forked
17739/// pipeline stages already get from [`waitpid_eintr`].
17740///
17741/// [`ForegroundWaitGuard`] alone is not enough. Its `child_block` masks
17742/// SIGCHLD for the CALLING thread, and zshrs is multi-threaded: the signal
17743/// is delivered to any thread that has not blocked it, whose `zhandler`
17744/// runs `wait_for_processes` → `waitpid(-1, WNOHANG)` and takes the child.
17745/// `Command::status()` then fails with ECHILD, and because it owns the
17746/// child and never hands back the pid, the status the reaper published
17747/// (`extensions/reaped_status.rs`) cannot be claimed — the caller can only
17748/// report a failure for a command that ran fine.
17749///
17750/// That is what `echo a b c | wc -w` hit: the last stage runs in THIS
17751/// process, so it goes through here rather than through `waitpid_eintr`.
17752/// Measured in a Debian container before this helper existed, it printed
17753/// the correct `3` and then exited 127 with
17754/// `zshrs: wc: No child processes (os error 10)` on 136 of 200 runs.
17755///
17756/// Spawning explicitly keeps the pid, so the losing collector can claim
17757/// the status exactly as the pipeline's targeted wait does. The status is
17758/// rebuilt with `ExitStatus::from_raw`, so every caller keeps its own
17759/// mapping of exit codes and signals.
17760#[cfg(unix)]
17761pub(crate) fn foreground_status(
17762    command: &mut std::process::Command,
17763) -> std::io::Result<std::process::ExitStatus> {
17764    let _wait_guard = ForegroundWaitGuard::enter();
17765    let child = command.spawn()?;
17766    wait_pid_status(child.id() as libc::pid_t)
17767}
17768
17769/// Wait for the foreground child `pid`, claiming its status back from the
17770/// SIGCHLD reaper when the reaper collected it first (ECHILD). The caller
17771/// holds a `ForegroundWaitGuard` from before the spawn. Shared by
17772/// `foreground_status` and the `posix_spawn` path in
17773/// `vm_helper::execute_external_bg`, which has a pid but no `Child`.
17774pub(crate) fn wait_pid_status(pid: libc::pid_t) -> std::io::Result<std::process::ExitStatus> {
17775    use std::os::unix::process::ExitStatusExt as _;
17776    loop {
17777        let mut raw: libc::c_int = 0;
17778        let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
17779        if unsafe { libc::wait4(pid, &mut raw, 0, &mut ru) } == pid {
17780            let usec = |tv: libc::timeval| tv.tv_sec as u64 * 1_000_000 + tv.tv_usec as u64;
17781            fg_child_times_add(usec(ru.ru_utime), usec(ru.ru_stime));
17782            return Ok(std::process::ExitStatus::from_raw(raw));
17783        }
17784        let e = std::io::Error::last_os_error();
17785        match e.raw_os_error() {
17786            Some(libc::EINTR) => continue,
17787            Some(libc::ECHILD) => {
17788                return match take_reaped_status(pid) {
17789                    Some((raw, utime, stime)) => {
17790                        fg_child_times_add(utime, stime);
17791                        Ok(std::process::ExitStatus::from_raw(raw))
17792                    }
17793                    None => Err(e),
17794                }
17795            }
17796            _ => return Err(e),
17797        }
17798    }
17799}
17800
17801thread_local! {
17802    /// User / system CPU (microseconds) of the foreground externals this
17803    /// thread has waited for since the collector was opened, or `None` when
17804    /// nothing collects. A timed pipeline's in-shell last stage opens it: C
17805    /// forks that stage and times it as one process (`pn->ti = ru`,
17806    /// c:Src/signals.c:352), zshrs runs it here and the externals it spawns
17807    /// are what that process would have been.
17808    ///
17809    /// !!! WARNING: RUST-ONLY COLLECTOR — C READS THE STAGE'S OWN wait3 RUSAGE !!!
17810    static FG_CHILD_TIMES: std::cell::Cell<Option<(u64, u64)>> = const { std::cell::Cell::new(None) };
17811}
17812
17813fn fg_child_times_add(utime: u64, stime: u64) {
17814    FG_CHILD_TIMES.with(|c| {
17815        if let Some((u, s)) = c.get() {
17816            c.set(Some((u + utime, s + stime)));
17817        }
17818    });
17819}
17820
17821/// Claim the reaper's record for `pid`, allowing for the fact that ECHILD
17822/// arrives BEFORE the record is published.
17823///
17824/// `wait_for_processes` takes the child with `waitpid` and only then stores
17825/// the status (`signals.rs`, `reaped_status::record`). Everything between
17826/// those two is a window in which the losing collector already sees ECHILD
17827/// and the ring is still empty: reading it once left 8 of 400 runs of
17828/// `echo a b c | wc -w` reporting `No child processes` even with the
17829/// recovery in place. The child is known dead by then, so waiting out the
17830/// window costs nothing that is not already lost, and this runs only on the
17831/// lost-race path.
17832///
17833/// The budget is bounded because the record may never come: a collector
17834/// that does not publish (the `waitpid(-1)` in `bin_wait`, say) leaves
17835/// nothing to claim, and the caller must be allowed to report the error
17836/// rather than spin.
17837#[cfg(unix)]
17838pub(crate) fn take_reaped_status(pid: i32) -> Option<(i32, u64, u64)> {
17839    const TRIES: u32 = 200; // 200 x 100us = 20ms
17840    for _ in 0..TRIES {
17841        if let Some(rec) = crate::reaped_status::take_rusage(pid) {
17842            return Some(rec);
17843        }
17844        let ts = libc::timespec { tv_sec: 0, tv_nsec: 100_000 };
17845        unsafe { libc::nanosleep(&ts, std::ptr::null_mut()) };
17846    }
17847    None
17848}
17849
17850/// !!! WARNING: RUST-ONLY HELPER !!!
17851///
17852/// C has no counterpart: `fork()` gives a forked `(...)` subshell its own
17853/// copy of the fd table, so the flock fds a subshell opens (`Src/utils.c:2111`
17854/// `addlockfd` marks them `FDT_FLOCK` / `FDT_FLOCK_EXEC`) vanish with the
17855/// child and its `fcntl(F_SETLK)` locks are released. zshrs runs `(...)`
17856/// in-process, so it has to enumerate those slots at subshell entry and
17857/// close the new ones at subshell exit. Walks the same `fdtable` /
17858/// `max_zsh_fd` pair as `zcloselockfd` (`Src/utils.c:2155-2164`).
17859fn current_flock_fds() -> Vec<i32> {
17860    use crate::ported::zsh_h::{FDT_FLOCK, FDT_FLOCK_EXEC};
17861    let max_fd = crate::ported::utils::MAX_ZSH_FD.load(std::sync::atomic::Ordering::Relaxed);
17862    if max_fd < 0 {
17863        return Vec::new();
17864    }
17865    (0..=max_fd)
17866        .filter(|fd| {
17867            let slot = crate::ported::utils::fdtable_get(*fd);
17868            slot == FDT_FLOCK || slot == FDT_FLOCK_EXEC
17869        })
17870        .collect()
17871}
17872
17873/// `exec`'s external command: C's `execute(args, cflags, use_defpath)`
17874/// (c:Src/exec.c:4369, body c:729-881) through the same search and zexecve
17875/// as every other external (`vm_helper::execute_spawn`). `in_place` execs
17876/// over this process and only returns on failure; otherwise (an in-process
17877/// subshell, whose C counterpart was a forked child) the command is spawned
17878/// and waited for. Returns the command's status, or C's exit status for a
17879/// failed exec. `args` is the command and its arguments; `exec_argv0` the
17880/// `exec -a` name; `login` the `-`/`exec -l` flag.
17881fn exec_execute(
17882    args: &[String],
17883    exec_argv0: Option<String>,
17884    login: bool,
17885    use_defpath: bool,
17886    in_place: bool,
17887) -> i32 {
17888    let cmd = &args[0];
17889    crate::lowfd::cloexec_internal_fds(); // c:4352
17890    // c:3318-3328 — `exec -a NAME` travels as ARGV0 in the environment;
17891    // execute() takes argv[0] from it and unsets it (c:758-768), else
17892    // prefixes `-` for `exec -l` / `-` (c:772-776).
17893    let argv0_env = exec_argv0.or_else(|| std::env::var("ARGV0").ok());
17894    let mut argv: Vec<String> = Vec::with_capacity(args.len());
17895    argv.push(match &argv0_env {
17896        Some(z) => z.clone(),
17897        None if login => format!("-{}", cmd),
17898        None => cmd.clone(),
17899    });
17900    argv.extend(args[1..].iter().cloned());
17901    // c:810-819 — `command -p`: the default-path hit alone.
17902    let defpath = if use_defpath && !cmd.contains('/') {
17903        crate::ported::exec::search_defpath(cmd, libc::PATH_MAX as usize)
17904    } else {
17905        None
17906    };
17907    let _wait_guard = (!in_place).then(ForegroundWaitGuard::enter);
17908    let eno = if use_defpath && !cmd.contains('/') && defpath.is_none() {
17909        0 // c:815-819 — not on the default path
17910    } else {
17911        match crate::vm_helper::execute_spawn(
17912            cmd,
17913            &argv,
17914            defpath.as_deref(),
17915            None,
17916            argv0_env.is_some(),
17917            in_place,
17918        ) {
17919            Ok(pid) => {
17920                return match wait_pid_status(pid) {
17921                    Ok(status) => crate::exec_jobs::wait_status_val(status),
17922                    Err(_) => 127,
17923                }
17924            }
17925            Err(eno) => eno,
17926        }
17927    };
17928    drop(_wait_guard);
17929    // c:871-881
17930    if eno != 0 {
17931        crate::ported::utils::zerr(&format!(
17932            "{}: {}",
17933            crate::ported::utils::zsh_errno_msg(eno),
17934            cmd
17935        ));
17936        return if eno == libc::EACCES || eno == libc::ENOEXEC { 126 } else { 127 };
17937    }
17938    // c:873-874 — `if (commandnotfound(arg0, args) == 0) _realexit();`
17939    if let Some(rc) =
17940        with_executor(|exec| exec.dispatch_function_call("command_not_found_handler", args))
17941    {
17942        return rc;
17943    }
17944    crate::ported::utils::zerr(&format!(
17945        "command not found: {}",
17946        crate::ported::utils::nicedupstring(cmd)
17947    ));
17948    127
17949}
17950
17951/// Whether `exec` is running inside a subshell that zshrs keeps in this
17952/// process — `( … )` (the snapshot stack) or a command substitution
17953/// (`SubshStateGuard`, the in-process `entersubsh`). C forked both, so its
17954/// exec replaced only that child; replacing THIS process would end the
17955/// parent shell too (`x=$(exec echo hi)` ended the script).
17956fn exec_in_process_subshell() -> bool {
17957    with_executor(|exec| !exec.subshell_snapshots.is_empty())
17958        || crate::ported::exec::SUBSH_STATE_DEPTH.load(std::sync::atomic::Ordering::SeqCst) > 0
17959}
17960
17961fn try_user_fn_override(name: &str, args: &[String]) -> Option<i32> {
17962    let has_fn = with_executor(|exec| {
17963        exec.functions_compiled.contains_key(name) || exec.function_exists(name)
17964    });
17965    if !has_fn {
17966        return None;
17967    }
17968    Some(with_executor(|exec| {
17969        exec.dispatch_function_call(name, args).unwrap_or(127)
17970    }))
17971}
17972
17973/// Builtin ID for `${name}` reads — routes through canonical
17974/// `getsparam` (Src/params.c:3076) via paramtab + env walk so nested
17975/// VMs (function calls) see the same storage.
17976pub const BUILTIN_GET_VAR: u16 = 283;
17977
17978/// Like `BUILTIN_GET_VAR` but forces double-quoted (DQ) semantics on
17979/// the read regardless of the runtime `in_dq_context`. The compiler
17980/// emits this for a QUOTED simple-var read (`"$name"`) — those compile
17981/// to a direct GET_VAR with no EXPAND_TEXT wrapper, so `in_dq_context`
17982/// is 0 and the plain GET_VAR would wrongly word-elide an array's empty
17983/// elements (`a=(1 "" 3); "$a"` must keep the empty → `1  3`, not
17984/// `1 3`). With force_dq the array joins via sepjoin keeping empties and
17985/// a scalar is returned verbatim (no empty-drop, no SH_WORD_SPLIT).
17986pub const BUILTIN_GET_VAR_DQ: u16 = 639;
17987
17988/// Builtin ID for `name=value` assignments — pops [name, value] and
17989/// routes through canonical `setsparam` (Src/params.c:3350).
17990pub const BUILTIN_SET_VAR: u16 = 284;
17991
17992/// Builtin ID that sets the thread-local [`SET_VAR_GLOB_ELIGIBLE`] flag true.
17993/// Emitted by the compiler immediately before a `BUILTIN_SET_VAR` whose scalar
17994/// RHS carried an UNQUOTED glob token, so the runtime knows the RHS is a literal
17995/// glob pattern eligible for GLOB_ASSIGN. Takes no stack args, pushes nothing.
17996pub const BUILTIN_MARK_GLOB_ELIGIBLE: u16 = 640;
17997
17998/// Builtin ID for pipeline execution. Pops N sub-chunk indices from the stack;
17999/// each index points into `vm.chunk.sub_chunks` (compiled stage bodies). Forks
18000/// N children, wires stdin/stdout between them via pipes, runs each stage's
18001/// bytecode on a fresh VM in its child, parent waits for all and pushes the
18002/// last stage's exit status. This is bytecode-native pipeline execution —
18003/// no tree-walker delegation.
18004pub const BUILTIN_RUN_PIPELINE: u16 = 285;
18005
18006/// Builtin ID for `Array → String` joining. Pops one value: if it's an Array,
18007/// joins its string-coerced elements with a single space; otherwise passes
18008/// through. Used after `Op::Glob` to convert the pattern's matched paths into
18009/// the single argv-token form the bytecode word model expects (no per-word
18010/// splitting yet — that's a future phase).
18011pub const BUILTIN_ARRAY_JOIN: u16 = 286;
18012
18013/// Builtin ID for `cmd &` background execution. IDs 287/288/289 are reserved
18014/// for the planned array work in Phase G1 (SET_ARRAY/SET_ASSOC/ARRAY_INDEX),
18015/// so this lands at 290. Pops the sub-chunk index then the job text; forks;
18016/// child detaches (`setsid`), runs the sub-chunk on a fresh VM, exits with
18017/// last_status; parent registers the job in the canonical JOBTAB
18018/// (initjob/addproc/spawnjob per c:Src/exec.c:1700-1758) so `jobs` / `wait
18019/// %N` / `kill %N` / `disown` and the zsh/parameter assocs all see it, then
18020/// returns Status(0) immediately.
18021pub const BUILTIN_RUN_BG: u16 = 290;
18022
18023/// Indexed-array assignment: `arr=(a b c)`. Compile_simple emits N element
18024/// pushes followed by name push, then `CallBuiltin(BUILTIN_SET_ARRAY, N+1)`.
18025/// The handler pops args (last popped = name in our pushing order) and stores
18026/// `Vec<String>` into `executor.arrays`. Tree-walker callers see the same
18027/// storage. Any prior scalar binding in `executor.variables` for `name` is
18028/// removed so `${name}` (scalar context) consistently reflects the array's
18029/// first element via `get_variable`.
18030pub const BUILTIN_SET_ARRAY: u16 = 287;
18031
18032/// Single-key set on an associative array: `foo[key]=val`. Stack (top-down):
18033/// [name, key, value]. Stores `value` into `executor.assoc_arrays[name][key]`,
18034/// creating the outer entry if missing. compile_simple detects `var[...]=...`
18035/// in assignments and emits this builtin.
18036pub const BUILTIN_SET_ASSOC: u16 = 288;
18037
18038/// `${arr[idx]}` — single-element array index. Pops two args:
18039///   stack: [name, idx_str]
18040/// Returns the indexed element as Value::str. Indexing semantics: zsh is
18041/// 1-based by default; bash is 0-based. We follow zsh.
18042/// Special idx values: `@` and `*` return the whole array as Value::Array
18043/// (which fuses correctly via the Op::Exec splice for argv splice).
18044pub const BUILTIN_ARRAY_INDEX: u16 = 289;
18045
18046/// `${#arr[@]}` and `${#arr}` (when arr is an array name) — array length.
18047/// Pops one arg: name. Returns Value::str of len.
18048
18049/// `${arr[@]}` — splice all elements as a Value::Array. Pops one arg: name.
18050/// The Array gets flattened by Op::Exec/ExecBg/CallFunction into argv.
18051pub const BUILTIN_ARRAY_ALL: u16 = 292;
18052
18053/// Flatten one level of Value::Array nesting. Pops N values; for each, if it's
18054/// a Value::Array, its elements are appended directly; otherwise the value is
18055/// appended as-is. Pushes a single Value::Array of the flattened result. Used
18056/// by the for-loop word-list compile path: when a word like `${arr[@]}`
18057/// produces a nested Array, this lets `for i in ${arr[@]}` iterate over the
18058/// inner elements rather than the outer single-element array.
18059pub const BUILTIN_ARRAY_FLATTEN: u16 = 293;
18060
18061/// `coproc [name] { body }` — bidirectional pipe to async child. Pops a name
18062/// (optional, "" for default) and a sub-chunk index. Creates two pipes, forks,
18063/// child redirects its fd 0/1 to the inner ends and runs the body, parent
18064/// stores [write_fd, read_fd] into the named array (default `COPROC`). Caller
18065/// closes the fds and `wait`s when done. Job-table integration deferred to
18066/// Phase G6 alongside the bg `&` work.
18067pub const BUILTIN_RUN_COPROC: u16 = 294;
18068
18069/// `arr+=(d e f)` — append N elements to an existing indexed array. Compile
18070/// emits N element pushes + name push, then `CallBuiltin(295, N+1)`. Handler
18071/// drains args (last popped = name), extends `executor.arrays[name]` (creates
18072/// the entry if missing). Mirrors zsh's `+=` semantics for indexed arrays.
18073pub const BUILTIN_APPEND_ARRAY: u16 = 295;
18074
18075/// `name[@]=(...)` / `name[*]=(...)` whole-array SET. Identical to
18076/// BUILTIN_SET_ARRAY for an indexed array / scalar (whole replace), but
18077/// rejects an associative target with "attempt to set slice of
18078/// associative array" (c:Src/params.c:3324-3327).
18079pub const BUILTIN_SET_ARRAY_AT: u16 = 633;
18080
18081/// `name[@]+=(...)` / `name[*]+=(...)` whole-array APPEND. Indexed
18082/// append (push), assoc target → same slice-of-assoc error as 633.
18083pub const BUILTIN_APPEND_ARRAY_AT: u16 = 634;
18084
18085/// `select var in words; do body; done` — interactive numbered-menu loop.
18086/// Compile emits N word pushes + var-name push + sub-chunk index push, then
18087/// `CallBuiltin(296, N+2)`. Handler prints `1) word1\n2) word2\n...` to
18088/// stderr, prints `$PROMPT3` (default `?# `) to stderr, reads a line from
18089/// stdin. On EOF returns 0. On a valid 1-based number, sets `var` to the
18090/// chosen word, runs the sub-chunk, then redisplays the menu and loops. On
18091/// invalid input redraws the menu without running the body. `break` from
18092/// inside the body exits the loop (handled by the body's own bytecode).
18093pub const BUILTIN_RUN_SELECT: u16 = 296;
18094
18095/// `m[k]+=value` — append onto an existing assoc-array value (string concat).
18096/// If the key doesn't exist, behaves like SET_ASSOC. Stack: [name, key, value].
18097
18098/// `break` from inside a body that runs on a sub-VM (select, future
18099/// loop-via-builtin constructs). Writes the canonical
18100/// `crate::ported::builtin::BREAKS` atomic (port of `Src/loop.c:46
18101/// breaks`). Outer-loop builtins drain BREAKS/CONTFLAG after each
18102/// body run, matching the loop.c:529-534 drain pattern.
18103pub const BUILTIN_SET_BREAK: u16 = 299;
18104
18105/// `continue` from inside a sub-VM body. Sets CONTFLAG=1 + bumps
18106/// BREAKS, matching `bin_break`'s WC_CONTINUE arm at Src/builtin.c
18107/// c:5836 `contflag = 1; FALLTHROUGH; breaks++;`.
18108pub const BUILTIN_SET_CONTINUE: u16 = 300;
18109
18110/// Brace expansion: `{a,b,c}` → 3 values, `{1..5}` → 5 values, `{01..05}` →
18111/// zero-padded numerics, `{a..e}` → letter range. Pops one string, returns
18112/// Value::Array of expansions (empty array → original string preserved).
18113pub const BUILTIN_BRACE_EXPAND: u16 = 301;
18114
18115/// Glob qualifier filter: `*(qualifier)` filters glob results by predicate.
18116/// Pops [pattern, qualifier_string]. Returns Value::Array of matching paths.
18117
18118/// Re-export the regex_match host method as a builtin so `[[ s =~ pat ]]`
18119/// works even when fusevm's Op::RegexMatch isn't routed (compat fallback).
18120
18121/// Word-split a string on IFS (default: whitespace). Pops one string,
18122/// returns Value::Array of fields. Used in array-literal context where
18123/// `arr=($(cmd))` should expand cmd's stdout into multiple elements.
18124pub const BUILTIN_WORD_SPLIT: u16 = 304;
18125
18126/// `${=name}` / SH_WORD_SPLIT forced IFS split — c:Src/subst.c:3920-3928
18127/// `aval = sepsplit(val, spsep, 0, 1);`.
18128///
18129/// Unlike BUILTIN_WORD_SPLIT (which routes through `multsub`'s
18130/// PREFORK_SPLIT walker — the c:553-620 loop that COLLAPSES runs of
18131/// separators and never emits an empty field), this is the *other* zsh
18132/// splitter: `sepsplit` → `Src/utils.c:3711 spacesplit(s, allownull=0)`,
18133/// which distinguishes the two IFS classes and preserves empty fields.
18134///
18135/// Stack: \[value\]. argc selects the empty-field rule:
18136///   * argc == 0 — the expansion is a bare unquoted word (`${=v}`): the
18137///     leading/trailing `""` fields spacesplit emits for skipped
18138///     IFS-WHITESPACE are empty argv words and prefork deletes them
18139///     (c:Src/subst.c:184-187 `uremnode`).
18140///   * argc == 1 — the expansion is quoted (`"${=v}"`) or has adjacent
18141///     word segments (`x${=v}y`): those fields survive, because in C the
18142///     word's `Dnull` quote markers / literal prefix+suffix attach to the
18143///     first and last elements (c:4386 / c:4429 strcatsub) and the node is
18144///     no longer empty. `v=$' a:b '` → `""`, `a:b`, `""` quoted; `x`,
18145///     `a:b`, `y` with surrounding text.
18146///
18147/// Empty fields that come from an IFS-NON-whitespace separator are the
18148/// `nulstring` (`Nularg`, c:Src/subst.c:36) and survive in BOTH cases —
18149/// `IFS=x; v=xaxbx` splits to `""`, `a`, `b`, `""` quoted or not.
18150pub const BUILTIN_FORCE_SPLIT: u16 = 643;
18151
18152/// Register a pre-compiled fusevm chunk as a function. Stack: [name,
18153/// base64-bincode-of-Chunk]. Used by compile_zsh's compile_funcdef to
18154/// register functions parsed via parse_init+parse without going through the
18155/// ShellCommand JSON serialization path.
18156pub const BUILTIN_REGISTER_COMPILED_FN: u16 = 305;
18157/// `BUILTIN_VAR_EXISTS` constant.
18158pub const BUILTIN_VAR_EXISTS: u16 = 306;
18159/// Native param-modifier builtins. Each takes a fixed argv shape and
18160/// returns the modified value as Value::Str.
18161///
18162/// `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
18163/// — pop [name, op_byte, rhs]. op_byte: 0=`:-`, 1=`:=`, 2=`:?`, 3=`:+`.
18164pub const BUILTIN_PARAM_DEFAULT_FAMILY: u16 = 307;
18165/// `${var:offset[:length]}` — pop [name, offset, length] (length=-1 means
18166/// "rest of value"; negative offset counts from end).
18167pub const BUILTIN_PARAM_SUBSTRING: u16 = 308;
18168/// `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}` — pop
18169/// [name, pattern, op_byte]. op_byte: 0=`#`, 1=`##`, 2=`%`, 3=`%%`.
18170pub const BUILTIN_PARAM_STRIP: u16 = 309;
18171/// `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
18172/// `${var/%pat/repl}` — pop [name, pattern, replacement, op_byte].
18173/// op_byte: 0=first, 1=all, 2=anchor-prefix, 3=anchor-suffix.
18174pub const BUILTIN_PARAM_REPLACE: u16 = 310;
18175/// `${#name}` — character length of a scalar value, or element count
18176/// of an indexed/assoc array. Pops \[name\], returns count as Value::Str.
18177pub const BUILTIN_PARAM_LENGTH: u16 = 311;
18178/// `$((expr))` arithmetic substitution. Pops \[expr_string\], evaluates
18179/// via the executor's MathEval (integer-aware), returns result as
18180/// Value::Str. Bypasses ArithCompiler's float-only Op::Div path so
18181/// `$((10/3))` returns "3" not "3.333...".
18182pub const BUILTIN_ARITH_EVAL: u16 = 312;
18183/// `(( ... ))` math command post-eval status hook. Pops nothing,
18184/// pushes Value::Status. If errflag is set (math error in the
18185/// preceding BUILTIN_ARITH_EVAL call), clears it and emits status=2
18186/// matching c:Src/math.c arith-failure semantics. Otherwise emits
18187/// the current vm.last_status. Used by compile_arith's `(( ... ))`
18188/// path so the math command swallows errors without halting the
18189/// script — `$((... ))` substitutions skip this hook so their
18190/// errflag propagates up to the containing command.
18191pub const BUILTIN_ARITH_CMD_FINISH: u16 = 527;
18192/// `$(cmd)` command substitution. Pops \[cmd_string\], runs through
18193/// `run_command_substitution` which compiles via parse_init+parse + ZshCompiler
18194/// and captures stdout via an in-process pipe. Returns trimmed output
18195/// as Value::Str. Avoids the sub-chunk word-emit quoting bug in the
18196/// raw Op::CmdSubst path.
18197pub const BUILTIN_CMD_SUBST_TEXT: u16 = 313;
18198/// Text-based word expansion. Pops \[preserved_text\]: the word with
18199/// quotes preserved (Dnull→`"`, Snull→`'`, Bnull→`\`), runs
18200/// `expand_string` (variable + cmd-sub + arith) then `xpandbraces`
18201/// then `expand_glob`. Returns Value::str (single match) or
18202/// Value::Array (multi-match brace/glob).
18203pub const BUILTIN_EXPAND_TEXT: u16 = 314;
18204
18205/// `[[ a -ef b ]]` — same-inode test. Stack: [a, b]. Pushes Bool true iff
18206/// both paths resolve to the same `(dev, inode)` pair (zsh + bash semantics).
18207pub const BUILTIN_SAME_FILE: u16 = 315;
18208
18209/// `[[ a -nt b ]]` — file `a` newer than file `b` (mtime strict).
18210/// Stack: [path_a, path_b]. Pushes Bool. zsh-compatible "missing"
18211/// rules: if both exist, compare mtime; if only `a` exists → true;
18212/// otherwise false.
18213pub const BUILTIN_FILE_NEWER: u16 = 324;
18214
18215/// `[[ a -ot b ]]` — mirror of `-nt`. If both exist, compare mtime;
18216/// if only `b` exists → true; otherwise false.
18217pub const BUILTIN_FILE_OLDER: u16 = 325;
18218
18219/// `[[ -k path ]]` — sticky bit (S_ISVTX) set on path.
18220pub const BUILTIN_HAS_STICKY: u16 = 326;
18221/// `[[ -u path ]]` — setuid bit (S_ISUID).
18222pub const BUILTIN_HAS_SETUID: u16 = 327;
18223/// `[[ -g path ]]` — setgid bit (S_ISGID).
18224pub const BUILTIN_HAS_SETGID: u16 = 328;
18225/// `[[ -O path ]]` — owned by effective UID.
18226pub const BUILTIN_OWNED_BY_USER: u16 = 329;
18227/// `[[ -G path ]]` — owned by effective GID.
18228pub const BUILTIN_OWNED_BY_GROUP: u16 = 330;
18229/// `[[ -N path ]]` — file modified since last accessed (atime <= mtime).
18230pub const BUILTIN_FILE_MODIFIED_SINCE_ACCESS: u16 = 341;
18231
18232/// `name+=val` (no parens) — runtime-dispatched append.
18233/// If name is an indexed array → push val as element.
18234/// If name is an assoc array → error (zsh requires `(k v)` form).
18235/// Else → scalar concat (existing SET_VAR behavior).
18236pub const BUILTIN_APPEND_SCALAR_OR_PUSH: u16 = 331;
18237
18238/// `[[ -c path ]]` — character device.
18239pub const BUILTIN_IS_CHARDEV: u16 = 332;
18240/// `[[ -b path ]]` — block device.
18241pub const BUILTIN_IS_BLOCKDEV: u16 = 333;
18242/// `[[ -p path ]]` — FIFO / named pipe.
18243pub const BUILTIN_IS_FIFO: u16 = 334;
18244/// `[[ -S path ]]` — socket.
18245pub const BUILTIN_IS_SOCKET: u16 = 335;
18246/// `BUILTIN_ERREXIT_CHECK` constant.
18247pub const BUILTIN_ERREXIT_CHECK: u16 = 336;
18248/// Fatal-error-only abort check, for use INSIDE an `&&` / `||` chain.
18249///
18250/// A chain suppresses the errexit check (a non-zero status is "consumed"
18251/// by the connector — `false && x` must not fire ERREXIT or the ZERR
18252/// trap). But an errflag — a *fatal* error such as a `[[ ]]` bad pattern
18253/// — is not a status the connector can consume: zsh abandons the whole
18254/// list. Without this, zshrs ran the `||` right-hand side after the
18255/// error (`[[ x = [a- ]] || touch f` created `f`; zsh does not) and the
18256/// aborted builtin then overwrote the cond's status 2 with 1.
18257///
18258/// Same errflag arm as `BUILTIN_ERREXIT_CHECK`, with the errexit/ZERR
18259/// half omitted.
18260pub const BUILTIN_FATAL_ABORT_CHECK: u16 = 641;
18261/// Post-`always`-arm checks for the canonical RETFLAG / BREAKS /
18262/// CONTFLAG atomics that mark try-block escapes. Each returns
18263/// Value::Int(1) when the corresponding atomic is set (and consumes
18264/// it so the next escape doesn't re-fire) and Value::Int(0) otherwise.
18265/// Paired with JumpIfFalse + Jump to outer return_patches /
18266/// break_patches / continue_patches by compile_zsh's `Try` arm.
18267pub const BUILTIN_RETFLAG_CHECK: u16 = 600;
18268/// `BUILTIN_BREAKS_CHECK` constant.
18269pub const BUILTIN_BREAKS_CHECK: u16 = 601;
18270/// `BUILTIN_CONTFLAG_CHECK` constant.
18271pub const BUILTIN_CONTFLAG_CHECK: u16 = 602;
18272/// `loops++` on entry to a compiled for/while/until/repeat
18273/// (c:Src/loop.c:114/427/523).
18274pub const BUILTIN_LOOP_ENTER: u16 = 656;
18275/// `loops--` on exit from a compiled for/while/until/repeat
18276/// (c:Src/loop.c:188/491/546).
18277pub const BUILTIN_LOOP_EXIT: u16 = 657;
18278/// Post-body `if (breaks) { breaks--; … }` drain (c:Src/loop.c:529-534).
18279/// Int(1) = terminate this loop, Int(0) = next iteration.
18280pub const BUILTIN_LOOP_BREAK_DRAIN: u16 = 658;
18281/// Non-consuming `breaks != 0` probe for execlist's per-statement gate
18282/// (c:Src/exec.c:1370).
18283pub const BUILTIN_BREAKS_PENDING: u16 = 659;
18284/// `shtokenize` the top-of-stack string in place — c:Src/subst.c:4419-4420
18285/// `if (globsubst) shtokenize(y)`, the step that makes a `${~spec}` /
18286/// `$~spec` value's metachars PATTERN-ACTIVE.
18287///
18288/// zshrs expands a `[[ ]]` operand at the VM level and hands `cond_str`
18289/// (c:Src/cond.c:525) a finished string, so the token state C carries in
18290/// the word itself has to be re-applied at the point of use. Without it a
18291/// module condition compiles the value as a literal: `[[ -prefix $~pat ]]`
18292/// (Completion/Base/Utility/_numbers sh:65) is the one shipped completer
18293/// that depends on it.
18294pub const BUILTIN_COND_SHTOKENIZE: u16 = 660;
18295/// Fire the DEBUG trap (SIGDEBUG) before each statement.
18296/// c:Src/exec.c:1357-1500 DEBUGBEFORECMD — when a "DEBUG" entry is
18297/// installed via `trap '...' DEBUG`, run the body just before the
18298/// next command. Cheap when no DEBUG trap is set (one hashmap lookup
18299/// returns None and we early-out).
18300pub const BUILTIN_DEBUG_TRAP: u16 = 603;
18301/// `set -n` / `set -o noexec` — parse but don't execute. Returns
18302/// Value::Int(1) when the noexec option is set so the caller's
18303/// JumpIfTrue skips the statement body. c:Src/exec.c:1390 main loop
18304/// check.
18305pub const BUILTIN_NOEXEC_CHECK: u16 = 604;
18306/// c:Src/exec.c:4051 — `else if (isset(EXECOPT) && !errflag)`: a simple
18307/// command's words are expanded (prefork c:3304, globlist c:3757) whether
18308/// or not NO_EXEC is set; only the execution is skipped. No args. Returns
18309/// Int(1) under NO_EXEC, after setting the status to 1 when the expansion
18310/// raised errflag (c:3760-3763); the caller's JumpIfTrue then drops the
18311/// expanded words without running the command. Int(0) otherwise.
18312pub const BUILTIN_NOEXEC_CMD_GATE: u16 = 748;
18313/// Block-level redirect-failure gate. Reads exec.redirect_failed
18314/// (set by host.redirect when a redirect open fails); returns
18315/// Value::Int(1) AND clears the flag if set, else 0. Emit-side at
18316/// compile_zsh.rs::compile_command's Redirected arm pairs with a
18317/// JumpIfTrue → WithRedirectsEnd to abandon the body. Without this,
18318/// a multi-statement block after a failed redir kept running every
18319/// statement after the first (the first builtin consumed the flag,
18320/// subsequent statements ran unimpeded).
18321pub const BUILTIN_REDIRECT_FAILED_CHECK: u16 = 605;
18322/// Drop-in replacement for fusevm's Op::Exec for the dynamic-first-
18323/// word path (`$cmd`, `$(cmd)`, `~/bin/foo`). Returns
18324/// Value::Status(vm.last_status) when post-expansion argv is empty
18325/// (preserves the inner cmd-subst's exit), Value::Status(126) with
18326/// "permission denied" when `argv[0]` is empty, otherwise routes
18327/// through executor.host_exec_external like Op::Exec did.
18328pub const BUILTIN_EXEC_DYNAMIC: u16 = 606;
18329/// Reset `use_cmdoutval` to 0 at the START of a dynamic command (before
18330/// its words expand), so a command substitution from a PREVIOUS command
18331/// can't leak into this command's null-command status decision
18332/// (c:Src/exec.c:3009 `use_cmdoutval = !args`). See BUILTIN_EXEC_DYNAMIC.
18333pub const BUILTIN_USE_CMDOUTVAL_RESET: u16 = 637;
18334/// Reset `cmdoutval` to 0 at the START of a simple command that carries
18335/// assignments, before any of its words or assignment values expand
18336/// (c:Src/exec.c:2796 execcmd_analyse, c:1368 execsimple WC_ASSIGN).
18337pub const BUILTIN_CMDOUTVAL_RESET: u16 = 731;
18338/// Tilde-expand a match pattern's leading `~`, the way `singsub` does
18339/// before the pattern reaches `patcompile`.
18340///
18341/// c:Src/cond.c:299-307 — `right = dupstring(ecrawstr(…)); singsub(&right);
18342/// … patcompile(right, …)`. `singsub` is `prefork(PREFORK_SINGLE)`
18343/// (c:Src/subst.c:520), and prefork runs `filesub` on every word, so an
18344/// unquoted `~` in a `[[ … == ~/* ]]` pattern — or one that arrives via
18345/// `${~var}` — is a home directory, not a literal character. `case`
18346/// patterns take the same route (c:Src/loop.c:610 `singsub(&pat)`).
18347/// zshrs untokenizes the pattern at compile time and re-tokenizes it in
18348/// the matcher, so the expansion has to happen here.
18349///
18350/// Only a LEADING `~` is considered, which is all `filesubstr`
18351/// (c:Src/subst.c:741) ever expands: a `~` elsewhere is EXTENDED_GLOB's
18352/// "except" operator and must survive untouched, and a quoted one has
18353/// already been backslash-escaped by `escape_quoted_glob_metas` so it
18354/// fails the leading-char test.
18355///
18356/// powerlevel10k's directory segment is the visible consumer: its
18357/// `_POWERLEVEL9K_DIR_CLASSES` walk matches `$PWD` against `~` and `~/*`
18358/// via `[[ $_p9k__cwd == ${~a} ]]` (internal/p10k.zsh:2029). Without the
18359/// expansion both classes missed, every path under `$HOME` fell through
18360/// to the `*` DEFAULT class, and the prompt showed the generic folder
18361/// icon in place of the home / home-subfolder one.
18362fn pattern_filesub(pattern: &str) -> String {
18363    let first = pattern.chars().next();
18364    if first != Some('~') && first != Some(crate::ported::zsh_h::Tilde) {
18365        return pattern.to_string();
18366    }
18367    // filesubstr keys on the Tilde TOKEN, so shtokenize first (a raw `~`
18368    // becomes Tilde; an already-tokenized one passes through), then
18369    // untokenize the surviving glob metas back for the matcher.
18370    let mut tok = pattern.to_string();
18371    crate::ported::glob::shtokenize(&mut tok);
18372    crate::ported::lex::untokenize(&crate::ported::subst::filesub(&tok, 0))
18373}
18374
18375/// `[[ lhs == pat ]]` / `!=` glob compare — cond-specific so the
18376/// bad-pattern diagnostic follows Src/cond.c:308-316: zwarnnam
18377/// "bad pattern: %s" WITHOUT errflag (the script continues) and the
18378/// cond statement exits 2. Stack: [lhs, pat] → Bool. On compile
18379/// failure pushes Bool(false) and arms COND_BAD_PATTERN so
18380/// BUILTIN_COND_STATUS_FROM_BOOL reports 2.
18381pub const BUILTIN_COND_STRMATCH: u16 = 624;
18382/// Pops the cond result Bool → Int status per Src/cond.c: true→0,
18383/// false→1, but 2 when COND_BAD_PATTERN was armed during this cond
18384/// (covers `!=` where LogNot flips the Bool before status time).
18385pub const BUILTIN_COND_STATUS_FROM_BOOL: u16 = 625;
18386/// `[[ ]]` unknown condition. Pops \[op_name\], emits `zerr("unknown
18387/// condition: %s")` and sets ERRFLAG_ERROR so the next BUILTIN_ERREXIT_CHECK
18388/// (trigger 4) aborts the input — matching zsh's COND_MODI "unknown condition"
18389/// path (Src/cond.c:150-188) for a `-X` op with no matching loadable module.
18390/// Returns Bool(false). Replaces a compile-time `eprintln!` hack that printed
18391/// the message but never set errflag (so the line didn't abort).
18392pub const BUILTIN_COND_UNKNOWN: u16 = 632;
18393/// Bare-`exec` redirect epilogue. Consumes `exec.redirect_failed` and
18394/// applies the C `done:` tail of execcmd_exec:
18395///   - c:Src/exec.c:252-259 execerr — `redir_err = lastval = 1` (the
18396///     failed redirect makes the exec statement exit 1, NOT fatal by
18397///     itself);
18398///   - c:Src/exec.c:4367-4386 — `if (isset(POSIXBUILTINS) && (cflags
18399///     & (BINF_PSPECIAL|BINF_EXEC)) ...) { if (redir_err || errflag)
18400///     { if (!isset(INTERACTIVE)) exit(1); } }` — POSIX_BUILTINS makes
18401///     a failed exec redirect fatal in a non-interactive shell.
18402/// Returns Value::Status(0|1) for the trailing SetStatus.
18403pub const BUILTIN_EXEC_REDIR_DONE: u16 = 626;
18404/// Assignment-prefix epilogue for bare `exec` redirects
18405/// (`x=$(cmd) exec >file`). c:Src/exec.c:3969-3976 — nullexec==1
18406/// runs addvars THEN, without POSIX_BUILTINS, restores the params
18407/// (`save_params` / `restore_params`): the RHS side effects fire but
18408/// the values don't persist. With POSIX_BUILTINS the assignments
18409/// stick. Pops the BEGIN_INLINE_ENV frame either way.
18410pub const BUILTIN_EXEC_INLINE_ENV_DONE: u16 = 627;
18411
18412/// `< file` / `> file` with no command word (NULLCMD path).
18413/// Resolves NULLCMD (default "cat") / READNULLCMD (default "more")
18414/// at runtime per Src/exec.c:3386-3419, then dispatches that word
18415/// exactly as execcmd's fall-through does: shell function (c:3485)
18416/// → builtin (c:3489) → external. Argc is 1: the int (0 or 1) on the
18417/// stack indicates whether this is a single REDIR_READ redirect
18418/// (selects READNULLCMD when set + non-empty).
18419pub const BUILTIN_NULLCMD_EXEC: u16 = 607;
18420/// c:Src/exec.c:3395-3399 — the redirection-only command's refusal,
18421/// taken before its redirections open: no NULLCMD, or CSH_NULLCMD set,
18422/// is `zerr("redirection with no command")` with `lastval = 1` and
18423/// ERRFLAG_ERROR. argc=0; Bool(true) = go on to NULLCMD_EXEC.
18424pub const BUILTIN_NULLCMD_CHECK: u16 = 760;
18425/// Emitted right before BUILTIN_EXEC_DYNAMIC when the simple command has
18426/// redirections. No args. The VM has already opened them, so the dispatch
18427/// passes `execcmd_exec` an empty `redir` list; this tells its AUTOCD test
18428/// (c:Src/exec.c:3596 `(!redir || empty(redir))`) that there were some.
18429pub const BUILTIN_EXEC_DYNAMIC_REDIRS: u16 = 761;
18430/// Emitted right before a command word compiled to a builtin opcode: the
18431/// command runs in the current shell, so execcmd_exec's is_cursh arm
18432/// (c:Src/exec.c:3674-3682) marks the pipeline's job STAT_CURSH, plus
18433/// STAT_NOPRINT while it has no processes. No args; pushes Int(0).
18434pub const BUILTIN_MARK_CURSH: u16 = 762;
18435/// Opens the window in which the prefix assignments of `X=… cmd` expand.
18436/// argc=1: the command word ("" when it is an expansion). Closed by
18437/// BUILTIN_SEAL_INLINE_ENV. See PREFIX_ASSIGN_CMD.
18438pub const BUILTIN_PREFIX_ASSIGNS_START: u16 = 763;
18439/// `.` (dot) — alias of source/bin_dot but dispatches with the
18440/// literal name "." so the diagnostic prefix matches zsh's
18441/// (`zsh:.:1: …` vs source's `zsh:source:1: …`).
18442/// c:Src/builtin.c:9308 — `BUILTIN(".", BINF_PSPECIAL, bin_dot, …)`.
18443pub const BUILTIN_DOT: u16 = 608;
18444/// `logout` — fusevm maps this to BUILTIN_EXIT alongside `exit`/`bye`,
18445/// which drops the name and dispatches with BIN_EXIT funcid. zsh's
18446/// `logout` outside a login shell must emit "not login shell" + exit 1,
18447/// which only fires when bin_break sees BIN_LOGOUT funcid. Dedicated
18448/// opcode dispatches via BUILTINS table by literal name "logout".
18449pub const BUILTIN_LOGOUT: u16 = 610;
18450/// `BUILTIN_PARAM_SUBSTRING_EXPR` constant.
18451pub const BUILTIN_PARAM_SUBSTRING_EXPR: u16 = 337;
18452/// `BUILTIN_XTRACE_LINE` constant.
18453pub const BUILTIN_XTRACE_LINE: u16 = 338;
18454/// `BUILTIN_XTRACE_ARRAY_LINE` — xtrace an `arr=(...)` assignment from the
18455/// whole assembled `Value::Array` (see compile_zsh array-literal codegen).
18456pub const BUILTIN_XTRACE_ARRAY_LINE: u16 = 649;
18457/// `BUILTIN_MAKE_ARRAY_COUNTED` — like `Op::MakeArray(u16)` but the element
18458/// count is a runtime `Int` on the stack, so it is not capped at 65535. Used
18459/// by the array-literal codegen only when the literal has > u16::MAX elements
18460/// (e.g. a .zcompdump's ~103k-element `_comps=(...)`).
18461pub const BUILTIN_MAKE_ARRAY_COUNTED: u16 = 650;
18462/// `BUILTIN_ARGV_RFLATTEN` — pop one `Op::MakeArray`-packed argv bundle and
18463/// push it back as ONE recursively-flattened `Value::Array` of scalars. Emitted
18464/// by the simple-command codegen ONLY on the >255-arg overflow path: the
18465/// `Call`/`CallFunction`/`CallBuiltin` opcodes carry argc as a u8, so a command
18466/// with more than 255 args is packed into a single Array (dispatched with
18467/// argc=1) instead. But those call ops flatten their argv only ONE level, which
18468/// would stringify a nested Array (a brace/glob/`$arr` word contributes a
18469/// `Value::Array`). Pre-flattening here — same descent as
18470/// [`flatten_array_value`], the array-assignment path — makes the bundle flat
18471/// so the call op's single-level splat restores every positional arg. Bit
18472/// compsys: a completer's `_arguments <specs…>` with a large brace-form option
18473/// set (curl ships 59 `{-x,--long}` specs) dropped the long forms.
18474pub const BUILTIN_ARGV_RFLATTEN: u16 = 653;
18475/// `BUILTIN_ARRAY_JOIN_STAR` constant.
18476pub const BUILTIN_ARRAY_JOIN_STAR: u16 = 339;
18477/// `BUILTIN_SET_RAW_OPT` constant.
18478pub const BUILTIN_SET_RAW_OPT: u16 = 340;
18479
18480/// `time { compound; ... }` — wall-clock-time the sub-chunk and print
18481/// elapsed seconds. Stack: [sub_chunk_idx as Int]. Runs the sub-chunk
18482/// on the current VM (so positional/local state is shared) and prints
18483/// the timing summary to stderr in zsh's format. Pushes Status.
18484pub const BUILTIN_TIME_SUBLIST: u16 = 316;
18485
18486/// `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocation.
18487/// Stack: [path, varid, op_byte]. Opens `path` per `op_byte`, gets the
18488/// new fd (≥10 in zsh; we use libc::open with O_CLOEXEC bit cleared so
18489/// the inherited fd survives Command::new spawns), stores the fd number
18490/// as a string in `$varid`. Pushes Status (0 success, 1 error).
18491pub const BUILTIN_OPEN_NAMED_FD: u16 = 317;
18492
18493/// Word-segment concat that does cartesian-product distribution over
18494/// arrays. Stack: [lhs, rhs]. Used for RC_EXPAND_PARAM `${arr}` and
18495/// explicit-distribute forms (`${^arr}`, `${(@)…}`).
18496///
18497/// - both scalar: `Value::str(a + b)` (fast path, identical to Op::Concat)
18498/// - lhs Array, rhs scalar: `Value::Array([a + rhs for a in lhs])`
18499/// - lhs scalar, rhs Array: `Value::Array([lhs + b for b in rhs])`
18500/// - both Array: cartesian product `[a + b for a in lhs for b in rhs]`
18501pub const BUILTIN_CONCAT_DISTRIBUTE: u16 = 318;
18502
18503/// Forced-distribute concat — like `BUILTIN_CONCAT_DISTRIBUTE` but
18504/// always distributes cartesian regardless of the `rcexpandparam`
18505/// option. Emitted by the segments fast-path when an
18506/// `is_distribute_expansion` segment is present (`${^arr}`,
18507/// `${(@)arr}`, `${(s.…)arr}` etc.) per zsh: the source-level
18508/// distribution flag overrides the option default.
18509/// Direct port of Src/subst.c:1875 `case Hat: nojoin = 1` and the
18510/// `rcexpandparam` test bypass for the explicit-distribute flags.
18511pub const BUILTIN_CONCAT_DISTRIBUTE_FORCED: u16 = 522;
18512
18513/// Capture current `last_status` into the `TRY_BLOCK_ERROR` variable.
18514/// Emitted between the try block and the always block of `{ … } always
18515/// { … }` so the finally arm can read $TRY_BLOCK_ERROR.
18516pub const BUILTIN_SET_TRY_BLOCK_ERROR: u16 = 320;
18517/// `BUILTIN_RESTORE_TRY_BLOCK_STATUS` constant.
18518pub const BUILTIN_RESTORE_TRY_BLOCK_STATUS: u16 = 432;
18519/// `BUILTIN_BEGIN_INLINE_ENV` constant.
18520pub const BUILTIN_BEGIN_INLINE_ENV: u16 = 433;
18521/// `BUILTIN_END_INLINE_ENV` constant.
18522pub const BUILTIN_END_INLINE_ENV: u16 = 434;
18523/// Closes the current inline-env frame's save list. Emitted right
18524/// after the prefix assignments of `X=foo cmd` have committed and
18525/// before `cmd` dispatches, so assignments performed BY the command
18526/// are not recorded into (and therefore not reverted with) the frame.
18527/// c:Src/exec.c:4410 `save_params` snapshots only the parsed
18528/// WC_ASSIGN chain; the list is closed before the builtin/shell
18529/// function runs. Without the seal, `X=y . file` reverted every
18530/// global the sourced file assigned — which emptied git's
18531/// `git-completion.bash` option tables (`__git_log_common_options`
18532/// et al.) that `_git` sources via `GIT_SOURCING_ZSH_COMPLETION=y . …`.
18533pub const BUILTIN_SEAL_INLINE_ENV: u16 = 654;
18534
18535/// End-of-sublist `waitonejob` for a sublist that ran wholly in the
18536/// current shell. Emitted by `compile_zsh::compile_sublist` after each
18537/// element of the `&&`/`||` chain whose parse-time `cmplx` flag is set
18538/// (see `compile_zsh::sublist_elem_is_cmplx`) and whose top level is
18539/// NOT a multi-stage pipeline.
18540///
18541/// c:Src/exec.c:1489-1492 — `execlist` routes each sublist element on
18542/// the parse-time flag: `if (WC_SUBLIST_FLAGS(code) & WC_SUBLIST_SIMPLE)
18543/// execsimple(state); else execpline(state, code, ltype, ...);`. Only
18544/// the `execpline` arm builds a job, and `execpline` ends by calling
18545/// `waitonejob` on it.
18546///
18547/// c:Src/jobs.c:1748-1757 — `waitonejob(Job jn)`:
18548/// ```c
18549/// if (jn->procs || jn->auxprocs) zwaitjob(jn - jobtab, 0);
18550/// else { deletejob(jn, 0); pipestats[0] = lastval; numpipestats = 1; }
18551/// ```
18552/// A sublist that forked (a real multi-stage pipeline) takes the
18553/// `zwaitjob` arm, whose `storepipestats` (c:Src/jobs.c:420) publishes
18554/// the per-stage array — in zshrs that is `BUILTIN_RUN_PIPELINE`'s own
18555/// `set_array("pipestatus", ...)`. Every other cmplx sublist runs with
18556/// an empty proc list and takes the `else` arm, which is what this
18557/// builtin performs. Resolving which arm applies is a compile-time
18558/// decision in C (the parse-time flag) and is a compile-time decision
18559/// here too, so no marker is emitted for the pipeline case at all.
18560///
18561/// This is what makes a compound command publish `$pipestatus`:
18562/// `if ...; fi`, `for ...; done`, `case ... esac`, `while ...; done`,
18563/// `{ ... }`, `( ... )` and a bare command all reach `execpline` when
18564/// their body is cmplx, so zsh leaves `numpipestats == 1`. It also
18565/// makes the OUTER sublist win over an inner pipeline's array —
18566/// `if true; then true|false; fi` is `n=1 p=(1)`, not `(0 1)` — because
18567/// the outer procs-less job overwrites what the inner job stored.
18568///
18569/// Fires BEFORE the `!` negation (`emit_negate_status`): C applies
18570/// `WC_SUBLIST_NOT` inside `execpline` after the wait, so
18571/// `! [[ -z x ]]` records the PRE-negation status — `p=(1)` with `$?`
18572/// of 0.
18573pub const BUILTIN_SUBLIST_FINISH: u16 = 655;
18574
18575/// `[[ -o option ]]` — shell-option-set test. Stack: \[option_name\].
18576/// Normalizes the name (strip underscores, lowercase) and reads
18577/// `exec.options`. Pushes Bool.
18578pub const BUILTIN_OPTION_SET: u16 = 321;
18579/// Tri-state `[[ -o NAME ]]` — same lookup as BUILTIN_OPTION_SET
18580/// but returns a Value::Int (0=set, 1=unset, 3=invalid-name). The
18581/// 3-state code matches zsh's `[[ -o invalid ]]` exit (Src/cond.c
18582/// :502 `optison()`). Used by compile_cond's `-o` arm to skip the
18583/// generic bool→status conversion and preserve the invalid-name
18584/// signal in `$?`.
18585pub const BUILTIN_OPTION_CHECK_TRISTATE: u16 = 609;
18586
18587/// `${var:#pattern}` — array filter: remove elements matching `pattern`.
18588/// Stack: [name, pattern]. For scalar `var`, returns empty if it matches
18589/// the pattern, else the value. For array `var`, returns Array of
18590/// non-matching elements.
18591pub const BUILTIN_PARAM_FILTER: u16 = 322;
18592
18593/// `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()` —
18594/// subscripted-array assign with array-literal RHS. Stack:
18595/// [...elements, name, key]. Empty elements + single-int key `a[i]=()`
18596/// removes that element. Comma-key `a[i,j]=(...)` splices.
18597pub const BUILTIN_SET_SUBSCRIPT_RANGE: u16 = 323;
18598
18599/// `[[ -X file ]]` for unknown unary test op `-X`. Stack: \[op_name\].
18600/// Emits zsh's `unknown condition: -X` diagnostic to stderr and
18601/// pushes Bool(false). Without this, unknown conditions silently
18602/// returned false matching neither zsh's error format nor the
18603/// expected status code (zsh returns 2 for parse error).
18604
18605/// `[[ -t fd ]]` — fd-is-a-tty check. Stack: \[fd_string\].
18606/// Routes through libc::isatty. Pushes Bool.
18607///
18608/// ID 644 (unique, next free above the previous max of 643). This was
18609/// 325, which COLLIDED with BUILTIN_FILE_OLDER (also 325). The VM's
18610/// builtin table is last-registration-wins, and FILE_OLDER registered
18611/// after IS_TTY, so every `[[ -t fd ]]` silently dispatched to the
18612/// file-`-ot` handler: it compared the mtime of a file NAMED by the fd
18613/// string ("0", "1", …) — which never exists — so `[[ -t 0 ]]` was
18614/// always false. That broke interactive detection (`[[ -t 0 && -t 1 ]]`)
18615/// and any config gated on it. c:Src/cond.c:390 `return !isatty(...)`.
18616pub const BUILTIN_IS_TTY: u16 = 644;
18617/// Runtime rejection of a process substitution used inside a `[[ … ]]`
18618/// cond operand. c:Src/exec.c:4918/5040/5069 — `getoutputfile`/`getproc`
18619/// error `"process substitution %s cannot be used here"` when `thisjob ==
18620/// -1`, which is the case during cond evaluation. zshrs's THISJOB never
18621/// distinguishes that context at runtime, so the compiler emits this
18622/// builtin (gated on `in_cond_operand`) instead of the ProcessSubIn/Out
18623/// opcode. Pops the substitution text, zerrs, sets errflag (aborting the
18624/// statement → empty stdout, exit 1, matching zsh), returns empty.
18625pub const BUILTIN_PROCSUB_COND_ERROR: u16 = 645;
18626/// `${^arr}` cross-product concat — RC_EXPAND_PARAM forced ON by the `^` flag.
18627///
18628/// Distinct from BUILTIN_CONCAT_DISTRIBUTE_FORCED, which the other distribute
18629/// shapes (`${(@)a}`, `${(f)v}`, `${a[@]}`) share: those keep the word when the
18630/// array is EMPTY (`x${(@)a}y` → `xy`), but plan9 DELETES it
18631/// (c:Src/subst.c:4362-4365 `if (plan9) { uremnode(l, n); return n; }`), so
18632/// `x${^a}y` with `a=()` produces no word at all. One builtin cannot serve both
18633/// — the plan9-ness is known only at compile time, from the `^` flag itself.
18634/// Routes to `concat_plan9`, which already ports both c:4362's removal and the
18635/// c:4316-4350 cartesian emit, and is what the OPTION path
18636/// (`setopt rcexpandparam`) has always used.
18637pub const BUILTIN_CONCAT_PLAN9: u16 = 646;
18638/// `${^^arr}` concat — RC_EXPAND_PARAM forced OFF by the doubled flag
18639/// (c:Src/subst.c:2553-2555 `plan9 = 0`).
18640///
18641/// The mirror of BUILTIN_CONCAT_PLAN9. Needed because every other concat
18642/// builtin consults `plan9_active()` (the runtime OPTION) and so cross-products
18643/// anyway under `setopt rcexpandparam`, while `^^` must override the option:
18644///     setopt rcexpandparam; a=(a b c); print -rl -- ${^^a}.x
18645///     # zsh: `a`, `b`, `c.x`  — spliced, NOT `a.x b.x c.x`
18646/// The override is computed in paramsubst but the distribution call is made
18647/// here, so — like the `^` flag — the only place that knows is the compiler.
18648/// Routes straight to `concat_splice`, C's non-plan9 join-first-and-last path
18649/// (c:4366-4437).
18650pub const BUILTIN_CONCAT_SPLICE_NOPLAN9: u16 = 647;
18651/// Atomic word assembler for a DQ word that MIXES a plan9 (`^`) segment with a
18652/// non-plan9 (splice/scalar) segment — e.g. `"${(@)^a}${(@)b}"`.
18653///
18654/// The per-pair concat fold (CONCAT_PLAN9 / CONCAT_SPLICE picked ONCE for the
18655/// whole word) cannot express a word where segment A distributes but segment B
18656/// splices: a single operator does full-cross OR first/last-splice, never both,
18657/// and it loses track of which trailing elements are still the "growing edge".
18658/// zsh (Src/subst.c:4316-4437) instead threads a growing edge through the whole
18659/// word — an element is "active" until a splice freezes all but the last.
18660///
18661/// This builtin ports that edge-tracking directly. Stack (bottom→top):
18662///   descriptor, seg0, seg1, …, seg(n-1)     with argc = n + 1
18663/// where `descriptor` is an n-char string, one char per segment: `'1'` = plan9
18664/// (`^`), `'0'` = splice/scalar/literal. Each segment value is an Array (splat)
18665/// or scalar (1 element). Result is the assembled Array (or scalar / deleted).
18666pub const BUILTIN_WORD_ASSEMBLE_PLAN9: u16 = 652;
18667/// `break N`/`continue N` runtime-count validator (see registration).
18668pub const BUILTIN_BREAK_COUNT_VALIDATE: u16 = 648;
18669/// `[[ -r/-w/-x file ]]` via access(2) (doaccess) — see handler.
18670pub const BUILTIN_COND_ACCESS: u16 = 638;
18671
18672/// Evaluate a `[[ ]]` module/completion condition (`-prefix`/`-suffix`/
18673/// `-after`/`-between`). Stack (top-first): argc operand words, then the
18674/// operator word. Dispatches to `complete::eval_mod_cond`. Result pushed as
18675/// Bool (true = condition matched). Used by the `ZshCond::ModCond` compile arm.
18676pub const BUILTIN_COND_MOD: u16 = 651;
18677/// c:Src/cond.c:122-193 COND_MODI — an infix module condition `[[ a -op b ]]`
18678/// (Src/parse.c:2697-2701). Stack: left word, right word, operator word
18679/// (top). Looks the operator up as an infix condition, autoloading its
18680/// module (`getconddef(1, name + 1, 1)`, c:150); failing that, as a prefix
18681/// condition named by the left word (c:173-177). Bool result; an unknown
18682/// condition arms the status-2 carrier like BUILTIN_COND_MOD.
18683pub const BUILTIN_COND_MODI: u16 = 749;
18684/// c:Src/exec.c:3548-3579 — the `rm *` confirmation. Stack: the expanded
18685/// argv words of an `rm` (left in place), then a mask (top) of the words
18686/// whose source ends in an unquoted `*`. Sets ERRFLAG_ERROR when the user
18687/// declines (checkrmall returns 0), which aborts the command.
18688pub const BUILTIN_RM_STAR_CHECK: u16 = 750;
18689
18690/// `provenance` — report the lineage of a tracked parameter: where its
18691/// bytes entered the shell (command substitution, glob, heredoc, an
18692/// earlier assignment) and every bytecode-level op that touched them
18693/// since. Handler: `ShellExecutor::builtin_provenance`; engine:
18694/// `src/extensions/provenance.rs`. ID 661 is the first free slot above
18695/// the 653-660 block.
18696pub const BUILTIN_PROVENANCE: u16 = 661;
18697
18698/// PRINT_EXIT_VALUE report for one finished simple command. Direct port
18699/// of c:Src/exec.c:4308-4316 (`execcmd_exec`'s tail):
18700/// ```c
18701///     if (isset(PRINTEXITVALUE) && isset(SHINSTDIN) && lastval && !subsh)
18702///         fprintf(stderr, "zsh: exit %lld\n", lastval);
18703/// ```
18704/// The ported `execcmd_exec` carries the same code (exec.rs), but fusevm —
18705/// not that walker — is what actually runs a command, so the report never
18706/// fired. `compile_simple` emits this call right after the dispatch's
18707/// `Op::SetStatus` (both the builtin and the function/external arm), which
18708/// is exactly where the C line sits. Pushes Status(0), which the emit side
18709/// pops; `vm.last_status` is left untouched.
18710pub const BUILTIN_PRINT_EXIT_VALUE: u16 = 662;
18711
18712/// Update `$LINENO` to track the source line of the next statement.
18713/// Stack: \[n\] (the line number from `ZshPipe.lineno`). Direct port
18714/// of zsh's `lineno` global tracking (Src/input.c:330) — the
18715/// compiler emits one of these per top-level pipe so `$LINENO`
18716/// reflects the source position at runtime. ID 342 picked because
18717/// the previous `326` collided with `BUILTIN_HAS_STICKY` (the 325
18718/// collision between IS_TTY and FILE_OLDER has since been fixed by
18719/// moving IS_TTY to 644).
18720pub const BUILTIN_SET_LINENO: u16 = 342;
18721
18722/// Pop a scalar from the VM stack, run expand_glob on it, push the
18723/// result as Value::Array. Used by the segment-concat compile path
18724/// when var refs concatenate with glob meta literals (`$D/*`,
18725/// `${prefix}*`, etc.) — those skip the bridge's pathname-expansion
18726/// pass and would otherwise leak the glob meta to argv as a literal.
18727pub const BUILTIN_GLOB_EXPAND: u16 = 343;
18728
18729/// MULTIOS-gated glob expansion for redirect-target words
18730/// (c:Src/glob.c:2161-2167 xpandredir: "Globbing is only done for
18731/// multios."). Same stack shape as BUILTIN_GLOB_EXPAND; additionally
18732/// passes the word through literally when `unsetopt multios`.
18733// 624 is BUILTIN_COND_STRMATCH — the VM's builtin table is
18734// last-registration-wins, so a duplicate id silently shadows the
18735// earlier handler.
18736pub const BUILTIN_REDIR_GLOB_EXPAND: u16 = 628;
18737
18738/// Reset the default-word glob-pending carrier at the START of a word
18739/// whose source contains a glob metachar (so the flag never leaks from a
18740/// prior word/statement). Paired with BUILTIN_DEFAULT_WORD_GLOB.
18741pub const BUILTIN_DEFAULT_WORD_GLOB_RESET: u16 = 635;
18742
18743/// Filename-generate the ASSEMBLED word ONLY when the default/alternate
18744/// paramsubst arm took a SOURCE word carrying glob metachars
18745/// (subst::DEFAULT_WORD_GLOB_PENDING). A parameter VALUE never sets the
18746/// flag, so `x='*file'; ${x:-d}` stays literal while `${x:-*file}` /
18747/// `${x:-a*}bar` glob. Reads+clears the flag. c:Src/subst.c → globlist.
18748pub const BUILTIN_DEFAULT_WORD_GLOB: u16 = 636;
18749/// `BUILTIN_SET_LOOP_VAR` constant — for-loop variable binding via
18750/// `setloopvar` (Src/params.c:6362): a PM_NAMEREF loop var REBINDS
18751/// to each word (SETREFNAME + setscope) instead of assigning
18752/// through the chain. Returns Bool(false) when zerr fired
18753/// (read-only reference / invalid self reference) so the loop
18754/// driver aborts, mirroring C execfor's errflag check.
18755pub const BUILTIN_SET_LOOP_VAR: u16 = 629;
18756
18757/// Emitted once per typeset-family command, just before its first
18758/// `NAME=…` argument: from there on every word is one of C's postassigns
18759/// (c:Src/parse.c:1986-1989, c:2008-2050), globbed inside execcmd_exec's
18760/// builtin branch (c:Src/exec.c:4167-4285) where a NOMATCH leaves `lastval`
18761/// alone. Sets aside a NOMATCH the earlier command words raised
18762/// (c:3755-3763, `lastval = 1`).
18763///
18764/// Arg: the number of postassign words (dispatch leaves them out of `$_`).
18765pub const BUILTIN_TYPESET_POSTASSIGNS_BEGIN: u16 = 684;
18766/// Emitted after the last word of a command that emitted
18767/// BUILTIN_TYPESET_POSTASSIGNS_BEGIN: moves a postassign NOMATCH off the
18768/// per-command cell (so argument collection does not store status 1) and
18769/// puts back the command-word NOMATCH BEGIN set aside.
18770pub const BUILTIN_TYPESET_POSTASSIGNS_END: u16 = 685;
18771/// Emitted right before the dispatch of a command whose command word was the
18772/// enabled, unquoted TYPESET reserved word (C's WC_TYPESET, c:Src/parse.c:1931-1932).
18773/// c:Src/exec.c:3088-3096 — `if (type == WC_TYPESET && (hn =
18774/// builtintab->getnode2(builtintab, cmdarg)))`: "If reserved word for typeset
18775/// command found (and so enabled), use regardless of whether builtin is
18776/// enabled as we share the implementation." `getnode2` ignores DISABLED, so
18777/// `disable typeset` does not stop the reserved-word form. No args; sets the
18778/// carrier dispatch_builtin consumes before its disabled-builtin test.
18779pub const BUILTIN_TYPESET_RESWD: u16 = 686;
18780/// The `[` spelling of the test builtin (c:Src/builtin.c:7231 `bin_test`,
18781/// funcid BIN_BRACKET). fusevm's name table maps `[` and `test` to one
18782/// BUILTIN_TEST slot, which cannot tell them apart when the closing `]` is
18783/// missing; compile_zsh emits this id when the command word is `[`, so
18784/// bin_test sees BIN_BRACKET and reports "']' expected".
18785pub const BUILTIN_TEST_BRACKET: u16 = 690;
18786/// Emitted right before the dispatch of a command that carried the `-`
18787/// precommand modifier (c:Src/builtin.c:42 `BIN_PREFIX("-", BINF_DASH)`,
18788/// accumulated into `cflags` at c:Src/exec.c:3246). No args; sets the
18789/// EXEC_DASH carrier. c:Src/exec.c:772-776 — an external command then gets
18790/// argv[0] `-name`; a function or builtin runs unchanged.
18791pub const BUILTIN_EXEC_DASH: u16 = 687;
18792/// Emitted right before the external dispatch of the SIMPLE command at the
18793/// top of a chunk that a driver may run in a child forked for it (`cmd &`, a
18794/// pipeline stage, a coproc — compile_zsh.rs `forked_simple_exec`). No args.
18795/// When that chunk's BUILTIN_EXECCMD_FORKED_LEVEL did find the fork, it sets
18796/// the EXEC_CARRIER_FORKED bit: c:Src/exec.c:3063 `last1 = forked = 1` and
18797/// c:4369 `execute(args, cflags, use_defpath)` — the child becomes the command.
18798pub const BUILTIN_EXEC_FORKED_SIMPLE: u16 = 741;
18799/// Emitted right before the external/function dispatch of a simple command,
18800/// with the command's job text (c:Src/exec.c:3536-3538 `getjobtext`). Sets
18801/// the JOB_TEXT carrier the foreground wait reads when it reports a job that
18802/// died of a signal (exec_jobs::foreground_job_report).
18803pub const BUILTIN_JOB_TEXT: u16 = 744;
18804/// c:Src/exec.c:3755-3757 `globlist(args, 0)` over the WHOLE argument list,
18805/// after prefork has expanded every word (c:3357-3359). Stack: the N
18806/// expanded word values in source order, then two Int bitmasks: bit i of the
18807/// first marks word i as an assembled value to glob like BUILTIN_GLOB_EXPAND,
18808/// bit i of the second marks it as a BUILTIN_EXPAND_TEXT mode-10 result that
18809/// still carries its glob tokens; argc = N + 2 (N <= 63). Globs the
18810/// eligible words in order and stops at the first error (c:Src/subst.c:494
18811/// `for (…; !errflag && node; …)`), leaving later words as they are. Pushes
18812/// one value per word (an Array where a glob expanded).
18813pub const BUILTIN_GLOBLIST: u16 = 688;
18814/// c:Src/subst.c:142-147 — `if (!(node = stringsubst(…))) return;`: prefork
18815/// stops at a NULL paramsubst (a failed `(e)` re-lex), and c:118-121 stops it
18816/// before the next word once errflag is set. No args; pushes
18817/// Bool(PREFORK_CUT || errflag & ERRFLAG_ERROR) without clearing either. The compiler checks it after each
18818/// expansion segment of a word (true: keep the prefix assembled so far, plus
18819/// a literal `"` for an opening DQ, and skip the word's remaining segments,
18820/// c:1878) and after each word (true: push the later words as their
18821/// unexpanded source text).
18822pub const BUILTIN_PREFORK_CUT_CHECK: u16 = 689;
18823
18824/// EXTEND step of typeset paren-init packing. Pops `argc` values:
18825/// [base, e1, …, eN] — base is either the opener (`name=(` /
18826/// `name+=(`) or a previous EXTEND result. Pushes base with
18827/// `\u{1f}` + element appended per element. Array values SPLICE
18828/// their items as separate elements (`typeset b=( x $arr )` splat);
18829/// an empty Array contributes nothing (unquoted-empty elision).
18830/// CallBuiltin's argc is u8, so the compiler emits one EXTEND per
18831/// ≤200-element chunk — p10k's 408-element `__p9k_colors=( … )`
18832/// overflowed a single-shot pack (argc wrapped mod 256 and the
18833/// stack spilled into the arg list: "not an identifier: 173…").
18834/// BUILTIN_TYPESET_PAREN_CLOSE appends the final `\u{1f})`,
18835/// yielding the exact REJOIN_SEP-delimited one-arg form
18836/// bin_typeset's single-arg splitter consumes (builtin.rs ~4891,
18837/// empties preserved, leading/trailing sentinel-empties trimmed
18838/// once). One arg in → one arg out: bin_typeset's multi-arg rejoin
18839/// (paren-depth scan, unsafe on EXPANDED paren-literal elements
18840/// like p10k's `')' ''`) never runs.
18841pub const BUILTIN_TYPESET_PAREN_PACK: u16 = 630;
18842
18843/// CLOSE step — pops the EXTEND chain's result, pushes it with
18844/// `\u{1f})` appended. See BUILTIN_TYPESET_PAREN_PACK.
18845pub const BUILTIN_TYPESET_PAREN_CLOSE: u16 = 631;
18846
18847/// Shared body of BUILTIN_GLOB_EXPAND / BUILTIN_REDIR_GLOB_EXPAND.
18848/// c:Src/glob.c:1872 — `zglob` runs per-word in the argv pipeline.
18849/// When the upstream EXPAND_TEXT returned an array (e.g. `${a:e}`
18850/// splat → ["txt","md"]), glob each element separately, not a
18851/// sepjoin'd scalar. `skip_glob` short-circuits to a literal
18852/// pass-through (noglob, or a redirect target under
18853/// `unsetopt multios`).
18854fn glob_expand_word_value(raw: Value, skip_glob: bool) -> Value {
18855    let patterns: Vec<String> = match raw {
18856        Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
18857        other => vec![other.to_str()],
18858    };
18859    // c:Src/glob.c:1230 — zglob returns before globbing when
18860    // `unset(EXECOPT)`, so NO_EXEC never reports a NOMATCH.
18861    if skip_glob || !crate::ported::zsh_h::isset(crate::ported::zsh_h::EXECOPT) {
18862        return if patterns.is_empty() {
18863            Value::array(Vec::new())
18864        } else if patterns.len() == 1 {
18865            Value::str(patterns.into_iter().next().unwrap())
18866        } else {
18867            Value::array(patterns.into_iter().map(Value::str).collect())
18868        };
18869    }
18870    let mut out: Vec<String> = Vec::with_capacity(patterns.len());
18871    for pattern in &patterns {
18872        // c:Src/subst.c — filename generation runs `filesub` (tilde/`=`
18873        // expansion) BEFORE globbing. A `~`/`=` reaching this word-glob op
18874        // comes from `${~spec}` / GLOB_SUBST marking a substituted VALUE:
18875        // literal and quoted `~` words are filesub'd (or skip glob) upstream
18876        // and never arrive here. filesubstr matches the Tilde TOKEN, so
18877        // shtokenize first (raw `~`->Tilde; already-Tilde `${~a[@]}` results
18878        // pass through), run filesub, then untokenize surviving glob metas
18879        // for expand_glob. Gated on `~`/`=` (raw or token) so ordinary
18880        // substituted words skip the roundtrip. Fixes `${~x}` x="~/foo".
18881        // c:178 — prefork's second-pass filesub only runs when
18882        // `unset(SHFILEEXPANSION)` (see the GLOB_SUBST arm above).
18883        let filesubbed = if (pattern.contains('~')
18884            || pattern.contains('=')
18885            || pattern.contains(crate::ported::zsh_h::Tilde)
18886            || pattern.contains(crate::ported::zsh_h::Equals))
18887            && !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHFILEEXPANSION)
18888        {
18889            let mut tok = pattern.clone();
18890            crate::ported::glob::shtokenize(&mut tok);
18891            crate::ported::lex::untokenize(&crate::ported::subst::filesub(&tok, 0))
18892        } else {
18893            pattern.clone()
18894        };
18895        let matches = with_executor(|exec| exec.expand_glob(&filesubbed));
18896        if matches.is_empty() {
18897            // c:1872 nullglob — drop this word, don't emit a hole
18898            continue;
18899        }
18900        for m in matches {
18901            out.push(m);
18902        }
18903    }
18904    if out.is_empty() {
18905        return Value::array(Vec::new());
18906    }
18907    if patterns.len() == 1 && out.len() == 1 && out[0] == patterns[0] {
18908        // No real matches; expand_glob returned the literal. Pass
18909        // back as scalar so downstream ops don't re-flatten.
18910        return Value::str(out.into_iter().next().unwrap());
18911    }
18912    Value::array(out.into_iter().map(Value::str).collect())
18913}
18914
18915/// Push a `CmdState` token onto the command-context stack. Direct
18916/// port of zsh's `cmdpush(int cmdtok)` (Src/prompt.c:1623). The
18917/// stack is consulted by `%_` in PS4/prompt expansion to produce
18918/// the cumulative control-flow-context labels (`if`, `then`,
18919/// `cmdand`, `cmdor`, `cmdsubst`, …) that `zsh -x` xtrace shows
18920/// in the trace prefix. Compile_zsh emits push/pop pairs around
18921/// each compound command (if/while/[[…]]/((…))/$(…) etc.).
18922/// Token is a `CmdState as u8`.
18923pub const BUILTIN_CMD_PUSH: u16 = 344;
18924
18925/// Pop the top of the command-context stack. Direct port of zsh's
18926/// `cmdpop(void)` (Src/prompt.c:1631).
18927pub const BUILTIN_CMD_POP: u16 = 345;
18928
18929/// Emit an xtrace line built from the top `argc` values on the VM
18930/// stack, peeked WITHOUT consuming. Used to trace simple commands
18931/// AFTER expansion, so `echo for $i` shows as `echo for a` / `echo
18932/// for b`. Direct port of Src/exec.c:2055-2066.
18933pub const BUILTIN_XTRACE_ARGS: u16 = 346;
18934
18935/// Trace one assignment: emits `name=<quoted-value> ` (no newline)
18936/// to xtrerr if XTRACE is on. Coalesces with subsequent
18937/// XTRACE_ASSIGN / XTRACE_ARGS calls onto the SAME line via the
18938/// `XTRACE_DONE_PS4` flag so `a=1 b=2 echo $a $b` produces:
18939///   `<PS4>a=1 b=2 echo 1 2\n`
18940/// matching C zsh's `execcmd_exec` body (Src/exec.c:2517-2582):
18941///   xtr = isset(XTRACE);
18942///   if (xtr) { printprompt4(); doneps4 = 1; }
18943///   while (assign) {
18944///       if (xtr) fprintf(xtrerr, "%s=", name);
18945///       ... eval value ...
18946///       if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
18947///   }
18948///
18949/// Stack contract on entry: [..., name, value]. Both peeked, NOT
18950/// consumed (the matching SET_VAR call pops them after). argc = 2.
18951pub const BUILTIN_XTRACE_ASSIGN: u16 = 525;
18952
18953/// Emit a trailing `\n` + flush iff XTRACE is on AND PS4 was
18954/// emitted by an earlier XTRACE_ASSIGN this line. Used at the end
18955/// of compile_simple's assignment-only path so the trace line gets
18956/// terminated. Mirrors C's exec.c:3397-3399 (the assign-only return
18957/// path through execcmd_exec which does `fputc('\n', xtrerr);
18958/// fflush(xtrerr)`).
18959///
18960/// Stack: untouched. argc = 0.
18961pub const BUILTIN_XTRACE_NEWLINE: u16 = 526;
18962
18963/// Push the live `xtrace` opt-state as `Value::Int(1)` (on) or
18964/// `Value::Int(0)` (off). Used by `compile_cond` to gate the
18965/// trace-string-building block on xtrace state at runtime — without
18966/// this the trace path's `compile_word_str` on each operand re-
18967/// evaluates side-effectful expressions (`$((i++))`) once for the
18968/// trace string and once for the actual condition, doubling the
18969/// effective increment. Bug #159 in docs/BUGS.md.
18970///
18971/// Stack: pushes Int(0|1). argc = 0.
18972pub const BUILTIN_XTRACE_IS_ON: u16 = 611;
18973
18974/// Reset the `DONETRAP` flag at the start of each top-level statement
18975/// (sublist boundary). Mirrors C `Src/exec.c:1455` — `donetrap = 0`.
18976/// Stack: untouched. argc = 0. Bug #303 in docs/BUGS.md.
18977pub const BUILTIN_DONETRAP_RESET: u16 = 612;
18978
18979/// c:Src/exec.c:1700-1706 — the `exiting` epilogue of execlist, run at the end
18980/// of a forked compound command (see compile_zsh `emit_exiting_exit_trap`):
18981/// `if (exiting && sigtrapped[SIGEXIT]) { errflag = 0; dotrap(SIGEXIT);
18982/// sigtrapped[SIGEXIT] = 0; errflag = eflag; }`. Stack: pushes Int(0). argc = 0.
18983pub const BUILTIN_EXITING_EXIT_TRAP: u16 = 729;
18984
18985/// Head of a pipeline-stage / `&` / coproc chunk: `execcmd_forked_level`.
18986/// Stack: pops Int(is_subsh), pushes Int(0). argc = 1.
18987pub const BUILTIN_EXECCMD_FORKED_LEVEL: u16 = 740;
18988/// Emitted right before the WithRedirectsBegin of the command at the top of
18989/// a chunk a driver may run in a child forked for it, when that command has
18990/// redirections. No args. When the chunk's BUILTIN_EXECCMD_FORKED_LEVEL found
18991/// the fork, the scope it opens saves no fds (c:Src/exec.c:2478
18992/// `if (!forked && …)`); see redir_scope_forked.
18993pub const BUILTIN_FORKED_REDIRS: u16 = 745;
18994/// c:Src/exec.c:1748 — execpline's `child_block();` ahead of a synchronous
18995/// pipeline, and its job frame (c:1656-1666 `pj = thisjob; thisjob = newjob =
18996/// initjob()`). Stack: \[depth\], the pipeline's nesting depth in its chunk.
18997/// Paired with BUILTIN_EXECPLINE_CHILD_UNBLOCK.
18998pub const BUILTIN_EXECPLINE_CHILD_BLOCK: u16 = 746;
18999/// c:Src/exec.c:2017 — execpline's `child_unblock();` once the pipeline
19000/// has run (and been waited for), after the job frame closes (the procs-less
19001/// job is deleted, `thisjob = pj`). Stack: \[depth\]. Unconditional, as in C: a
19002/// pipeline nested inside another one's command (a function body, `eval`)
19003/// leaves SIGCHLD unblocked for the rest of the outer command.
19004pub const BUILTIN_EXECPLINE_CHILD_UNBLOCK: u16 = 747;
19005
19006/// Take a copy of stderr for this simple command's xtrace output before
19007/// its redirections apply (c:Src/exec.c:3765-3773). Released when the
19008/// redirect scope it precedes ends (c:4445-4452).
19009///
19010/// Stack: untouched. argc = 0.
19011pub const BUILTIN_XTRERR_COPY: u16 = 730;
19012
19013/// c:Src/exec.c:1417 (`int oldnoerrexit = noerrexit;`) + c:1536-1538
19014/// (`if (isandor || isnot) noerrexit |= NOERREXIT_EXIT|NOERREXIT_RETURN;`).
19015/// Saves the current `noerrexit` on a per-thread stack and ORs in the two
19016/// suppression bits for the duration of one `&&`/`||` chain operand (or a
19017/// `!`-negated command). Stack: untouched. argc = 0.
19018pub const BUILTIN_NOERREXIT_SUPPRESS: u16 = 665;
19019
19020/// c:Src/exec.c:1621 / c:1626 — `noerrexit = oldnoerrexit;`. Pops the
19021/// matching save pushed by [`BUILTIN_NOERREXIT_SUPPRESS`].
19022/// Stack: untouched. argc = 0.
19023pub const BUILTIN_NOERREXIT_RESTORE: u16 = 666;
19024
19025/// c:Src/loop.c:144 + :201 (execfor), :480 (execwhile), :536 (execrepeat) —
19026/// `lastval = 1;` on the `errflag` exit from a loop body. Emitted only on the
19027/// fatal-abort path of a compiled for/while/until/repeat; `execselect` has no
19028/// such assignment in C and never emits it.
19029/// Stack: pushes Int(0). argc = 0.
19030pub const BUILTIN_LOOP_ERRFLAG_STATUS: u16 = 667;
19031
19032/// `BUILTIN_LOOP_ERRFLAG_BREAK` — the loop's own post-body errflag guard,
19033/// `c:Src/loop.c:479-482` (execwhile) / `:198-203` (execfor) / `:535-538`
19034/// (execrepeat) / `:329-330` (execselect). Returns 1 when the loop must
19035/// BREAK. argc = 0.
19036///
19037/// This is NOT the same test as the per-statement prologue. C's list loop
19038/// (`c:Src/exec.c:1443`, `while (… && !breaks && !retflag && !errflag)`)
19039/// ENDS the list on errflag; the prologue gate only SKIPS one statement.
19040/// Outside a loop those are indistinguishable, which is why the gap survived:
19041/// inside one, every statement is skipped, the loop's own exit test still
19042/// runs, and a residual `$? == 0` means it NEVER terminates.
19043pub const BUILTIN_LOOP_ERRFLAG_BREAK: u16 = 671;
19044
19045// ───────────────────────────────────────────────────────────────────────
19046// Compiled-arithmetic runtime ops.
19047//
19048// `ArithCompiler` lowers `(( … ))` and `$(( … ))` straight to fusevm ops.
19049// Six operators have no faithful fusevm op, so they lower to these
19050// instead — one indirect call per OCCURRENCE of the operator, against
19051// the whole-expression re-lex `BUILTIN_ARITH_EVAL` pays on EVERY
19052// evaluation.
19053// ───────────────────────────────────────────────────────────────────────
19054
19055/// `a / b` — c:Src/math.c:1237-1257. Integer division when both operands
19056/// are integers (`Op::Div` is unconditionally float), `notzero`'s
19057/// "division by zero" on an integer zero divisor, and the c:1252
19058/// `b == -1` special case that avoids the `INT_MIN / -1` trap. argc = 2.
19059pub const BUILTIN_ARITH_DIV: u16 = 672;
19060
19061/// `a % b` — c:Src/math.c:1258-1272. Same zero-divisor diagnostic as
19062/// DIV (`Op::Mod` silently yields 0), `fmod` on floats, and c:1269's
19063/// `b == -1 → 0`. argc = 2.
19064pub const BUILTIN_ARITH_MOD: u16 = 673;
19065
19066/// `a ** b` — c:Src/math.c:1335-1358. Integer power by repeated
19067/// multiplication when both operands are integers (`Op::Pow` is
19068/// `powf`, which returns a float and loses precision above 2^53),
19069/// float promotion on a negative exponent (c:1337-1342), and the
19070/// c:1352 "imaginary power" error. argc = 2.
19071pub const BUILTIN_ARITH_POW: u16 = 674;
19072
19073/// `a && b` in its NON-short-circuiting form — the `&&=` compound
19074/// assignment (c:Src/math.c:40 DANDEQ), where both sides are already on
19075/// the stack. Result is integer 1/0, c:1200's `BOOL` type. argc = 2.
19076pub const BUILTIN_ARITH_LOGAND: u16 = 675;
19077
19078/// `a || b` for `||=` — c:Src/math.c:41 DOREQ. argc = 2.
19079pub const BUILTIN_ARITH_LOGOR: u16 = 676;
19080
19081/// `a ^^ b` — c:Src/math.c:26 DXOR, logical xor. No C equivalent and no
19082/// fusevm op; both operands always evaluate. argc = 2.
19083pub const BUILTIN_ARITH_LOGXOR: u16 = 677;
19084
19085/// Write one arithmetic result back to a parameter — c:Src/math.c:972
19086/// `setmathvar`, reached from c:1364-1372 when the operator was
19087/// `OP_E2`/`OP_E2IO`. Pops \[name, value\].
19088///
19089/// Distinct from `BUILTIN_SET_VAR`, which stores a shell scalar: only
19090/// `setmathvar` applies the PM_INTEGER / FORCEFLOAT coercions and the
19091/// `lastbase` display base that make `typeset -i x; (( x = 3.7 ))`
19092/// store 3 rather than "3.7". argc = 2.
19093pub const BUILTIN_SET_MATH_VAR: u16 = 678;
19094
19095/// Finish a compiled `(( … ))` math command. Pops the numeric result,
19096/// records it as `lastmathval` (c:Src/math.c:1500, which
19097/// `callmathfunc`'s MFF_USERFUNC branch returns at c:1115), and sets
19098/// the command status from it: c:Src/exec.c:5267 `val == 0`, i.e. 0
19099/// when the expression is non-zero and 1 when it is zero — or 2, with
19100/// `errflag` cleared, when the expression raised a math error.
19101///
19102/// Replaces the `LoadConst("0") / StrEq / …six status ops… /
19103/// BUILTIN_ARITH_CMD_FINISH` tail the string-evaluator path emits, and
19104/// keeps the numeric result numeric instead of round-tripping it
19105/// through its printed form. argc = 1.
19106pub const BUILTIN_ARITH_CMD_FINISH_VAL: u16 = 679;
19107
19108/// `lastbase = <n>` for the COMPILED arithmetic path. Pops \[base\], sets the
19109/// math layer's input base, returns it.
19110///
19111/// c:Src/math.c:367 `mathevall` opens every evaluation with `lastbase = -1;`
19112/// and `lexconstant` (c:462) sets the base of each literal it reads. The
19113/// interpreted evaluator is that code; the compiled path never enters it, so
19114/// nothing reset the base between evaluations and `assignsparam`'s
19115/// `pm.base == 0 ? lastbase` inheritance (c:Src/params.c:2801) handed the next
19116/// freshly created integer whatever base the LAST evaluation had left behind:
19117///
19118/// ```text
19119/// typeset -i iv="2#101"   # sets lastbase = 2, for `iv`
19120/// (( c += 3 ))            # compiled: no literal, no reset
19121/// print -r -- "$c"        # zsh: 3      zshrs: 2#11
19122/// ```
19123///
19124/// Emitted once at the head of every compiled expression with `-1`, and again
19125/// by [`crate::arith_compiler`] with the base of a `0x`/`0b`/`0o` literal it
19126/// lexes — which is what `lexconstant` does, at the point the literal is read.
19127/// argc = 1.
19128pub const BUILTIN_ARITH_LASTBASE: u16 = 683;
19129
19130/// The whole quiet-case statement prologue in one call.
19131///
19132/// c:Src/exec.c:1451-1502 runs four things before every sublist: the
19133/// `lineno` write (c:1451), `donetrap = 0` (c:1455), the
19134/// DEBUG_BEFORE_CMD block (c:1476-1502) and the `noexec` gate (c:1390).
19135/// In C those are inline field writes and flag tests inside `execlist`;
19136/// zshrs emitted them as four separate `CallBuiltin`s plus the constant
19137/// load for `$ZSH_DEBUG_CMD`'s text, on EVERY statement, whether or not
19138/// a DEBUG trap existed to read any of it.
19139///
19140/// This op does the two unconditional writes and then reports whether
19141/// the other two blocks can be skipped entirely: `Bool(true)` when no
19142/// DEBUG trap is installed in EITHER registry (`sigtrapped[SIGDEBUG]`
19143/// or `traps_table["DEBUG"]` — the same pair `BUILTIN_DEBUG_TRAP`
19144/// consults), `noexec` is unset, and `errflag` is clear. Those are
19145/// exactly the conditions under which `BUILTIN_DEBUG_TRAP` returns
19146/// `Int(0)` and `BUILTIN_NOEXEC_CHECK` returns `Int(0)`, so a `true`
19147/// answer means the skipped ops were provably no-ops. On `false` the
19148/// compiler's slow path runs both blocks unchanged.
19149///
19150/// argc = 1: the line number, or -1 for a construct that records none
19151/// (c:Src/parse.c:2112's braceless short-function body), which C
19152/// signals with `code == 0`.
19153pub const BUILTIN_STMT_PROLOGUE_FAST: u16 = 680;
19154
19155/// `BUILTIN_WORD_DEFER_EMPTIES` constant — opens a word whose surrounding
19156/// literals are attached by the compiler's own concat opcodes, not by
19157/// `paramsubst`.
19158///
19159/// c:Src/subst.c:183-186 removes an assembled list node whose text is
19160/// empty, and `stringsubst` has already glued the word's prefix and
19161/// suffix onto the array elements by then (c:4386 / c:4414, or every
19162/// element under plan9 at c:4341). `src/ported/subst.rs`'s port of that
19163/// removal is correctly ordered when the caller hands it the whole word,
19164/// but the compiled fast paths hand it only the `${…}` body. This marker
19165/// tells it so (`PARAMSUBST_AFFIXES_DEFERRED`); the matching end-of-word
19166/// `BUILTIN_WORD_ELIDE_EMPTY` / `BUILTIN_ARRAY_DROP_EMPTY` then performs
19167/// the removal on the finished word, which is C's order.
19168///
19169/// argc 0 opens the word, argc 1 closes it; the pushed status is popped by
19170/// the emitter in both halves. Emitted only where a drop will run.
19171pub const BUILTIN_WORD_DEFER_EMPTIES: u16 = 681;
19172
19173/// c:`Src/subst.c:3030-3033` + c:3912-3930 for the BARE-name `${=NAME}` /
19174/// `${==NAME}` / `$=NAME` spelling — everything up to, but not including,
19175/// c:3932's `sepsplit`, which the compile site emits as `BUILTIN_FORCE_SPLIT`.
19176///
19177/// argc is two bits:
19178///   bit 0  the `spbreak`/`nojoin` pair the spelling sets — set for `${=NAME}`
19179///          (c:2566-2570 `spbreak = 2; if (nojoin < 2) nojoin = !(ifs && *ifs);`)
19180///          and clear for `${==NAME}` (c:2561-2565 `spbreak = 0; if (nojoin < 2)
19181///          nojoin = 0;`).
19182///   bit 1  `qt` — the word is double-quoted, which c:3033 needs. The runtime
19183///          `in_dq_context` counter cannot answer it: a whole-word `"${=a}"`
19184///          compiles to this call with no `BUILTIN_EXPAND_TEXT` wrapper around
19185///          it, so the counter is 0 there. Same dual check the bare `$NAME`
19186///          fast path makes (`compile_zsh.rs`, `word_is_single_dq_span`).
19187///
19188/// The `ssub` callers do NOT come here: c:4226 joins and stops for them, and
19189/// the compile site keeps them on `BUILTIN_GET_VAR_DQ`.
19190pub const BUILTIN_BARE_SPBREAK: u16 = 682;
19191
19192thread_local! {
19193    /// c:Src/exec.c:1417 — C keeps `oldnoerrexit` as an execlist-local
19194    /// automatic, so the save/restore pairs nest with the C call stack.
19195    /// zshrs's compiler emits the two halves as separate ops, so the saved
19196    /// values need an explicit stack. Thread-local because `noerrexit`
19197    /// itself is per-shell state and worker threads run their own lists.
19198    static NOERREXIT_SAVES: std::cell::RefCell<Vec<i32>> =
19199        const { std::cell::RefCell::new(Vec::new()) };
19200}
19201
19202/// `[[ -z X ]]` / `[[ -n X ]]` operand-empty test that honours zsh's
19203/// array-splice semantics. C zsh evaluates `[[ -z X ]]` per
19204/// `Src/cond.c:347` (case 'z'): `s` is the SCALAR operand passed
19205/// through `cond_str`'s singsub. For `"${arr[@]}"` zsh expands per
19206/// `Src/subst.c:multsub` which yields each element as its own word
19207/// list node; cond.c then sees the joined-or-single-element form.
19208///
19209/// The compile-side `-z` shortcut at `compile_zsh.rs:5371` used
19210/// `Op::StringLen` which calls `Value::len` — for `Value::Array`
19211/// that returns ARRAY LENGTH, not string length. `b=("")` produced
19212/// `Value::Array([""])` → `len = 1` → `-z` returned false.
19213///
19214/// This builtin pops one `Value` and pushes `1` (empty) or `0`
19215/// (non-empty) per the cond context:
19216///   - `Value::Str(s)` → s.is_empty()
19217///   - `Value::Array([])` → true (zero words → vacuous-empty)
19218///   - `Value::Array([s])` → s.is_empty() (single-word case)
19219///   - `Value::Array([_; n>=2])` → false (multiple non-empty
19220///     words; zsh would raise "unknown condition" but the
19221///     observable test result is non-empty/false)
19222///
19223/// Companion to BUILTIN_COND_STR_NONEMPTY (#185 in docs/BUGS.md).
19224pub const BUILTIN_COND_STR_EMPTY: u16 = 613;
19225
19226/// `[[ -n X ]]` operand-non-empty test (logical complement of
19227/// BUILTIN_COND_STR_EMPTY).
19228pub const BUILTIN_COND_STR_NONEMPTY: u16 = 614;
19229
19230/// `N<<<"str"` / `N<<HERE` — here-string or here-document redirect to
19231/// an explicit fd. Pops `[content, fd, from_heredoc]` from the stack;
19232/// creates a temp file, writes the body, reopens read-only, dup2's to
19233/// `fd`, unlinks the temp path so it disappears on close. Mirrors C
19234/// `Src/exec.c:4655 getherestr` + `addfd(forked, save, mfds, fn->fd1,
19235/// fil, 0, NULL)` at c:3766-3780. Bug #205 in docs/BUGS.md.
19236///
19237/// `from_heredoc` is C's `REDIRF_FROM_HEREDOC` (set at
19238/// c:Src/parse.c:2970-2971): 0 appends the trailing newline of
19239/// c:4671-4672, 1 passes the body through byte-for-byte.
19240///
19241/// The fd itself is parked in the enclosing redirect scope via
19242/// `save_fd_for_scope` unless a bare `exec` is being applied
19243/// (c:3978-3986 nullexec==1), so `cmd N<<E` is undone at the end of
19244/// the command while `exec N<<E` persists.
19245///
19246/// Stack: pushes `Value::Status(0)` on success, `Status(1)` on
19247/// failure. argc = 3.
19248pub const BUILTIN_EXEC_HERESTR_FD: u16 = 615;
19249
19250/// MULTIOS write/append fan-out for `cmd > a > b` / `cmd > a >> b`
19251/// style redirects (Bug #36 in docs/BUGS.md). zsh's MULTIOS option
19252/// (Src/exec.c:2418 `mfds[fd1]` check + addfd splice) creates a
19253/// pipe at fd1, spawns an internal "tee" process that copies
19254/// stdin → every collected target file. Without this, only the
19255/// LAST redirect target survives because each dup2 overwrites the
19256/// previous binding.
19257///
19258/// Stack layout (pushed by compile_zsh's compile_redirs coalescing
19259/// pass): `[target_1, op_byte_1, target_2, op_byte_2, …, target_N,
19260/// op_byte_N, fd]`. Pops 2N+1 elements; `argc = 2*N + 1`. A target
19261/// may be a Value::Array of glob matches (spliced into one member
19262/// per match, c:Src/glob.c:2195-2203); an op may be DUP_WRITE for a
19263/// numeric `>&N` member (c:Src/exec.c:3895-3917).
19264///
19265/// Runtime (MULTIOS set):
19266///   1. Seed the member list with `dup(1)` when this command's
19267///      stdout is the pipeline output (c:Src/exec.c:3722-3724).
19268///   2. Open/dup all targets per their op_byte in redirect order
19269///      (WRITE truncate + noclobber gate / APPEND / DUP_WRITE live
19270///      dup); the first member replaces the fd (c:2448-2450).
19271///   3. Save `dup(fd)` onto the active redirect_scope_stack so
19272///      `host_redirect_scope_end` restores the original fd.
19273///   4. Create a pipe; spawn a thread that reads from the pipe
19274///      read-end and writes every chunk to every opened target.
19275///   5. dup2 the pipe write-end onto `fd` so the command's writes
19276///      go through the splitter.
19277///   6. Track `(pipe_write_fd, JoinHandle)` so scope-end can close
19278///      the pipe (draining the thread) and join before restoring.
19279///
19280/// MULTIOS unset (c:2418 `unset(MULTIOS)` replace arm): each entry
19281/// is applied as a plain sequential replace via host_apply_redirect
19282/// — every file still opened/truncated, last one wins.
19283pub const BUILTIN_MULTIOS_REDIRECT: u16 = 617;
19284
19285/// MULTIOS input-side concatenation for `cmd < a < b` shapes
19286/// (Bug #36 input arm). C zsh's `Src/exec.c:2418` mfds dispatch
19287/// also covers the read direction — when multiple `<` redirects
19288/// target the same fd, mfds[fd] grows and addfd splices a
19289/// concatenating cat into the pipe.
19290///
19291/// Stack layout (mirrors the write side): `[source_1, op_1,
19292/// source_2, op_2, …, source_N, op_N, fd]`. Pops 2N + 1 elements
19293/// (argc = 2N + 1). op is READ for file sources, DUP_READ for
19294/// numeric `<&N` members; a source may be a Value::Array of glob
19295/// matches (spliced, c:Src/glob.c:2195-2203).
19296///
19297/// Runtime (MULTIOS set):
19298///   1. Open/dup every source in redirect order; first member
19299///      replaces the fd (c:Src/exec.c:2448-2450).
19300///   2. Save `dup(fd)` onto the redirect_scope_stack.
19301///   3. Create a pipe; spawn a thread that reads each source in
19302///      order and writes every chunk to the pipe write-end. Close
19303///      write-end when done so the consumer sees EOF.
19304///   4. dup2 the pipe read-end onto `fd`.
19305///   5. Track the JoinHandle so scope-end joins (no fd-close needed
19306///      here — the producer thread closes its own pipe write-end
19307///      on exit).
19308///
19309/// MULTIOS unset: sequential replace via host_apply_redirect — last
19310/// source wins (c:2418).
19311pub const BUILTIN_MULTIOS_READ: u16 = 618;
19312
19313/// BUILTIN_MULTIOS_READ member op for a here-string body: the runtime writes
19314/// the body plus a trailing newline to a temp file (c:Src/exec.c:4671-4672,
19315/// `if (!(fn->flags & REDIRF_FROM_HEREDOC)) t[len++] = '\n';`). fusevm's
19316/// `redirect_op` values stop at 8.
19317pub const MULTIOS_OP_HERESTR_BODY: u8 = 9;
19318/// BUILTIN_MULTIOS_READ member op for a here-document body: written verbatim
19319/// (REDIRF_FROM_HEREDOC, c:Src/parse.c:2970-2971).
19320pub const MULTIOS_OP_HEREDOC_BODY: u8 = 10;
19321
19322/// c:Src/exec.c:4673-4679 getherestr — `gettempfile`, `write_loop`, `close`,
19323/// reopen read-only, `unlink`: a read descriptor on the body, as
19324/// BUILTIN_EXEC_HERESTR_FD builds for a single here-string.
19325fn multios_body_fd(body: &str, append_newline: bool) -> std::io::Result<i32> {
19326    let text = if append_newline { format!("{}\n", body) } else { body.to_string() };
19327    let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
19328    let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
19329    if write_fd < 0 {
19330        return Err(std::io::Error::last_os_error());
19331    }
19332    let bytes = text.as_bytes();
19333    let mut off = 0;
19334    while off < bytes.len() {
19335        let n = unsafe {
19336            libc::write(write_fd, bytes[off..].as_ptr() as *const libc::c_void, bytes.len() - off)
19337        };
19338        if n <= 0 {
19339            let err = std::io::Error::last_os_error();
19340            unsafe {
19341                libc::close(write_fd);
19342                libc::unlink(tmpl.as_ptr() as *const libc::c_char);
19343            }
19344            return Err(err);
19345        }
19346        off += n as usize;
19347    }
19348    unsafe { libc::close(write_fd) }; // c:4676
19349    let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
19350    let err = std::io::Error::last_os_error();
19351    unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) }; // c:4678
19352    if read_fd < 0 {
19353        Err(err)
19354    } else {
19355        Ok(read_fd)
19356    }
19357}
19358
19359/// c:Src/exec.c:3775-3777 + c:2447-2480 addfd — when `fd` is 0 and the
19360/// top redirect scope is the pipeline stage command's own list, take the
19361/// pipeline input as the input multio's first member: returns a private
19362/// dup of the pipe (above the script's fd range). One-shot per scope. With
19363/// MULTIOS unset C's addfd replaces the pipe instead (c:2418), so the seed is
19364/// dropped.
19365fn take_pipe_input_seed(exec: &ShellExecutor, fd: i32) -> Option<i32> {
19366    if fd != 0
19367        || PIPE_INPUT_SCOPE.with(|c| c.get()) != Some(exec.redirect_scope_stack.len())
19368    {
19369        return None;
19370    }
19371    PIPE_INPUT_SCOPE.with(|c| c.set(None));
19372    if !opt_state_get("multios").unwrap_or(true) {
19373        return None;
19374    }
19375    let dup = unsafe { libc::dup(0) };
19376    (dup >= 0).then(|| crate::ported::utils::movefd(dup)) // c:2458 `fdN = movefd(fd1)`
19377}
19378
19379/// The concatenating half of an input multio (c:Src/exec.c:2447-2480): a
19380/// pipe onto `fd` fed by a thread that reads every member in order. The
19381/// members are closed by the thread; the join is tracked on the top multios
19382/// scope. Returns false when the pipe cannot be created (members closed).
19383fn multios_read_concat(exec: &mut ShellExecutor, fd: i32, source_fds: Vec<i32>) -> bool {
19384    let (read_end, write_end) = match os_pipe::pipe() {
19385        Ok(p) => p,
19386        Err(_) => {
19387            for f in &source_fds {
19388                unsafe {
19389                    libc::close(*f);
19390                }
19391            }
19392            return false;
19393        }
19394    };
19395    // dup the pipe read-end onto fd before spawning the
19396    // producer; close the original read_end so the consumer
19397    // (reading via fd) is the sole reference until scope-end.
19398    let read_dup = unsafe { libc::dup(AsRawFd::as_raw_fd(&read_end)) };
19399    drop(read_end);
19400    if read_dup < 0 {
19401        for f in &source_fds {
19402            unsafe {
19403                libc::close(*f);
19404            }
19405        }
19406        return false;
19407    }
19408    unsafe {
19409        libc::dup2(read_dup, fd);
19410        libc::close(read_dup);
19411    }
19412    // macOS raises a write's SIGPIPE on the process, not the writing
19413    // thread, so the thread mask below does not cover it there; mark the
19414    // write end itself instead.
19415    #[cfg(target_os = "macos")]
19416    unsafe {
19417        // <sys/fcntl.h>: `#define F_SETNOSIGPIPE 73` (absent from the libc crate).
19418        const F_SETNOSIGPIPE: libc::c_int = 73;
19419        libc::fcntl(AsRawFd::as_raw_fd(&write_end), F_SETNOSIGPIPE, 1);
19420    }
19421    // Spawn the producer.
19422    let handle = std::thread::spawn(move || {
19423        // !!! WARNING: RUST-ONLY !!! C's multio concatenator is a forked
19424        // child (c:Src/exec.c:2472 `zfork`), so a reader that stops early
19425        // (`read x`) SIGPIPEs only that child. This producer is a thread of
19426        // the shell: block SIGPIPE here so the write fails with EPIPE
19427        // instead of killing the shell. A write-raised SIGPIPE is directed at
19428        // the writing thread, so the mask covers it.
19429        unsafe {
19430            let mut set: libc::sigset_t = std::mem::zeroed();
19431            libc::sigemptyset(&mut set);
19432            libc::sigaddset(&mut set, libc::SIGPIPE);
19433            libc::pthread_sigmask(libc::SIG_BLOCK, &set, std::ptr::null_mut());
19434        }
19435        let mut w = write_end;
19436        let mut buf = [0u8; 8192];
19437        for sfd in source_fds {
19438            loop {
19439                let n = unsafe { libc::read(sfd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
19440                if n <= 0 {
19441                    break;
19442                }
19443                let n = n as usize;
19444                if std::io::Write::write_all(&mut w, &buf[..n]).is_err() {
19445                    break;
19446                }
19447            }
19448            // zclose, not close: movefd marked the member FDT_INTERNAL
19449            // (c:Src/utils.c:2007-2010), and zclose clears that entry.
19450            let _ = crate::ported::utils::zclose(sfd);
19451        }
19452        // Closing w (the write_end) at scope drop signals EOF
19453        // to the consumer.
19454    });
19455    // Track using a closed-write sentinel — the producer
19456    // owns write_end so we just need to join. Use -1 fd
19457    // marker meaning "no fd to close".
19458    if let Some(top) = exec.multios_scope_stack.last_mut() {
19459        top.push((-1, handle));
19460    } else {
19461        let _ = handle.join();
19462    }
19463    true
19464}
19465
19466/// One member of an input multio applied as a plain replacement (the
19467/// NO_MULTIOS and single-member paths): a here-document / here-string body
19468/// goes through `multios_body_fd` and addfd's save + dup2 (c:2421-2443);
19469/// every other op keeps `host_apply_redirect`.
19470fn multios_apply_member(exec: &mut ShellExecutor, fd: i32, op_byte: u8, source: &str) {
19471    if op_byte != MULTIOS_OP_HERESTR_BODY && op_byte != MULTIOS_OP_HEREDOC_BODY {
19472        exec.host_apply_redirect(fd as u8, op_byte, source);
19473        return;
19474    }
19475    exec.save_fd_for_scope(fd);
19476    match multios_body_fd(source, op_byte == MULTIOS_OP_HERESTR_BODY) {
19477        Ok(read_fd) => {
19478            if read_fd != fd {
19479                unsafe {
19480                    libc::dup2(read_fd, fd);
19481                    libc::close(read_fd);
19482                }
19483            }
19484        }
19485        Err(e) => {
19486            // c:Src/exec.c:3828 — `zwarn("can't create temp file for here document: %e", errno);`
19487            crate::ported::utils::zwarn(&format!(
19488                "can't create temp file for here document: {}",
19489                crate::ported::utils::zsh_errno_msg(e.raw_os_error().unwrap_or(0))
19490            ));
19491            exec.redirect_failed = true;
19492        }
19493    }
19494}
19495
19496/// Toggle `ShellExecutor::exec_redirs_permanent`. Emitted by
19497/// compile_zsh's bare-`exec`-with-redirects arm tightly around each
19498/// `Op::Redirect`: `LoadInt(1); CallBuiltin; …Redirect…; LoadInt(0);
19499/// CallBuiltin`. While set, `host_apply_redirect` skips pushing the
19500/// saved fd into the enclosing redirect scope, making the fd change
19501/// permanent.
19502///
19503/// c:Src/exec.c:3978-3986 — nullexec==1 (`exec` carrying only
19504/// redirections): "If nullexec is 1 we specifically *don't* restore
19505/// the original fd's before returning" — the per-execcmd `save[]`
19506/// dups are closed unrestored. An ENCLOSING group's own saves are a
19507/// different execcmd's `save[]` and still restore (verified:
19508/// `{ exec 2>/dev/null; } 2>&1; ls /nope` prints the ls error in zsh).
19509pub const BUILTIN_EXEC_PERM_REDIRS: u16 = 619;
19510
19511/// Set `ShellExecutor::pipe_output_pending`. Emitted by compile_pipe
19512/// at the head of a NON-LAST pipeline-stage sub-chunk when that
19513/// stage's top-level command carries redirects (`Simple` with redirs
19514/// or `Redirected` compound). The forked stage child runs the chunk
19515/// with stdout already dup2'd onto the pipe; the first
19516/// `host_redirect_scope_begin` (the stage command's own redirect
19517/// list) consumes the flag into `pipe_output_scope`, enabling the
19518/// MULTIOS stream-split for fd-1 write redirects in that list.
19519///
19520/// c:Src/exec.c:3722-3724 — `addfd(forked, save, mfds, 1, output, 1,
19521/// NULL)` registers the pipe in mfds[1] in the SAME execcmd that
19522/// walks the stage command's redirect list; mfds is per-execcmd, so
19523/// nested body commands (`{ echo a > f; } | cat`) never see it.
19524pub const BUILTIN_PIPE_OUTPUT_MARK: u16 = 620;
19525
19526/// Install the pipeline stage's parked fds onto 0/1.
19527///
19528/// c:Src/exec.c:3720-3724 — `addfd(forked, save, mfds, 0, input, 0,
19529/// NULL)` / `addfd(..., 1, output, 1, NULL)`. Runs after prefork
19530/// (c:3304) and globlist (c:3702) have expanded the stage's argument
19531/// words, which is why a `$(...)` in those words reads the shell's
19532/// original stdin rather than the pipe. Emitted by
19533/// `compile_zsh.rs::emit_stage_fds_install`; the fds themselves are
19534/// parked by `BUILTIN_RUN_PIPELINE`.
19535pub const BUILTIN_PIPE_FDS_INSTALL: u16 = 642;
19536
19537/// Magic-equals prefork for a single arg word of a
19538/// `BINF_MAGICEQUALS` builtin head (`alias`). Direct port of
19539/// c:Src/exec.c:3298-3304 — `esprefork = PREFORK_TYPESET;
19540/// prefork(args, esprefork, NULL)` runs on the argv BEFORE the addfd
19541/// redirect loop at c:3720, so an expansion zerr (`alias bad===` →
19542/// equalsubstr "= not found" at Src/subst.c:726) prints to the
19543/// command's UN-redirected stderr. argc=1: pops the just-pushed
19544/// word value, runs shtokenize → prefork(PREFORK_TYPESET) →
19545/// untokenize on it (each element for Array splices), pushes the
19546/// result back. Emitted by compile_simple per arg word when the
19547/// dispatch head is `alias`; BUILTIN_ALIAS itself no longer
19548/// preforks (it would double-fire the diagnostic).
19549pub const BUILTIN_MAGIC_EQUALS_PREFORK: u16 = 621;
19550
19551/// Bare (unbraced) `$name[idx]` subscript — same dispatch as
19552/// `BUILTIN_ARRAY_INDEX` while KSHARRAYS is unset, but under
19553/// KSHARRAYS the unbraced form does NOT subscript (c:Src/subst.c:
19554/// 2800-2802 + 2867): `$name` expands bare and `[idx]` stays literal
19555/// trailing text that undergoes filename generation. Operands:
19556/// [name, idx, suffix, quoted].
19557pub const BUILTIN_ARRAY_INDEX_UNBRACED: u16 = 622;
19558
19559/// Assignment-only simple-command exit status. Direct port of
19560/// `lv = (errflag ? errflag : cmdoutval)` (c:Src/exec.c:1322,
19561/// execsimple's WC_ASSIGN arm) / `if (errflag) lastval = 1; else
19562/// lastval = cmdoutval;` (c:Src/exec.c:3393-3396, execcmd_exec's
19563/// no-command-word varspc path; redir variant at c:3977). Pops
19564/// [had_cmd_subst]; cmdoutval is the live vm.last_status when a
19565/// `$()` ran in any RHS of the chain, 0 otherwise. Writes the
19566/// canonical LASTVAL (C's single `lastval` global) so the
19567/// non-interactive errflag abort exits with this value per
19568/// Src/init.c:234. Caller pairs with SetStatus.
19569pub const BUILTIN_ASSIGN_ONLY_STATUS: u16 = 623;
19570
19571/// c:Src/exec.c addvars — `if (!pm) { lastval = 1; if (!cmdoutval)
19572/// cmdoutval = 1; }`. Set by BUILTIN_SET_VAR on assignsparam
19573/// failure, consumed by BUILTIN_ASSIGN_ONLY_STATUS so the
19574/// assignment-only command reports status 1. Process-global like
19575/// C's `cmdoutval` (function bodies may run on a different thread
19576/// than the opcode that reads the status back).
19577pub static ASSIGN_FAILED_FLAG: std::sync::atomic::AtomicBool =
19578    std::sync::atomic::AtomicBool::new(false);
19579
19580/// Precommand walk ahead of the redirections, for a `builtin`/`command`/
19581/// `exec` head with redirections (c:Src/exec.c:3104-3402 run before c:3720).
19582/// Operands: [prefix, words…]; returns the words minus the prefix as one
19583/// array. Pairs with BUILTIN_PRECMD_CHECK_OK.
19584pub const BUILTIN_PRECMD_CHECK: u16 = 742;
19585
19586/// Whether the last BUILTIN_PRECMD_CHECK let the command run. argc=0;
19587/// pushes Bool. Resets the flag to true.
19588pub const BUILTIN_PRECMD_CHECK_OK: u16 = 743;
19589
19590thread_local! {
19591    /// BUILTIN_PRECMD_CHECK's verdict, read once by BUILTIN_PRECMD_CHECK_OK
19592    /// (the two ops run back to back on the same VM thread).
19593    static PRECMD_CHECK_PASSED: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
19594}
19595
19596/// The error exits of C's precommand walk (c:Src/exec.c:3104-3402) for
19597/// `full` = [prefix, words…]; `false` when the walk ended the command.
19598///
19599/// * A shell function named like the prefix shadows it (c:3483-3487): the
19600///   redirections belong to the function call.
19601/// * c:3250-3290 — `exec`'s option errors: the walk `zerr`s and sets
19602///   errflag itself.
19603/// * c:3395-3402 — the walk consumed every word and redirections remain:
19604///   `zerr("redirection with no command")`.
19605///
19606/// `builtin NAME` naming no builtin (c:3490) stays with BUILTIN_BUILTIN: its
19607/// notion of "a builtin" also covers the zshrs extension and native-command
19608/// registries, which only that handler consults.
19609fn precmd_walk_ok(full: &[String]) -> bool {
19610    use std::sync::atomic::Ordering::Relaxed;
19611    let Some(prefix) = full.first() else {
19612        return true;
19613    };
19614    let has_fn = with_executor(|exec| {
19615        exec.functions_compiled.contains_key(prefix.as_str()) || exec.function_exists(prefix)
19616    });
19617    if has_fn {
19618        return true;
19619    }
19620    let before = crate::ported::utils::errflag.load(Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR;
19621    let walk = crate::ported::exec::execcmd_compile_head(full, crate::ported::zsh_h::WC_SIMPLE);
19622    if before == 0
19623        && (crate::ported::utils::errflag.load(Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR) != 0
19624    {
19625        return false; // c:3258/c:3276/c:3285 `zerr(...); … goto done`
19626    }
19627    if walk.is_empty_command && !walk.has_command_vv {
19628        crate::ported::utils::zerr("redirection with no command"); // c:3397
19629        return false;
19630    }
19631    // c:3488-3499 — a word reached through `builtin` is looked up in
19632    // builtintab here, before c:3786 applies the redirections, so
19633    // `builtin nosuch 2>/dev/null` still reports on the original stderr and
19634    // opens nothing.
19635    if walk.precmd_skip > 0
19636        && crate::lex::untokenize(&walk.preargs[walk.precmd_skip - 1]) == "builtin"
19637    {
19638        let cmdarg = crate::lex::untokenize(&walk.preargs[walk.precmd_skip]);
19639        if !builtin_prefix_finds(&cmdarg) {
19640            // c:3491 — `zwarn("no such builtin: %s", cmdarg);`
19641            crate::ported::utils::zwarn(&format!("no such builtin: {}", cmdarg));
19642            return false; // c:3492 `lastval = 1;` … `return;`
19643        }
19644    }
19645    true
19646}
19647
19648/// `redirection with no command` parse-time error for bare
19649/// `builtin 2>&1` / `command < file` / `exec >&-` precmd-keyword
19650/// shapes with a redirect but no following command. Direct port
19651/// of `Src/exec.c:3342 zerr("redirection with no command")`.
19652/// argc=0; pushes Value::Status(1).
19653pub const BUILTIN_REDIR_NO_CMD: u16 = 616;
19654
19655/// GLOB_SUBST guard for `[[ x == $pat ]]` pattern RHS coming from
19656/// parameter / command substitution. C-zsh's `[[ == ]]` semantics
19657/// (Src/options.c GLOB_SUBST default OFF + Src/cond.c:552
19658/// `cond_match` + Src/pattern.c patcompile tokenization-based
19659/// meta detection) treat chars from substitution as LITERAL
19660/// unless GLOB_SUBST is on. The Rust patcompile accepts both
19661/// tokenized and raw-ASCII meta chars, losing the distinction,
19662/// so `pat="h*"; [[ hello == $pat ]]` matched in zshrs but not
19663/// in zsh. Bug #116 in docs/BUGS.md.
19664///
19665/// Compile-time signal: emitted by `compile_cond_expr` ONLY when
19666/// the RHS contains `$` or backtick. Runtime checks the live
19667/// option state. If GLOB_SUBST is OFF, the popped string has
19668/// its glob metachars escaped with `\` so the downstream StrMatch
19669/// → patcompile treats them as literals. If GLOB_SUBST is ON,
19670/// the value passes through unchanged so `setopt glob_subst`
19671/// restores zsh's pattern-on-expansion behavior.
19672///
19673/// Stack: pops one string, pushes the (possibly escaped) result.
19674/// argc = 1.
19675pub const BUILTIN_GLOB_SUBST_GUARD: u16 = 528;
19676
19677/// `${~spec}` / `$~spec` pattern-data guard — the `strcatsub`
19678/// `if (glbsub) shtokenize(dest)` step (c:Src/subst.c:822/830) applied
19679/// to a value that is about to become a `[[ … == pat ]]` RHS or a
19680/// `case` arm pattern.
19681///
19682/// `BUILTIN_GLOB_SUBST_GUARD` covers the option-driven leg (GLOB_SUBST
19683/// off → escape the value's metas). It cannot cover the FLAG leg,
19684/// because `${~spec}` forces the substitution's metachars ACTIVE and
19685/// the compiler therefore emits no guard at all — the value reached
19686/// `patcompile` as raw bytes, which made a DATA backslash
19687/// indistinguishable from a SOURCE-level quote. c:Src/glob.c:3651
19688/// leaves a backslash before a non-`ztokens` char in the string as
19689/// ordinary data (`p='a\ b'; [[ 'a b' == ${~p} ]]` must NOT match,
19690/// `[[ 'a\ b' == ${~p} ]]` must), so the value has to be respelled in
19691/// the normalizer's literal-backslash form before it is compiled.
19692/// docs/BUGS.md #1090, globsubst leg.
19693///
19694/// Stack: pops one string, pushes the respelled result. argc = 1.
19695pub const BUILTIN_PAT_DATA_BACKSLASH: u16 = 668;
19696/// Stage an ALREADY-EXPANDED here-document body as pending stdin,
19697/// verbatim — no trailing newline appended.
19698///
19699/// c:Src/exec.c:4671-4672 — `getherestr` appends the newline only when
19700/// the here-string did NOT come from a here-document:
19701///     if (!(fn->flags & REDIRF_FROM_HEREDOC))
19702///         t[len++] = '\n';
19703/// The quoted form already had a non-appending sink (`Op::HereDoc`),
19704/// but the UNQUOTED form has to run `BUILTIN_EXPAND_TEXT` first, and
19705/// the only stack-consuming sink available afterwards was
19706/// `Op::HereString` — which appends unconditionally, because `<<<`
19707/// genuinely must. The lowering compensated with
19708/// `trim_end_matches('\n')`, and strip-all-then-append-one is lossy in
19709/// both directions: it ADDED a newline to a body that ended without one
19710/// (`cat <<EOF` + `hello` with no final newline printed `hello\n` where
19711/// zsh prints `hello`), and COLLAPSED N trailing newlines to one
19712/// (`hello\n\n\n` printed as `hello\n`). argc = 1.
19713pub const BUILTIN_HEREDOC_BODY_SINK: u16 = 669;
19714
19715/// `BUILTIN_WORD_ELIDE_EMPTY` — prefork's empty-word removal
19716/// (c:Src/subst.c:180-187) for a word that is EXACTLY one unquoted
19717/// `$NAME`. Emitted only by that compile fast path, because C's test is
19718/// post-assembly and only the word SHAPE makes it decidable early.
19719/// argc = 1.
19720pub const BUILTIN_WORD_ELIDE_EMPTY: u16 = 670;
19721
19722
19723/// Coerce a string parameter value to a math number (Int or Float)
19724/// for arithmetic-context reads, mirroring C-zsh's `getmathparam`
19725/// (Src/math.c:337). When the variable holds a string like "hello"
19726/// that isn't numeric, C falls back to recursively evaluating the
19727/// raw string as an arith expression; if that fails too, returns 0.
19728///
19729/// Used by the ArithCompiler pre-load path so `(( y = x ))` with
19730/// `x="hello"` reads `x` as integer 0, then assigns y as integer 0
19731/// — matching zsh's behaviour. The previous Rust port used
19732/// BUILTIN_GET_VAR which returned the raw string "hello"; the
19733/// ArithCompiler stored it verbatim in y's slot, and the post-sync
19734/// BUILTIN_SET_VAR wrote y="hello" as scalar instead of y=0 as
19735/// integer. Bug #118 in docs/BUGS.md.
19736///
19737/// Stack: pops `name` (string), pushes coerced numeric Value.
19738/// argc = 1.
19739pub const BUILTIN_GET_MATH_VAR: u16 = 529;
19740
19741/// GLOB_SUBST runtime gate for words containing parameter / command
19742/// substitution. C-zsh's `prefork` (Src/subst.c) runs `shtokenize`
19743/// on the substituted value when `GLOB_SUBST` is set, making the
19744/// substituted chars eligible for filename generation. With the
19745/// option off, substituted chars stay literal.
19746///
19747/// The Rust port's compile_zsh emits `compile_word_str` for words
19748/// like `/tmp/X/$pat`, which returns the post-expansion string but
19749/// never runs glob expansion (no path here triggers
19750/// BUILTIN_GLOB_EXPAND). Bug #119 in docs/BUGS.md: with `setopt
19751/// glob_subst`, `for f in /tmp/X/$pat` (pat="*.txt") never matched
19752/// `*.txt` files.
19753///
19754/// This opcode wraps the substitution result and dispatches at
19755/// runtime: when GLOB_SUBST is OFF, return unchanged; when ON,
19756/// pass the value through `expand_glob` so glob metas become
19757/// active. Emitted by `compile_for_words` (and similar sites)
19758/// after WORD_SPLIT for words with unquoted expansion.
19759///
19760/// Stack: pops a Value (Str or Array of Str), pushes the glob-
19761/// expanded result (still Str or Array depending on input shape).
19762/// argc = 1.
19763pub const BUILTIN_GLOB_SUBST_EXPAND: u16 = 530;
19764/// `BUILTIN_ASSOC_HAS_KEY` constant — `${(k)assoc[name]}` key-existence
19765/// query. Returns the key text on hit, empty string on miss. Bug #145.
19766pub const BUILTIN_ASSOC_HAS_KEY: u16 = 531;
19767/// `BUILTIN_ARRAY_DROP_EMPTY` constant — filter empty elements from
19768/// an Array on the stack. Used by `for x in $@` / `for x in $*`
19769/// unquoted forms. Bug #166.
19770pub const BUILTIN_ARRAY_DROP_EMPTY: u16 = 532;
19771/// `BUILTIN_QUOTED_STAR_ONE_WORD` — normalize the result of a QUOTED
19772/// `"$*"` / `"${*}"` expansion to EXACTLY ONE word.
19773///
19774/// c:Src/subst.c:3032 — the quoted (`qt`) branch of paramsubst ends in
19775/// `val = sepjoin(aval, sep, 1)`, a plain string join. Joining the
19776/// EMPTY positional list yields `""`, so `"$*"` with no positionals is
19777/// one empty word, exactly like `"$empty"` — which is why
19778/// `set --; printf '%d|%s|%d\n' $# "$*" 7` prints `0||7` in zsh, bash,
19779/// dash and ksh alike.
19780///
19781/// zshrs routes `"$*"` through `BUILTIN_EXPAND_TEXT` mode 1, whose
19782/// `multsub` returns a ZERO-node list for the empty case (correct for
19783/// `"$@"`, which really does vanish) and the bridge turns that into
19784/// `Value::Array(vec![])` — so the word was elided and printf saw one
19785/// argument fewer. This op restores the join's single-word shape at the
19786/// one call site that knows the splat was `*` and not `@`.
19787///
19788/// Stack: pops the expansion result, pushes `Value::str("")` when it
19789/// was an empty Array, and the value unchanged otherwise. argc = 1.
19790pub const BUILTIN_QUOTED_STAR_ONE_WORD: u16 = 663;
19791/// `BUILTIN_KSH_FUNSUB` — zsh NOFORK command substitution
19792/// (`Src/subst.c:1904-2100`), which also covers the ksh93 funsub
19793/// `${ list; }` and mksh valsub `${| list; }`: a command substitution that
19794/// runs in the CURRENT shell environment rather than a subshell.
19795///
19796/// Three forms, selected by the character right after `${`
19797/// (c:Src/subst.c:1924/1930):
19798///   * blank → `${ cmd }`, value is the body's STDOUT. c:2026-2029 scopes
19799///     it under `.zsh.cmdsubst`; c:2035-2044 captures via a temp-file
19800///     redirect so the body still runs in the current shell.
19801///   * `|` → `${| cmd }`, value is `$REPLY`, which is LOCAL to the body
19802///     (c:2018-2024 `createparam("REPLY", PM_LOCAL|PM_UNSET|PM_HIDE)`).
19803///   * `{VAR}` → `${{VAR} cmd }`, value is `$VAR`, NOT localised, and an
19804///     array stays an array (c:2082-2083 re-enters the parameter path).
19805///
19806/// ksh(1), Command Substitution: "${ command;} … the command is executed
19807/// in the current shell environment", and the value is the standard output
19808/// with trailing newlines removed (zsh strips ONE newline unquoted and
19809/// none quoted — c:1908 `trim = (!EMULATION(EMULATE_ZSH)) ? 2 : !qt`).
19810/// mksh(1)'s valsub matches zsh's `${| … }` exactly.
19811///
19812/// Stack (bottom→top): body, rplyvar, kind, qt — kind 0 = stdout capture,
19813/// 1 = REPLY form, 2 = named-variable form; qt = 1 when the word was
19814/// double-quoted. argc = 4. Pushes the resulting string, or an Array when
19815/// the named variable holds one / when the unquoted result is word-split.
19816pub const BUILTIN_KSH_FUNSUB: u16 = 664;
19817/// `BUILTIN_QUOTEDZPUTS` constant — run top-of-stack value through
19818/// `crate::ported::utils::quotedzputs` and push the quoted result.
19819/// Used by the cond xtrace path so non-printable bytes (e.g.
19820/// `$'\C-[OP'` expanded ESC+OP) get re-wrapped in `$'…'` form for
19821/// the trace prefix line, matching zsh's `Src/exec.c` cond trace
19822/// which calls `quotedzputs(operand, xtrerr)` on each side. Bug
19823/// surfaced when `[[ -n $'\C-[OP' ]]` traced as `[[ -n OP ]]`
19824/// (raw bytes leaked through the terminal) vs zsh's
19825/// `[[ -n $'\C-[OP' ]]` source-form preservation.
19826pub const BUILTIN_QUOTEDZPUTS: u16 = 533;
19827/// `BUILTIN_QUOTE_TOKENIZED_OUTPUT` — port of
19828/// `crate::ported::exec::quote_tokenized_output` (Src/exec.c:2114)
19829/// applied to top-of-stack scalar. Used by cond xtrace for the RHS
19830/// of pattern-context comparisons (`=` / `==` / `!=`) where C zsh
19831/// emits the SOURCE form: untokenize lexer tokens (Star → `*`,
19832/// Inpar → `(`, …) and backslash-escape special chars, but
19833/// preserve literal ASCII unchanged. Distinct from quotedzputs
19834/// which wraps the whole string in `'…'` / `$'…'` based on
19835/// non-printability — that's wrong for `[[ x = a* ]]` which must
19836/// render as `[[ x = a* ]]`, not `'a*'`.
19837pub const BUILTIN_QUOTE_TOKENIZED_OUTPUT: u16 = 534;
19838
19839/// Bridge into subst_port::substitute_brace_array for nested forms
19840/// that need to PRESERVE array shape across the expand_string
19841/// boundary. Stack: `[content_string]`. Returns Value::Array of the
19842/// per-element words. Used by the compile path for
19843/// `${(@)<nested>...##pat}` shapes — the standard substitute_brace
19844/// returns String which collapses array→scalar; this builtin
19845/// preserves the multi-word output via paramsubst's third return
19846/// (`nodes` vec, the C source's `aval` thread).
19847pub const BUILTIN_BRIDGE_BRACE_ARRAY: u16 = 347;
19848
19849/// Word-segment concat with FIRST/LAST sticking. Stack: [lhs, rhs].
19850/// Used for default unquoted splice forms (`${arr[@]}`, `$@`, `$*`)
19851/// where prefix sticks to first element only and suffix to last only.
19852///
19853/// Distribution table:
19854/// - both scalar: `Value::str(a + b)` (fast path)
19855/// - lhs scalar, rhs Array(b₀..bₙ): `Value::Array([lhs+b₀, b₁, …, bₙ])`
19856/// - lhs Array(a₀..aₙ), rhs scalar: `Value::Array([a₀, …, aₙ₋₁, aₙ+rhs])`
19857/// - both Array: `Value::Array([a₀, …, aₙ₋₁, aₙ+b₀, b₁, …, bₙ])`
19858///   (last of lhs merges with first of rhs; the rest stay separate)
19859///
19860/// This is the default zsh semantics for `print -l X${arr[@]}Y` →
19861/// "Xa", "b", "cY" — three distinct args, surrounding text only on ends.
19862pub const BUILTIN_CONCAT_SPLICE: u16 = 319;
19863
19864/// `${(flags)name}` — zsh parameter expansion flags. Stack: [name, flags].
19865/// Flags applied left-to-right. Supported subset (high-value, used by zpwr):
19866///
19867///   `L` — lowercase the value (scalar; or each element if array)
19868///   `U` — uppercase
19869///   `j:sep:` — join array with `sep` (delim is the char after `j`)
19870///   `s:sep:` — split scalar on `sep` (returns Value::Array)
19871///   `f` — split on newlines (shorthand for `s.\n.`)
19872///   `o` — sort array ascending
19873///   `O` — sort array descending
19874///   `P` — indirect: read name's value as another var name, return that's value
19875///   `@` — keep as array (returns Value::Array — useful before `j` etc.)
19876///   `k` — keys of assoc array
19877///   `v` — values of assoc array
19878///   `#` — word count (array length as scalar)
19879///
19880/// Flags can stack: `(jL)` joins then lowercases; `(s.,.U)` splits on `,`
19881/// then uppercases each element. The long-tail flags (`q`, `qq`, `qqq` for
19882/// quoting, `A` for assoc, `%` for prompt expansion, `e`/`g` for re-eval,
19883/// `n`/`p` for numeric, `t` for type, etc.) are deferred — they hit the
19884/// runtime fallback via the catch-all expansion path.
19885pub const BUILTIN_PARAM_FLAG: u16 = 297;
19886
19887/// `ShellHost` implementation that delegates to the current `ShellExecutor`
19888/// via the `with_executor` thread-local.
19889///
19890/// Construct fresh on each VM run (it carries no state itself). The VM
19891/// dispatches host method calls during `vm.run()`, and `with_executor`
19892/// resolves to the executor pointer set by `ExecutorContext::enter`.
19893/// fusevm-host implementation tying bytecode ops to the
19894/// shell executor.
19895/// zshrs-original — no C counterpart. C zsh has no bytecode VM
19896/// to host; everything runs through `execlist()`/`execpline()`
19897/// directly (Src/exec.c lines 1349/1668).
19898pub struct ZshrsHost;
19899
19900/// Short label for a sub-chunk, used as a provenance origin. A `Chunk`
19901/// keeps no original source text (only ops, constants and a source
19902/// *file* name), so the readable handle is reconstructed from the
19903/// leading string constants — for `$(date +%s)` that is `date +%s`.
19904fn prov_chunk_label(sub: &fusevm::Chunk) -> String {
19905    sub.constants
19906        .iter()
19907        .filter_map(|c| match c {
19908            Value::Str(s) if !s.is_empty() => Some(s.as_str()),
19909            _ => None,
19910        })
19911        .take(3)
19912        .collect::<Vec<_>>()
19913        .join(" ")
19914}
19915
19916impl fusevm::ShellHost for ZshrsHost {
19917    fn glob(&mut self, pattern: &str, _recursive: bool) -> Vec<String> {
19918        let matches = with_executor(|exec| exec.expand_glob(pattern));
19919        if crate::provenance::active() {
19920            crate::provenance::on_glob(pattern, &matches);
19921        }
19922        matches
19923    }
19924
19925    fn tilde_expand(&mut self, s: &str) -> String {
19926        with_executor(|exec| s.to_string())
19927    }
19928
19929    fn brace_expand(&mut self, s: &str) -> Vec<String> {
19930        // Direct call to the canonical brace expander
19931        // (Src/glob.c::xpandbraces port at glob.rs:1678). Was
19932        // routing through singsub which uses PREFORK_SINGLE — that
19933        // flag explicitly suppresses brace expansion in subst.c:166,
19934        // so `print X{1,2,3}Y` returned the literal string.
19935        //
19936        // brace_ccl: respect the BRACE_CCL option which the bracket-
19937        // class form `{a-z}` requires. Pull from executor options.
19938        let brace_ccl = with_executor(|exec| opt_state_get("braceccl").unwrap_or(false));
19939        crate::ported::glob::xpandbraces(s, brace_ccl)
19940    }
19941
19942    fn str_match(&mut self, s: &str, pattern: &str) -> bool {
19943        let pattern: &str = &pattern_filesub(pattern);
19944        // bash `shopt -s nocasematch` — bash(1): "bash matches patterns in a
19945        // case-insensitive fashion when performing matching while executing
19946        // CASE or [[ conditional commands". This host method is the `case`
19947        // arm dispatch, the half BUILTIN_COND_STRMATCH (which already does
19948        // this, ~4.4k lines up) does not cover, so `shopt -s nocasematch;
19949        // case ABC in abc)` wrongly took the `*)` arm. Same treatment: fold
19950        // BOTH sides for the match decision — glob metacharacters are not
19951        // letters, so `*` / `?` / `[…]` structure survives `to_lowercase`.
19952        // No-op unless the bash shopt is active; --zsh unaffected.
19953        let (folded_s, folded_pat);
19954        let (s, pattern): (&str, &str) = if crate::dash_mode::nocasematch() {
19955            folded_s = s.to_lowercase();
19956            folded_pat = pattern.to_lowercase();
19957            (&folded_s, &folded_pat)
19958        } else {
19959            (s, pattern)
19960        };
19961        // Shell glob match — `*`, `?`, `[...]`, alternation. After the
19962        // cond path moved to BUILTIN_COND_STRMATCH, the consumer here
19963        // is the `case` arm dispatch, whose bad-pattern semantics are
19964        // Src/loop.c:663-667: `if (!(pprog = patcompile(pat, ...)))
19965        // zerr("bad pattern: %s", pat);` — errflag set, the arm
19966        // doesn't match, and the script aborts at the next command
19967        // boundary (matching `zsh -fc 'case x in [a-) ...'` printing
19968        // the diagnostic with exit 0 = untouched lastval).
19969        // Same drop-in split as the cond path: a bare POSIX-family drop-in
19970        // reads a source-level `(` by its own rules, not by zsh's parse-time
19971        // tokenization — see `dropin_source_pattern_parens_literal`.
19972        let pat_eff = if crate::pattern_data_escape::dropin_source_pattern_parens_literal() {
19973            crate::pattern_data_escape::escape_shglob_parens(
19974                pattern,
19975                crate::pattern_data_escape::dropin_keeps_ksh_groups(),
19976            )
19977        } else {
19978            pattern.to_string()
19979        };
19980        let mut pat_tok = pat_eff.clone();
19981        crate::ported::glob::tokenize(&mut pat_tok);
19982        if crate::ported::pattern::patcompile(
19983            &pat_tok,
19984            crate::ported::zsh_h::PAT_STATIC as i32,
19985            None,
19986        )
19987        .is_none()
19988        {
19989            crate::ported::utils::zerr(&format!("bad pattern: {}", pattern)); // c:667
19990            return false;
19991        }
19992        glob_match_static(s, &pat_eff)
19993    }
19994
19995    fn expand_param(&mut self, name: &str, _modifier: u8, _args: &[Value]) -> Value {
19996        // Sole funnel: route through `getsparam` matching C zsh's
19997        // `getsparam(name)` → `getvalue` → `getstrvalue` →
19998        // `Param.gsu->getfn` dispatch (Src/params.c:3076 / 2335).
19999        //
20000        // The lookup chain (GSU dispatch + variables + env + array-
20001        // join) lives in `params::getsparam`; subst.rs and this
20002        // bridge both call into it so the logic is in exactly one
20003        // place — mirroring C's "every read goes through getsparam"
20004        // architecture. fuseVM bytecode triggers this bridge when
20005        // the VM hits a PARAM opcode, equivalent to C's wordcode VM
20006        // resolving a parameter read during `exec.c` execution.
20007        //
20008        // Modifier handling: the `_modifier` / `_args` parameters
20009        // are populated by the bytecode compiler but applied by
20010        // separate VM opcodes (LENGTH/STRIP/SUBST/etc.) downstream
20011        // of this fetch — matching C's split between getsparam
20012        // (value fetch) and paramsubst's modifier-walk loop. This
20013        // bridge is the value-fetch step only.
20014        let val_str = crate::ported::params::getsparam(name).unwrap_or_default();
20015        let value = Value::str(val_str);
20016        // Provenance: a read of a tracked parameter hands the
20017        // parameter's chain to the produced value, by `Arc` identity
20018        // for the rest of this chunk and by content for the host
20019        // boundaries downstream that only see `String`.
20020        if crate::provenance::active() {
20021            crate::provenance::on_param_read(name, &value);
20022        }
20023        value
20024    }
20025
20026    fn process_sub_in(&mut self, sub: &fusevm::Chunk) -> String {
20027        // c:Src/exec.c:4999 (`getoutputfile`: `child_block();`) and, for
20028        // `<(cmd)`, the enclosing execpline's c:1748 block that C's
20029        // `getproc` forks inside. The VM opens no execpline span around a
20030        // simple command, so take it here. See ChildBlockSpan.
20031        #[cfg(unix)]
20032        let _child_block = ChildBlockSpan::enter();
20033        // c:Src/exec.c:4906 getoutputfile — `=(cmd)` (marked "equalsubst" by the
20034        // compiler) is the TEMP-FILE flavor: create a real regular file, fork a
20035        // writer whose stdout is the file, WAIT for it (so the file is complete
20036        // and seekable before the consumer runs), and return the file path. It
20037        // is unlinked at job end. This differs from `<(cmd)` below, which is a
20038        // /dev/fd pipe that is never waited on.
20039        if sub.source == "equalsubst" {
20040            let nam = crate::ported::utils::gettempname(None, true)
20041                .unwrap_or_else(|| format!("/tmp/zshrs_eqsub_{}", std::process::id()));
20042            let cpath = match std::ffi::CString::new(nam.as_str()) {
20043                Ok(c) => c,
20044                Err(_) => return String::from("/dev/null"),
20045            };
20046            // c:4945 — O_WRONLY|O_CREAT|O_EXCL|O_NOCTTY, 0600.
20047            let fd = unsafe {
20048                libc::open(
20049                    cpath.as_ptr(),
20050                    libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOCTTY,
20051                    0o600,
20052                )
20053            };
20054            if fd < 0 {
20055                return String::from("/dev/null");
20056            }
20057            let sub_for_child = sub.clone();
20058            match unsafe { libc::fork() } {
20059                -1 => {
20060                    unsafe { libc::close(fd) };
20061                    let _ = fs::remove_file(&nam);
20062                    return String::from("/dev/null");
20063                }
20064                0 => {
20065                    forked_child_subsh_levels(); // c:Src/exec.c:1221
20066                    // c:Src/exec.c:5037 entersubsh → c:1200 `zsh_subshell++`.
20067                    // The child _exits, so the guard is never dropped.
20068                    std::mem::forget(CmdSubstSubshellBump::enter());
20069                    // c:4985 — child: stdout → the temp file, run the body, exit.
20070                    // Clear the inherited pending-file list so this child never
20071                    // unlinks the PARENT's =() temp files when its own commands
20072                    // dispatch (fork copies the list; unlink hits the shared fs).
20073                    // c:Src/exec.c:5044 — `execode(prog, 0, 1, "equalsubst")`.
20074                    std::mem::forget(crate::ported::exec::EvalContextFrame::push("equalsubst"));
20075                    PSUB_PENDING_FILES.with(|v| v.borrow_mut().clear());
20076                    unsafe {
20077                        libc::dup2(fd, libc::STDOUT_FILENO);
20078                        libc::close(fd);
20079                    }
20080                    let mut vm = fusevm::VM::new(sub_for_child);
20081                    register_builtins(&mut vm);
20082                    vm.set_shell_host(Box::new(ZshrsHost));
20083                    let _ = vm.run();
20084                    let _ = std::io::stdout().flush();
20085                    unsafe { libc::_exit(0) };
20086                }
20087                child_pid => {
20088                    // c:4976-4980 — parent: close the write fd and WAIT so the
20089                    // file is fully written before the consumer opens it.
20090                    unsafe {
20091                        libc::close(fd);
20092                        let mut status: libc::c_int = 0;
20093                        libc::waitpid(child_pid, &mut status, 0);
20094                    }
20095                    psub_pending_file_add(&nam); // c:4960
20096                    return nam;
20097                }
20098            }
20099        }
20100        // c:Src/exec.c::getproc — `<(cmd)` uses pipe + fork + the
20101        // `/dev/fd/N` filesystem entry (where N is the read end of
20102        // the pipe held open in the parent). Consumer opens
20103        // `/dev/fd/N`, reads the cmd's stdout through the pipe.
20104        // Both macOS and Linux expose `/dev/fd` for held-open file
20105        // descriptors. Previous Rust port captured stdout into
20106        // `/tmp/zshrs_psub_*` tempfiles synchronously — works for
20107        // `diff <(a) <(b)` style readers that scan once but diverges
20108        // from zsh's observable path string and breaks any consumer
20109        // that introspects the path or expects a non-seekable pipe.
20110        let mut fds: [libc::c_int; 2] = [-1, -1];
20111        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
20112            // Pipe creation failed — fall back to tempfile so we at
20113            // least return SOMETHING.
20114            let fifo_path = format!(
20115                "/tmp/zshrs_psub_fallback_{}_{}",
20116                std::process::id(),
20117                with_executor(|e| {
20118                    let n = e.process_sub_counter;
20119                    e.process_sub_counter += 1;
20120                    n
20121                })
20122            );
20123            let _ = fs::remove_file(&fifo_path);
20124            return fifo_path;
20125        }
20126        let (read_end, write_end) = (fds[0], fds[1]);
20127        let sub_for_child = sub.clone();
20128        match unsafe { libc::fork() } {
20129            -1 => {
20130                unsafe {
20131                    libc::close(read_end);
20132                    libc::close(write_end);
20133                }
20134                return String::from("/dev/null");
20135            }
20136            0 => {
20137                forked_child_subsh_levels(); // c:Src/exec.c:1221
20138                // c:Src/exec.c:5095 entersubsh → c:1200 `zsh_subshell++`.
20139                std::mem::forget(CmdSubstSubshellBump::enter());
20140                // Child: close read end, dup write end to stdout,
20141                // run the sub-chunk, exit. The exit closes the
20142                // write end automatically, so the parent's reader
20143                // gets EOF when the cmd finishes.
20144                PSUB_PENDING_FILES.with(|v| v.borrow_mut().clear());
20145                unsafe {
20146                    libc::close(read_end);
20147                    libc::dup2(write_end, libc::STDOUT_FILENO);
20148                    libc::close(write_end);
20149                }
20150                // c:Src/exec.c:5101/5150 — `execode(prog, 0, 1, out ?
20151                // "outsubst" : "insubst");`. execode (c:1245-1266) APPENDS its
20152                // context for the duration of the body, so `<(cmd)` — whose child WRITES (out=1) — runs as `…:outsubst`.
20153                // zshrs's ported getproc carries these citations but is NOT the
20154                // live path (established in #1062) — the VM forks here instead,
20155                // so the push belongs in this child. No pop needed: this runs
20156                // INSIDE the forked child and dies with it. Bug #1069 (procsub
20157                // legs).
20158                if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
20159                    ctx.push("outsubst".to_string());
20160                    let joined = ctx.join(":");
20161                    if let Ok(mut tab) = crate::ported::params::paramtab().write() {
20162                        if let Some(pm) = tab.get_mut("zsh_eval_context") {
20163                            pm.u_arr = Some(ctx.clone());
20164                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20165                        }
20166                        if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
20167                            pm.u_str = Some(joined);
20168                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20169                        }
20170                    }
20171                }
20172                crate::fusevm_disasm::maybe_print_stdout("process_subst_in", &sub_for_child);
20173                let mut vm = fusevm::VM::new(sub_for_child);
20174                register_builtins(&mut vm);
20175                vm.set_shell_host(Box::new(ZshrsHost));
20176                let _ = vm.run();
20177                let _ = std::io::stdout().flush();
20178                unsafe { libc::_exit(0) };
20179            }
20180            child_pid => {
20181                // c:Src/exec.c:5092 `procsubstpid = pid;` — record the
20182                // forked child's PID so `${sysparams[procsubstpid]}`
20183                // returns it (was reading the never-updated atomic, so it
20184                // always came back 0). p10k's gitstatus daemon reads
20185                // `sysparams[procsubstpid]` right after `sysopen <(cmd)`
20186                // to track its worker PID; with 0 the daemon's self-check
20187                // failed and gitstatus fell back to re-downloading
20188                // gitstatusd — surfacing as "no prebuilt gitstatusd".
20189                crate::ported::exec::procsubstpid
20190                    .store(child_pid, std::sync::atomic::Ordering::Relaxed);
20191                // Parent: close write end, keep read end open under
20192                // the same fd value so `/dev/fd/N` resolves to the
20193                // pipe's read side. NOTE: FD_CLOEXEC must STAY clear
20194                // — consumers like `cat <(cmd)` and `diff <(a) <(b)`
20195                // discover the fd via exec inheritance, so closing
20196                // on exec defeats the whole point. C zsh's getproc
20197                // (Src/exec.c:5045+) leaves the fd open across exec.
20198                unsafe {
20199                    libc::close(write_end);
20200                }
20201                // Park read_end for close-after-consuming-command,
20202                // exactly like process_sub_out does for its write_end
20203                // (c:Src/exec.c addfilelist(NULL, fd) → deletefilelist).
20204                // WITHOUT this the parent's read_end stayed open for
20205                // the whole shell lifetime: p10k's async worker /
20206                // realtime clock do `exec {fd}< <(cmd)` on every prompt,
20207                // so each keystroke/redraw leaked a pipe fd until the
20208                // ~256-fd limit was hit and the shell locked up
20209                // (107 leaked pipes + 107 unreaped children observed).
20210                let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
20211                PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, read_end)));
20212                // Reap the forked child so it doesn't linger as a
20213                // zombie. `<(cmd)` children are fire-and-forget (their
20214                // output flows through the pipe); C reaps them via the
20215                // job machinery. A non-blocking reap here is scheduled;
20216                // do a best-effort WNOHANG now and the rest drain on
20217                // subsequent proc-subs / prompt cycles.
20218                crate::fusevm_bridge::note_psub_child(child_pid);
20219            }
20220        }
20221        let path = format!("{}/{}", crate::ported::config_h::PATH_DEV_FD, read_end); // c:Src/exec.c:5144
20222        if crate::provenance::active() {
20223            crate::provenance::on_process_subst(&prov_chunk_label(sub), &path);
20224        }
20225        path
20226    }
20227
20228    fn process_sub_out(&mut self, sub: &fusevm::Chunk) -> String {
20229        // c:Src/exec.c:1748 — `getproc` forks inside the enclosing
20230        // execpline's `child_block()` span; the VM has none around a simple
20231        // command, so take it here. See ChildBlockSpan.
20232        #[cfg(unix)]
20233        let _child_block = ChildBlockSpan::enter();
20234        // c:Src/exec.c:5025 getproc, PATH_DEV_FD branch — `>(cmd)`
20235        // (out == 0): `mpipe(pipes)`, fork; the CHILD `redup(pipes[0],
20236        // 0)` (pipe read end onto stdin) and `closem` drops the write
20237        // end; the PARENT closes pipes[0] and hands the consumer
20238        // `/dev/fd/<pipes[1]>` (the write end). The previous Rust port
20239        // used mkfifo + a child that BLOCKED in open(FIFO, O_RDONLY)
20240        // before running cmd — with no writer the child never started,
20241        // never exited, and kept its inherited stdout (e.g. a `$()`
20242        // capture pipe) open forever: `a=$(print -r -- >(true))` hung.
20243        // With the pipe shape the child runs immediately and exits,
20244        // releasing inherited fds exactly like zsh (verified: zsh
20245        // blocks ~2s on `a=$(print -r -- >(sleep 2))`, then EOFs).
20246        let mut fds: [libc::c_int; 2] = [-1, -1];
20247        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
20248            // Pipe creation failed — fall back to a plain temp file so
20249            // the consumer at least has a writable path.
20250            let fallback = format!(
20251                "/tmp/zshrs_psub_out_{}_{}",
20252                std::process::id(),
20253                with_executor(|e| {
20254                    let n = e.process_sub_counter;
20255                    e.process_sub_counter += 1;
20256                    n
20257                })
20258            );
20259            let _ = fs::write(&fallback, "");
20260            return fallback;
20261        }
20262        let (read_end, write_end) = (fds[0], fds[1]);
20263        let sub_for_child = sub.clone();
20264        match unsafe { libc::fork() } {
20265            -1 => {
20266                unsafe {
20267                    libc::close(read_end);
20268                    libc::close(write_end);
20269                }
20270                String::from("/dev/null")
20271            }
20272            0 => {
20273                forked_child_subsh_levels(); // c:Src/exec.c:1221
20274                // c:Src/exec.c:5150 entersubsh → c:1200 `zsh_subshell++`.
20275                std::mem::forget(CmdSubstSubshellBump::enter());
20276                // Child: close the write end (c: closem after redup),
20277                // dup the read end onto stdin (c: redup(pipes[0], 0)),
20278                // run the sub-chunk, exit. Other std fds stay
20279                // inherited — zsh's child keeps the surrounding
20280                // command's stdout/stderr.
20281                unsafe {
20282                    libc::close(write_end);
20283                    libc::dup2(read_end, libc::STDIN_FILENO);
20284                    libc::close(read_end);
20285                }
20286                // c:Src/exec.c:5101/5150 — `execode(prog, 0, 1, out ?
20287                // "outsubst" : "insubst");`. execode (c:1245-1266) APPENDS its
20288                // context for the duration of the body, so `>(cmd)` — whose child READS (out=0) — runs as `…:insubst`.
20289                // zshrs's ported getproc carries these citations but is NOT the
20290                // live path (established in #1062) — the VM forks here instead,
20291                // so the push belongs in this child. No pop needed: this runs
20292                // INSIDE the forked child and dies with it. Bug #1069 (procsub
20293                // legs).
20294                if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
20295                    ctx.push("insubst".to_string());
20296                    let joined = ctx.join(":");
20297                    if let Ok(mut tab) = crate::ported::params::paramtab().write() {
20298                        if let Some(pm) = tab.get_mut("zsh_eval_context") {
20299                            pm.u_arr = Some(ctx.clone());
20300                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20301                        }
20302                        if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
20303                            pm.u_str = Some(joined);
20304                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20305                        }
20306                    }
20307                }
20308                crate::fusevm_disasm::maybe_print_stdout("process_subst_out:child", &sub_for_child);
20309                let mut vm = fusevm::VM::new(sub_for_child);
20310                register_builtins(&mut vm);
20311                vm.set_shell_host(Box::new(ZshrsHost));
20312                let _ = vm.run();
20313                unsafe { libc::_exit(0) };
20314            }
20315            child_pid => {
20316                // c:Src/exec.c:5143 `procsubstpid = pid;` — same fix as
20317                // the `<(cmd)` in-path above: record the forked child's
20318                // PID for `${sysparams[procsubstpid]}` (was always 0).
20319                crate::ported::exec::procsubstpid
20320                    .store(child_pid, std::sync::atomic::Ordering::Relaxed);
20321                // Parent: close the read end, keep the write end open
20322                // under its fd value so `/dev/fd/N` resolves to the
20323                // pipe's write side. FD_CLOEXEC must STAY clear —
20324                // consumers (`tee >(cmd)`) discover the fd via exec
20325                // inheritance, matching process_sub_in above and C's
20326                // fdtable[fd] = FDT_PROC_SUBST bookkeeping. Park the
20327                // fd for close-after-consuming-command (c: addfilelist
20328                // (NULL, fd) → deletefilelist) so the child's reader
20329                // EOFs — without this `tee >(wc -c) </dev/null` left
20330                // wc blocked until shell exit.
20331                unsafe {
20332                    libc::close(read_end);
20333                }
20334                let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
20335                PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, write_end)));
20336                let path = format!("{}/{}", crate::ported::config_h::PATH_DEV_FD, write_end); // c:Src/exec.c:5144
20337                if crate::provenance::active() {
20338                    crate::provenance::on_process_subst(&prov_chunk_label(sub), &path);
20339                }
20340                path
20341            }
20342        }
20343    }
20344
20345    fn subshell_begin(&mut self) {
20346        with_executor(|exec| {
20347            // Special parameters whose value lives in a process GLOBAL behind a
20348            // GSU (`Src/params.c`'s `ifs`, `wordchars`, `home`, `histsiz`, …)
20349            // rather than in the param table. Mirrors the getfn dispatch list at
20350            // params.rs:12548. C isolates these for free by forking `(...)`;
20351            // zshrs's in-process subshell has to snapshot them by hand, or a
20352            // subshell-local `IFS=,` rewrites the parent's word-splitting.
20353            const SUBSHELL_SPECIAL_GLOBALS: &[&str] = &[
20354                "IFS",
20355                "HOME",
20356                "TERM",
20357                "USERNAME",
20358                "WORDCHARS",
20359                "TERMINFO",
20360                "TERMINFO_DIRS",
20361                "KEYBOARD_HACK",
20362                "histchars",
20363                "HISTSIZE",
20364                "SAVEHIST",
20365            ];
20366            // An UNSET special yields None and is skipped: the paramtab snapshot
20367            // restores its PM_UNSET flag, and the getfn dispatch refuses to read
20368            // the (stale) global while PM_UNSET is set (params.rs:12552).
20369            let special_globals_snap: Vec<(String, String)> = SUBSHELL_SPECIAL_GLOBALS
20370                .iter()
20371                .filter_map(|n| crate::ported::params::getsparam(n).map(|v| ((*n).to_string(), v)))
20372                .collect();
20373            // Snapshot paramtab + hashed-storage too (step 1 of the
20374            // store unification mirrors writes there; restoring only
20375            // the HashMaps leaks subshell-scoped writes to the parent
20376            // via paramtab readers like `paramsubst → vars_get`).
20377            let paramtab_snap = crate::ported::params::paramtab()
20378                .read()
20379                .ok()
20380                .map(|t| t.clone())
20381                // c:Src/params.c:854 — a fresh table is 151 buckets, not
20382                // the 17-bucket `Default`.
20383                .unwrap_or_else(|| crate::ported::hashtable::hashtable_nodes::newhashtable(151));
20384            let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
20385                .lock()
20386                .ok()
20387                .map(|m| m.clone())
20388                .unwrap_or_default();
20389            let loop_flags_snap = {
20390                use std::sync::atomic::Ordering::SeqCst;
20391                (
20392                    crate::ported::builtin::LOOPS.load(SeqCst),
20393                    crate::ported::builtin::BREAKS.load(SeqCst),
20394                    crate::ported::builtin::CONTFLAG.load(SeqCst),
20395                    crate::ported::builtin::ANCESTOR_LOOPS.load(SeqCst),
20396                )
20397            };
20398            // c:Src/exec.c:1263-1264 (55061) — entersubsh's `ancestor_loops
20399            // += loops; loops = 0;`: a `break` in `( … )` has no loop of its
20400            // own to leave.
20401            {
20402                use std::sync::atomic::Ordering::SeqCst;
20403                crate::ported::builtin::ANCESTOR_LOOPS
20404                    .fetch_add(crate::ported::builtin::LOOPS.swap(0, SeqCst), SeqCst);
20405            }
20406            exec.subshell_snapshots.push(SubshellSnapshot {
20407                // c:Src/utils.c:2111 `addlockfd` — the fds carrying
20408                // `zsystem flock` locks. Recorded so subshell_end can close
20409                // the ones the subshell itself opened (C's fork does it for
20410                // free). See SubshellSnapshot::flock_fds.
20411                flock_fds: current_flock_fds(),
20412                loop_flags: loop_flags_snap,
20413                cmdoutval: crate::ported::exec::cmdoutval.load(std::sync::atomic::Ordering::Relaxed),
20414                stopmsg: crate::ported::builtin::STOPMSG.load(std::sync::atomic::Ordering::Relaxed),
20415                paramtab: paramtab_snap,
20416                paramtab_hashed_storage: paramtab_hashed_snap,
20417                special_globals: special_globals_snap,
20418                positional_params: exec.pparams(),
20419                // Snapshot canonical `traps_table` — bin_trap writes
20420                // there (`Src/builtin.c`).
20421                traps: crate::ported::builtin::traps_table()
20422                    .lock()
20423                    .map(|t| t.clone())
20424                    .unwrap_or_default(),
20425                // Snapshot option store so `(set -e)` /
20426                // `(setopt extendedglob)` don't leak to parent.
20427                opts: crate::ported::options::opt_state_snapshot(),
20428                // c:Src/exec.c:2880 — fork() copies the alias tables to
20429                // the subshell. `(alias x=y)` inside the subshell dies
20430                // with the child; the parent doesn't see x. Bug #209.
20431                tables: crate::ported::exec::SubshForkCopy::save(),
20432                // c:Src/exec.c::entersubsh — same fork-copy
20433                //   semantics for shfunctab. `(f() { ... })` defined
20434                //   inside the subshell dies with the child; parent's
20435                //   `type f` reports "not found". Bug #208 in
20436                //   docs/BUGS.md.
20437                shfuncs: crate::ported::hashtable::shfunctab_lock()
20438                    .read()
20439                    .ok()
20440                    .map(|t| t.snapshot())
20441                    .unwrap_or_default(),
20442                functions_compiled: exec.functions_compiled.clone(),
20443                function_source: exec.function_source.clone(),
20444                // c:Src/exec.c::entersubsh — fork-copy semantics for
20445                // THINGYTAB (ZLE widget registry). A subshell `zle -N`
20446                // / `zle -D` mutation dies with the child in C zsh;
20447                // mirror via in-process snapshot. Bug #453.
20448                thingytab: crate::ported::zle::zle_thingy::thingytab()
20449                    .lock()
20450                    .ok()
20451                    .map(|t| t.clone())
20452                    .unwrap_or_default(),
20453                // c:Src/exec.c::entersubsh — same fork-copy for the
20454                // KEYMAPNAMTAB (named keymap registry). `bindkey -N km`
20455                // / `bindkey -D km` inside a subshell dies with the
20456                // child. Bug #454.
20457                keymapnamtab: crate::ported::zle::zle_keymap::keymapnamtab()
20458                    .lock()
20459                    .ok()
20460                    .map(|t| t.clone())
20461                    .unwrap_or_default(),
20462                // c:Src/exec.c::entersubsh fork semantics — `$!`
20463                // (clone::lastpid) set by a `&` INSIDE the subshell
20464                // dies with the child: `( : & ); echo $!` -> 0.
20465                lastpid: crate::ported::modules::clone::lastpid
20466                    .load(std::sync::atomic::Ordering::Relaxed),
20467                // c:Src/exec.c::entersubsh fork semantics — the
20468                // subshell gets a COPY of the job table; its disown/
20469                // wait/`&` mutations die with it. Bug #462.
20470                jobtab: crate::ported::jobs::JOBTAB
20471                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
20472                    .lock()
20473                    .map(|t| t.clone())
20474                    .unwrap_or_default(),
20475                curjob: *crate::ported::jobs::CURJOB
20476                    .get_or_init(|| std::sync::Mutex::new(-1))
20477                    .lock()
20478                    .unwrap(),
20479                prevjob: *crate::ported::jobs::PREVJOB
20480                    .get_or_init(|| std::sync::Mutex::new(-1))
20481                    .lock()
20482                    .unwrap(),
20483                maxjob: *crate::ported::jobs::MAXJOB
20484                    .get_or_init(|| std::sync::Mutex::new(0))
20485                    .lock()
20486                    .unwrap(),
20487                thisjob: *crate::ported::jobs::THISJOB
20488                    .get_or_init(|| std::sync::Mutex::new(-1))
20489                    .lock()
20490                    .unwrap(),
20491                oldjobtab: crate::ported::jobs::OLDJOBTAB
20492                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
20493                    .lock()
20494                    .map(|t| t.clone())
20495                    .unwrap_or_default(),
20496                oldmaxjob: *crate::ported::jobs::OLDMAXJOB
20497                    .get_or_init(|| std::sync::Mutex::new(0))
20498                    .lock()
20499                    .unwrap(),
20500                // c:Src/exec.c:2880 — fork copies the fd table; the
20501                // child's `exec >file` / `exec N<&-` die with it.
20502                fd_frame: crate::ported::exec::SubshFdFrame::enter(),
20503                redir_depth: exec.redirect_scope_stack.len(),
20504                slots_mark: crate::exec_jobs::execpline_slots_mark(),
20505                // c:Src/signals.c:39 `sigtrapped` — saved so End restores the
20506                // parent's per-signal trap flags (see the field docs).
20507                sigtrapped: crate::ported::signals::sigtrapped
20508                    .lock()
20509                    .map(|g| g.clone())
20510                    .unwrap_or_default(),
20511                // c:Src/exec.c:160 `int subsh;` — saved so End can put the
20512                // parent's value back (subshells nest).
20513                subsh: crate::ported::exec::subsh.load(std::sync::atomic::Ordering::Relaxed),
20514                // The parent's libc locale — see SubshellSnapshot::locale.
20515                locale: crate::vm_helper::subsh_locale_save(),
20516            });
20517            // c:Src/exec.c:1192-1193 — `if (!(flags & ESUB_FAKE)) subsh = 1;`
20518            // A `( … )` is a real subshell, so the body runs with subsh set.
20519            // The forked child carries it in C; the in-process body needs it
20520            // set explicitly or per-command checks that read it — notably
20521            // PRINT_EXIT_VALUE (c:4309 `&& !subsh`) — behave as if the
20522            // command ran in the parent.
20523            crate::ported::exec::subsh.store(1, std::sync::atomic::Ordering::Relaxed);
20524            // C forks for `(...)` — count the fork-equivalent so
20525            // `time (builtin)` reports like zsh (see FORK_EVENTS).
20526            crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
20527            // c:Src/exec.c:1088-1092 — entersubsh resets traps in the child:
20528            //     if (!(flags & ESUB_KEEPTRAP))
20529            //         for (sig = 0; sig <= SIGCOUNT; sig++)
20530            //             if (!(sigtrapped[sig] & ZSIG_FUNC) &&
20531            //                 !(isset(POSIXTRAPS) && (sigtrapped[sig] & ZSIG_IGNORED)))
20532            //                 unsettrap(sig);
20533            //
20534            // A subshell does NOT inherit string-form traps. Two exemptions:
20535            // FUNCTION-form traps (`TRAPUSR1() { … }`, ZSIG_FUNC) survive, and
20536            // under POSIX_TRAPS an IGNORED trap (`trap '' SIG`) survives.
20537            //
20538            // The ZSIG_FUNC exemption is structural here rather than a flag
20539            // test: zshrs keeps string-form bodies in traps_table and
20540            // function-form ones in shfunctab as TRAPxxx, so filtering only
20541            // traps_table leaves the function form untouched by construction.
20542            // ZSIG_IGNORED is `trap '' SIG`, which stores an empty body.
20543            //
20544            // The LOOP BOUND is also part of the spec, not an implementation
20545            // detail: `sig <= SIGCOUNT` never reaches the PSEUDO-signals,
20546            // which zsh numbers above the real ones —
20547            //     #define SIGZERR   (SIGCOUNT+1)
20548            //     #define SIGDEBUG  (SIGCOUNT+2)      (c:Src/signals.h:34-35)
20549            // so ERR/ZERR and DEBUG traps SURVIVE a subshell, while SIGEXIT
20550            // (sig 0) is inside the loop and is cleared. Verified against the
20551            // oracle: `trap 'print e' ERR; (trap)` lists the ERR trap;
20552            // `trap 'print u' USR1; (trap)` lists nothing.
20553            //
20554            // Without this a subshell kept the parent's traps: `(trap)` listed
20555            // them where zsh lists nothing, and — the part that isn't
20556            // cosmetic — an inherited trap FIRED inside the child, so
20557            // `trap 'print p' USR1; (kill -USR1 $$; print after)` printed
20558            // p before after instead of after…p (the signal is meant to reach
20559            // the parent, whose trap runs there).
20560            //
20561            // The snapshot pushed above restores the parent's table at
20562            // subshell_end, which is what makes clearing safe for zshrs's
20563            // in-process subshell.
20564            {
20565                // Record the PARENT's `sigtrapped[]` BEFORE the reset below
20566                // wipes it. C gets it from the fork: the child mutates a
20567                // private copy (`Src/signals.c:39`) and the parent's stays
20568                // put. (`limits[]` travels in SubshForkCopy.)
20569                subshell_signal_enter();
20570                entersubsh_reset_traps();
20571            }
20572            // c:Src/exec.c:2862 — subshell fork flags carry ESUB_PGRP,
20573            // so entersubsh runs `clearjobtab(monitor)` (c:1219): the
20574            // child gets an EMPTY job table plus the procless control
20575            // job grabbed at Src/jobs.c:1828 (`thisjob = initjob()`).
20576            // That's why zsh's `(jobs)` prints nothing and `(kill %1)`
20577            // hits the empty control job instead of the parent's job 1.
20578            // The snapshot pushed above restores the parent's table at
20579            // subshell_end. Bug #462.
20580            let monitor = crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
20581            crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
20582            // c:Src/exec.c:1133 + c:1245-1246 — `job_control_ok = monitor &&
20583            // (flags & ESUB_JOB_CONTROL) && isset(POSIXJOBS)`, and a
20584            // non-async `( … )` carries ESUB_JOB_CONTROL (c:2919-2920);
20585            // `if (!job_control_ok) opts[MONITOR] = 0;`. With MONITOR off
20586            // `jobs` lists the parent's oldjobtab and fg/bg refuse with
20587            // "no job control in this shell". The option snapshot above
20588            // restores it at subshell_end.
20589            if !(monitor != 0 && crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXJOBS)) {
20590                crate::ported::options::dosetopt(crate::ported::zsh_h::MONITOR, 0, 0);
20591            }
20592            // clearjobtab left THISJOB on the control job (Src/jobs.c:
20593            // 1828). In C the very next pipeline's execpline reassigns
20594            // thisjob (Src/exec.c:1700 `thisjob = newjob = initjob()`),
20595            // so by the time any builtin runs, thisjob never aliases
20596            // the control job. zshrs has no per-pipeline job slot —
20597            // model the between-pipelines state (-1) so getjob's
20598            // `jobnum != thisjob` (c:jobs.c:2107) doesn't reject %1 and
20599            // setcurjob doesn't demote an inherited curjob that
20600            // collides with the control slot.
20601            *crate::ported::jobs::THISJOB
20602                .get_or_init(|| std::sync::Mutex::new(-1))
20603                .lock()
20604                .unwrap() = -1;
20605            // Subshell starts with EXIT trap cleared so the parent's
20606            // EXIT handler doesn't fire when the subshell ends. zsh:
20607            // each subshell has its own trap context. Other signals
20608            // are inherited (well, parent's are still in place — but
20609            // a trap set INSIDE the subshell shouldn't leak out).
20610            if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
20611                t.remove("EXIT");
20612            }
20613            let level = exec
20614                .scalar("ZSH_SUBSHELL")
20615                .and_then(|s| s.parse::<i32>().ok())
20616                .unwrap_or(0);
20617            // c:Src/exec.c — ZSH_SUBSHELL carries PM_READONLY (declared
20618            // in params.rs special_params); setsparam would be rejected
20619            // by assignstrvalue's PM_READONLY guard. Write u_val
20620            // directly — same bypass pattern as BUILTIN_SET_LINENO at
20621            // line 2784. C zsh's PM_SPECIAL GSU vtable handles this
20622            // implicitly via the setfn callback.
20623            let new_level = (level + 1) as i64;
20624            if let Ok(mut tab) = crate::ported::params::paramtab().write() {
20625                if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
20626                    pm.u_val = new_level;
20627                    pm.u_str = Some(new_level.to_string());
20628                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20629                }
20630            }
20631        });
20632        // Bump SUBSHELL_DEPTH so zexit defers process::exit (see
20633        // SUBSHELL_DEPTH declaration in src/ported/builtin.rs for
20634        // rationale).
20635        crate::ported::builtin::SUBSHELL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
20636        // c:Src/exec.c::entersubsh — C zsh's subshell is a forked
20637        // child process: signals sent to the parent (via `kill $$`
20638        // inside the subshell, where `$$` is the parent's pid)
20639        // never reach the child's signal handlers. zshrs's
20640        // in-process subshell shares the process pid with the
20641        // parent, so without queueing the subshell's trap handler
20642        // fires for signals that zsh would deliver only to the
20643        // parent. Queue signals across the subshell body so the
20644        // parent's restored trap table sees them after
20645        // subshell_end's unqueue drain. Bug #450.
20646        crate::ported::signals_h::queue_signals();
20647    }
20648
20649    fn subshell_end(&mut self) -> Option<i32> {
20650        // Fire subshell's EXIT trap BEFORE restoring parent state so
20651        // the trap body sees the subshell's vars and exit status. zsh
20652        // forks for `(...)` so the trap runs in the child process,
20653        // before exit. We mirror by running it here, just before the
20654        // pop+restore. REMOVE the trap before firing so the inner
20655        // execute_script doesn't fire it again at its own end.
20656        let exit_trap_body = crate::ported::builtin::traps_table()
20657            .lock()
20658            .ok()
20659            .and_then(|mut t| t.remove("EXIT"));
20660        if let Some(body) = exit_trap_body {
20661            // Execute the trap body. Errors during trap execution
20662            // don't bubble — zsh ignores trap-body errors.
20663            with_executor(|exec| {
20664                let _ = exec.execute_string_without_exit_hooks(&body);
20665            });
20666        }
20667        with_executor(|exec| {
20668            // c:Src/exec.c::entersubsh — the subshell is a forked child, so a
20669            // NOMATCH failure raised inside it (c:Src/glob.c:1877) lives and
20670            // dies with that process. The per-command glob-failed cell is the
20671            // in-process stand-in for that failure, and a body like `( y* )`
20672            // ends before any command boundary consumes it: the parent's NEXT
20673            // command then read it and returned 1 without running
20674            // (`( y* ); print a` lost `print a`, `( y* ) || print x` lost
20675            // the or-branch).
20676            exec.current_command_glob_failed.set(false);
20677            if let Some(snap) = exec.subshell_snapshots.pop() {
20678                // c:Src/exec.c:1123 entersubsh — the forked child owns its fds, so
20679                // redirections a body error left open (`WithRedirectsEnd` skipped)
20680                // go with it: `(print ${a[]} 2>&1); print -u2 err` keeps fd 2.
20681                exec.unwind_redirect_scopes_to(snap.redir_depth);
20682                crate::exec_jobs::execpline_slots_forget_to(snap.slots_mark);
20683                // c:Src/exec.c::entersubsh fork semantics — `loops` /
20684                // `breaks` / `contflag` are process globals the child
20685                // owns a private copy of, so `(break)` inside a loop
20686                // cannot end the PARENT's loop. See
20687                // SubshellSnapshot::loop_flags.
20688                {
20689                    use std::sync::atomic::Ordering::SeqCst;
20690                    let (loops, breaks, contflag, ancestor_loops) = snap.loop_flags;
20691                    crate::ported::builtin::ANCESTOR_LOOPS.store(ancestor_loops, SeqCst);
20692                    crate::ported::builtin::LOOPS.store(loops, SeqCst);
20693                    crate::ported::builtin::BREAKS.store(breaks, SeqCst);
20694                    crate::ported::builtin::CONTFLAG.store(contflag, SeqCst);
20695                }
20696                // c:Src/exec.c:225 `cmdoutval` — the child's copy dies with
20697                // it. See SubshellSnapshot::cmdoutval.
20698                crate::ported::exec::cmdoutval.store(snap.cmdoutval, std::sync::atomic::Ordering::Relaxed);
20699                // See SubshellSnapshot::stopmsg.
20700                crate::ported::builtin::STOPMSG.store(snap.stopmsg, std::sync::atomic::Ordering::Relaxed);
20701                // c:Src/utils.c:2155-2164 `zcloselockfd` — release the
20702                // `zsystem flock` locks the subshell itself took. Under C
20703                // the forked child's fds close on exit; here we close the
20704                // fds that appeared while the subshell was running.
20705                // See SubshellSnapshot::flock_fds.
20706                for fd in current_flock_fds() {
20707                    if !snap.flock_fds.contains(&fd) {
20708                        crate::ported::utils::zcloselockfd(fd);
20709                    }
20710                }
20711                // c:Src/exec.c:160 / :1192-1193 — the child's `subsh = 1`
20712                // dies with the fork in C; restore the parent's value here.
20713                crate::ported::exec::subsh.store(snap.subsh, std::sync::atomic::Ordering::Relaxed);
20714                // c:Src/signals.c:39 — same fork-copy reasoning for the
20715                // per-signal trap flags cleared at subshell entry.
20716                //
20717                // The `sigaction` DISPOSITIONS the body installed leak
20718                // out too, and the flag restore alone does not undo
20719                // them: `( trap 'echo IN' USR1 )` runs `settrap` →
20720                // `install_handler` (c:Src/signals.c:730) on the shared
20721                // process, so afterwards a `kill -USR1 $$` in the parent
20722                // hit zshrs's handler, found no trap, and was swallowed
20723                // where zsh — whose parent still had SIG_DFL, the child
20724                // having installed the handler on its own copy — dies.
20725                // Roll the dispositions back for exactly the signals
20726                // whose flags differ.
20727                let child_sigtrapped = match crate::ported::signals::sigtrapped.lock() {
20728                    Ok(mut st) => {
20729                        let prev = st.clone();
20730                        *st = snap.sigtrapped.clone();
20731                        prev
20732                    }
20733                    Err(_) => Vec::new(),
20734                };
20735                subshell_restore_signal_dispositions(&snap.sigtrapped, &child_sigtrapped);
20736                // c:Src/exec.c:2880 — alias, hash-table, `environ`, cwd,
20737                // umask, rlimit, zstyle, module, sched … writes in a
20738                // subshell die with the forked child: the parent never sees
20739                // `(alias x=y)` (Bug #209 in docs/BUGS.md), `(export y=sub)`
20740                // or `(zmodload zsh/X)` (Bug #210). See SubshForkCopy.
20741                // BEFORE the paramtab restore below: rolling a module back
20742                // looks its parameters up in the LIVE paramtab.
20743                snap.tables.restore();
20744                // Restore paramtab + hashed storage so subshell-scoped
20745                // writes via setsparam/setaparam/sethparam don't leak
20746                // to the parent via paramtab readers.
20747                if let Some(tab) = crate::ported::params::paramtab()
20748                    .write()
20749                    .ok()
20750                    .as_deref_mut()
20751                {
20752                    *tab = snap.paramtab;
20753                }
20754                // Restore the global-backed specials (see
20755                // SubshellSnapshot::special_globals). MUST run after the
20756                // paramtab restore above: setsparam writes through the GSU setfn
20757                // to BOTH the process global and the param node, so the paramtab
20758                // overwrite would otherwise clobber the node half of it.
20759                // Restore parent's option store so `(set -e)` /
20760                // `(setopt extendedglob)` don't leak. zsh forks
20761                // subshells so child option changes die with the
20762                // child; we run in-process and must restore. This runs
20763                // BEFORE the special-global replay below: that replay is
20764                // assignsparam, which honours options the body set.
20765                crate::ported::options::opt_state_restore(snap.opts);
20766                for (name, val) in &snap.special_globals {
20767                    crate::ported::params::setsparam(name, val);
20768                }
20769                // The body's `setlocale` (c:Src/params.c:4811 setlang,
20770                // c:4877 lcsetfn) changed only the forked child in C.
20771                crate::vm_helper::subsh_locale_restore(snap.locale.as_deref());
20772                // c:Src/exec.c::entersubsh fork semantics — restore
20773                // the parent's `$!`; a background job inside `(...)`
20774                // dies with the child in C zsh.
20775                crate::ported::modules::clone::lastpid
20776                    .store(snap.lastpid, std::sync::atomic::Ordering::Relaxed);
20777                // c:Src/exec.c::entersubsh fork semantics — restore the
20778                // parent's job table + curjob/prevjob/maxjob/thisjob.
20779                // The subshell mutated only its own copy. Bug #462.
20780                if let Ok(mut t) = crate::ported::jobs::JOBTAB
20781                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
20782                    .lock()
20783                {
20784                    *t = snap.jobtab;
20785                }
20786                *crate::ported::jobs::CURJOB
20787                    .get_or_init(|| std::sync::Mutex::new(-1))
20788                    .lock()
20789                    .unwrap() = snap.curjob;
20790                *crate::ported::jobs::PREVJOB
20791                    .get_or_init(|| std::sync::Mutex::new(-1))
20792                    .lock()
20793                    .unwrap() = snap.prevjob;
20794                *crate::ported::jobs::MAXJOB
20795                    .get_or_init(|| std::sync::Mutex::new(0))
20796                    .lock()
20797                    .unwrap() = snap.maxjob;
20798                *crate::ported::jobs::THISJOB
20799                    .get_or_init(|| std::sync::Mutex::new(-1))
20800                    .lock()
20801                    .unwrap() = snap.thisjob;
20802                // c:Src/jobs.c:101/104 — the subshell's oldjobtab died with
20803                // the child in C; put the parent's back.
20804                *crate::ported::jobs::OLDJOBTAB
20805                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
20806                    .lock()
20807                    .unwrap() = snap.oldjobtab;
20808                *crate::ported::jobs::OLDMAXJOB
20809                    .get_or_init(|| std::sync::Mutex::new(0))
20810                    .lock()
20811                    .unwrap() = snap.oldmaxjob;
20812                if let Some(m) = crate::ported::params::paramtab_hashed_storage()
20813                    .lock()
20814                    .ok()
20815                    .as_deref_mut()
20816                {
20817                    *m = snap.paramtab_hashed_storage;
20818                }
20819                exec.set_pparams(snap.positional_params);
20820                // Restore parent's traps (the subshell's own traps die
20821                // with it). zsh: `(trap "X" USR1)` doesn't leak the
20822                // USR1 trap out of the subshell. Write back to the
20823                // canonical `traps_table` (bin_trap writes there).
20824                if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
20825                    *t = snap.traps;
20826                }
20827                // (The parent's option store was put back above, before
20828                // the special-global replay.)
20829                // c:Src/exec.c::entersubsh — same fork-copy
20830                //   semantics for shfunctab. Restore parent's function
20831                //   table from snapshot so `(f() { ... })` definitions
20832                //   inside the subshell don't leak to the parent.
20833                //   Bug #208 in docs/BUGS.md.
20834                if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
20835                    tab.restore(snap.shfuncs);
20836                }
20837                // Restore the runtime dispatch tables (compiled chunks
20838                // + source). Without these, a subshell-defined
20839                // override leaves its bytecode in place even after
20840                // shfunctab is restored — `g` after the subshell would
20841                // still run the override.
20842                exec.functions_compiled = snap.functions_compiled;
20843                exec.function_source = snap.function_source;
20844                // c:Src/exec.c::entersubsh — restore parent's THINGYTAB
20845                // so a subshell's `zle -N w f` / `zle -D w` doesn't
20846                // affect the parent's widget registry. Bug #453.
20847                if let Ok(mut t) = crate::ported::zle::zle_thingy::thingytab().lock() {
20848                    *t = snap.thingytab;
20849                }
20850                // Same for KEYMAPNAMTAB. Bug #454.
20851                if let Ok(mut t) = crate::ported::zle::zle_keymap::keymapnamtab().lock() {
20852                    *t = snap.keymapnamtab;
20853                }
20854                // c:Src/exec.c:2880 — a bare `exec >file` / `exec N>&-`
20855                // inside `(...)` died with the C child; put the parent's
20856                // descriptors back (see SubshFdFrame).
20857                drop(snap.fd_frame);
20858            }
20859        });
20860        // Decrement SUBSHELL_DEPTH. If a deferred subshell exit
20861        // landed inside (EXIT_PENDING set with depth > 0), promote
20862        // the deferred status into the subshell's exit status now
20863        // that we're at the boundary, then clear so the parent
20864        // continues. Matches C zsh's "subshell-exit-via-fork"
20865        // boundary where the child's process::exit(N) becomes
20866        // $WAITSTATUS / $? in the parent.
20867        crate::ported::builtin::SUBSHELL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
20868        // c:Src/exec.c — drain the signal queue against the now-
20869        // restored parent trap table. Pairs with the
20870        // queue_signals() call at the end of subshell_begin.
20871        // Any `kill $$` from inside the subshell is processed
20872        // here against OUTER's trap, matching C zsh's
20873        // signal-delivery-to-parent semantics. Bug #450.
20874        crate::ported::signals_h::unqueue_signals();
20875        // Replay the signals that arrived while the body ran and that
20876        // the PARENT traps, now that the parent's `sigtrapped[]` and
20877        // `traps_table` are back. See `subshell_defer_signal`.
20878        subshell_signal_leave();
20879        // c:Src/exec.c — a `( … )` subshell is a FORK in C: an errflag
20880        // abort inside the child ends the child with its lastval as
20881        // the exit status, and the flag dies with the child process.
20882        // The parent's $? picks up the status and the parent's lists
20883        // keep running. zsh 5.9: `(readonly r=1; r=2); echo "after
20884        // $?"` prints `after 1`. zshrs runs the subshell in-process,
20885        // so mirror the fork isolation by clearing ERRFLAG_ERROR at
20886        // the subshell boundary — exec.last_status() already carries
20887        // the child's lastval (synced by ERREXIT_CHECK trigger 4).
20888        //
20889        // ERRFLAG_HARD must die at this boundary too: `${u:?msg}` sets
20890        // errflag |= ERRFLAG_HARD (c:Src/subst.c:3344) and then, in a
20891        // C forked subshell, `_exit(1)` (c:3353) — the parent never
20892        // sees ANY errflag bit. A leaked HARD bit here made every
20893        // subsequent zerr() take the silent arm (c:Src/utils.c:175-177
20894        // `if (errflag || noerrs) { errflag |= ERRFLAG_ERROR; return; }`),
20895        // so the next eval/source's parse silently "failed" and the
20896        // D04 harness shell wedged after chunk 10's
20897        // `(print ${unset1:?exiting1})`.
20898        //
20899        // !!! DASH-FAMILY GATE — see dash_mode::fatal_error_status !!!
20900        // dash's `sh_error()` unwinds via `exraise(EXERROR)`, which sets
20901        // `exitstatus = 2` before `exitshell()`; the `( … )` boundary IS
20902        // one of the two places that unwind lands, so the subshell reports
20903        // 2 rather than zsh's `lastval == ERRFLAG_ERROR == 1`. Read the
20904        // flag BEFORE the clear below; the status is published at the
20905        // deferred-`exit` arm uses (run_chunk otherwise restores
20906        // `vm.last_status` over any write made here).
20907        let dash_fatal_status = {
20908            let ef = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
20909            let fatal = crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD;
20910            if ef & fatal != 0 {
20911                crate::extensions::dash_mode::fatal_error_status()
20912            } else {
20913                None
20914            }
20915        };
20916        crate::ported::utils::errflag.fetch_and(
20917            !(crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
20918            std::sync::atomic::Ordering::Relaxed,
20919        );
20920        // c:Src/builtin.c:5834 / Src/exec.c:1443 — `retflag` dies at the
20921        // fork boundary for the same reason `errflag` above does. In C a
20922        // `return` inside `( … )` sets retflag in the CHILD; the child's
20923        // execlist unwinds, the child _exit()s, and the PARENT's retflag
20924        // was never touched. zshrs runs subshells in-process, so the flag
20925        // survived and returned from the enclosing FUNCTION:
20926        //   f() { ( return 1 ); print IN }; f
20927        // printed nothing where zsh prints `IN`. Storing 0 is exactly a
20928        // restore-to-entry: a non-zero retflag unwinds its list
20929        // immediately (c:1443's `!retflag` gate), so the parent can never
20930        // be sitting at a subshell with the flag already set. Twin of the
20931        // save/restore `$( … )` already does in vm_helper.rs (the
20932        // `saved_retflag` pair around the cmd-subst sub-VM), and of the
20933        // loops/breaks/contflag restore in SubshellSnapshot::loop_flags.
20934        crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
20935        let exit_pending =
20936            crate::ported::builtin::EXIT_PENDING.load(std::sync::atomic::Ordering::Relaxed);
20937        if exit_pending != 0 {
20938            // c:Src/builtin.c — `exit N` masks N to 8 bits because
20939            // POSIX _exit takes the low byte as status. `(exit 256)`
20940            // and `(exit 0)` are indistinguishable to the parent;
20941            // `(exit 257)` exits with 1. Without the mask zshrs's
20942            // in-process subshell propagated the full i32 (256) into
20943            // the parent's $?, diverging from zsh.
20944            let raw = crate::ported::builtin::EXIT_VAL.load(std::sync::atomic::Ordering::Relaxed);
20945            // dash's `exraise(EXERROR)` assigns `exitstatus = 2` at the
20946            // RAISE, so a fatal error wins over whatever deferred-exit
20947            // value the unwind happened to carry. Only an errflag-driven
20948            // unwind reaches this — a real `(exit 5)` never sets the flag,
20949            // so `(exit 5)` still reports 5.
20950            let val = dash_fatal_status.unwrap_or(raw & 0xFF);
20951            with_executor(|exec| exec.set_last_status(val));
20952            crate::ported::builtin::EXIT_PENDING.store(0, std::sync::atomic::Ordering::Relaxed);
20953            crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
20954            crate::ported::builtin::BREAKS.store(0, std::sync::atomic::Ordering::Relaxed);
20955            // Return the deferred-exit status so the VM updates its
20956            // own `last_status`. Otherwise run_chunk's post-script
20957            // `set_last_status(vm.last_status)` would clobber LASTVAL
20958            // back to the stale pre-subshell value.
20959            return Some(val);
20960        }
20961        // Same publication path for a fatal error that did NOT arm a
20962        // deferred exit (e.g. the failed-assignment unwind).
20963        if let Some(st) = dash_fatal_status {
20964            with_executor(|exec| exec.set_last_status(st));
20965            return Some(st);
20966        }
20967        None
20968    }
20969
20970    fn redirect(&mut self, fd: u8, op: u8, target: &str) {
20971        // Apply a redirection at the OS level for the next command/builtin.
20972        // The host tracks saved fds in a per-executor stack so a future
20973        // `with_redirects_end` can restore. For now, this is a thin wrapper
20974        // that performs the dup2; pairing with explicit save/restore is
20975        // delivered by `with_redirects_begin/end`.
20976        with_executor(|exec| exec.host_apply_redirect(fd, op, target));
20977    }
20978
20979    fn with_redirects_begin(&mut self, count: u8) {
20980        with_executor(|exec| exec.host_redirect_scope_begin(count));
20981    }
20982
20983    fn regex_match(&mut self, s: &str, regex: &str) -> bool {
20984        // c:Src/Modules/regex.c:54 `zcond_regex_match` — POSIX ERE
20985        // matching + populate `$MATCH` / `$MBEGIN` / `$MEND` /
20986        // `$match[]` / `$mbegin[]` / `$mend[]` (or `$BASH_REMATCH`
20987        // under BASHREMATCH). Direct delegation to the canonical
20988        // port at src/ported/modules/regex.rs:58.
20989        //
20990        // The bridge passthru path delivers TOKEN-form bytes here
20991        // (Inbrack \u{e191}, Outbrack \u{e192}, Star \u{e187}, Quest
20992        // \u{e186}, etc.) since the lexer tokenizes regex meta chars
20993        // inside `[[ ]]`. The host regex engine expects ASCII, so
20994        // untokenize the pattern (and subject, for safety) once at
20995        // this boundary. zsh C reaches its POSIX-ERE engine through
20996        // the same untokenize path inside zcond_regex_match.
20997        let s_clean = crate::lex::untokenize(s);
20998        let regex_clean = crate::lex::untokenize(regex);
20999        // c:Src/cond.c:113-119 — WHICH engine `=~` uses is an option:
21000        //
21001        //   char *modname = isset(REMATCHPCRE) ? "zsh/pcre" : "zsh/regex";
21002        //
21003        // and the two speak different languages (POSIX ERE vs PCRE), so the
21004        // option decides whether `\d` is a digit class or a literal `d`, and
21005        // whether `(?<name>…)` compiles at all. This dispatch was missing:
21006        // `=~` always used the regex module, so `setopt rematchpcre` silently
21007        // did nothing.
21008        if crate::ported::zsh_h::isset(crate::ported::zsh_h::REMATCHPCRE) {
21009            // c:115 — "zsh/pcre" → the `-pcre-match` cond.
21010            crate::ported::modules::pcre::cond_pcre_match(
21011                &[s_clean, regex_clean],
21012                crate::ported::modules::pcre::CPCRE_PLAIN,
21013            ) != 0
21014        } else {
21015            // c:115 — "zsh/regex" → the `-regex-match` cond.
21016            crate::ported::modules::regex::zcond_regex_match(
21017                &[s_clean.as_str(), regex_clean.as_str()],
21018                crate::ported::modules::regex::ZREGEX_EXTENDED,
21019            ) != 0
21020        }
21021    }
21022
21023    fn with_redirects_end(&mut self) {
21024        with_executor(|exec| exec.host_redirect_scope_end());
21025        // c:Src/exec.c:5172 — if any redirect in this scope failed
21026        // (noclobber-blocked, ENOENT for read, etc.), the command's
21027        // exit status is forced to 1 regardless of what the (still-
21028        // executed) command's own exit was. C zsh prevents the
21029        // command from running at all when a redirect fails; the
21030        // Rust port still runs it (sinking output to /dev/null in
21031        // the noclobber arm at host_apply_redirect:5481) and then
21032        // overrides $? here. Same observable effect for the common
21033        // pattern `echo x > existing-file` under noclobber.
21034        let failed = with_executor(|exec| {
21035            let f = exec.redirect_failed;
21036            exec.redirect_failed = false;
21037            f
21038        });
21039        if failed {
21040            with_executor(|exec| exec.set_last_status(1));
21041        }
21042    }
21043
21044    fn heredoc(&mut self, content: &str) {
21045        // C `Src/exec.c:4641` — `parsestr(&buf)` runs parameter +
21046        // command substitution on the heredoc body. The lexer's
21047        // quoted-delimiter detection (`<<'EOF'`) routes through the
21048        // `Op::HereDoc` path in `compile_zsh.rs` which short-circuits
21049        // before reaching here; unquoted forms route through the
21050        // BUILTIN_EXPAND_TEXT mode-4 emit path that calls singsub.
21051        // This handler covers the verbatim/quoted case.
21052        if crate::provenance::active() {
21053            crate::provenance::on_heredoc("heredoc", content);
21054        }
21055        with_executor(|exec| exec.host_set_pending_stdin(content.to_string()));
21056    }
21057
21058    fn herestring(&mut self, content: &str) {
21059        // Shell semantics: herestring appends a newline. `<<<` body
21060        // substitution (`Src/exec.c:4655 getherestr` calls
21061        // `quotesubst` + `untokenize`) lands here verbatim; the
21062        // upstream compiler routes through `Op::HereString` after
21063        // BUILTIN_EXPAND_TEXT for the substitution pass, so callers
21064        // of `host.herestring` see the already-expanded form.
21065        let mut s = content.to_string();
21066        s.push('\n');
21067        if crate::provenance::active() {
21068            crate::provenance::on_heredoc("herestring", &s);
21069        }
21070        with_executor(|exec| exec.host_set_pending_stdin(s));
21071    }
21072
21073    fn exec(&mut self, args: Vec<String>) -> i32 {
21074        // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close
21075        // any `>(cmd)` write ends owned by this command once it
21076        // finishes (drops on every return path below).
21077        let _psub_fds = PsubFdGuard;
21078        // c:Src/subst.c paramsubst — when `${var:?msg}` or `${var?msg}`
21079        // triggered the "parameter null or not set" error, errflag
21080        // is raised and zsh aborts the simple command without
21081        // attempting exec. The expansion may have produced empty
21082        // argv[0] which falls into the c:?/permission-denied path
21083        // below, masking the real diagnostic with a spurious
21084        // "permission denied:" line and rc=126 instead of rc=1.
21085        // Honour errflag here so the script ends with the
21086        // paramsubst error as the sole diagnostic. Bug #86.
21087        //
21088        // c:Src/exec.c — C's execlist loop clears ERRFLAG_ERROR
21089        // between sublists when the error came from a NOMATCH-style
21090        // command failure (glob no-match, etc.) so subsequent
21091        // sublists run. zshrs's vm dispatch handles this at the
21092        // post-command-boundary HERE: if THIS command has its
21093        // `current_command_glob_failed` cell set (meaning the glob
21094        // NOMATCH happened during this command's argv prep), surface
21095        // status 1 and clear BOTH the cell AND ERRFLAG_ERROR so the
21096        // NEXT exec call sees a clean state. The errflag from
21097        // genuine script-fatal errors (parse, redirect, paramsubst
21098        // `${:?msg}`) does NOT come paired with glob_failed, so
21099        // those still short-circuit + propagate.
21100        if let Some(status) = external_glob_failure_status() {
21101            return status;
21102        }
21103        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
21104            & crate::ported::zsh_h::ERRFLAG_ERROR)
21105            != 0
21106        {
21107            // c:Src/exec.c:3636-3637 — `if (errflag) { lastval = 1; … return; }`
21108            // after prefork. The abort that follows exits with LASTVAL, so
21109            // store it: `/bin/echo $((1/0))` exited with the previous status.
21110            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
21111            return 1;
21112        }
21113        // c:Src/exec.c — two distinct empty-command cases:
21114        //
21115        // 1. args=[""]  — an explicit empty-string command word
21116        //    (`""`, `"\$unset"`, `\$'\$x'`). zsh attempts exec(2)
21117        //    on the empty path → EACCES → "permission denied", \$?
21118        //    = 126.
21119        //
21120        // 2. args=[]    — the WORD LIST is empty (unquoted \$(\$cmd)
21121        //    that produced empty, or an unquoted unset \$var that
21122        //    elided). zsh: no exec is attempted; \$? becomes the
21123        //    last cmd-subst's exit status (the inner sub-VM
21124        //    already set last_status), and the line completes
21125        //    silently. Critically NOT 126.
21126        if args.is_empty() {
21127            // c:Src/exec.c — empty word list passes through to a
21128            // no-op; preserve whatever the inner cmd-subst's exit
21129            // is. Return last_status so the caller's SetStatus
21130            // round-trips correctly.
21131            return with_executor(|exec| exec.last_status());
21132        }
21133        if args[0].is_empty() {
21134            let script_name =
21135                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
21136            let lineno: u64 = with_executor(|exec| {
21137                exec.scalar("LINENO")
21138                    .and_then(|s| s.parse::<u64>().ok())
21139                    .unwrap_or(1)
21140            });
21141            eprintln!("{}:{}: permission denied: ", script_name, lineno);
21142            return 126;
21143        }
21144        // c:Src/exec.c — when any redirect in the current scope
21145        // failed (e.g. noclobber blocked a `>` overwrite), zsh
21146        // refuses to execute the command and exits with status 1.
21147        // The Rust port still applied the command (writing to the
21148        // /dev/null sink installed by host_apply_redirect's
21149        // noclobber arm), but the success status overwrote the
21150        // intended `1`. Short-circuit here so the exec returns 1
21151        // without running the body.
21152        let redir_failed = with_executor(|exec| {
21153            let f = exec.redirect_failed;
21154            exec.redirect_failed = false;
21155            f
21156        });
21157        if redir_failed {
21158            return 1;
21159        }
21160        // c:Src/exec.c:3545-3547 — `setunderscore(lastnode(args))` for the
21161        // command about to run. Write the canonical `zunderscore` global,
21162        // NOT the paramtab node: `_`'s setfn is `nullstrsetfn`
21163        // (c:Src/params.c:252-253), so a table write has no counterpart in
21164        // C and clobbers the PM_UNSET bit that `unset _` relies on.
21165        if let Some(last) = args.last() {
21166            crate::ported::params::set_zunderscore(std::slice::from_ref(last)); // c:3546
21167        }
21168        // Provenance: record which argv slot each tracked value landed
21169        // in, before the command consumes it.
21170        if crate::provenance::active() {
21171            crate::provenance::on_exec("exec", &args);
21172        }
21173        // Route external command spawning through `executor.execute_external`
21174        // so intercepts (AOP before/after/around), command_hash lookups,
21175        // pre/postexec hooks, and zsh-specific fork-then-exec all apply.
21176        // Without this override, fusevm's default `host.exec` calls
21177        // `Command::new` directly, bypassing zshrs's dispatch logic.
21178        let status = with_executor(|exec| exec.host_exec_external(&args));
21179        // c:Src/jobs.c:1748 waitonejob (no-procs else-branch). zshrs's
21180        // exec model routes external commands through host_exec_external
21181        // (which already waitpid'd in-line); the canonical waitonejob
21182        // expects a Job to derive lastval, but here we already know
21183        // it. Synthesize a procs-less job so waitonejob's no-procs
21184        // branch fires the `pipestats[0]=lastval; numpipestats=1;`
21185        // update via the canonical port.
21186        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
21187        let mut synth = crate::ported::zsh_h::job::default();
21188        crate::ported::jobs::waitonejob(&mut synth);
21189        status
21190    }
21191
21192    fn cmd_subst(&mut self, sub: &fusevm::Chunk) -> String {
21193        // Run the sub-chunk on a nested VM with the same host wired up,
21194        // capturing stdout. The current executor remains active via the
21195        // thread-local — the nested VM uses CallBuiltin to dispatch shell
21196        // ops back through `with_executor`.
21197        // c:Src/exec.c:4800 getoutput — `mpipe(pipes)`: both ends are moved
21198        // out of the script's fd range (c:Src/utils.c:2210 mpipe → movefd),
21199        // so a closed stdout cannot hand the pipe fd 1 itself.
21200        let (read_end, write_end) = match os_pipe::pipe() {
21201            Ok(p) => p,
21202            Err(_) => return String::new(),
21203        };
21204        let read_end = unsafe {
21205            <os_pipe::PipeReader as std::os::fd::FromRawFd>::from_raw_fd(crate::lowfd::movefd_past_script(
21206                std::os::fd::IntoRawFd::into_raw_fd(read_end),
21207            ))
21208        };
21209        // A closed fd 1 is saved as -1 and restored by closing it again
21210        // (c:Src/utils.c:2047-2048 redup: `if (x < 0) zclose(y)`). Bailing out
21211        // here skipped the body entirely: `{ x=$(print b >&2) } >&-` lost `b`.
21212        // The saves exist only because the body runs in process (C's forked
21213        // child saves nothing): keep them past the script's `{var}` range.
21214        let saved_stdout = unsafe {
21215            libc::fcntl(libc::STDOUT_FILENO, libc::F_DUPFD_CLOEXEC, crate::lowfd::EXTENSION_FD_FLOOR)
21216        };
21217        let saved_stderr = unsafe {
21218            libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD_CLOEXEC, crate::lowfd::EXTENSION_FD_FLOOR)
21219        };
21220        // A job the body forks must not keep them: they belong to the
21221        // parent C never forked the body from (see mark_saved_fd).
21222        mark_saved_fd(saved_stdout);
21223        mark_saved_fd(saved_stderr);
21224        let write_fd = AsRawFd::as_raw_fd(&write_end);
21225        unsafe {
21226            libc::dup2(write_fd, libc::STDOUT_FILENO); // c:4840 redup(pipes[1], 1)
21227        }
21228        drop(write_end);
21229
21230        // c:Bug #56 — publish the saved outer fds so a trap firing
21231        // during the nested VM run can route its body output to the
21232        // PARENT's stdout instead of the cmdsub's pipe-bound fd 1.
21233        // zsh's forked cmdsub gets this for free (trap runs in the
21234        // parent process whose fd 1 is untouched). zshrs's
21235        // in-process cmdsub needs this thread-local stack so the
21236        // trap dispatcher can find the right destination fd.
21237        CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().push((saved_stdout, saved_stderr)));
21238
21239        // Nested scope for `>(cmd)` fd ownership — commands inside
21240        // the cmdsub must not drain the enclosing command's pending
21241        // psub fds (see PSUB_SCOPE_DEPTH).
21242        let _psub_scope = PsubScope::enter();
21243
21244        // c:Src/exec.c:1161 — forked cmdsub child runs entersubsh()
21245        // which does `zsh_subshell++`; in-process equivalent.
21246        let _subshell_bump = CmdSubstSubshellBump::enter();
21247
21248        crate::fusevm_disasm::maybe_print_stdout("host.cmd_subst", sub);
21249        let mut vm = fusevm::VM::new(sub.clone());
21250        register_builtins(&mut vm);
21251        vm.set_shell_host(Box::new(ZshrsHost));
21252        let _ = vm.run();
21253        let cmd_status = vm.last_status;
21254
21255        CMDSUBST_OUTER_FDS.with(|s| {
21256            s.borrow_mut().pop();
21257        });
21258
21259        unsafe {
21260            libc::dup2(saved_stdout, libc::STDOUT_FILENO);
21261        }
21262        crate::ported::utils::zclose(saved_stdout);
21263        if saved_stderr >= 0 {
21264            crate::ported::utils::zclose(saved_stderr);
21265        }
21266
21267        // Inner cmd's status not propagated for the same reason as
21268        // run_command_substitution — see GAPS.md.
21269        let _ = cmd_status;
21270
21271        let mut buf = String::new();
21272        let mut reader = read_end;
21273        let _ = reader.read_to_string(&mut buf);
21274        // Strip trailing newlines (POSIX command substitution semantics)
21275        while buf.ends_with('\n') {
21276            buf.pop();
21277        }
21278        // Provenance: a command substitution is a lineage ORIGIN — the
21279        // bytes did not exist in the shell before this ran.
21280        if crate::provenance::active() {
21281            crate::provenance::on_cmd_subst(&prov_chunk_label(sub), &buf);
21282        }
21283        buf
21284    }
21285
21286    fn call_function(&mut self, name: &str, args: Vec<String>) -> Option<i32> {
21287        // c:Src/exec.c — when the command word is empty (e.g. `""`
21288        // or `"$nonexistent"`), zsh attempts the exec(2) which
21289        // returns EACCES ("permission denied") and exits 126. The
21290        // Rust port silently treated empty as a no-op (status 0).
21291        // Match zsh by emitting the diagnostic and returning 126.
21292        if name.is_empty() {
21293            let script_name =
21294                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
21295            let lineno: u64 = with_executor(|exec| {
21296                exec.scalar("LINENO")
21297                    .and_then(|s| s.parse::<u64>().ok())
21298                    .unwrap_or(1)
21299            });
21300            eprintln!("{}:{}: permission denied: ", script_name, lineno);
21301            with_executor(|exec| exec.set_last_status(126));
21302            return Some(126);
21303        }
21304        // c:Src/exec.c — redirect failure in this scope means the
21305        // command should NOT run. The Host::exec path already has
21306        // this gate (at fn exec above); call_function takes external
21307        // commands like `cat <&3` through a different code path, so
21308        // gate here too. Without this, bad-fd redirects produced
21309        // the diagnostic but the external command still ran, so $?
21310        // came out from the command's natural exit instead of the
21311        // forced 1.
21312        let redir_failed = with_executor(|exec| {
21313            let f = exec.redirect_failed;
21314            exec.redirect_failed = false;
21315            f
21316        });
21317        // c:Src/exec.c:4147-4154 — a failed prefix assignment skips the
21318        // command with `lastval = 1` in the same way. For an external the
21319        // assignments run in the forked child (c:4343-4350 `addvars(…); if
21320        // (errflag) _exit(1);`), so the shell again only sees status 1:
21321        //   x=${bad?err} /bin/echo ran; print rc=$?     zsh: rc=1
21322        // while a shell function runs them in the shell, whose errflag ends
21323        // the list.
21324        let prefix_failed = PREFIX_ASSIGN_FAILED.with(|c| c.replace(false));
21325        if redir_failed || prefix_failed {
21326            // c:Src/exec.c:3719 forks an external command (execcmd_fork)
21327            // BEFORE the redirection loop at c:3785, so a redirection that
21328            // `zerr`s (`<&""` → "file number expected", c:Src/glob.c:2192)
21329            // raises errflag in the CHILD, which `_exit(1)`s; the parent only
21330            // sees status 1 and runs the next command:
21331            //   /bin/cat <&""; print after $?     zsh: after 1
21332            // zshrs applies redirections in the shell before choosing what
21333            // runs, so the errflag landed in the shell and ended the script.
21334            // For a name that resolves to neither a function nor a zsh
21335            // builtin, take the error back out of the shell's errflag. A
21336            // zshrs extension builtin counts as external here: zsh has no
21337            // such builtin, so the same name reaches execcmd_fork there
21338            // (an in-process stand-in for `env`, `cat`, … must fail like the
21339            // command it stands in for: `env <&""; print after` continues).
21340            let is_external = !with_executor(|exec| exec.function_exists(name))
21341                && !crate::ported::builtin::createbuiltintable().contains_key(name);
21342            if is_external {
21343                // A `${name?msg}` in the prefix assignments also sets
21344                // ERRFLAG_HARD; the forked child took both bits with it.
21345                let bits = if prefix_failed {
21346                    crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD
21347                } else {
21348                    crate::ported::zsh_h::ERRFLAG_ERROR
21349                };
21350                crate::ported::utils::errflag.fetch_and(!bits, std::sync::atomic::Ordering::Relaxed);
21351                // A NOMATCH / NULL_GLOB failure in the redirect target's
21352                // filename generation belongs to the same child: take its
21353                // carrier too, or the command-boundary gate still ends the list.
21354                let _ = external_glob_failure_status();
21355            }
21356            with_executor(|exec| exec.set_last_status(1));
21357            return Some(1);
21358        }
21359        // c:Src/exec.c:3755-3763 — a shell function is not forked, so a
21360        // failed argument glob is `lastval = 1; goto err;` in the shell: the
21361        // body never runs, and errflag (left set) ends the list with status
21362        // 1 (`f() { :; }; f *nomatch` exits 1).
21363        if with_executor(|exec| exec.function_exists(name))
21364            && with_executor(|exec| exec.current_command_glob_failed.replace(false))
21365        {
21366            consume_tilde_globsubst_carrier();
21367            with_executor(|exec| exec.set_last_status(1));
21368            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
21369            return Some(1);
21370        }
21371        // Provenance: same argv record as `exec`, but ONLY when the name
21372        // really resolves to a shell function — an external command
21373        // reaches `exec` further down and would otherwise be recorded
21374        // twice for the same call site.
21375        if crate::provenance::active() && with_executor(|exec| exec.function_exists(name)) {
21376            let mut argv = Vec::with_capacity(args.len() + 1);
21377            argv.push(name.to_string());
21378            argv.extend(args.iter().cloned());
21379            crate::provenance::on_exec("call", &argv);
21380        }
21381        // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
21382        // functions, NOT builtins. zshrs ships fast native impls, but they
21383        // must behave like the zsh functions — command-not-found until
21384        // `autoload -Uz <name>` creates a function entry. When autoloaded we
21385        // run the native impl here (short-circuiting the fpath source, which
21386        // can hang zshrs's parser on zsh-specific syntax); when NOT autoloaded
21387        // we fall through (return None → resolution ends in command-not-found),
21388        // matching `zsh -f; zmv` → "command not found: zmv".
21389        // A DEFINED zmv/zcp/zln/zcalc body (user-written, or a stub already
21390        // loaded by `autoload +X`) is an ordinary shell function and runs as
21391        // one through the function dispatch below. The native port stands in
21392        // only for the fpath load of a still-PM_UNDEFINED stub
21393        // (c:Src/exec.c:5691-5695 execautofn → loadautofn, which clears
21394        // PM_UNDEFINED at c:5790/c:5815).
21395        let native_autoload =
21396            matches!(name, "zmv" | "zcp" | "zln" | "zcalc") && autoload_stub_pending(name);
21397        if matches!(name, "zmv" | "zcp" | "zln" | "zcalc")
21398            && !with_executor(|exec| exec.function_exists(name))
21399        {
21400            return None;
21401        }
21402        // c:Src/exec.c:3485-3489 — `shfunctab->getnode` is consulted before
21403        // `builtintab`: a shell function shadows every builtin of the same
21404        // name. The zshrs-only builtins dispatched by name below (znative,
21405        // ztest_*, the daemon z* family) are builtins too, so they sit behind
21406        // the same function check as the builtintab arm further down.
21407        let has_user_fn = user_fn_shadows_builtin(name);
21408        match name {
21409            "zmv" if native_autoload => {
21410                return Some(crate::extensions::ext_builtins::zmv(&args, "mv"));
21411            }
21412            "zcp" if native_autoload => {
21413                return Some(crate::extensions::ext_builtins::zmv(&args, "cp"));
21414            }
21415            "zln" if native_autoload => {
21416                return Some(crate::extensions::ext_builtins::zmv(&args, "ln"));
21417            }
21418            "zcalc" if native_autoload => {
21419                return Some(crate::extensions::ext_builtins::zcalc(&args));
21420            }
21421            // znative — the plugin package manager (src/extensions/pkg/). Installs
21422            // + loads zsh script and native (Rust cdylib) plugins from a global
21423            // content-addressed store. `znative add owner/repo`, `znative load`, ...
21424            "znative" if !has_user_fn => {
21425                return Some(crate::extensions::pkg::builtin::znative(&args));
21426            }
21427            // ztest framework (src/extensions/ztest.rs — port of
21428            // ../strykelang's unit-test framework). All zassert_*/
21429            // ztest_* names route through the single try_dispatch
21430            // helper so adding/removing assertions only touches
21431            // ztest.rs.
21432            n if !has_user_fn && crate::extensions::ztest::try_dispatch_known(n) => {
21433                let status = with_executor(|exec| {
21434                    crate::extensions::ztest::try_dispatch(exec, n, &args).unwrap_or(1)
21435                });
21436                return Some(status);
21437            }
21438            // Daemon-managed z* builtins — thin IPC wrappers. Short-circuit BEFORE
21439            // the function-lookup path so a missing daemon doesn't fall through to
21440            // "command not found". The name list is owned by the daemon crate
21441            // (zshrs_daemon::builtins::ZSHRS_BUILTIN_NAMES); routing through
21442            // try_dispatch keeps this site zero-touch as new z* builtins land.
21443            n if !has_user_fn && crate::daemon::builtins::is_zshrs_builtin(n) => {
21444                return run_daemon_builtin(n, &args);
21445            }
21446            _ => {}
21447        }
21448
21449        // c:Src/exec.c:3050-3068 — module-provided builtins (registered
21450        // via each module's `bintab` and folded into the canonical
21451        // `builtintab` by `createbuiltintable`) must dispatch BEFORE
21452        // PATH lookup. fusevm's `shell_builtins::builtin_id` doesn't
21453        // know about per-module entries like `log`
21454        // (Src/Modules/watch.c:693) — they reach call_function as
21455        // CallFunction ops. Consult the merged builtintab here so
21456        // `log` runs the canonical `bin_log` instead of falling
21457        // through to `/usr/bin/log` on macOS. Bug #72 in docs/BUGS.md.
21458        //
21459        // User-defined functions still take precedence over builtins
21460        // (zsh's `alias → function → builtin → external` resolution
21461        // order, c:Src/exec.c:3038-3068). Check `functions_compiled`
21462        // first so a user `log() { ... }` shadows the module bin_log.
21463        // c:Src/exec.c — shfunctab->getnode (the DISABLED-filtering
21464        // accessor) returns NULL for entries flipped to DISABLED via
21465        // `disable -f NAME`. functions_compiled holds the body
21466        // independently of the DISABLED flag, so check shfunctab first
21467        // and mask the lookup when the entry is disabled (see
21468        // `user_fn_shadows_builtin`; `has_user_fn` computed above). Bug #221
21469        // in docs/BUGS.md.
21470        if !has_user_fn {
21471            // c:Src/exec.c:3056 — `builtintab->getnode(builtintab,
21472            // cmdarg)` returns NULL for DISABLED entries, falling
21473            // execcmd through to PATH lookup. Mirror by gating the
21474            // bn_in_tab match on the BUILTINS_DISABLED set. Bug #106
21475            // in docs/BUGS.md.
21476            let disabled = crate::ported::builtin::BUILTINS_DISABLED
21477                .lock()
21478                .map(|s| s.contains(name))
21479                .unwrap_or(false);
21480            let bn_in_tab =
21481                !disabled && crate::ported::builtin::createbuiltintable().contains_key(name);
21482            if bn_in_tab {
21483                // c:Src/exec.c:4287 — `lastval = execbuiltin(args, assigns,
21484                // (Builtin) hn);`. The store happens BEFORE any errflag
21485                // handling, so a builtin that BOTH raises errflag (zerr) and
21486                // returns non-zero still publishes its status. zshrs relied on
21487                // the VM's trailing `SetStatus` op to publish it, and that op
21488                // is skipped once the builtin set ERRFLAG_ERROR — so
21489                // `() { private SECONDS }` (makeprivate's zerrnam + return 1)
21490                // reported 0 where zsh reports 1 (V10private.ztst:22).
21491                let __ret = dispatch_builtin_raw(name, args);
21492                // c:4289-4293 — `if (!(errflag & ERRFLAG_INT)) lastval = ret;`
21493                // (see `dispatch_builtin`).
21494                let __st = if (crate::ported::utils::errflag
21495                    .load(std::sync::atomic::Ordering::Relaxed)
21496                    & crate::ported::zsh_h::ERRFLAG_INT)
21497                    != 0
21498                {
21499                    crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed)
21500                } else {
21501                    __ret
21502                };
21503                crate::ported::builtin::LASTVAL.store(__st, std::sync::atomic::Ordering::Relaxed); // c:4287
21504                with_executor(|exec| exec.set_last_status(__st)); // c:4287
21505                return Some(__st);
21506            }
21507            // zshrs-original opcode builtins (async, doctor, peach, …) are not
21508            // in builtintab, so a run-time-resolved name (`$var`) never reaches
21509            // them. Dispatch by name here — after ported builtins, before
21510            // external — matching the shell's function -> builtin -> external
21511            // order (`has_user_fn` was checked above, so functions still win).
21512            if let Some(status) = try_run_registered_builtin(name, &args) {
21513                return Some(status);
21514            }
21515        }
21516
21517        // c:Src/lex.c:1928 — an alias is replaced by its text while the line is
21518        // LEXED; nothing consults the alias table once the command runs. The
21519        // dispatch-time rewrite that used to sit here (gated on INTERACTIVE)
21520        // expanded the already-expanded command name a second time:
21521        // `alias ls='ls -G'; ls x` ran `ls -G -G x`, and `\ls` picked it up.
21522
21523        // $_ pre-body bump and pending-underscore tracking are
21524        // ZshrsHost-only concerns (prompt rendering). Apply BEFORE
21525        // delegating to dispatch_function_call so the body sees the
21526        // bumped value.
21527        //
21528        // c:Src/exec.c:3491 — `setunderscore((args && nonempty(args))
21529        // ? ((char *) getdata(lastnode(args))) : "")`. C sets $_ to
21530        // the LAST node of the WHOLE args list (which includes argv[0]
21531        // == the function name). So for a no-arg `f`, $_ becomes "f"
21532        // inside the function body. The Rust port at the CallFunction
21533        // op-handler receives `args` WITHOUT the command name
21534        // (compile_zsh.rs:1571 only pushes simple.words[1..]). The
21535        // last() fallback `|| fn_name.clone()` already covers the
21536        // no-arg case, but `exec.set_scalar("_", ...)` writes paramtab
21537        // — the canonical `$_` read goes through `underscoregetfn`
21538        // (params.rs:7836) which reads the `zunderscore` Mutex.
21539        // setsparam("_") doesn't update that mutex, so the body's
21540        // `${_}` returned empty. Bug #279 in docs/BUGS.md. Mirror the
21541        // C `setunderscore` by writing via `set_zunderscore` directly.
21542        let fn_name = name.to_string();
21543        // c:Src/exec.c:5487-5488 — an ANONYMOUS function (execfuncdef, no names)
21544        // sets `$_` from its own arguments only, `""` when it has none; the
21545        // name pushed in front (c:5493) is not one of them.
21546        let anon_fn = fn_name.starts_with("_zshrs_anon_");
21547        let no_arg_underscore = if anon_fn { String::new() } else { fn_name.clone() };
21548        {
21549            let dollar_underscore = args.last().cloned().unwrap_or_else(|| no_arg_underscore.clone());
21550            // c:3546 — zunderscore is the only store; the paramtab write
21551            // that used to accompany this cleared PM_UNSET (see pop_args).
21552            crate::ported::params::set_zunderscore(std::slice::from_ref(&dollar_underscore));
21553        }
21554
21555        // Delegate the actual function dispatch to the canonical
21556        // `dispatch_function_call` (which itself wraps the canonical
21557        // `doshfunc` port from `Src/exec.c:5823`). Single doshfunc
21558        // call-site keeps scope-mgmt invariants in one place.
21559        // c:Src/exec.c:772-776 — a function runs unchanged under `-`; only a
21560        // name that is not a function falls through to the external spawn,
21561        // which still needs the carrier.
21562        let exec_dash = take_exec_carrier();
21563        let job_text = JOB_TEXT.with(|t| t.borrow_mut().take());
21564        PREFORK_CUT.with(|c| c.set(false)); // the words are complete
21565        // c:Src/exec.c:3674-3680 — a shell function runs in the current shell.
21566        if with_executor(|exec| exec.function_exists(&fn_name)) {
21567            crate::exec_jobs::mark_thisjob_cursh(false);
21568        }
21569        let status = with_executor(|exec| exec.dispatch_function_call(&fn_name, &args));
21570        if status.is_none() {
21571            EXEC_DASH.with(|c| c.set(exec_dash));
21572            JOB_TEXT.with(|t| *t.borrow_mut() = job_text);
21573        } else {
21574            // c:Src/exec.c:5643-5644 execshfunc — `if (!list_pipe)
21575            // deletefilelist(last_file_list, 0);` once doshfunc returns: the
21576            // `=(cmd)` temp files and `>(cmd)` fds made for the call's
21577            // arguments go with it, also when the body ran `exit` (that only
21578            // sets exit_pending, the function still returns here first).
21579            close_pending_psub_fds();
21580        }
21581
21582        // Anonymous functions (`() { … } args`, compiled by
21583        // parse_anon_funcdef as `_zshrs_anon_N` / `_zshrs_anon_kw_N`)
21584        // execute exactly ONCE and must not persist. zsh runs the body and
21585        // frees the function, so `${functions}` / `typeset -f` never show
21586        // it. Remove every trace right after the single invocation —
21587        // AFTER `status` is captured, so the body's exit code is preserved
21588        // ($? — calling `unfunction` here would reset it to 0 instead).
21589        // Without this, real plugins that use `() { … }` (fzf-tab, zinit,
21590        // p10k, …) leaked dozens of `_zshrs_anon_N` into `$functions`,
21591        // diverging from zsh's function table on every such config.
21592        if fn_name.starts_with("_zshrs_anon_") {
21593            // `${functions}` / `typeset -f` enumerate the canonical
21594            // `shfunctab` (via scanpmfunctions); the bytecode call path
21595            // also keeps the body in the executor's compiled-fn maps. Clear
21596            // BOTH so no trace of the one-shot anon survives.
21597            crate::ported::hashtable::removeshfuncnode(&fn_name);
21598            with_executor(|exec| {
21599                exec.functions_compiled.remove(&fn_name);
21600                exec.function_source.remove(&fn_name);
21601                exec.function_line_base.remove(&fn_name);
21602                exec.function_def_file.remove(&fn_name);
21603            });
21604        }
21605
21606        // c:Src/exec.c:6207-6265 — doshfunc saves `ou = zunderscore`
21607        // around the body and runs `setunderscore(ou)` (c:6257) on the way
21608        // out, so a function call leaves `$_` at the CALL's last argument
21609        // rather than at whatever the body's last command set. The value
21610        // saved there is the one execcmd_exec installed just before the
21611        // call (c:3546), i.e. exactly `args.last()`.
21612        {
21613            let last_call_arg = args.last().cloned().unwrap_or(no_arg_underscore);
21614            crate::ported::params::set_zunderscore(std::slice::from_ref(&last_call_arg));
21615            // c:6257
21616        }
21617
21618        status
21619    }
21620}
21621
21622// ───────────────────────────────────────────────────────────────────────────
21623/// Render a failed-redirect open error the way C's `zerrmsg` `%e` format
21624/// code does (Src/utils.c): `strerror(errno)` with the first character
21625/// lowercased, except `EIO` (kept capitalized) and `EINTR` (→ "interrupt").
21626/// C's redirect open failures call `zwarn("%e: %s", errno, fname)`
21627/// (Src/exec.c:3741); zshrs's `zwarning` takes a pre-built string, so the
21628/// `%e` part is built here. Replaces the prior hardcoded `ErrorKind` match
21629/// that fell back to a generic "redirect failed" for `EROFS`/`EACCES`/etc.
21630/// Port of `IS_WRITE_FILE` (zsh.h:403, `REDIR_WRITE <= X <= REDIR_READWRITE`)
21631/// over the fusevm redirect op bytes compile_zsh lowers those types to:
21632/// `>`/`>|` (WRITE/CLOBBER), `>>`/`>>|` (APPEND), `>&`/`&>` and their append
21633/// forms (WRITE_BOTH/APPEND_BOTH, compile_zsh.rs REDIR_ERRWRITE..), `<>`.
21634fn redir_is_write_file(op_byte: u8) -> bool {
21635    matches!(
21636        op_byte,
21637        r::WRITE | r::APPEND | r::CLOBBER | r::READ_WRITE | r::WRITE_BOTH | r::APPEND_BOTH
21638    )
21639}
21640
21641fn redir_errno_msg(err: &std::io::Error) -> String {
21642    let errno = match err.raw_os_error() {
21643        Some(n) if n != 0 => n,
21644        _ => return "redirect failed".to_string(),
21645    };
21646    if errno == libc::EINTR {
21647        return "interrupt".to_string(); // c:zerrmsg %e — EINTR special-case
21648    }
21649    let cptr = unsafe { libc::strerror(errno) };
21650    if cptr.is_null() {
21651        return "redirect failed".to_string();
21652    }
21653    let msg = unsafe { std::ffi::CStr::from_ptr(cptr) }.to_string_lossy();
21654    if errno == libc::EIO {
21655        return msg.into_owned(); // c:zerrmsg %e — EIO keeps capitalization
21656    }
21657    // c:zerrmsg %e — `fputc(tulower(errmsg[0])); fputs(errmsg + 1)`.
21658    let mut chars = msg.chars();
21659    match chars.next() {
21660        Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
21661        None => "redirect failed".to_string(),
21662    }
21663}
21664
21665// Host-routed shell ops: ShellExecutor methods invoked by ZshrsHost from the
21666// fusevm VM. Not a port of Src/exec.c (see file-level docs above) — they're
21667// the bridge between fusevm opcodes and ShellExecutor state.
21668// ───────────────────────────────────────────────────────────────────────────
21669impl ShellExecutor {
21670    // ─── Host-routed shell ops (called by ZshrsHost from fusevm) ────────────
21671
21672    /// Apply a single redirection. The current scope's saved-fd vec gets a
21673    /// dup of the original fd so it can be restored by `host_redirect_scope_end`.
21674    /// `op_byte` matches `fusevm::op::redirect_op::*`.
21675    /// Park the CURRENT contents of `fd` in the enclosing redirect
21676    /// scope so `host_redirect_scope_end` can restore them.
21677    ///
21678    /// c:Src/exec.c:2421-2443 — `addfd`'s "starting a new multio" arm:
21679    ///
21680    /// ```c
21681    /// if (!forked && save[fd1] == -2) {
21682    ///     if (fd1 == fd2) save[fd1] = -1;
21683    ///     else {
21684    ///         int fdN = movefd(fd1);
21685    ///         /* fd1 may already be closed here, so
21686    ///          * ignore bad file descriptor error */
21687    ///         if (fdN < 0) { if (errno != EBADF) { … } }
21688    ///         …
21689    ///         save[fd1] = fdN;
21690    ///     }
21691    /// }
21692    /// ```
21693    ///
21694    /// A `save[]` slot of -1 therefore means "fd1 was CLOSED before
21695    /// this redirection", and `fixfds` (c:4522-4532) feeds it to
21696    /// `redup(-1, i)`, whose first arm is `zclose(y)` (c:Src/utils.c:
21697    /// 2047-2048). So zsh CLOSES the fd again at teardown rather than
21698    /// leaving the redirection behind — `print x 3<file; cat <&3`
21699    /// reports "3: bad file descriptor" in zsh.
21700    ///
21701    /// c:Src/exec.c:3978-3986 — a bare `exec` (nullexec==1)
21702    /// "specifically *doesn't* restore the original fd's", so nothing
21703    /// is parked for it.
21704    pub fn save_fd_for_scope(&mut self, fd: i32) {
21705        if self.exec_redirs_permanent {
21706            // Permanent — except inside an in-process subshell, whose
21707            // fd table C forks away (see SubshFdFrame).
21708            crate::ported::exec::SubshFdFrame::touch(fd);
21709            return;
21710        }
21711        // c:2478 — `if (!forked && save[fd1] == -2)`.
21712        if redir_scope_forked(self.redirect_scope_stack.len()) {
21713            return;
21714        }
21715        // c:2425 `movefd(fd1)` — zshrs keeps the original fd open and
21716        // dups it aside instead (the caller's dup2 overwrites it), so
21717        // F_DUPFD stands in for movefd's dup-then-close.
21718        let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
21719        // c:2422-2423 / c:2430-2436 — a closed fd1 parks -1, not a dup.
21720        let slot = if saved >= 0 { saved } else { -1 };
21721        if let Some(top) = self.redirect_scope_stack.last_mut() {
21722            top.push((fd, slot));
21723            mark_saved_fd(slot);
21724        } else if saved >= 0 {
21725            // No scope — leave saved fd open and let the next scope
21726            // reclaim it. (Caller without a scope leaks the dup; this
21727            // matches `WithRedirects` parser construction always wrapping.)
21728            unsafe { libc::close(saved) };
21729        }
21730    }
21731
21732    /// Apply a file-open result to a redirect fd; on error, emit
21733    /// zsh-format diagnostic, set redirect_failed, sink fd to /dev/null.
21734    /// Shared between WRITE/APPEND/READ/CLOBBER arms in
21735    /// host_apply_redirect to keep the error-handling identical.
21736    fn redir_open_or_fail(
21737        fd: i32,
21738        result: std::io::Result<fs::File>,
21739        target: &str,
21740        redirect_failed: &mut bool,
21741    ) -> bool {
21742        match result {
21743            Ok(file) => {
21744                let new_fd = file.into_raw_fd();
21745                unsafe {
21746                    // When the target fd was already closed (e.g. `exec 0<&-;
21747                    // cmd < file`), open() returns the lowest free fd, which is
21748                    // `fd` itself. Then `dup2(fd, fd)` is a no-op: closing new_fd
21749                    // would CLOSE the fd we just opened, AND — since Rust's
21750                    // File::open sets O_CLOEXEC and a no-op dup2 does NOT clear
21751                    // it — an exec'd child would lose the descriptor. So in the
21752                    // reuse case, keep the fd and clear its close-on-exec flag;
21753                    // otherwise dup2 (which clears cloexec on the copy) + close.
21754                    if new_fd != fd {
21755                        libc::dup2(new_fd, fd);
21756                        libc::close(new_fd);
21757                    } else {
21758                        libc::fcntl(fd, libc::F_SETFD, 0);
21759                    }
21760                }
21761                true
21762            }
21763            Err(e) => {
21764                // c:Src/exec.c:3741 — zwarn("%e: %s", errno, fname) with the
21765                // real lineno prefix; redir_errno_msg builds the `%e` errno
21766                // message for all errnos (not just the few hardcoded before).
21767                let msg = redir_errno_msg(&e);
21768                crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
21769                *redirect_failed = true;
21770                // The /dev/null sink keeps a failed scoped redirect
21771                // from leaking the aborted command's output to the
21772                // wrong fd until scope-end restores it. For a bare
21773                // `exec` redirect (permanent, no scope restore) C
21774                // leaves the fd UNTOUCHED — execerr() aborts the
21775                // statement and the original fd 1 keeps flowing
21776                // (A04redirect: `exec >./nonexistent/x` then `echo
21777                // output` still prints). c:Src/exec.c:3735-3742.
21778                let permanent = with_executor(|exec| exec.exec_redirs_permanent);
21779                if !permanent {
21780                    if let Ok(devnull) = fs::OpenOptions::new()
21781                        .read(true)
21782                        .write(true)
21783                        .open("/dev/null")
21784                    {
21785                        let new_fd = devnull.into_raw_fd();
21786                        unsafe {
21787                            if new_fd != fd {
21788                                libc::dup2(new_fd, fd);
21789                                libc::close(new_fd);
21790                            } else {
21791                                libc::fcntl(fd, libc::F_SETFD, 0);
21792                            }
21793                        }
21794                    }
21795                }
21796                false
21797            }
21798        }
21799    }
21800    /// REDIR_ERRWRITE / REDIR_ERRAPP (`>&word`, `>>&word`, `&>`, `&>>`):
21801    /// one file onto BOTH fd 1 and fd 2. c:Src/exec.c:3985-3999 — the open
21802    /// failing is `zwarn("%e: %s", errno, fn->name); execerr();`, i.e. the
21803    /// diagnostic, status 1 and the command abandoned; an empty name fails
21804    /// with ENOENT (`>&''` → `no such file or directory: `).
21805    fn redir_errwrite(&mut self, result: std::io::Result<fs::File>, target: &str) {
21806        match result {
21807            Ok(file) => {
21808                let new_fd = file.into_raw_fd();
21809                unsafe {
21810                    libc::dup2(new_fd, 1);
21811                    libc::dup2(new_fd, 2);
21812                    libc::close(new_fd);
21813                }
21814            }
21815            Err(e) => {
21816                crate::ported::utils::zwarn(&format!("{}: {}", redir_errno_msg(&e), target));
21817                self.set_last_status(1);
21818                self.redirect_failed = true;
21819            }
21820        }
21821    }
21822    /// `host_apply_redirect` — see implementation.
21823    pub fn host_apply_redirect(&mut self, fd: u8, op_byte: u8, target: &str) {
21824        if redir_target_expansion_failed(self) {
21825            return;
21826        }
21827        // c:Src/exec.c:3775-3777 — in a pipeline stage whose input is the
21828        // pipe, mfds[0] already holds it, so `<file` / `<&N` becomes the
21829        // multio's second member (c:2447-2480) instead of replacing it.
21830        if fd == 0
21831            && matches!(op_byte, r::READ | r::DUP_READ)
21832            && target.trim_start_matches('&') != "-"
21833        {
21834            if let Some(seed) = take_pipe_input_seed(self, 0) {
21835                self.host_apply_redirect(0, op_byte, target);
21836                let member = if self.redirect_failed {
21837                    -1
21838                } else {
21839                    unsafe { libc::dup(0) }
21840                };
21841                if member < 0 {
21842                    let _ = crate::ported::utils::zclose(seed);
21843                    return;
21844                }
21845                let member = crate::ported::utils::movefd(member); // c:2465
21846                multios_read_concat(self, 0, vec![seed, member]);
21847                return;
21848            }
21849        }
21850        // `&>` / `&>>` always target both fd 1 and fd 2 regardless of the
21851        // fd byte the parser supplied (the lexer's tokfd clamp makes the
21852        // raw value unreliable for these forms).
21853        let fd: i32 = if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
21854            1
21855        } else {
21856            fd as i32
21857        };
21858        // c:Src/exec.c — for DUP_READ / DUP_WRITE forms (<&N / >&N),
21859        // validate the source fd is open BEFORE the save-and-dup
21860        // dance below. The save's `dup(fd)` reclaims the lowest free
21861        // fd, which on closed-fd reuse would let dup2(src=N, …)
21862        // succeed against the freshly-claimed slot — masking the
21863        // user's "bad file descriptor" error. Check src_fd first.
21864        if matches!(op_byte, r::DUP_READ | r::DUP_WRITE) {
21865            let n_check = target.trim_start_matches('&');
21866            if n_check != "-" {
21867                if let Ok(src_fd) = n_check.parse::<i32>() {
21868                    // c:Src/exec.c:3884-3897 — a descriptor above 9 that
21869                    // the shell knows about is NOT the script's to
21870                    // duplicate:
21871                    //
21872                    //   else if (fn->fd2 > 9 &&
21873                    //            (fn->fd2 <= max_zsh_fd &&
21874                    //             ((fdtable[fn->fd2] != FDT_UNUSED &&
21875                    //               fdtable[fn->fd2] != FDT_EXTERNAL) ||
21876                    //              fn->fd2 == coprocin ||
21877                    //              fn->fd2 == coprocout))) {
21878                    //       fil = -1;
21879                    //       errno = EBADF;
21880                    //
21881                    // `FDT_EXTERNAL` is exempt because that is a
21882                    // descriptor the script itself asked for (`{v}>file`,
21883                    // c:2409) and 0/1/2 (c:Src/init.c:1900). Anything
21884                    // past `max_zsh_fd` is left alone on purpose —
21885                    // c:3886-3891: "the shell doesn't know about it. Just
21886                    // assume the user knows what they're doing."
21887                    //
21888                    // Only the open-ness of the descriptor was checked
21889                    // here, so `>&11` happily duplicated the shell's own
21890                    // history database and `>&10` its log. The `exec
21891                    // N>&-` half of this pair was already ported
21892                    // (fusevm_bridge.rs:7047, c:3830-3835); this half was
21893                    // not, and the ported copy in `ported/exec.rs:11466`
21894                    // is not on the VM's redirection path.
21895                    let shell_owned = src_fd > 9 && {
21896                        let max_fd =
21897                            crate::ported::utils::MAX_ZSH_FD.load(std::sync::atomic::Ordering::Relaxed);
21898                        let cin = crate::ported::modules::clone::coprocin
21899                            .load(std::sync::atomic::Ordering::Relaxed);
21900                        let cout = crate::ported::modules::clone::coprocout
21901                            .load(std::sync::atomic::Ordering::Relaxed);
21902                        src_fd <= max_fd && {
21903                            let kind = crate::ported::utils::fdtable_get(src_fd)
21904                                & crate::ported::zsh_h::FDT_TYPE_MASK;
21905                            (kind != crate::ported::zsh_h::FDT_UNUSED
21906                                && kind != crate::ported::zsh_h::FDT_EXTERNAL)
21907                                || src_fd == cin
21908                                || src_fd == cout
21909                        }
21910                    };
21911                    if unsafe { libc::fcntl(src_fd, libc::F_GETFD) } == -1 || shell_owned {
21912                        // c:Src/exec.c — zwarn with real lineno prefix.
21913                        crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src_fd));
21914                        self.set_last_status(1);
21915                        self.redirect_failed = true;
21916                        return;
21917                    }
21918                }
21919            }
21920        }
21921        // c:Src/exec.c:3978-3986 — bare `exec` redirects (nullexec==1)
21922        // skip the save entirely: "we specifically *don't* restore the
21923        // original fd's". C's save[] is per-execcmd, so exec's redirs
21924        // never enter an enclosing group's save list either; pushing
21925        // into `redirect_scope_stack.last_mut()` here (the enclosing
21926        // group's scope) made `{ exec 1>&-; … } 2>/dev/null` restore
21927        // stdout at group end — diverging from zsh, which keeps fd 1
21928        // closed for the rest of the script. `save_fd_for_scope` makes
21929        // that call (it parks nothing for a bare `exec` outside an
21930        // in-process subshell).
21931        self.save_fd_for_scope(fd);
21932        // For `&>` / `&>>` also save fd 2 so the scope restores it after
21933        // the body. Otherwise stderr stays redirected past the command.
21934        if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
21935            self.save_fd_for_scope(2);
21936        }
21937        // c:Src/exec.c:3722-3724 + 2447-2480 — MULTIOS split when this
21938        // command's stdout IS the pipeline output. C registers the pipe
21939        // in mfds[1] (`addfd(forked, save, mfds, 1, output, 1, NULL)`)
21940        // BEFORE walking the explicit redirect list, so a write-side
21941        // redirect of fd 1 finds mfds[1] occupied and, with MULTIOS
21942        // set, "split[s] the stream": fd 1 becomes the write end of an
21943        // internal pipe whose reader tees every chunk to BOTH the
21944        // pipeline pipe and the new target. That is why
21945        // `{ echo a; echo b >&2; } 3>&1 1>&2 2>&3 3>&- | cat` sends
21946        // `a` to the pipe (via the tee) AND to stderr — plain dup2
21947        // replacement loses the pipe stream. The scope-depth gate
21948        // mirrors mfds being per-execcmd: only the redirect list
21949        // attached to the stage's own command joins the pipe; nested
21950        // commands inside the body (`{ echo a > f; } | cat`) get a
21951        // fresh "mfds" and replace as usual.
21952        if fd == 1
21953            && self
21954                .pipe_output_scope
21955                .is_some_and(|d| d + 1 == self.redirect_scope_stack.len())
21956            && crate::ported::options::opt_state_get("multios").unwrap_or(true)
21957        {
21958            // Resolve the new write target exactly as the plain arms
21959            // below would, but as a raw fd for the tee.
21960            let new_target_fd: i32 = match op_byte {
21961                r::DUP_WRITE => {
21962                    // Numeric `>&N` only; `-` (close) and `p` (coproc)
21963                    // fall through to the plain arms.
21964                    target
21965                        .trim_start_matches('&')
21966                        .parse::<i32>()
21967                        .map(|src| unsafe { libc::fcntl(src, libc::F_DUPFD_CLOEXEC, 10) })
21968                        .unwrap_or(-1)
21969                }
21970                r::WRITE | r::CLOBBER => fs::File::create(target)
21971                    .map(|f| f.into_raw_fd())
21972                    .unwrap_or(-1),
21973                r::APPEND => fs::OpenOptions::new()
21974                    .create(true)
21975                    .append(true)
21976                    .open(target)
21977                    .map(|f| f.into_raw_fd())
21978                    .unwrap_or(-1),
21979                _ => -1,
21980            };
21981            if new_target_fd >= 0 {
21982                let pipe_dup = unsafe { libc::fcntl(1, libc::F_DUPFD_CLOEXEC, 10) };
21983                match (pipe_dup >= 0).then(os_pipe::pipe) {
21984                    Some(Ok((read_end, write_end))) => {
21985                        // c:Src/exec.c:5222 / Src/utils.c:1990-2012 —
21986                        // `mpipe()` runs both pipe ends through
21987                        // `movefd()`, which lifts any fd below 10 out
21988                        // of the user-visible `>&N` range. Same abort
21989                        // hazard as the BUILTIN_MULTIOS_REDIRECT arm.
21990                        let read_end = unsafe {
21991                            <os_pipe::PipeReader as std::os::unix::io::FromRawFd>::from_raw_fd(
21992                                crate::extensions::fds::movefd(read_end.into_raw_fd()),
21993                            )
21994                        };
21995                        // Splitter: same read-loop shape as
21996                        // BUILTIN_MULTIOS_REDIRECT, with one ordering
21997                        // refinement. C's tee is a forked process
21998                        // (closemn → teeproc) whose wakeup latency lets
21999                        // the stage's DIRECT pipe writes land first —
22000                        // observed zsh output for `{ echo a; echo b >&2; }
22001                        // 3>&1 1>&2 2>&3 3>&- | cat` is `b` then `a`,
22002                        // 15/15 runs. A Rust thread wakes faster than
22003                        // the debug-build VM dispatches the next echo,
22004                        // inverting the order. Emulate the C timing
22005                        // observably: stream to the NEW target (file /
22006                        // stderr dup) immediately, but defer the
22007                        // pipe-bound copy until EOF (or a 64KB cap so a
22008                        // long-running stream still flows instead of
22009                        // growing memory unboundedly).
22010                        let write_now = |tfd: i32, data: &[u8]| {
22011                            let mut off = 0;
22012                            while off < data.len() {
22013                                let w = unsafe {
22014                                    libc::write(
22015                                        tfd,
22016                                        data[off..].as_ptr() as *const libc::c_void,
22017                                        data.len() - off,
22018                                    )
22019                                };
22020                                if w <= 0 {
22021                                    break;
22022                                }
22023                                off += w as usize;
22024                            }
22025                        };
22026                        let handle = std::thread::spawn(move || {
22027                            let mut rd = read_end;
22028                            let mut buf = [0u8; 8192];
22029                            let mut pipe_pending: Vec<u8> = Vec::new();
22030                            loop {
22031                                match std::io::Read::read(&mut rd, &mut buf) {
22032                                    Ok(0) | Err(_) => break,
22033                                    Ok(n) => {
22034                                        write_now(new_target_fd, &buf[..n]);
22035                                        pipe_pending.extend_from_slice(&buf[..n]);
22036                                        if pipe_pending.len() >= 65536 {
22037                                            write_now(pipe_dup, &pipe_pending);
22038                                            pipe_pending.clear();
22039                                        }
22040                                    }
22041                                }
22042                            }
22043                            write_now(pipe_dup, &pipe_pending);
22044                            unsafe {
22045                                libc::close(pipe_dup);
22046                                libc::close(new_target_fd);
22047                            }
22048                        });
22049                        let write_raw = AsRawFd::as_raw_fd(&write_end);
22050                        unsafe { libc::dup2(write_raw, 1) };
22051                        drop(write_end);
22052                        // A forked command's scope parked no copy of fd 1
22053                        // (save_fd_for_scope), so nothing would release this
22054                        // write end at scope end and the join below would
22055                        // wait forever; a -1 slot closes it there, as the
22056                        // child's exit closes it in C.
22057                        if redir_scope_forked(self.redirect_scope_stack.len()) {
22058                            if let Some(top) = self.redirect_scope_stack.last_mut() {
22059                                top.push((1, -1));
22060                            }
22061                        }
22062                        // Scope-end closes this dup (the last writer once
22063                        // the saved fd 1 is restored) → EOF → join.
22064                        let close_on_end = unsafe { libc::fcntl(1, libc::F_DUPFD_CLOEXEC, 10) };
22065                        if let Some(top) = self.multios_scope_stack.last_mut() {
22066                            top.push((close_on_end, handle));
22067                        } else {
22068                            unsafe { libc::close(close_on_end) };
22069                            let _ = handle.join();
22070                        }
22071                        return;
22072                    }
22073                    _ => unsafe {
22074                        // pipe()/dup failure — fall through to plain replace.
22075                        if pipe_dup >= 0 {
22076                            libc::close(pipe_dup);
22077                        }
22078                        libc::close(new_target_fd);
22079                    },
22080                }
22081            }
22082        }
22083        match op_byte {
22084            r::WRITE => {
22085                // Honor `setopt noclobber`: refuse to overwrite an
22086                // existing regular file unless `>!` / `>|` (CLOBBER).
22087                // zsh internally stores the inverted-name `clobber`
22088                // (default ON); `setopt noclobber` writes
22089                // `clobber=false`. Honor both keys.
22090                //
22091                // c:Src/exec.c:2241-2245 clobber_open recover path:
22092                // after O_EXCL fails, reopen and `if (!S_ISREG(...))
22093                // return fd;` — non-regular targets (char/block-
22094                // special, FIFO, socket) bypass the noclobber check.
22095                // Bug #30 in docs/BUGS.md: this bridge-side check did
22096                // a bare `Path::exists()` and treated `/dev/null` as
22097                // a protected file, breaking `setopt no_clobber; echo
22098                // hi > /dev/null` and every `2> /dev/null` idiom.
22099                // Add a regular-file stat gate that matches the C
22100                // semantic. The canonical clobber_open at
22101                // src/ported/exec.rs:2123 already handles this; the
22102                // bridge duplicates a stripped-down version here and
22103                // must mirror the same check.
22104                let noclobber = opt_state_get("noclobber").unwrap_or(false)
22105                    || !opt_state_get("clobber").unwrap_or(true);
22106                let target_meta = std::fs::metadata(target).ok();
22107                let target_is_regular_file = target_meta
22108                    .as_ref()
22109                    .map(|m| m.file_type().is_file())
22110                    .unwrap_or(false);
22111                // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY permits
22112                // re-using an EMPTY regular file under noclobber: `setopt
22113                // noclobber clobberempty; : >f; echo hi >f` overwrites f.
22114                // The inline bridge check ignored this and errored.
22115                let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
22116                    && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
22117                if noclobber && target_is_regular_file && !clobber_empty_ok {
22118                    eprintln!(
22119                        "{}:{}: file exists: {}",
22120                        shname(),
22121                        crate::ported::lex::lineno(),
22122                        target
22123                    );
22124                    self.set_last_status(1);
22125                    // c:Src/exec.c — set redirect_failed so the scope-end
22126                    // hook (`with_redirects_end` in this file) forces
22127                    // $? to 1 regardless of the still-running command's
22128                    // own exit. Without this the next command (e.g.
22129                    // `echo x` writing to /dev/null below) succeeds
22130                    // and overwrites the redirect-failure status,
22131                    // making noclobber unobservable from $?.
22132                    self.redirect_failed = true;
22133                    // Sink the upcoming command's stdout to /dev/null
22134                    // so we don't leak its output to the terminal.
22135                    // zsh skips the command entirely; we approximate by
22136                    // discarding the output (the redirect target was
22137                    // the user's chosen sink, but with noclobber the
22138                    // file is protected — discarding matches the
22139                    // user's intent better than printing to terminal).
22140                    if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
22141                        let new_fd = file.into_raw_fd();
22142                        unsafe {
22143                            libc::dup2(new_fd, fd);
22144                            libc::close(new_fd);
22145                        }
22146                    }
22147                    return;
22148                }
22149                if !Self::redir_open_or_fail(
22150                    fd,
22151                    fs::File::create(target),
22152                    target,
22153                    &mut self.redirect_failed,
22154                ) {
22155                    self.set_last_status(1);
22156                }
22157            }
22158            r::CLOBBER => {
22159                if !Self::redir_open_or_fail(
22160                    fd,
22161                    fs::File::create(target),
22162                    target,
22163                    &mut self.redirect_failed,
22164                ) {
22165                    self.set_last_status(1);
22166                }
22167            }
22168            r::APPEND => {
22169                // c:Src/exec.c:3924-3927 — `>>` honors NO_CLOBBER+!APPENDCREATE
22170                // by opening O_APPEND|O_WRONLY WITHOUT O_CREAT, so missing
22171                // files yield ENOENT. zsh source:
22172                //   if (!isset(CLOBBER) && !isset(APPENDCREATE) &&
22173                //       !IS_CLOBBER_REDIR(fn->type))
22174                //       mode = O_WRONLY|O_APPEND|O_NOCTTY;
22175                //   else mode = O_WRONLY|O_APPEND|O_CREAT|O_NOCTTY;
22176                // (IS_CLOBBER_REDIR — `>>!`/`>>|` — is currently flattened
22177                // to plain APPEND at compile time in
22178                // src/extensions/compile_zsh.rs:1654-1655, so the bang/pipe
22179                // forms can't be distinguished here yet.)
22180                let noclobber = opt_state_get("noclobber").unwrap_or(false)
22181                    || !opt_state_get("clobber").unwrap_or(true);
22182                let append_create = opt_state_get("appendcreate").unwrap_or(false)
22183                    || opt_state_get("append_create").unwrap_or(false);
22184                let open_result = if noclobber && !append_create {
22185                    fs::OpenOptions::new().append(true).open(target) // no create
22186                } else {
22187                    fs::OpenOptions::new()
22188                        .create(true)
22189                        .append(true)
22190                        .open(target)
22191                };
22192                if !Self::redir_open_or_fail(fd, open_result, target, &mut self.redirect_failed) {
22193                    self.set_last_status(1);
22194                }
22195            }
22196            r::READ => {
22197                if !Self::redir_open_or_fail(
22198                    fd,
22199                    fs::File::open(target),
22200                    target,
22201                    &mut self.redirect_failed,
22202                ) {
22203                    self.set_last_status(1);
22204                }
22205            }
22206            r::READ_WRITE => {
22207                if let Ok(file) = fs::OpenOptions::new()
22208                    .create(true)
22209                    .truncate(false) // <> opens existing-or-new without truncating
22210                    .read(true)
22211                    .write(true)
22212                    .open(target)
22213                {
22214                    let new_fd = file.into_raw_fd();
22215                    unsafe {
22216                        // See redir_open_or_fail: when the opened fd IS the
22217                        // destination (target fd was closed), keep it and clear
22218                        // O_CLOEXEC; else dup2 + close.
22219                        if new_fd != fd {
22220                            libc::dup2(new_fd, fd);
22221                            libc::close(new_fd);
22222                        } else {
22223                            libc::fcntl(fd, libc::F_SETFD, 0);
22224                        }
22225                    }
22226                }
22227            }
22228            r::DUP_READ | r::DUP_WRITE => {
22229                // Target is a numeric fd reference like `&3`. The parser
22230                // strips the `&` prefix before we get here in some paths,
22231                // others retain it — accept both. Also support `-` for
22232                // close-fd (`<&-` / `>&-`) per POSIX. The src_fd
22233                // validity check ran above before the save-and-dup.
22234                let n = target.trim_start_matches('&');
22235                if n == "-" {
22236                    unsafe { libc::close(fd) };
22237                } else if n == "p" {
22238                    // c:Src/exec.c — `<&p` / `>&p` route through the
22239                    // coprocin / coprocout globals. zsh's `coproc CMD`
22240                    // launch publishes those fds; the canonical
22241                    // bin_print / bin_read `-p` arms already consume
22242                    // them. The DUP redirect form is the third
22243                    // consumer: it must dup the coproc fd onto the
22244                    // target slot so the next command's stdin/stdout
22245                    // is wired to the running coprocess. Bug #388.
22246                    let coproc_fd = if op_byte == r::DUP_READ {
22247                        crate::ported::modules::clone::coprocin
22248                            .load(std::sync::atomic::Ordering::Relaxed)
22249                    } else {
22250                        crate::ported::modules::clone::coprocout
22251                            .load(std::sync::atomic::Ordering::Relaxed)
22252                    };
22253                    if coproc_fd < 0 {
22254                        eprintln!("{}:1: no coprocess", shname());
22255                        self.set_last_status(1);
22256                        self.redirect_failed = true;
22257                    } else {
22258                        unsafe {
22259                            libc::dup2(coproc_fd, fd);
22260                        }
22261                    }
22262                } else if let Ok(src_fd) = n.parse::<i32>() {
22263                    unsafe { libc::dup2(src_fd, fd) };
22264                } else if op_byte == r::DUP_WRITE {
22265                    // c:Src/glob.c:2184-2187 xpandredir — a MERGEOUT
22266                    // word that expands to a non-number becomes
22267                    // REDIR_ERRWRITE: `cmd >& word` opens `word` and
22268                    // routes BOTH fd 1 and fd 2 there. Reached only
22269                    // for dynamic words (`>&$var`); static filenames
22270                    // were converted at compile time.
22271                    self.redir_errwrite(fs::File::create(target), target);
22272                } else {
22273                    // c:Src/glob.c:2185 — MERGEIN non-number:
22274                    // `zerr("file number expected")`.
22275                    crate::ported::utils::zerr("file number expected");
22276                    self.set_last_status(1);
22277                    self.redirect_failed = true;
22278                }
22279            }
22280            r::WRITE_BOTH => {
22281                self.redir_errwrite(fs::File::create(target), target);
22282            }
22283            r::APPEND_BOTH => {
22284                self.redir_errwrite(
22285                    fs::OpenOptions::new().create(true).append(true).open(target),
22286                    target,
22287                );
22288            }
22289            _ => {}
22290        }
22291    }
22292
22293    /// Push a fresh redirect scope. `_count` is informational — the actual
22294    /// saved fds are appended by host_apply_redirect into the top scope.
22295    pub fn host_redirect_scope_begin(&mut self, _count: u8) {
22296        // c:Src/exec.c:3722-3724 — the pipeline child set
22297        // `pipe_output_pending` right after dup2'ing its stdout onto
22298        // the pipe; the FIRST redirect scope opened in that child is
22299        // the stage command's own redirect list (same execcmd as the
22300        // pipe's addfd into mfds[1]). Capture the depth so only THAT
22301        // list's fd-1 write redirects MULTIOS-join the pipe.
22302        if self.pipe_output_pending {
22303            self.pipe_output_pending = false;
22304            self.pipe_output_scope = Some(self.redirect_scope_stack.len());
22305        }
22306        let clean = (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
22307            & crate::ported::zsh_h::ERRFLAG_ERROR)
22308            == 0;
22309        let depth = self.redirect_scope_stack.len();
22310        REDIR_SCOPE_ERRFLAG_CLEAN.with(|v| {
22311            let mut v = v.borrow_mut();
22312            v.truncate(depth);
22313            v.push(clean);
22314        });
22315        self.redirect_scope_stack.push(Vec::new());
22316        self.multios_scope_stack.push(Vec::new());
22317        let serial = SUBLIST_SERIAL.with(|c| c.get());
22318        REDIR_SCOPE_OPENED.with(|c| c.set((serial, self.redirect_scope_stack.len())));
22319        // c:Src/exec.c:3775-3777 — only the stage command's own redirect
22320        // list (same sublist as the pipe install) sees the seeded mfds[0].
22321        if PIPE_INPUT_INSTALLED.with(|c| c.take()) == Some(serial) {
22322            PIPE_INPUT_SCOPE.with(|c| c.set(Some(self.redirect_scope_stack.len())));
22323        }
22324    }
22325
22326    /// Restore every redirect scope opened above `depth`.
22327    ///
22328    /// c:Src/exec.c:4364 — `fixfds(save)` runs on EVERY exit path out of
22329    /// `execcmd_exec`, the one an early `return` takes out of a compound
22330    /// command carrying redirections included (`while … done < f`,
22331    /// `{ …; return } < f`, `if …; then return; fi < f`). zshrs compiles
22332    /// `return` to a Jump past the body's `WithRedirectsEnd`, so that
22333    /// scope stayed on the stack and its saved fds never came back — the
22334    /// caller inherited the callee's redirected fd.
22335    ///
22336    /// gitstatus hit this: `gitstatus.plugin.zsh`'s daemon sources
22337    /// `gitstatus/install`, whose `_gitstatus_install_main` returns out of
22338    /// `while … done <"$gitstatus_dir"/install.info`. fd 0 stayed on
22339    /// install.info instead of reverting to the request FIFO, so
22340    /// `gitstatusd` read EOF on startup and exited ("EOF. Exiting."),
22341    /// `gitstatus_start` failed, `VCS_STATUS_REMOTE_URL` came back empty
22342    /// and powerlevel10k rendered the generic git icon in place of the
22343    /// per-forge one.
22344    pub fn unwind_redirect_scopes_to(&mut self, depth: usize) {
22345        while self.redirect_scope_stack.len() > depth {
22346            self.host_redirect_scope_end();
22347        }
22348    }
22349
22350    /// Pop the top redirect scope, restoring saved fds.
22351    pub fn host_redirect_scope_end(&mut self) {
22352        // c:Src/exec.c — restore saved fds FIRST so the multios
22353        // pipe-write end is released from `fd`, then close our
22354        // tracked close_on_end (the last surviving writer dup), then
22355        // join the splitter thread. If we closed close_on_end before
22356        // restoring saved, `fd` would still hold a pipe writer and
22357        // the thread would block forever waiting for EOF.
22358        if redir_scope_forked(self.redirect_scope_stack.len()) {
22359            FORKED_REDIR_SCOPE.with(|c| c.set(0));
22360        }
22361        if let Some(saved) = self.redirect_scope_stack.pop() {
22362            for (fd, saved_fd) in saved.into_iter().rev() {
22363                // c:Src/exec.c:4530 `redup(save[i], i)` →
22364                // c:Src/utils.c:2047-2048 `if (x < 0) zclose(y);`.
22365                // A -1 slot means the fd was CLOSED before the
22366                // redirection (c:2422-2423, c:2430-2436), so the
22367                // restore is a close, not a dup2 — otherwise the
22368                // redirection outlives the command it was written on
22369                // (`print x 3<file; cat <&3` printed the file instead
22370                // of "3: bad file descriptor").
22371                if saved_fd < 0 {
22372                    unsafe { libc::close(fd) };
22373                    continue;
22374                }
22375                unsafe {
22376                    libc::dup2(saved_fd, fd);
22377                }
22378                // c:Src/utils.c:2049 — redup's `zclose(x)`, which also
22379                // clears the fdtable mark mark_saved_fd set.
22380                crate::ported::utils::zclose(saved_fd);
22381            }
22382        }
22383        if let Some(scope) = self.multios_scope_stack.pop() {
22384            // Close ALL tracked writer dups BEFORE joining any
22385            // thread. When one splitter holds a dup of another's
22386            // pipe write-end (two multios in one scope where a later
22387            // one duped fd 1 while an earlier splitter owned it),
22388            // joining in push order deadlocks: splitter A's EOF
22389            // waits on splitter B's writer dup, which only closes
22390            // after B's thread exits — blocked behind A's join.
22391            let mut handles = Vec::with_capacity(scope.len());
22392            for (write_fd, handle) in scope {
22393                if write_fd >= 0 {
22394                    unsafe {
22395                        libc::close(write_fd);
22396                    }
22397                }
22398                handles.push(handle);
22399            }
22400            for handle in handles {
22401                let _ = handle.join();
22402            }
22403        }
22404        // The scope that captured the pipeline-output marker is gone;
22405        // deeper-nested future scopes must not re-match its depth.
22406        if self.pipe_output_scope == Some(self.redirect_scope_stack.len()) {
22407            self.pipe_output_scope = None;
22408        }
22409        if PIPE_INPUT_SCOPE.with(|c| c.get()).is_some_and(|d| d > self.redirect_scope_stack.len()) {
22410            PIPE_INPUT_SCOPE.with(|c| c.set(None));
22411        }
22412        // c:Src/exec.c:4445-4452 — the command is done: drop its xtrace copy.
22413        let depth = self.redirect_scope_stack.len();
22414        while let Some(fd) = XTRERR_COPIES.with(|v| {
22415            let mut v = v.borrow_mut();
22416            if v.last().is_some_and(|&(d, _)| d > depth) { v.pop().map(|(_, fd)| fd) } else { None }
22417        }) {
22418            xtrerr_flush(); // c:4448 fclose(newxtrerr)
22419            crate::ported::utils::xtrerr.store(libc::STDERR_FILENO, std::sync::atomic::Ordering::Relaxed); // c:4449
22420            let _ = crate::ported::utils::zclose(fd); // c:4451
22421        }
22422    }
22423
22424    /// Set up `content` as stdin (fd 0) for the next command.
22425    /// Used by `Op::HereDoc(idx)` and `Op::HereString`.
22426    ///
22427    /// c:Src/exec.c:4655 getherestr — C writes the body to a TEMP
22428    /// FILE (gettempfile → write_loop → close → reopen O_RDONLY →
22429    /// unlink), NOT a pipe. The previous pipe+writer-thread shape
22430    /// SIGPIPE'd the whole shell when the consumer never read the
22431    /// body (`: <<< ${(F)x/y}` — D04parameter chunk 211, flaky
22432    /// rc=141): the redirect-scope teardown closed the read end
22433    /// while the detached thread was still in write_all, and the
22434    /// shell's SIGPIPE disposition is SIG_DFL. A temp file has no
22435    /// reader/writer coupling — matching C exactly, including
22436    /// lseek-ability of fd 0, which pipes don't give.
22437    pub fn host_set_pending_stdin(&mut self, content: String) {
22438        // c:Src/exec.c:3775-3777 — a here-document or here-string on a
22439        // pipeline stage fed by a pipe joins the pipe as mfds[0]'s second
22440        // member (c:2447-2480): `cat o1 | cat <<<hs` reads o1 then hs.
22441        if let Some(seed) = take_pipe_input_seed(self, 0) {
22442            self.host_set_pending_stdin(content);
22443            let member = unsafe { libc::dup(0) };
22444            if member < 0 {
22445                let _ = crate::ported::utils::zclose(seed);
22446                return;
22447            }
22448            let member = crate::ported::utils::movefd(member); // c:2465
22449            multios_read_concat(self, 0, vec![seed, member]);
22450            return;
22451        }
22452        // c:4673 — `gettempfile(NULL, 1, &s)`.
22453        let mut tmp = std::env::temp_dir();
22454        tmp.push(format!(
22455            "zshrs-herestr-{}-{:x}",
22456            std::process::id(),
22457            std::time::SystemTime::now()
22458                .duration_since(std::time::UNIX_EPOCH)
22459                .map(|d| d.as_nanos())
22460                .unwrap_or(0)
22461        ));
22462        // c:Src/exec.c:4719 — `unmetafy(t, &len);` runs BEFORE
22463        // `write_loop(fd, t, len)`: what lands in the temp file is the RAW
22464        // byte stream, never zshrs's metafied `Meta` + `byte ^ 32` pairs.
22465        // Writing `content.as_bytes()` leaked the metafication onto fd 0,
22466        // so `read -d $'\xa0' <<<$'first\xa0second'` saw `c2 83 c2 80`
22467        // where every other writer (`print`, a pipe) emits the single `a0`.
22468        let raw = crate::ported::utils::unmetafy_str(&content); // c:4719
22469                                                                // c:4675 — `write_loop(fd, t, len); close(fd);`
22470        if std::fs::write(&tmp, &raw).is_err() {
22471            return; // c:4674 — tempfile failure → no redirect
22472        }
22473        // c:Src/utils.c gettempfile → mkstemp creates the temp file mode
22474        // 0600 IGNORING the umask, so the O_RDONLY reopen below always
22475        // succeeds. `std::fs::write` honors the umask, so under `umask
22476        // 0777` the file landed mode 0000 and the reopen failed with
22477        // EACCES — `cat <<<x` then read empty stdin. Force 0600 to match
22478        // mkstemp's umask-independent permissions.
22479        let _ = std::fs::set_permissions(
22480            &tmp,
22481            <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
22482        );
22483        // c:4677 — `fd = open(s, O_RDONLY | O_NOCTTY);`
22484        let file = match std::fs::File::open(&tmp) {
22485            Ok(f) => f,
22486            Err(_) => {
22487                let _ = std::fs::remove_file(&tmp);
22488                return;
22489            }
22490        };
22491        // c:4678 — `unlink(s);` — fd stays valid, name disappears.
22492        let _ = std::fs::remove_file(&tmp);
22493        // c:Src/exec.c:2478 — `if (!forked && save[fd1] == -2)`.
22494        let saved = if redir_scope_forked(self.redirect_scope_stack.len()) {
22495            -1
22496        } else {
22497            unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD_CLOEXEC, 10) }
22498        };
22499        if saved >= 0 {
22500            if let Some(top) = self.redirect_scope_stack.last_mut() {
22501                top.push((libc::STDIN_FILENO, saved));
22502                mark_saved_fd(saved);
22503            } else {
22504                unsafe { libc::close(saved) };
22505            }
22506        }
22507        // c:Src/utils.c:redup — `if (x != y) { dup2(x, y); zclose(x); }`.
22508        // When fd 0 was already CLOSED before this heredoc runs,
22509        // `File::open` returns the lowest free descriptor, which is 0
22510        // itself — so `read_fd == STDIN_FILENO`. C's redup skips both the
22511        // dup2 (a no-op for equal fds) AND the close in that case, leaving
22512        // the just-opened temp file installed at fd 0. Unconditionally
22513        // dropping the File here closed that fd back to nothing, so an
22514        // external NULLCMD (`cat`) inherited a closed fd 0 and failed with
22515        // EBADF (`cat <<EOF` inside `$(...)` when exec 0<&- closed stdin).
22516        let read_fd = AsRawFd::as_raw_fd(&file);
22517        if read_fd != libc::STDIN_FILENO {
22518            // dup2 installs a fresh fd 0 with FD_CLOEXEC clear (dup2 never
22519            // copies the flag), then we close the CLOEXEC-tagged source.
22520            unsafe { libc::dup2(read_fd, libc::STDIN_FILENO) };
22521            drop(file); // c:redup zclose(x)
22522        } else {
22523            // File::open reused fd 0. Rust opens with O_CLOEXEC, so fd 0
22524            // now carries FD_CLOEXEC and would be auto-closed when an
22525            // external NULLCMD (`cat`) exec's — the child then reads a
22526            // closed fd 0 and fails with EBADF. zsh opens the heredoc temp
22527            // via `open(s, O_RDONLY|O_NOCTTY)` (no CLOEXEC), so its child
22528            // inherits the fd. Clear the flag to match, then keep fd 0 open
22529            // (redup's x==y arm: no dup2, no close).
22530            unsafe {
22531                let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFD);
22532                if flags >= 0 {
22533                    libc::fcntl(libc::STDIN_FILENO, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
22534                }
22535            }
22536            std::mem::forget(file);
22537        }
22538    }
22539
22540    /// Spawn an external command using zshrs's full dispatch logic
22541    /// (intercepts, command_hash, redirect handling). Used by
22542    /// `ZshrsHost::exec` so the bytecode VM's `Op::Exec` and
22543    /// `Op::CallFunction` external fallback get the same semantics as
22544    /// the tree-walker's `execute_external` rather than a plain
22545    /// `Command::new` shortcut. Returns the exit status.
22546    pub fn host_exec_external(&mut self, args: &[String]) -> i32 {
22547        // c:Src/exec.c:772-776 — BINF_DASH reaches `execute()` only; every
22548        // other route below runs the command without it.
22549        let exec_dash = take_exec_carrier();
22550        PREFORK_CUT.with(|c| c.set(false)); // the words are complete
22551        // Native p10k API: the `p10k(){ zshrs-p10k-api "$@" }` stub's
22552        // body lands here (the name is neither function nor builtin).
22553        // Route into the engine instead of a PATH miss.
22554        if let Some(name) = args.first() {
22555            if let Some(status) = crate::p10k::maybe_intercept_command(name, &args[1..]) {
22556                self.set_last_status(status);
22557                return status;
22558            }
22559        }
22560        // If a glob expansion in this command's argv triggered the
22561        // nomatch error path, suppress the actual exec and return
22562        // status 1 — mirrors zsh's command-aborted-on-glob-error
22563        // behaviour. The flag is reset BEFORE returning so the next
22564        // command starts clean.
22565        //
22566        // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH sets
22567        // ERRFLAG_ERROR but C's execlist clears the bit per-sublist
22568        // so subsequent commands run. Symmetric with the builtin
22569        // dispatcher's clear at fusevm_bridge.rs:299 — clear it here
22570        // too at the external-command post-command-boundary.
22571        consume_tilde_globsubst_carrier();
22572        if self.current_command_glob_failed.get() {
22573            self.current_command_glob_failed.set(false);
22574            crate::ported::utils::errflag.fetch_and(
22575                !crate::ported::zsh_h::ERRFLAG_ERROR,
22576                std::sync::atomic::Ordering::Relaxed,
22577            );
22578            self.set_last_status(1);
22579            return 1;
22580        }
22581        // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the
22582        // NOMATCH gate above, same external-path semantics (skip
22583        // command, `no match`, clear ERRFLAG so the next sublist
22584        // runs).
22585        if consume_badcshglob() {
22586            crate::ported::utils::errflag.fetch_and(
22587                !crate::ported::zsh_h::ERRFLAG_ERROR,
22588                std::sync::atomic::Ordering::Relaxed,
22589            );
22590            self.set_last_status(1);
22591            return 1;
22592        }
22593        let Some((cmd, rest)) = args.split_first() else {
22594            return 0;
22595        };
22596        // Empty command name (e.g. result of an empty `$(false)`
22597        // command-sub being the only word) — zsh: no command runs,
22598        // exit status preserved from prior step. Was hitting the
22599        // "command not found: " path with empty name.
22600        if cmd.is_empty() && rest.is_empty() {
22601            return self.last_status();
22602        }
22603        let rest_vec: Vec<String> = rest.to_vec();
22604        // Update `$_` with the just-arriving argv so the next command
22605        // reads `_=<last_arg>`. Mirrors C zsh's writeback in
22606        // `execcmd_exec` (Src/exec.c). Per `args.last()` semantics,
22607        // when invoked as `cmd a b c`, `$_` becomes "c" — for a bare
22608        // command with no args, `$_` becomes the command name itself.
22609        crate::ported::params::set_zunderscore(args);
22610
22611        // Builtins not in fusevm's name→id table fall through to
22612        // host.exec. Catch them here before the OS-level exec attempts
22613        // to spawn a non-existent binary.
22614        match cmd.as_str() {
22615            "sched" => return dispatch_builtin("sched", rest_vec.clone()),
22616            "echotc" => return dispatch_builtin("echotc", rest_vec.clone()),
22617            "echoti" => return dispatch_builtin("echoti", rest_vec.clone()),
22618            "zpty" => return dispatch_builtin("zpty", rest_vec.clone()),
22619            "ztcp" => return dispatch_builtin("ztcp", rest_vec.clone()),
22620            "zsocket" => {
22621                // c:Src/Modules/socket.c:276 BUILTIN spec — BUILTINS["zsocket"]
22622                // optstr "ad:ltv" parsed by execbuiltin.
22623                return dispatch_builtin("zsocket", rest_vec.clone());
22624            }
22625            "private" => {
22626                // c:Src/Modules/param_private.c:217 — bin_private via
22627                // BUILTINS["private"]. The autoload require_module
22628                // (exec.c:2700-2717) fires inside
22629                // dispatch_builtin_raw, the chokepoint for all routes.
22630                return dispatch_builtin("private", rest_vec.clone());
22631            }
22632            "zformat" => return dispatch_builtin("zformat", rest_vec.clone()),
22633            "zregexparse" => return dispatch_builtin("zregexparse", rest_vec.clone()),
22634            // `unalias`/`unhash`/`unfunction` share `bin_unhash` but
22635            // each carries its own funcid (BIN_UNALIAS / BIN_UNHASH /
22636            // BIN_UNFUNCTION) — dispatch_builtin handles the BUILTINS
22637            // lookup + funcid propagation via execbuiltin.
22638            "unalias" | "unhash" | "unfunction" => {
22639                return dispatch_builtin(cmd.as_str(), rest_vec.clone());
22640            }
22641            // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
22642            // functions — implemented natively in Rust so `autoload -Uz zmv`
22643            // works without shipping the function source (and without the
22644            // fpath source hanging the parser). The stub guard keeps them command-not-found until autoloaded, exactly like zsh;
22645            // an un-guarded arm ran them for bare `zmv`, diverging from
22646            // `zsh -f; zmv` → "command not found: zmv".
22647            // Only a still-undefined autoload stub takes the native port; a
22648            // defined body runs as the shell function (see
22649            // `autoload_stub_pending`).
22650            "zmv" if autoload_stub_pending("zmv") => {
22651                return crate::extensions::ext_builtins::zmv(&rest_vec, "mv")
22652            }
22653            "zcp" if autoload_stub_pending("zcp") => {
22654                return crate::extensions::ext_builtins::zmv(&rest_vec, "cp")
22655            }
22656            "zln" if autoload_stub_pending("zln") => {
22657                return crate::extensions::ext_builtins::zmv(&rest_vec, "ln")
22658            }
22659            "zcalc" if autoload_stub_pending("zcalc") => {
22660                return crate::extensions::ext_builtins::zcalc(&rest_vec)
22661            }
22662            "zselect" => {
22663                // Route through canonical dispatch_builtin which goes
22664                // via execbuiltin → BUILTINS["zselect"] (zselect.c:272).
22665                return dispatch_builtin("zselect", rest_vec.clone());
22666            }
22667            "cap" => return dispatch_builtin("cap", rest_vec.clone()),
22668            "getcap" => return dispatch_builtin("getcap", rest_vec.clone()),
22669            "setcap" => return dispatch_builtin("setcap", rest_vec.clone()),
22670            "yes" => return self.builtin_yes(&rest_vec),
22671            "nl" => return self.builtin_nl(&rest_vec),
22672            "env" => return self.builtin_env(&rest_vec),
22673            "printenv" => return self.builtin_printenv(&rest_vec),
22674            "tty" => return self.builtin_tty(&rest_vec),
22675            // c:Src/Modules/files.c:806 — BUILTINS["chgrp"] with
22676            // BIN_CHGRP funcid + "hRs" optstr.
22677            "chgrp" => return dispatch_builtin("chgrp", rest_vec.clone()),
22678            "nproc" => return self.builtin_nproc(&rest_vec),
22679            "expr" => return self.builtin_expr(&rest_vec),
22680            "sha256sum" => return self.builtin_sha256sum(&rest_vec),
22681            "base64" => return self.builtin_base64(&rest_vec),
22682            "tac" => return self.builtin_tac(&rest_vec),
22683            "expand" => return self.builtin_expand(&rest_vec),
22684            "unexpand" => return self.builtin_unexpand(&rest_vec),
22685            "paste" => return self.builtin_paste(&rest_vec),
22686            "fold" => return self.builtin_fold(&rest_vec),
22687            "shuf" => return self.builtin_shuf(&rest_vec),
22688            "comm" => return self.builtin_comm(&rest_vec),
22689            "cksum" => return self.builtin_cksum(&rest_vec),
22690            "factor" => return self.builtin_factor(&rest_vec),
22691            "tsort" => return self.builtin_tsort(&rest_vec),
22692            "sum" => return self.builtin_sum(&rest_vec),
22693            "mkfifo" => return self.builtin_mkfifo(&rest_vec),
22694            "link" => return self.builtin_link(&rest_vec),
22695            "unlink" => return self.builtin_unlink(&rest_vec),
22696            "dircolors" => return self.builtin_dircolors(&rest_vec),
22697            "groups" => return self.builtin_groups(&rest_vec),
22698            "arch" => return self.builtin_arch(&rest_vec),
22699            "nice" => return self.builtin_nice(&rest_vec),
22700            "logname" => return self.builtin_logname(&rest_vec),
22701            "tput" => return self.builtin_tput(&rest_vec),
22702            "users" => return self.builtin_users(&rest_vec),
22703            // "sync" => return self.bin_sync(&rest_vec),
22704            "zbuild" => return self.builtin_zbuild(&rest_vec),
22705            // `zf_*` aliases from `zsh/files` (Src/Modules/files.c
22706            // BUILTIN table at line 816-824). The C source binds
22707            // both unprefixed (`chmod`) and prefixed (`zf_chmod`)
22708            // names to the SAME `bin_chmod` etc. handlers — the
22709            // prefixed forms exist so a script can portably reach
22710            // the builtin even when a function or alias has shadowed
22711            // the bare name. Each arm routes through the canonical
22712            // zf_* aliases route through canonical BUILTINS entries
22713            // (files.c:816-824) — execbuiltin parses each fn's optstr
22714            // automatically.
22715            "mkdir" | "zf_mkdir" | "zf_rm" | "zf_rmdir" | "zf_chmod" | "zf_chown" | "zf_chgrp"
22716            | "zf_ln" | "zf_mv" | "zf_sync"
22717                // `--zsh` parity gate: zsh -fc has zsh/files UNLOADED
22718                // — bare `mkdir` is /bin/mkdir (so `command mkdir -p`
22719                // honors the system flag set; zconvey.plugin.zsh:44
22720                // got "File exists" from the in-process bin_mkdir
22721                // that this arm intercepted) and `zf_*` names are
22722                // command-not-found 127 until `zmodload zsh/files`.
22723                // Fall through to the external/exec path in --zsh
22724                // mode; default zshrs mode keeps the anti-fork
22725                // intercept.
22726                if !crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) =>
22727            {
22728                return dispatch_builtin(cmd.as_str(), rest_vec.clone());
22729            }
22730            // `zstat` — port of zsh/stat module (Src/Modules/stat.c
22731            // BUILTIN("zstat", …)). Returns file metadata as
22732            // `field value` pairs / an assoc / a plus-separated
22733            // list depending on flags. zsh ALSO registers `stat`
22734            // bound to the same handler, but that name conflicts
22735            // with the system `stat(1)` binary (every script that
22736            // calls `stat -f '%Lp' …` would break). zsh resolves
22737            // this through opt-in `zmodload`; zshrs's modules are
22738            // statically linked so we keep `stat` routing to the
22739            // external command and only intercept the unambiguous
22740            // `zstat` name.
22741            "zstat" => {
22742                // Canonical bin_stat per stat.c:638 via BUILTINS["zstat"].
22743                return dispatch_builtin("zstat", rest_vec.clone());
22744            }
22745            _ => {}
22746        }
22747
22748        // AOP intercepts: when an `intercept :before/:around/:after foo` block
22749        // is registered, dynamic-command-name dispatch must consult it before
22750        // spawning. Without this, `cmd=ls; $cmd` bypasses every intercept that
22751        // a literal `ls` would trigger. The full_cmd string mirrors what the
22752        // tree-walker era passed (cmd + args joined by space) so existing
22753        // pattern matchers continue to work.
22754        if !self.intercepts.is_empty() {
22755            let full_cmd = if rest_vec.is_empty() {
22756                cmd.clone()
22757            } else {
22758                format!("{} {}", cmd, rest_vec.join(" "))
22759            };
22760            if let Some(intercept_result) = self.run_intercepts(cmd, &full_cmd, &rest_vec) {
22761                return intercept_result.unwrap_or(127);
22762            }
22763        }
22764
22765        // User-defined function lookup before OS-level exec. zsh's
22766        // dynamic-command-name dispatch (`cmd=hook1; $cmd`) checks
22767        // the function table FIRST — without this, `$f` for a
22768        // function-name `f` was always falling through to
22769        // `execute_external` and erroring "command not found".
22770        // Plugin code uses this pattern constantly:
22771        //   for f in "${precmd_functions[@]}"; do "$f"; done
22772        if self.function_exists(cmd) {
22773            if let Some(status) = self.dispatch_function_call(cmd, &rest_vec) {
22774                return status;
22775            }
22776        }
22777
22778        EXEC_DASH.with(|c| c.set(exec_dash));
22779        self.execute_external(cmd, &rest_vec, &[]).unwrap_or(127)
22780    }
22781}
22782
22783#[cfg(test)]
22784mod word_assemble_tests {
22785    use super::{word_assemble_plan9, Value};
22786
22787    fn arr(xs: &[&str]) -> Value {
22788        Value::array(xs.iter().map(|s| Value::str(*s)).collect())
22789    }
22790    fn out(v: Value) -> Vec<String> {
22791        match v {
22792            Value::Array(items) => items.iter().map(|i| i.to_str()).collect(),
22793            other => vec![other.to_str()],
22794        }
22795    }
22796
22797    // The edge-tracking fold (c:Src/subst.c:4316-4437). A naive per-segment
22798    // operator gets s,p,p and p,s,p wrong because it forgets which trailing
22799    // elements are still the "growing edge". These pin the exact zsh output
22800    // (verified against zsh 5.9) for every plan9/splice permutation.
22801    #[test]
22802    fn plan9_then_splice() {
22803        // "${(@)^a}${(@)b}" a=(1 2) b=(A B) -> 1A 2A B
22804        let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[true, false]);
22805        assert_eq!(out(r), vec!["1A", "2A", "B"]);
22806    }
22807    #[test]
22808    fn splice_then_plan9() {
22809        // "${(@)a}${(@)^b}" -> 1 2A 2B
22810        let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[false, true]);
22811        assert_eq!(out(r), vec!["1", "2A", "2B"]);
22812    }
22813    #[test]
22814    fn plan9_then_plan9_is_full_cross() {
22815        let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[true, true]);
22816        assert_eq!(out(r), vec!["1A", "1B", "2A", "2B"]);
22817    }
22818    #[test]
22819    fn splice_then_splice() {
22820        let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[false, false]);
22821        assert_eq!(out(r), vec!["1", "2A", "B"]);
22822    }
22823    #[test]
22824    fn plan9_splice_plan9_growing_edge() {
22825        // "${(@)^a}${(@)b}${(@)^c}" -> 1A 2A Bp Bq  (only B, the edge, distributes)
22826        let r = word_assemble_plan9(
22827            &[arr(&["1", "2"]), arr(&["A", "B"]), arr(&["p", "q"])],
22828            &[true, false, true],
22829        );
22830        assert_eq!(out(r), vec!["1A", "2A", "Bp", "Bq"]);
22831    }
22832    #[test]
22833    fn splice_plan9_plan9_keeps_frozen_prefix() {
22834        // "${(@)a}${(@)^b}${(@)^c}" -> 1 2Ap 2Aq 2Bp 2Bq  (1 stays frozen)
22835        let r = word_assemble_plan9(
22836            &[arr(&["1", "2"]), arr(&["A", "B"]), arr(&["p", "q"])],
22837            &[false, true, true],
22838        );
22839        assert_eq!(out(r), vec!["1", "2Ap", "2Aq", "2Bp", "2Bq"]);
22840    }
22841    #[test]
22842    fn empty_plan9_array_deletes_word() {
22843        // "${(@)^a}${(@)b}" with a=() -> word deleted
22844        let r = word_assemble_plan9(&[Value::array(vec![]), arr(&["A", "B"])], &[true, false]);
22845        assert!(out(r).is_empty(), "plan9 empty array deletes the word");
22846    }
22847    #[test]
22848    fn leading_literal_then_mixed() {
22849        // "X${(@)^a}${(@)b}" -> X1A X2A B
22850        let r = word_assemble_plan9(
22851            &[Value::str("X"), arr(&["1", "2"]), arr(&["A", "B"])],
22852            &[false, true, false],
22853        );
22854        assert_eq!(out(r), vec!["X1A", "X2A", "B"]);
22855    }
22856
22857    // c:Src/subst.c:4261 — a NON-plan9 empty expansion collapses to the empty
22858    // string and the word SURVIVES; only plan9 (c:4362 `uremnode`) deletes it.
22859    // A leading empty segment used to leave `words` empty, so every following
22860    // segment cross-multiplied against nothing and the word vanished.
22861    // Verified against zsh 5.9:
22862    //     n=""; a=(x y z); print -rl -- $n${^a}      -> x / y / z
22863    #[test]
22864    fn leading_empty_splice_keeps_word_and_crosses() {
22865        let r = word_assemble_plan9(
22866            &[Value::array(vec![]), arr(&["x", "y", "z"])],
22867            &[false, true],
22868        );
22869        assert_eq!(out(r), vec!["x", "y", "z"]);
22870    }
22871
22872    //     n=""; a=(x y z); print -rl -- $n"pre"${^a} -> prex / prey / prez
22873    #[test]
22874    fn leading_empty_then_literal_then_plan9() {
22875        let r = word_assemble_plan9(
22876            &[
22877                Value::array(vec![]),
22878                Value::str("pre"),
22879                arr(&["x", "y", "z"]),
22880            ],
22881            &[false, false, true],
22882        );
22883        assert_eq!(out(r), vec!["prex", "prey", "prez"]);
22884    }
22885
22886    //     n=""; a=(x y z); print -rl -- $n$a${^a} -> x / y / zx / zy / zz
22887    // The leading empty must not consume the splice's first element.
22888    #[test]
22889    fn leading_empty_does_not_eat_first_splice_element() {
22890        let r = word_assemble_plan9(
22891            &[
22892                Value::array(vec![]),
22893                arr(&["x", "y", "z"]),
22894                arr(&["x", "y", "z"]),
22895            ],
22896            &[false, false, true],
22897        );
22898        assert_eq!(out(r), vec!["x", "y", "zx", "zy", "zz"]);
22899    }
22900
22901    //     n=""; e=(); print -rl -- $n${^e} -> nothing (plan9 empty still wins)
22902    #[test]
22903    fn leading_empty_then_empty_plan9_still_deletes_word() {
22904        let r = word_assemble_plan9(
22905            &[Value::array(vec![]), Value::array(vec![])],
22906            &[false, true],
22907        );
22908        assert!(
22909            out(r).is_empty(),
22910            "plan9 empty array still deletes the word"
22911        );
22912    }
22913}
22914
22915
22916// ═══════════════════════════════════════════════════════════════════════
22917// Compiled-arithmetic value plumbing.
22918//
22919// !!! WARNING: RUST-ONLY HELPERS !!!
22920//
22921// C has no equivalent of these: `Src/math.c` keeps every operand in an
22922// `mnumber` on its own value stack, so an operator arm reads `a.u.l` /
22923// `b.u.d` directly and never converts anything. zshrs's compiled path
22924// keeps operands as `fusevm::Value` on the VM stack instead, so the two
22925// representations have to meet at each builtin boundary. `arith_div` /
22926// `arith_mod` / `arith_pow` below ARE line-by-line ports of their C
22927// arms; only the `Value` ↔ `mnumber` conversion is Rust-only.
22928// ═══════════════════════════════════════════════════════════════════════
22929
22930/// Pop one VM value as an `mnumber`.
22931///
22932/// `Bool` appears because the comparison ops (`Op::NumLt` and friends)
22933/// push one; c:Src/math.c:1200 gives those operators integer 1/0, so
22934/// that is what a `Bool` becomes here. A `Str` reaching this point is a
22935/// value the compiler could not prove numeric; c:Src/math.c:337
22936/// `getmathparam` coerces such a string through the math evaluator,
22937/// which `matheval_str_operand` mirrors.
22938pub(crate) fn arith_pop_mnumber(vm: &mut fusevm::VM) -> crate::ported::zsh_h::mnumber {
22939    use crate::ported::zsh_h::{mnumber, MN_FLOAT, MN_INTEGER};
22940    match vm.pop() {
22941        fusevm::Value::Int(n) => mnumber { l: n, d: 0.0, type_: MN_INTEGER },
22942        fusevm::Value::Float(f) => mnumber { l: 0, d: f, type_: MN_FLOAT },
22943        fusevm::Value::Bool(b) => mnumber { l: i64::from(b), d: 0.0, type_: MN_INTEGER },
22944        other => {
22945            let s = other.to_str();
22946            if s.is_empty() {
22947                // c:Src/math.c:345 — an unset/empty parameter is 0.
22948                mnumber { l: 0, d: 0.0, type_: MN_INTEGER }
22949            } else {
22950                // c:Src/params.c:2646-2647 `matheval(getstrvalue(v))` —
22951                // the default arm of getnumvalue, which re-lexes a
22952                // scalar's string form. A math error reads as 0, as C's
22953                // does after `zerr`.
22954                crate::ported::math::matheval(&s).unwrap_or(mnumber {
22955                    l: 0,
22956                    d: 0.0,
22957                    type_: MN_INTEGER,
22958                })
22959            }
22960        }
22961    }
22962}
22963
22964/// The inverse: an `mnumber` back onto the VM stack.
22965pub(crate) fn arith_push(n: crate::ported::zsh_h::mnumber) -> fusevm::Value {
22966    if n.type_ == crate::ported::zsh_h::MN_FLOAT {
22967        fusevm::Value::Float(n.d)
22968    } else {
22969        fusevm::Value::Int(n.l)
22970    }
22971}
22972
22973/// c:Src/math.c:1144 `notzero` — an INTEGER zero is a math error; a
22974/// float zero is not (it divides to an infinity).
22975pub(crate) fn mn_notzero(a: crate::ported::zsh_h::mnumber) -> bool {
22976    if a.type_ & crate::ported::zsh_h::MN_INTEGER != 0 && a.l == 0 {
22977        crate::ported::utils::zerr("division by zero"); // c:1147
22978        return false; // c:1148
22979    }
22980    true // c:1150
22981}
22982
22983/// Truthiness of an `mnumber` — c:Src/math.c:1200's `BOOL` reading,
22984/// where the operand is compared against zero in its own type.
22985pub(crate) fn mn_is_true(a: crate::ported::zsh_h::mnumber) -> bool {
22986    if a.type_ == crate::ported::zsh_h::MN_FLOAT {
22987        a.d != 0.0
22988    } else {
22989        a.l != 0
22990    }
22991}
22992
22993/// c:Src/math.c:1178-1183 — the two operands of an `OP_A2` operator are
22994/// promoted to float if EITHER is float; the result carries that type.
22995pub(crate) fn mn_promote(
22996    a: crate::ported::zsh_h::mnumber,
22997    b: crate::ported::zsh_h::mnumber,
22998) -> (crate::ported::zsh_h::mnumber, crate::ported::zsh_h::mnumber, u32) {
22999    use crate::ported::zsh_h::{mnumber, MN_FLOAT};
23000    if a.type_ == MN_FLOAT || b.type_ == MN_FLOAT {
23001        let af = if a.type_ == MN_FLOAT { a.d } else { a.l as f64 };
23002        let bf = if b.type_ == MN_FLOAT { b.d } else { b.l as f64 };
23003        (
23004            mnumber { l: 0, d: af, type_: MN_FLOAT },
23005            mnumber { l: 0, d: bf, type_: MN_FLOAT },
23006            MN_FLOAT,
23007        )
23008    } else {
23009        (a, b, a.type_)
23010    }
23011}
23012
23013/// Port of the `DIV` / `DIVEQ` arm, c:Src/math.c:1237-1257.
23014pub(crate) fn arith_div(
23015    a: crate::ported::zsh_h::mnumber,
23016    b: crate::ported::zsh_h::mnumber,
23017) -> crate::ported::zsh_h::mnumber {
23018    use crate::ported::zsh_h::{mnumber, MN_FLOAT};
23019    let (a, b, ctype) = mn_promote(a, b);
23020    let mut c = mnumber { l: 0, d: 0.0, type_: ctype };
23021    if !mn_notzero(b) {
23022        return c; // c:1240
23023    }
23024    if ctype == MN_FLOAT {
23025        c.d = a.d / b.d; // c:1242
23026    } else if b.l == -1 {
23027        // c:1244-1251 — avoid the INT_MIN / -1 trap by treating it as
23028        // multiplication, exactly as C does.
23029        c.l = a.l.wrapping_neg(); // c:1253
23030    } else {
23031        c.l = a.l / b.l; // c:1255
23032    }
23033    c
23034}
23035
23036/// Port of the `MOD` / `MODEQ` arm, c:Src/math.c:1258-1272.
23037pub(crate) fn arith_mod(
23038    a: crate::ported::zsh_h::mnumber,
23039    b: crate::ported::zsh_h::mnumber,
23040) -> crate::ported::zsh_h::mnumber {
23041    use crate::ported::zsh_h::{mnumber, MN_FLOAT};
23042    let (a, b, ctype) = mn_promote(a, b);
23043    let mut c = mnumber { l: 0, d: 0.0, type_: ctype };
23044    if !mn_notzero(b) {
23045        return c; // c:1261
23046    }
23047    if ctype == MN_FLOAT {
23048        c.d = a.d % b.d; // c:1268 fmod
23049    } else if b.l == -1 {
23050        c.l = 0; // c:1270
23051    } else {
23052        c.l = a.l % b.l; // c:1272
23053    }
23054    c
23055}
23056
23057/// Port of the `POWER` / `POWEREQ` arm, c:Src/math.c:1335-1358.
23058pub(crate) fn arith_pow(
23059    a: crate::ported::zsh_h::mnumber,
23060    b: crate::ported::zsh_h::mnumber,
23061) -> crate::ported::zsh_h::mnumber {
23062    use crate::ported::zsh_h::{mnumber, MN_FLOAT, MN_INTEGER};
23063    let (mut a, mut b, mut ctype) = mn_promote(a, b);
23064    if ctype == MN_INTEGER && b.l < 0 {
23065        // c:1337-1342 — a negative integer exponent produces a real
23066        // result, so both operands are cast to real first.
23067        a = mnumber { l: 0, d: a.l as f64, type_: MN_FLOAT };
23068        b = mnumber { l: 0, d: b.l as f64, type_: MN_FLOAT };
23069        ctype = MN_FLOAT;
23070    }
23071    let mut c = mnumber { l: 0, d: 0.0, type_: ctype };
23072    if ctype == MN_INTEGER {
23073        // c:1344 `for (c.u.l = 1; b.u.l--; c.u.l *= a.u.l);`
23074        c.l = 1;
23075        let mut n = b.l;
23076        while n > 0 {
23077            c.l = c.l.wrapping_mul(a.l);
23078            n -= 1;
23079        }
23080    } else {
23081        if b.d <= 0.0 && !mn_notzero(a) {
23082            return c; // c:1346-1347
23083        }
23084        if a.d < 0.0 {
23085            // c:1348-1355 — a negative base to a non-integer power is
23086            // imaginary.
23087            let tst = b.d as i64 as f64;
23088            if tst != b.d {
23089                crate::ported::utils::zerr("bad math expression: imaginary power"); // c:1352
23090                return c; // c:1353
23091            }
23092        }
23093        c.d = a.d.powf(b.d); // c:1356
23094    }
23095    c
23096}
23097
23098/// Body of `BUILTIN_SET_LINENO`, factored out so
23099/// `BUILTIN_STMT_PROLOGUE_FAST` can perform the same write without a
23100/// second indirect call. c:Src/exec.c:1355/1451/2056 `lineno = code - 1`.
23101pub(crate) fn set_lineno_impl(n: i64) -> fusevm::Value {
23102        // c:Src/exec.c:1355 — `/* In evaluated traps, don't modify the
23103        // line number. */  if (!IN_EVAL_TRAP() && !ineval && code)
23104        // lineno = code - 1;` (same gate at c:1451 and c:2056).
23105        // `ineval` is set by `eval()` to `!isset(EVALLINENO)`
23106        // (Src/builtin.c:6155), so under NO_EVAL_LINENO the eval body
23107        // must NOT renumber $LINENO — the caller's line stands.
23108        //
23109        // c:1354 — `/* In evaluated traps, don't modify the line number. */`
23110        // The `IN_EVAL_TRAP()` half of the same gate was missing, so an
23111        // eval-form trap body (`trap 'print $LINENO' DEBUG`) renumbered
23112        // $LINENO to its own line 1 and — because nothing restores
23113        // `lineno` on the way out (C does, via execlist's oldlineno
23114        // save/restore at c:1429/1696) — every later statement in the
23115        // trapped scope reported line 1 as well.
23116        if crate::ported::zsh_h::IN_EVAL_TRAP()
23117            || crate::ported::builtin::ineval.load(std::sync::atomic::Ordering::Relaxed) != 0
23118        {
23119            return Value::Status(0);
23120        }
23121        // Provenance: mirror the line into the lineage ledger's own
23122        // counter. The param-write hooks run inside the parameter
23123        // table's lock, so they cannot read `$LINENO` back out of it.
23124        if crate::provenance::active() {
23125            crate::provenance::note_line(n.max(0) as usize);
23126        }
23127        // c:Src/exec.c:lineno = N — direct write to the param's
23128        // u_val. Cannot go through setsparam because LINENO carries
23129        // PM_READONLY (so `(t)LINENO` reads `integer-readonly-special`
23130        // per zsh); setsparam → assignstrvalue's PM_READONLY guard
23131        // would reject the internal write. C zsh handles this via the
23132        // PM_SPECIAL GSU vtable's setfn callback which bypasses the
23133        // generic readonly check; the Rust port writes the canonical
23134        // field directly instead.
23135        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
23136            if let Some(pm) = tab.get_mut("LINENO") {
23137                // c:Src/utils.c:121 `zlong lineno` — the value lives in the C
23138                // GLOBAL, reached through LINENO's GSU. A `typeset -h +g LINENO`
23139                // local shadow has no PM_SPECIAL and no GSU, so C's `lineno = N`
23140                // never touches it; skip the paramtab mirror for the same reason.
23141                if (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) != 0 {
23142                    pm.u_val = n;
23143                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
23144                }
23145            }
23146        }
23147        // Mirror to the file-static `lineno` (utils.c:121) that
23148        // zerrmsg reads at utils.c:301 for the `:N: msg` prefix.
23149        crate::ported::utils::set_lineno(n as i32);
23150        // Also drive lex::LEX_LINENO — zerrmsg (utils.rs:376) reads
23151        // THAT counter for the `name:N:` prefix. C zsh interleaves
23152        // parse and execute per top-level list, so its single
23153        // `lineno` global serves both; zshrs compiles the whole
23154        // script before running, leaving LEX_LINENO parked at EOF.
23155        // Without this write, every runtime zwarn/zerr reported the
23156        // script's LAST line instead of the failing statement's.
23157        crate::ported::lex::set_lineno(n as u64);
23158        // DAP hook — checks breakpoints / step mode / pause-request
23159        // for the line we just landed on. O(1) no-op when DAP is off
23160        // (single atomic load on a OnceLock). Inside `--dap` mode
23161        // this is the call that blocks the executor on a Condvar
23162        // until the IDE sends `continue`. Mirrors strykelang's
23163        // `debugger.should_stop(line) → debugger.prompt(...)` flow.
23164        crate::extensions::dap::check_line(n as u32);
23165    fusevm::Value::Status(0)
23166}
23167
23168/// !!! WARNING: RUST-ONLY HELPER — NO DIRECT C COUNTERPART !!!
23169/// The `if (lastval) { errreturn / errexit }` half of execlist's
23170/// `sublist_done:` block (c:Src/exec.c:1660-1680), shared by the normal
23171/// check and the retflag-escape check of BUILTIN_ERREXIT_CHECK: C runs it
23172/// at EVERY sublist_done, including the one a `return 1` just ended.
23173/// Returns the builtin's result (1 = unwind this scope).
23174fn errexit_tail(last: i32) -> Value {
23175    use std::sync::atomic::Ordering;
23176    // c:Src/exec.c:1605-1610 — compute errreturn / errexit.
23177    //   errreturn = ERRRETURN && (INTERACTIVE || locallevel || sourcelevel)
23178    //               && !(noerrexit & NOERREXIT_RETURN)
23179    //   errexit   = (ERREXIT || (ERRRETURN && !errreturn))
23180    //               && !(noerrexit & NOERREXIT_EXIT)
23181    let no_err = crate::ported::exec::noerrexit.load(Ordering::Relaxed);
23182    let locallvl = crate::ported::params::locallevel.load(Ordering::Relaxed);
23183    let sourcelvl = crate::ported::init::sourcelevel.load(Ordering::Relaxed);
23184    let errreturn_opt = isset(crate::ported::zsh_h::ERRRETURN);
23185    let in_unwindable_scope =
23186        isset(crate::ported::zsh_h::INTERACTIVE) || locallvl != 0 || sourcelvl != 0;
23187    let errreturn = errreturn_opt
23188        && in_unwindable_scope
23189        && (no_err & crate::ported::zsh_h::NOERREXIT_RETURN) == 0;
23190    let (errexit_on, in_subshell) = with_executor(|exec| {
23191        let on_canonical = isset(ERREXIT) || (errreturn_opt && !errreturn); // c:1608-1609
23192        let on_legacy = opt_state_get("errexit").unwrap_or(false);
23193        (
23194            (on_canonical || on_legacy) && (no_err & crate::ported::zsh_h::NOERREXIT_EXIT) == 0,
23195            !exec.subshell_snapshots.is_empty(),
23196        )
23197    });
23198    if !errexit_on {
23199        // c:1667-1674 — `if (errexit) { … realexit(); }` comes FIRST;
23200        // `if (errreturn) { retflag = 1; breaks = loops; }` only matters
23201        // when the shell did not exit. With both options set inside a
23202        // function, ERR_EXIT wins.
23203        if errreturn {
23204            // c:1675-1678 — unwind to the function boundary without
23205            // exiting the shell.
23206            crate::ported::builtin::RETFLAG.store(1, Ordering::Relaxed);
23207            let loops = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
23208            crate::ported::builtin::BREAKS.store(loops, Ordering::Relaxed);
23209            return Value::Int(1);
23210        }
23211        return Value::Int(0);
23212    }
23213    // c:Src/exec.c:1611-1618 — under ERR_EXIT a failing command exits the
23214    // whole shell via realexit() FROM THE POINT OF FAILURE, before any
23215    // enclosing `always` arm can run. zsh 5.9.2 (the reference) has no
23216    // `this_noerrexit` deferral, so at top-level / function scope the
23217    // faithful behavior is to process-exit here (zexit fires the SIGEXIT
23218    // trap and exits). This bypasses the always arm, fixing
23219    // `setopt errexit; { false } always { print A }` which wrongly ran the
23220    // always body: the deferred EXIT_PENDING routed the unwind through
23221    // always_entry (compile_zsh.rs re-points it there) and
23222    // SET_TRY_BLOCK_ERROR then cleared the pending exit so the body ran.
23223    if crate::ported::builtin::SUBSHELL_DEPTH.load(Ordering::Relaxed) == 0 {
23224        crate::ported::builtin::zexit(last, crate::ported::zsh_h::ZEXIT_NORMAL);
23225        // c:1618 realexit
23226    }
23227    // Subshell: zshrs runs subshells in-process, so it cannot process-exit
23228    // the whole shell here — defer to the subshell-end unwind.
23229    crate::ported::builtin::EXIT_VAL.store(last, Ordering::Relaxed);
23230    crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
23231    let _ = in_subshell;
23232    Value::Int(1)
23233}
23234
23235/// !!! WARNING: RUST-ONLY HELPER — NO DIRECT C COUNTERPART !!!
23236/// `sigtrapped[SIGZERR]` (c:Src/exec.c:1652) read through the port's
23237/// mutex-guarded `sigtrapped` table. C indexes the array inline.
23238fn zerr_sigtrapped() -> bool {
23239    crate::ported::signals::sigtrapped
23240        .lock()
23241        .ok()
23242        .is_some_and(|st| st.get(crate::ported::signals_h::SIGZERR as usize).is_some_and(|&s| s != 0))
23243}
23244
23245/// Body of `BUILTIN_DONETRAP_RESET`, factored out for the same reason.
23246/// c:Src/exec.c:1455 `donetrap = 0`.
23247pub(crate) fn donetrap_reset_impl() -> fusevm::Value {
23248        // c:Src/jobs.c:651-652 — a CHLD trap update_bg_job owed while the
23249        // shell was not in a wait loop. C runs it from the SIGCHLD handler;
23250        // a trap body run from zshrs's handler can block on a mutex the
23251        // interrupted statement holds, so it runs here, at the next
23252        // statement prologue (c:Src/exec.c:1451-1455).
23253        for _ in 0..crate::ported::jobs::CHLD_TRAP_PENDING.swap(0, std::sync::atomic::Ordering::SeqCst) {
23254            crate::ported::signals::dotrap(libc::SIGCHLD);
23255        }
23256        // c:Src/exec.c:1455 — `donetrap = 0;` at sublist start.
23257        // Reset before each top-level statement so the next
23258        // sublist's ERREXIT_CHECK fires the ZERR trap on its FIRST
23259        // non-zero command. Carries the "already fired" state
23260        // across function-call returns within the SAME outer
23261        // sublist (per C semantics — donetrap is process-global).
23262        // Bug #303 in docs/BUGS.md.
23263        crate::ported::exec::DONETRAP.store(0, std::sync::atomic::Ordering::Relaxed);
23264        // `${~spec}` carrier: C's `globsubst` is a paramsubst-LOCAL
23265        // int (c:Src/subst.c:1671 `int globsubst = isset(GLOBSUBST);`,
23266        // set to 2 by `${~}` at c:2597-2603) whose only effect is the
23267        // `shtokenize()` of THAT substitution's own result
23268        // (c:4419-4420). It can therefore never be observed by a later
23269        // statement. zshrs carries the flag on the global option table
23270        // (subst.rs:5125-5136) so the compile-emitted glob ops in the
23271        // same word pipeline can see it, and restores it at
23272        // command-dispatch boundaries — but a `${~}` sitting in a word
23273        // that dispatches NO command (a `for`/`select` word list, a
23274        // loop/`case` header) had no such boundary before the NEXT
23275        // statement's words were expanded, so GLOB_SUBST leaked into
23276        // them. This op is emitted exactly once per sublist, in
23277        // compile_list's prologue (compile_zsh.rs:557) — i.e. BEFORE
23278        // the sublist's words expand — which is the same "state is
23279        // gone by the next statement" guarantee C gets for free.
23280        // Without it, `_parameters`' `for i in ${…:#${~pfilt}*}` loop
23281        // globbed its `ary+=($i:"$val")` body word and died with
23282        // "bad pattern: HISTCHARS:!^#", killing `-<TAB>` completion.
23283    consume_tilde_globsubst_carrier();
23284    fusevm::Value::Status(0)
23285}
23286
23287#[cfg(test)]
23288mod foreground_wait_guard_tests {
23289    use super::ForegroundWaitGuard;
23290
23291    /// c:Src/jobs.c:1638-1669 — the mask a foreground wait presents.
23292    ///
23293    /// C blocks for a child inside `signal_suspend(SIGCHLD, wait_cmd)`
23294    /// (c:1658 / c:1710), and that call's sigsuspend mask is EMPTY —
23295    /// c:Src/signals.c:216-232 does `sigemptyset(&set)` and adds at most a
23296    /// conditional SIGINT. SIGCHLD is held off by `child_block()` (c:1642,
23297    /// "unblocked in signal_suspend()") rather than by userspace queueing.
23298    /// So while the shell waits: SIGCHLD blocked, SIGWINCH deliverable.
23299    ///
23300    /// This guard used to call `queue_signals()` and nothing else, which
23301    /// left the standing `winch_block()` (c:Src/init.c:1458, re-armed by
23302    /// preprompt at c:Src/utils.c:1541) in force for the whole of any
23303    /// command — so a resize was invisible to a completion however many
23304    /// children it forked, and `trashzle`'s repaint
23305    /// (c:Src/Zle/compresult.c:1922 → c:Src/Zle/zle_main.c:2071-2095) never
23306    /// saw the `resetneeded`/`winchanged` that c:Src/utils.c:1956-1958
23307    /// raises.
23308    #[test]
23309    fn foreground_wait_unblocks_sigwinch_and_blocks_sigchld() {
23310        let _g = crate::test_util::global_state_lock();
23311        let blocked = |sig: libc::c_int| -> bool {
23312            unsafe {
23313                let mut cur: libc::sigset_t = std::mem::zeroed();
23314                libc::sigemptyset(&mut cur);
23315                libc::pthread_sigmask(libc::SIG_BLOCK, std::ptr::null(), &mut cur);
23316                libc::sigismember(&cur, sig) == 1
23317            }
23318        };
23319        let saved_winch = blocked(libc::SIGWINCH);
23320        let saved_chld = blocked(libc::SIGCHLD);
23321
23322        // The standing mask between prompts (c:Src/utils.c:1541).
23323        crate::ported::signals_h::winch_block();
23324        crate::ported::signals_h::child_unblock();
23325        assert!(blocked(libc::SIGWINCH), "precondition: winch_block() holds");
23326
23327        let (winch_in, chld_in) = {
23328            let _w = ForegroundWaitGuard::enter();
23329            (blocked(libc::SIGWINCH), blocked(libc::SIGCHLD))
23330        };
23331        let winch_out = blocked(libc::SIGWINCH);
23332
23333        if !saved_winch {
23334            crate::ported::signals_h::winch_unblock();
23335        }
23336        if saved_chld {
23337            crate::ported::signals_h::child_block();
23338        } else {
23339            crate::ported::signals_h::child_unblock();
23340        }
23341
23342        assert!(
23343            !winch_in,
23344            "a foreground wait is C's one mid-command delivery point for \
23345             SIGWINCH (c:Src/signals.c:216-232 — signal_suspend's mask is empty)"
23346        );
23347        assert!(
23348            chld_in,
23349            "c:Src/jobs.c:1642 — `child_block(); /* unblocked in signal_suspend() */`"
23350        );
23351        assert!(
23352            winch_out,
23353            "c:Src/utils.c:1541 — the standing winch_block() comes back after the wait"
23354        );
23355    }
23356}
23357
23358#[cfg(test)]
23359mod builtin_completer_coverage_tests {
23360    use super::registered_builtin;
23361
23362    /// Every zshrs-original builtin ships a completer in `completions/`
23363    /// (bundled into `~/.zshrs/functions` by build.rs), so `name <TAB>` and
23364    /// `name -<TAB>` offer its arguments instead of nothing. zsh's own
23365    /// completion tree cannot cover these: the commands do not exist there.
23366    ///
23367    /// The set: the in-process builtins [`registered_builtin`] dispatches
23368    /// (taken from `EXT_BUILTIN_NAMES`), the ztest framework, and the
23369    /// daemon-backed `z*` builtins. `pgrep` is left out: it stands in for the
23370    /// external `pgrep`, which zsh's stock `_pgrep` already completes.
23371    #[test]
23372    fn every_zshrs_builtin_has_a_completer() {
23373        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("completions");
23374        let mut claimed = std::collections::HashSet::new();
23375        for entry in std::fs::read_dir(&dir).expect("completions/") {
23376            let path = entry.expect("dir entry").path();
23377            let Ok(text) = std::fs::read_to_string(&path) else { continue };
23378            if let Some(line) = text.lines().next().and_then(|l| l.strip_prefix("#compdef ")) {
23379                claimed.extend(line.split_whitespace().map(str::to_string));
23380            }
23381        }
23382        let names = crate::ext_builtins::EXT_BUILTIN_NAMES
23383            .iter()
23384            .filter(|n| registered_builtin(n).is_some() && **n != "pgrep")
23385            .chain(crate::extensions::ztest::ZTEST_BUILTIN_NAMES)
23386            .chain(crate::daemon::builtins::ZSHRS_BUILTIN_NAMES);
23387        let missing: Vec<&str> = names.copied().filter(|n| !claimed.contains(*n)).collect();
23388        assert!(
23389            missing.is_empty(),
23390            "zshrs builtins with no `#compdef` completer in completions/: {missing:?}"
23391        );
23392    }
23393}
23394
23395#[cfg(test)]
23396mod function_shadows_builtin_tests {
23397    //! c:Src/exec.c:3485-3489 — command lookup consults `shfunctab` before
23398    //! `builtintab`, so a shell function shadows a builtin of the same name.
23399    //! The zshrs-only builtins dispatched by name in `call_function` (daemon
23400    //! z* family, ztest_*, znative) used to be matched before that check.
23401    use crate::vm_helper::ShellExecutor;
23402
23403    fn run(script: &str) -> (ShellExecutor, i32) {
23404        let mut exec = ShellExecutor::new();
23405        let st = exec.execute_script(script).unwrap_or(-1);
23406        (exec, st)
23407    }
23408
23409    #[test]
23410    fn function_shadows_daemon_z_builtin() {
23411        let _g = crate::test_util::global_state_lock();
23412        let (exec, _) = run("hit=; zls() { hit=fn; return 42 }; zls; st=$?");
23413        assert_eq!(exec.scalar("hit").as_deref(), Some("fn"));
23414        assert_eq!(exec.scalar("st").as_deref(), Some("42"));
23415    }
23416
23417    #[test]
23418    fn function_shadows_extension_builtin() {
23419        let _g = crate::test_util::global_state_lock();
23420        let (exec, _) =
23421            run("hit=; basename() { hit=fn; return 43 }; basename a/b; st=$?");
23422        assert_eq!(exec.scalar("hit").as_deref(), Some("fn"));
23423        assert_eq!(exec.scalar("st").as_deref(), Some("43"));
23424    }
23425
23426    #[test]
23427    fn builtin_prefix_and_disable_f_bypass_the_function() {
23428        let _g = crate::test_util::global_state_lock();
23429        // c:Src/exec.c:3483-3487 — after `builtin`, shfunctab is skipped.
23430        let (exec, _) = run(
23431            "hit=; zls() { hit=fn; return 42 }; builtin zls >/dev/null 2>&1; st=$?",
23432        );
23433        assert_eq!(exec.scalar("hit").as_deref(), Some(""));
23434        assert_ne!(exec.scalar("st").as_deref(), Some("42"));
23435        // c:Src/hashtable.c:239 — getnode skips a DISABLED shfunc node.
23436        let (exec, _) = run(
23437            "hit=; zls() { hit=fn; return 42 }; disable -f zls; zls >/dev/null 2>&1; st=$?",
23438        );
23439        assert_eq!(exec.scalar("hit").as_deref(), Some(""));
23440        assert_ne!(exec.scalar("st").as_deref(), Some("42"));
23441    }
23442}
23443
23444#[cfg(test)]
23445mod zmv_native_autoload_tests {
23446    //! zmv/zcp/zln/zcalc are zsh autoload functions with native ports. The
23447    //! port stands in only for loading a PM_UNDEFINED stub (c:Src/exec.c:
23448    //! 5691-5695); a defined body is an ordinary shell function.
23449    use crate::vm_helper::ShellExecutor;
23450
23451    fn run(script: &str) -> ShellExecutor {
23452        let mut exec = ShellExecutor::new();
23453        let _ = exec.execute_script(script);
23454        exec
23455    }
23456
23457    #[test]
23458    fn user_defined_zmv_and_zcalc_bodies_run() {
23459        let _g = crate::test_util::global_state_lock();
23460        let exec = run("hit=; zmv() { hit=fn; return 44 }; zmv a b; st=$?; unfunction zmv");
23461        assert_eq!(exec.scalar("hit").as_deref(), Some("fn"));
23462        assert_eq!(exec.scalar("st").as_deref(), Some("44"));
23463        let exec = run("hit=; zcalc() { hit=fn; return 45 }; zcalc; st=$?; unfunction zcalc");
23464        assert_eq!(exec.scalar("hit").as_deref(), Some("fn"));
23465        assert_eq!(exec.scalar("st").as_deref(), Some("45"));
23466    }
23467
23468    #[test]
23469    fn undefined_autoload_stub_uses_native_port() {
23470        let _g = crate::test_util::global_state_lock();
23471        // The native zmv prints its own usage and returns 1 for a missing
23472        // pattern pair; the stub stays PM_UNDEFINED, which an fpath load
23473        // (stock zmv body) would have cleared.
23474        let exec = run("autoload -Uz zmv; zmv 2>/dev/null; st=$?");
23475        assert_eq!(exec.scalar("st").as_deref(), Some("1"));
23476        assert!(super::autoload_stub_pending("zmv"));
23477        run("unfunction zmv 2>/dev/null");
23478    }
23479
23480    #[test]
23481    fn disabled_or_absent_zmv_is_command_not_found() {
23482        let _g = crate::test_util::global_state_lock();
23483        let exec = run("zmv 2>/dev/null; st=$?");
23484        assert_eq!(exec.scalar("st").as_deref(), Some("127"));
23485        let exec = run("autoload -Uz zmv; disable -f zmv; zmv 2>/dev/null; st=$?");
23486        assert_eq!(exec.scalar("st").as_deref(), Some("127"));
23487        run("unfunction zmv 2>/dev/null");
23488    }
23489}