zsh/fusevm_bridge.rs
1//! fusevm bytecode-VM bridge for ShellExecutor.
2//!
3//! ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4//! !!! LAST-RESORT FILE — NOT FOR NEW LOGIC !!!
5//!
6//! This file is a **bridge**, not a port. It exists ONLY because zshrs uses
7//! a fusevm bytecode VM where C zsh uses its own wordcode walker (Src/exec.c
8//! `execlist`). Every line here is plumbing that hooks fusevm opcodes onto
9//! the canonical ports in `src/ported/`.
10//!
11//! **Before adding code to this file, STOP and ask:**
12//!
13//! 1. Is this logic that already lives in `src/ported/`?
14//! → Call the canonical fn. Don't reinline.
15//!
16//! 2. Is this logic that SHOULD live in `src/ported/` but isn't ported yet?
17//! → Port it. Add it to `src/ported/<file>.rs` with a `c:` citation.
18//! Then call the canonical fn from here.
19//!
20//! 3. Is this purely fusevm/bytecode plumbing (Op decode, Value conversion,
21//! VM-stack manipulation, thread-local executor pointer, etc.)?
22//! → OK to put it here. Cite the closest C analog in the comment.
23//!
24//! **NEVER:** reinvent paramsubst/expansion/glob/typeset/redirect logic here.
25//! Those have canonical ports in `src/ported/subst.rs`, `src/ported/glob.rs`,
26//! `src/ported/builtin.rs`, etc. The bridge should be SHRINKING over time,
27//! not growing.
28//!
29//! See also: memory `feedback_no_shortcuts_in_porting` (port C bodies
30//! faithfully, no structural shells), `feedback_no_exec_script_from_ported`
31//! (the inverse direction — src/ported must not call back into the bridge).
32//! ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
33//!
34//! **Extension** — has no Src/exec.c counterpart. C zsh's `Src/exec.c::execlist`
35//! (and related routines) implement the native **wordcode VM** that executes
36//! compiler output from `parse.c`. zshrs compiles the parsed AST to fusevm
37//! bytecode and runs it on a stack VM; this
38//! file holds the bridge between fusevm's `ShellHost` trait and our
39//! `ShellExecutor` state, the thread-local executor pointer, all
40//! `BUILTIN_*` opcode constants, and the giant `register_builtins`
41//! handler table that wires zsh builtins onto fusevm CallBuiltin
42//! opcodes.
43
44#![allow(unused_imports)]
45
46use indexmap::IndexMap;
47use std::collections::{HashMap, HashSet};
48use std::env;
49use std::path::PathBuf;
50
51use crate::exec_jobs::JobState;
52use crate::intercepts::Intercept;
53use crate::ported::vm_helper::*;
54use std::io::Write;
55
56// ═══════════════════════════════════════════════════════════════════════════
57// Thread-local executor context for VM builtin dispatch
58// ═══════════════════════════════════════════════════════════════════════════
59
60use crate::ported::options::opt_state_get;
61use crate::ported::zsh_h::{isset, options, ERREXIT, MAX_OPS};
62use fusevm::op::redirect_op as r;
63use fusevm::shell_builtins::*;
64use fusevm::Value;
65use std::cell::{Cell, RefCell};
66use std::cmp::Ordering;
67use std::ffi::CString;
68use std::fs;
69use std::io::BufRead;
70use std::io::Read;
71use std::io::Write as _;
72use std::os::unix::fs::FileTypeExt;
73use std::os::unix::fs::MetadataExt;
74use std::os::unix::fs::PermissionsExt;
75use std::os::unix::io::AsRawFd;
76use std::os::unix::io::IntoRawFd;
77use std::time::Instant;
78use std::time::{SystemTime, UNIX_EPOCH};
79
80thread_local! {
81 /// Mirror of C zsh's `doneps4` local in execcmd_exec
82 /// (Src/exec.c:2517+). Tracks whether PS4 has been emitted
83 /// for the current xtrace line so a coalesced sequence of
84 /// XTRACE_ASSIGN + XTRACE_ARGS produces ONE line:
85 /// `<PS4>a=1 b=2 echo 1 2\n`
86 /// instead of three. Reset to false by XTRACE_ARGS /
87 /// XTRACE_NEWLINE after emitting the trailing `\n`.
88 static XTRACE_DONE_PS4: Cell<bool> = const { Cell::new(false) };
89
90 /// Port of C's `FILE *xtrerr` xtrace stream (Src/exec.c:81). C builds
91 /// each trace line in this stdio buffer — `printprompt4` does
92 /// `fprintf(xtrerr, …)`, then args via `fputs`/`fputc` — and
93 /// `fflush(xtrerr)` writes the WHOLE line to stderr in one syscall
94 /// (makecline c:2122-2123, addvars c:2588, condition c:1372). That
95 /// single flush is exactly why a forked pipeline stage's trace line
96 /// reaches the shared stderr fd atomically and never interleaves with
97 /// a concurrent stage. zshrs previously emitted PS4 and the command
98 /// text as separate `eprint!` writes, which raced under load. Model
99 /// the FILE buffer as this thread-local String (a forked child owns
100 /// its own copy); `xtrerr_fputs` appends, `xtrerr_flush` does the
101 /// single write.
102 static XTRERR: RefCell<String> = const { RefCell::new(String::new()) };
103
104 /// Stack of (RETFLAG, BREAKS, CONTFLAG, EXIT_PENDING) tuples saved
105 /// at try-block exit so the always-arm body can run cleanly even
106 /// when the try-block fired `return` / `break` / `continue` /
107 /// `exit`. Restored right before the post-always re-jump so the
108 /// escape resumes propagation past the construct.
109 /// c:Src/exec.c WC_TRYBLOCK — zsh's wordcode walker handles this
110 /// inline; the zshrs port lifts it into a paired SET / RESTORE
111 /// pair around the always-arm.
112 /// Tuple: `(retflag, breaks, contflag, exit_pending, try_errflag,
113 /// try_interrupt)`. The last two are c:Src/loop.c:762-763's
114 /// `save_try_errflag` / `save_try_interrupt` — the ENCLOSING try
115 /// block's values, restored at c:778-779 so nested
116 /// `{…} always {…}` constructs don't leak `$TRY_BLOCK_ERROR`
117 /// outward.
118 static TRY_ESCAPE_SAVE: RefCell<Vec<(i32, i32, i32, i32, i64, i64)>> =
119 const { RefCell::new(Vec::new()) };
120 /// Re-entry guard for BUILTIN_DEBUG_TRAP. While the DEBUG trap
121 /// body is running, the per-statement DEBUG_TRAP dispatch in the
122 /// trap body must NOT re-fire (otherwise infinite recursion +
123 /// stack overflow). zsh's in_trap counter at Src/signals.c
124 /// serves the same purpose.
125 static DEBUG_TRAP_REENTRY: Cell<bool> = const { Cell::new(false) };
126 /// Stack of (saved_stdout, saved_stderr) tuples pushed by
127 /// `cmd_subst` around its nested-VM run. RUST-ONLY: zsh forks
128 /// each cmdsub so trap output during the cmdsub naturally
129 /// lands on the PARENT's stdout. zshrs's in-process cmdsub
130 /// dups fd 1 → pipe, so a trap firing during cmdsub would
131 /// emit into the captured value. Traps consult this stack
132 /// to route their body output to the topmost saved_stdout
133 /// instead of the cmdsub's fd 1. Bug #56 in docs/BUGS.md.
134 pub static CMDSUBST_OUTER_FDS: RefCell<Vec<(i32, i32)>> =
135 const { RefCell::new(Vec::new()) };
136 /// c:Src/exec.c:5025 getproc (PATH_DEV_FD branch) — the parent
137 /// keeps the `>(cmd)` pipe WRITE end open under `/dev/fd/N`,
138 /// parks it in the job's filelist (`fdtable[fd] =
139 /// FDT_PROC_SUBST; addfilelist(NULL, fd)`), and deletefilelist
140 /// closes it when the consuming job finishes — that close is
141 /// what lets the `>(cmd)` child's reader see EOF. zshrs runs
142 /// commands in-process, so the equivalent is: record
143 /// `(scope_depth, fd)` here and drain after the consuming
144 /// command (external exec or builtin dispatch) completes.
145 static PSUB_PENDING_FDS: RefCell<Vec<(usize, i32)>> = const { RefCell::new(Vec::new()) };
146 /// `(scope_depth, path)` for `=(cmd)` temp files, unlinked at the same
147 /// job-end boundary as the pending fds (c:Src/jobs.c deletefilelist).
148 static PSUB_PENDING_FILES: RefCell<Vec<(usize, String)>> = const { RefCell::new(Vec::new()) };
149 /// Scope depth for PSUB_PENDING_FDS tagging. Incremented around
150 /// nested execution contexts (cmd-subst bodies, shell-function
151 /// bodies) so a command running INSIDE the nested context only
152 /// drains its own `>(cmd)` fds, never the enclosing command's
153 /// (e.g. `tee >(wc) $(print x)` — print must not close tee's
154 /// fd). Mirrors C's per-job filelist ownership.
155 static PSUB_SCOPE_DEPTH: Cell<usize> = const { Cell::new(0) };
156 /// Forked `<(cmd)`/`>(cmd)` child pids awaiting reap. Drained
157 /// non-blockingly (WNOHANG) by note_psub_child so proc-sub children
158 /// don't accumulate as zombies across a shell session.
159 static PSUB_CHILDREN: RefCell<Vec<i32>> = const { RefCell::new(Vec::new()) };
160}
161
162/// Record a proc-sub child pid and best-effort reap any already-exited
163/// proc-sub children (WNOHANG). Non-blocking: still-running children
164/// stay parked and get reaped on a later call.
165pub(crate) fn note_psub_child(pid: i32) {
166 if pid <= 0 {
167 return;
168 }
169 PSUB_CHILDREN.with(|v| {
170 let mut v = v.borrow_mut();
171 v.push(pid);
172 v.retain(|&p| {
173 let mut status = 0;
174 // WNOHANG: reap if exited, else keep parked.
175 let r = unsafe { libc::waitpid(p, &mut status, libc::WNOHANG) };
176 // r == p → reaped; r == 0 → still running (keep); r < 0 →
177 // already gone/not ours (drop).
178 r == 0
179 });
180 });
181}
182
183/// Port of `fputs(s, xtrerr)` / `fprintf(xtrerr, "%s", s)` (Src/exec.c):
184/// append `s` to the buffered xtrace line. Nothing reaches stderr until
185/// [`xtrerr_flush`] (the port of `fflush(xtrerr)`) writes the line whole.
186pub(crate) fn xtrerr_fputs(s: &str) {
187 XTRERR.with(|b| b.borrow_mut().push_str(s));
188}
189
190/// Port of `fflush(xtrerr)` (Src/exec.c:1373/2123/2596): write the
191/// buffered xtrace line to stderr in ONE `write` and clear the buffer, so
192/// the line lands on the shared fd atomically (no interleaving across
193/// concurrent pipeline stages).
194pub(crate) fn xtrerr_flush() {
195 XTRERR.with(|b| {
196 let mut buf = b.borrow_mut();
197 if !buf.is_empty() {
198 use std::io::Write;
199 let _ = std::io::stderr().write_all(buf.as_bytes());
200 buf.clear();
201 }
202 });
203}
204
205/// RAII guard bumping the psub scope depth — see PSUB_SCOPE_DEPTH.
206pub(crate) struct PsubScope;
207
208impl PsubScope {
209 pub(crate) fn enter() -> Self {
210 PSUB_SCOPE_DEPTH.with(|d| d.set(d.get() + 1));
211 PsubScope
212 }
213}
214
215impl Drop for PsubScope {
216 fn drop(&mut self) {
217 PSUB_SCOPE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
218 }
219}
220
221/// RAII guard bumping `$ZSH_SUBSHELL` for the duration of an
222/// in-process command substitution.
223///
224/// c:Src/exec.c:1161 — entersubsh() does `zsh_subshell++;` and zsh's
225/// cmdsub FORKS, so the increment dies with the child and the parent's
226/// value is untouched. zshrs runs cmdsubs in-process on a nested VM,
227/// so the visible param must be bumped on entry and restored on exit.
228/// Writes paramtab u_val directly because ZSH_SUBSHELL is PM_READONLY
229/// (same bypass pattern as the subshell-builtin bump below); also
230/// mirrors into ported::exec::zsh_subshell so exec.c:4376-style
231/// `forked | zsh_subshell` reads agree.
232pub(crate) struct CmdSubstSubshellBump {
233 saved_val: i64,
234 saved_str: Option<String>,
235}
236
237impl CmdSubstSubshellBump {
238 pub(crate) fn enter() -> Self {
239 let mut saved_val = 0i64;
240 let mut saved_str = None;
241 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
242 if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
243 saved_val = pm.u_val;
244 saved_str = pm.u_str.clone();
245 pm.u_val = saved_val + 1;
246 pm.u_str = Some((saved_val + 1).to_string());
247 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
248 }
249 }
250 crate::ported::exec::zsh_subshell.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
251 CmdSubstSubshellBump {
252 saved_val,
253 saved_str,
254 }
255 }
256}
257
258impl Drop for CmdSubstSubshellBump {
259 fn drop(&mut self) {
260 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
261 if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
262 pm.u_val = self.saved_val;
263 pm.u_str = self.saved_str.take();
264 }
265 }
266 crate::ported::exec::zsh_subshell.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
267 }
268}
269
270/// Port of deletefilelist() from Src/jobs.c (the `>(cmd)` fd arm):
271/// closes every pending proc-subst write end created at or inside
272/// the current scope depth, exactly when C deletes the consuming
273/// job's filelist (getproc parks the fd there via
274/// `addfilelist(NULL, fd)`, Src/exec.c:5025+).
275fn close_pending_psub_fds() {
276 let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
277 PSUB_PENDING_FDS.with(|v| {
278 v.borrow_mut().retain(|&(d, fd)| {
279 if d >= depth {
280 unsafe { libc::close(fd) };
281 false
282 } else {
283 true
284 }
285 });
286 });
287 // c:Src/jobs.c deletefilelist — `=(cmd)` temp files are unlinked at the
288 // same job-end boundary. `f==(print x); [[ -f $f ]]` is false after the
289 // command line ends.
290 PSUB_PENDING_FILES.with(|v| {
291 v.borrow_mut().retain(|(d, path)| {
292 if *d >= depth {
293 let _ = fs::remove_file(path);
294 false
295 } else {
296 true
297 }
298 });
299 });
300}
301
302/// RAII drain guard — instantiated at the top of the consuming-
303/// command paths (dispatch_builtin, ZshrsHost::exec) so the pending
304/// `>(cmd)` write ends close on every exit path once the command
305/// finished, exactly when C's job filelist would be deleted.
306struct PsubFdGuard;
307
308impl Drop for PsubFdGuard {
309 fn drop(&mut self) {
310 close_pending_psub_fds();
311 }
312}
313
314/// Peek the outermost cmdsub-saved (stdout, stderr) fds, if any.
315/// Returns None when no cmdsub is currently capturing. Used by the
316/// trap dispatcher in `src/ported/signals.rs::dotrap` to route trap
317/// body output to the parent's real stdout (matching zsh's forked
318/// cmdsub behaviour) instead of the cmdsub's pipe-bound fd 1.
319/// Bug #56 in docs/BUGS.md.
320pub fn cmdsubst_outer_stdout() -> Option<i32> {
321 CMDSUBST_OUTER_FDS.with(|s| s.borrow().last().map(|(o, _)| *o))
322}
323
324thread_local! {
325 /// The pipeline fds a stage still has to install onto 0/1, as
326 /// `(input, output)` with -1 meaning "leave this fd alone".
327 ///
328 /// c:Src/exec.c:3720-3724 — the pipe's read/write ends are the
329 /// FIRST entries of the command's multio table:
330 /// /* Add pipeline input/output to mnodes */
331 /// if (input) addfd(forked, save, mfds, 0, input, 0, NULL);
332 /// if (output) addfd(forked, save, mfds, 1, output, 1, NULL);
333 /// and that runs AFTER prefork (c:3304) + globlist (c:3702) have
334 /// expanded the command's argument words. So a `$(...)` inside a
335 /// stage's ARGS reads the shell's original fd 0, not the pipe:
336 /// `print -rl -- c a b | print -r -- "[$(cat)]"` prints `[]`.
337 /// (The stage's own fork at c:3000 happens before the expansion,
338 /// which is why `${x::=v}` in a non-last stage doesn't survive —
339 /// but the fds are still installed after it.)
340 ///
341 /// zshrs's [`BUILTIN_RUN_PIPELINE`] forks per stage, so it parks
342 /// the stage's fds here instead of dup2'ing them itself, and the
343 /// compiled stage chunk installs them via
344 /// [`BUILTIN_PIPE_FDS_INSTALL`] at the C-faithful point: after the
345 /// arg-word ops, before the redirect scope
346 /// (compile_zsh.rs::emit_stage_fds_install). A compound stage
347 /// (`{ … }`, `( … )`, a function) installs at chunk entry — its
348 /// body legitimately reads the pipe.
349 static PENDING_STAGE_FDS: std::cell::Cell<(i32, i32)> = const { std::cell::Cell::new((-1, -1)) };
350}
351
352/// Park the current stage's `(input, output)` pipe fds for the stage
353/// chunk's `BUILTIN_PIPE_FDS_INSTALL` to pick up. Returns the previous
354/// value so a nested pipeline (`print -- "$(a | b)" | c`) can restore
355/// the outer stage's still-uninstalled fds when it finishes.
356fn stage_fds_park(input: i32, output: i32) -> (i32, i32) {
357 PENDING_STAGE_FDS.with(|c| c.replace((input, output)))
358}
359
360/// Take (and clear) the parked stage fds.
361fn stage_fds_take() -> (i32, i32) {
362 PENDING_STAGE_FDS.with(|c| c.replace((-1, -1)))
363}
364
365// Thread-local pointer to the current ShellExecutor.
366// Set before VM execution, cleared after. Used by builtin handlers.
367thread_local! {
368 static CURRENT_EXECUTOR: RefCell<Option<*mut ShellExecutor>> = const { RefCell::new(None) };
369 /// The installed session executor, registered by
370 /// `exec::install_session_executor`. Lets [`with_session_context`]
371 /// establish a VM execution context for STARTUP work that runs
372 /// before the loop's first `execode` enters one (rc-file sourcing in
373 /// `run_init_scripts`, c:1914). Mirrors exec.rs's own
374 /// `SESSION_EXECUTOR`; kept here so the context helper lives next to
375 /// `ExecutorContext`/`CURRENT_EXECUTOR`.
376 static SESSION_EXECUTOR_PTR: std::cell::Cell<Option<*mut ShellExecutor>> = const { std::cell::Cell::new(None) };
377 /// GLOB_ASSIGN eligibility carrier. Set true by BUILTIN_MARK_GLOB_ELIGIBLE
378 /// (emitted by the compiler ONLY when a scalar-assignment RHS carries an
379 /// UNQUOTED glob token — Star/Quest/Inbrack), read+cleared by the next
380 /// BUILTIN_SET_VAR. Matches C zsh's `GLOB_ASSIGN` (Src/exec.c:2554): only
381 /// a literal unquoted glob pattern in the wordcode is globbed; values from
382 /// `$param` / `$(cmd)` / quoted strings are NOT (verified against zsh).
383 /// The runtime SET_VAR value arrives untokenized (the compiler DQ-wraps to
384 /// suppress compile-time globbing), so quoting can no longer be recovered
385 /// from the value bytes — this flag carries the compile-time decision.
386 static SET_VAR_GLOB_ELIGIBLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
387}
388
389/// Register the session executor pointer (called from
390/// `install_session_executor`). See [`with_session_context`].
391pub fn register_session_executor(exec: &mut ShellExecutor) {
392 SESSION_EXECUTOR_PTR.with(|c| c.set(Some(exec as *mut ShellExecutor)));
393}
394
395/// Run `f` with the registered session executor established as
396/// `CURRENT_EXECUTOR`, so code reaching the live executor via
397/// `try_with_executor` works even when no per-command `execode` context
398/// is active yet.
399///
400/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
401/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
402/// first `execode`. Without an active context those sourced bodies
403/// `try_with_executor` → `None` → no-op, so the shell silently ignored
404/// the user's dotfiles. The scope is entered once around the startup
405/// sourcing window and dropped before the loop begins — deliberately NOT
406/// Run `f` with the registered session executor established as
407/// `CURRENT_EXECUTOR`, so code reaching the live executor via
408/// `try_with_executor` works even when no per-command `execode` context
409/// is active yet.
410///
411/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
412/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
413/// first `execode`. Without an active context those sourced bodies
414/// `try_with_executor` → `None` → no-op, so the shell silently ignored
415/// the user's dotfiles. The scope is entered once around the startup
416/// sourcing window and dropped before the loop begins — deliberately NOT
417/// a global fallback inside `execute_script_zsh_pipeline`, which would
418/// re-enter the executor on nested command substitution and block on
419/// input.
420pub fn with_session_context<R>(f: impl FnOnce() -> R) -> R {
421 let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
422 match ptr {
423 // SAFETY: set by install_session_executor to an executor that
424 // outlives the single-threaded interactive session.
425 Some(ptr) => {
426 let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
427 f()
428 }
429 None => f(),
430 }
431}
432
433/// Merge finished background-compinit results into shell state, callable
434/// from any site (active VM context first, session executor otherwise —
435/// the ZLE completion path runs OUTSIDE a VM frame, where
436/// `try_with_executor` alone is None and $_comps stayed empty forever).
437pub fn drain_compinit_bg_hook() {
438 if try_with_executor(|exec| exec.drain_compinit_bg()).is_some() {
439 return;
440 }
441 let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
442 if let Some(ptr) = ptr {
443 // SAFETY: per with_session_context.
444 let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
445 unsafe { (*ptr).drain_compinit_bg() }
446 }
447}
448
449/// RAII guard that sets/clears the thread-local executor pointer.
450///
451/// Idempotent: calling `enter` when a context is already active is a no-op
452/// for the entry side, and the guard's drop only clears the thread-local if
453/// *this* call was the one that set it. Nested `execute_command` invocations
454/// (e.g. from inside a builtin handler) reuse the outer pointer instead of
455/// stomping it.
456pub(crate) struct ExecutorContext {
457 we_set_it: bool,
458}
459
460impl ExecutorContext {
461 pub(crate) fn enter(executor: &mut ShellExecutor) -> Self {
462 let we_set_it = CURRENT_EXECUTOR.with(|cell| {
463 let mut slot = cell.borrow_mut();
464 if slot.is_some() {
465 false
466 } else {
467 *slot = Some(executor as *mut ShellExecutor);
468 true
469 }
470 });
471 ExecutorContext { we_set_it }
472 }
473}
474
475impl Drop for ExecutorContext {
476 fn drop(&mut self) {
477 if self.we_set_it {
478 CURRENT_EXECUTOR.with(|cell| {
479 *cell.borrow_mut() = None;
480 });
481 }
482 }
483}
484
485/// Access the current executor from a builtin handler.
486/// # Safety
487/// Only call this from within a VM execution context (after ExecutorContext::enter).
488#[inline]
489pub(crate) fn with_executor<F, R>(f: F) -> R
490where
491 F: FnOnce(&mut ShellExecutor) -> R,
492{
493 CURRENT_EXECUTOR.with(|cell| {
494 let ptr = cell
495 .borrow()
496 .expect("with_executor called outside VM context");
497 // SAFETY: The pointer is valid for the duration of VM execution,
498 // and we're single-threaded within the executor.
499 let executor = unsafe { &mut *ptr };
500 f(executor)
501 })
502}
503
504/// Non-panicking variant of [`with_executor`]: runs `f` against the
505/// current executor and returns `Some(result)`, or `None` when no
506/// executor is in scope (`CURRENT_EXECUTOR` unset — e.g. unit tests /
507/// compsys contexts with no fusevm bridge running).
508///
509/// This is the primitive the `crate::ported::exec` accessor wrappers
510/// (array/assoc/dispatch_function_call/execute_script/...) use to
511/// reach the live executor while preserving the exact "no executor →
512/// fall back to the direct param table / default value" semantics that
513/// the deleted `exec_hooks` OnceLock layer encoded via its
514/// "is-the-hook-installed?" check. `CURRENT_EXECUTOR` being set is the
515/// faithful equivalent of "the bridge installed the hooks".
516#[inline]
517pub(crate) fn try_with_executor<F, R>(f: F) -> Option<R>
518where
519 F: FnOnce(&mut ShellExecutor) -> R,
520{
521 CURRENT_EXECUTOR.with(|cell| {
522 let ptr = (*cell.borrow())?;
523 // SAFETY: same contract as with_executor — the pointer is valid
524 // for the duration of VM execution and access is single-threaded.
525 let executor = unsafe { &mut *ptr };
526 Some(f(executor))
527 })
528}
529
530/// Look up a canonical builtin by name in `BUILTINS` and dispatch
531/// via `execbuiltin` (Src/builtin.c:250). NO shadow check — calls the
532/// builtin even if a user function with the same name exists. Used by
533/// the `builtin foo` prefix opcode (which explicitly bypasses function
534/// lookup per zsh semantics) and by internal call sites where shadowing
535/// is unwanted. For zsh's normal name-resolution order (function shadows
536/// builtin), use `dispatch_builtin` instead.
537/// Shell-identifier prefix for diagnostic lines. Reads the canonical
538/// scriptname (`zsh` in `--zsh` parity mode, `zshrs` otherwise) so a
539/// single helper replaces hardcoded `"zshrs:"` literals across the
540/// file's eprintln paths.
541fn shname() -> String {
542 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string())
543}
544
545/// c:Src/subst.c:505-507 + Src/exec.c:3378-3380 — per-command
546/// CSH_NULL_GLOB outcome check. During this command's word expansion
547/// `expand_glob` accumulated `badcshglob |= 1` per failed glob and
548/// `|= 2` per successful one (Src/glob.c:1871-1875). Exactly 1 —
549/// failures and no successes — is the csh-style error: `no match`,
550/// command skipped, status 1. Any other value (0 = no globs, 2/3 =
551/// at least one matched) is silent. Always resets the counter for
552/// the next command (C resets at prefork entry, subst.rs:1307).
553/// Returns true when the error fired; callers mirror their
554/// glob_failed handling (builtins leave ERRFLAG_ERROR set so the
555/// script aborts, externals clear it so the next sublist runs —
556/// verified against zsh 5.9.1).
557/// Restore the user's GLOB_SUBST after a `${~spec}` carrier flip
558/// (see subst::TILDE_GLOBSUBST_CARRIER). Runs at the same
559/// command-dispatch boundaries that consume glob_failed /
560/// badcshglob — by then every glob op of the current word pipeline
561/// has read the carrier.
562pub(crate) fn consume_tilde_globsubst_carrier() {
563 crate::ported::subst::TILDE_GLOBSUBST_CARRIER.with(|c| {
564 if let Some(saved) = c.take() {
565 crate::ported::options::opt_state_set("globsubst", saved);
566 }
567 });
568}
569
570/// Pop `argc` stack slots for a whole-array assignment: the LAST popped
571/// (deepest pushed) is the param name, the rest are the values in stack
572/// order, with any `Value::Array` flattened to its elements. Mirrors the
573/// Flatten an array-assignment RHS value into scalar strings, descending
574/// through nested `Value::Array`s. zsh arrays are always flat, so recursion
575/// only collapses the wrapper layers the compiler introduces — in particular
576/// the single `Value::Array` built by `Op::MakeArray` for `arr=(...)` literals
577/// (used to dodge `CallBuiltin`'s u8 argc cap), whose own elements may
578/// themselves be arrays from an unquoted `$other_array` expansion. A
579/// one-level flatten would stringify those inner arrays into a single element.
580fn flatten_array_value(v: Value, out: &mut Vec<String>) {
581 match v {
582 Value::Array(items) => {
583 for it in items {
584 flatten_array_value(it, out);
585 }
586 }
587 other => out.push(other.to_str()),
588 }
589}
590
591/// pop/flatten prologue of BUILTIN_SET_ARRAY / BUILTIN_APPEND_ARRAY.
592fn pop_array_args_with_name(vm: &mut fusevm::VM, argc: u8) -> (String, Vec<String>) {
593 let n = argc as usize;
594 let mut popped: Vec<Value> = Vec::with_capacity(n);
595 for _ in 0..n {
596 popped.push(vm.pop());
597 }
598 popped.reverse();
599 let name = popped.pop().map(|v| v.to_str()).unwrap_or_default();
600 let mut values: Vec<String> = Vec::new();
601 for v in popped {
602 flatten_array_value(v, &mut values);
603 }
604 (name, values)
605}
606
607fn consume_badcshglob() -> bool {
608 let v = crate::ported::glob::BADCSHGLOB.swap(0, std::sync::atomic::Ordering::Relaxed);
609 if v == 1 {
610 crate::ported::utils::zerr("no match"); // c:Src/subst.c:507
611 true
612 } else {
613 false
614 }
615}
616
617/// Map a builtin name to the zsh module that owns it, IFF zsh does
618/// not auto-load that builtin on first use. Used by
619/// `dispatch_builtin_raw` to gate `--zsh` mode dispatch behind
620/// `zmodload`, mirroring `zsh -fc <name>` returning 127 for these
621/// names without an explicit module load.
622///
623/// Returns `Some(module_name)` if `name` belongs to a non-auto-load
624/// module per the per-module `Src/Modules/<x>.c` `bintab[]` plus
625/// the auto-load flag set at module-build time. `None` for core
626/// builtins and for auto-loaded module builtins (sched, log, echotc,
627/// echoti, zformat, zparseopts, zregexparse, zstyle, strftime,
628/// private, vared, zle, bindkey, comp*) which work without zmodload.
629fn module_bound_builtin_module(name: &str) -> Option<&'static str> {
630 match name {
631 "zftp" => Some("zsh/zftp"),
632 "zsocket" => Some("zsh/net/socket"),
633 "ztcp" => Some("zsh/net/tcp"),
634 "zstat" => Some("zsh/stat"),
635 "zselect" => Some("zsh/zselect"),
636 "zpty" => Some("zsh/zpty"),
637 "zprof" => Some("zsh/zprof"),
638 "zsystem" | "syserror" => Some("zsh/system"),
639 "clone" => Some("zsh/clone"),
640 "zcurses" => Some("zsh/curses"),
641 "ztie" | "zuntie" | "zgdbmpath" => Some("zsh/db/gdbm"),
642 "pcre_compile" | "pcre_match" | "pcre_study" => Some("zsh/pcre"),
643 "example" => Some("zsh/example"),
644 "cap" | "getcap" | "setcap" => Some("zsh/cap"),
645 "zgetattr" | "zsetattr" | "zdelattr" | "zlistattr" => Some("zsh/attr"),
646 // c:Src/Modules/datetime.c — `strftime` is registered via
647 // partab[] when zsh/datetime loads. Verified by
648 // `zsh -fc 'strftime -s s %Y 0'` → 127 "command not found".
649 "strftime" => Some("zsh/datetime"),
650 _ => None,
651 }
652}
653
654pub(crate) fn dispatch_builtin_raw(name: &str, args: Vec<String>) -> i32 {
655 // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
656 // Native p10k engine intercept (src/extensions/p10k): sourcing
657 // powerlevel10k.zsh-theme activates the Rust segment engine
658 // instead of executing the ~13k-line zsh theme. The user's
659 // `.p10k.zsh` CONFIG is NOT intercepted — it sources normally so
660 // its POWERLEVEL9K_* typesets land in the paramtab, which the
661 // engine reads live at every render. Placed here (the chokepoint
662 // every builtin route funnels through) so `source`, `.`, and
663 // `builtin source` all hit it.
664 if matches!(name, "source" | ".") {
665 if let Some(status) = crate::p10k::maybe_intercept_theme_source(&args) {
666 // Register a `p10k` stub function so `${+functions[p10k]}`
667 // guards in .zshrc templates stay truthy. The body forwards
668 // to the bridge-intercepted `zshrs-p10k-api` name so
669 // `p10k segment` (custom-segment protocol) and the other
670 // API subcommands reach the native engine (p10k_api).
671 try_with_executor(|exec| {
672 let _ = exec.execute_script("function p10k() { zshrs-p10k-api \"$@\" }");
673 });
674 return status;
675 }
676 }
677 // Native p10k API dispatch — the `p10k` stub function forwards
678 // here (see the theme intercept above). Must run before the
679 // generic builtintab lookup: the name is not a real builtin.
680 if let Some(status) = crate::p10k::maybe_intercept_command(name, &args) {
681 return status;
682 }
683 // c:Src/exec.c:2700-2717 — `private` is an autoloaded builtin in
684 // zsh (autofeature b:private of zsh/param/private): first use
685 // runs ensurefeature → require_module → load_module → boot_,
686 // marking the module MOD_INIT_B (what `zmodload -e` reads) and
687 // installing the wrap_private FuncWrap (param_private.c:712).
688 // doshfunc gates the wrapper dispatch on that load state, so
689 // this require_module is what activates private scoping. The
690 // raw dispatcher is the chokepoint every builtin route funnels
691 // through; require_module is idempotent after the first call
692 // (needs_load checks MOD_INIT_B).
693 if name == "private" {
694 if let Ok(mut tab) = crate::ported::module::MODULESTAB.lock() {
695 let _ = crate::ported::module::require_module(&mut tab, "zsh/param/private", None, 0);
696 // c:2710 ensurefeature
697 }
698 }
699 // c:Src/Modules/param_private.c:682-685 setup_ — loading
700 // zsh/param/private REPLACES the `local` builtintab node's
701 // handlerfunc + optstr with bin_private's ("Even more horrible
702 // hack"), so once the module is loaded `local` IS bin_private: it
703 // accepts the -P/-Pa private-scope flags, and without -P delegates
704 // to bin_typeset, which already treats `local` and `private`
705 // identically (is_locallike, builtin.rs:3666). Replicate the swap by
706 // routing `local` through the `private` node only after the module
707 // is loaded — before then, `local -P` still errors "bad option: -P"
708 // exactly like stock zsh. The `private` node carries the augmented
709 // optstr (with P) that the `local` node lacks.
710 if name == "local"
711 && crate::ported::module::MODULESTAB
712 .lock()
713 .map(|t| t.is_bound("zsh/param/private"))
714 .unwrap_or(false)
715 {
716 return dispatch_builtin_raw("private", args);
717 }
718 // c:Bugs #475/#504/#555 — bash-only builtins (`mapfile`,
719 // `readarray`, `compopt`) should emit "command not found" in
720 // `--zsh` mode matching zsh's external-command-lookup miss.
721 // The per-opcode closures for caller/help/complete/compgen
722 // already gate via IS_ZSH_MODE at their registration sites;
723 // names without dedicated opcodes (compopt/mapfile/readarray)
724 // route through this generic builtintab lookup and need the
725 // gate here.
726 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
727 && matches!(name, "compopt" | "mapfile" | "readarray")
728 {
729 eprintln!("zsh:1: command not found: {}", name);
730 let _ = args;
731 return 127;
732 }
733 // c:Src/exec.c:2700-2724 resolvebuiltin — autoloaded-builtin stub
734 // (registered by `zmodload -ab MOD NAME`, Src/module.c:426
735 // add_autobin) fires on first use: ensurefeature loads the owning
736 // module, then dispatch proceeds against the real builtin. Must
737 // run BEFORE the module-bound 127 gate below — `zmodload -ab
738 // zsh/zselect zselect; zselect` previously died there with
739 // `command not found` because the gate only checked is_loaded,
740 // never the autoload ledger.
741 if let Some(rc) = crate::ported::module::resolvebuiltin(name) {
742 if rc != 0 {
743 // Load failed or feature undefined — diagnostics already
744 // printed (load_module zwarn / resolvebuiltin zerr).
745 // C's execbuiltin head returns 1 (Src/builtin.c:264-267).
746 return 1;
747 }
748 // Module loaded — fall through; the is_loaded gates below now
749 // pass and the normal dispatch chain runs the real builtin.
750 }
751 // c:Src/Modules/<mod>.c boot_/setup_ chain — module-bound builtins
752 // (zftp, zsocket, ztcp, zstat, etc.) are only registered into
753 // `builtintab` when their module is loaded via `zmodload`. In
754 // zsh `-fc` (the parity test harness's invocation), the modules
755 // are NOT pre-loaded, so each name reports "command not found"
756 // with exit 127. zshrs intentionally pre-loads all module bintabs
757 // in `createbuiltintable` (builtin.rs:131-152) for the default
758 // mode so users can call these without `zmodload`; that auto-load
759 // diverges from zsh's gate behavior. Match zsh's stance only when
760 // the user explicitly asked for parity via `--zsh`.
761 //
762 // The list is the union of builtins from modules that zsh does
763 // NOT auto-load (verified via `zsh -fc <name>` returning 127):
764 // zsh/zftp → zftp
765 // zsh/net/socket → zsocket
766 // zsh/net/tcp → ztcp
767 // zsh/stat → zstat (NOT `stat`; that name resolves to
768 // /bin/stat on PATH per zsh's setup)
769 // zsh/zselect → zselect
770 // zsh/zpty → zpty
771 // zsh/zprof → zprof
772 // zsh/system → zsystem, syserror
773 // zsh/clone → clone
774 // zsh/curses → zcurses
775 // zsh/db/gdbm → ztie, zuntie, zgdbmpath
776 // zsh/pcre → pcre_compile, pcre_match, pcre_study
777 // zsh/example → example
778 // zsh/cap → cap, getcap, setcap
779 // zsh/attr → zgetattr, zsetattr, zdelattr, zlistattr
780 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
781 && module_bound_builtin_module(name)
782 .map(|m| {
783 !crate::ported::module::MODULESTAB
784 .lock()
785 .map(|t| t.is_loaded(m))
786 .unwrap_or(false)
787 })
788 .unwrap_or(false)
789 {
790 eprintln!("zsh:1: command not found: {}", name);
791 let _ = args;
792 return 127;
793 }
794 // c:Src/Modules/files.c:806-824 — zsh/files registers `chmod`,
795 // `chown`, `chgrp`, `ln`, `mkdir`, `mv`, `rm`, `rmdir`, `sync`
796 // (plus their `zf_*` aliases) into builtintab on module load.
797 // Without an explicit `zmodload zsh/files`, zsh resolves the
798 // names through PATH lookup — `zsh -fc 'chmod +x f'` runs
799 // `/bin/chmod`, whose argv-parser accepts symbolic modes like
800 // `+x` that bin_chmod's octal-only parser rejects with
801 // "invalid mode `+x'". The shadow-aware wrapper at
802 // `dispatch_builtin` (line 438) already has this gate, but the
803 // direct `dispatch_builtin_raw` path used by fusevm's
804 // CallBuiltin opcode bypasses it. Mirror the gate here so the
805 // low-level dispatch matches C's PATH-fall-through behavior.
806 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
807 && module_gated_files_builtin(name)
808 && !crate::ported::module::MODULESTAB
809 .lock()
810 .map(|t| t.is_loaded("zsh/files"))
811 .unwrap_or(false)
812 {
813 // PATH lookup uses the LITERAL name: bare `mkdir` finds
814 // /bin/mkdir; a `zf_*` alias finds nothing and exits 127 —
815 // matching zsh -fc `zf_mkdir d` → "command not found:
816 // zf_mkdir" (the aliases exist ONLY in the loaded module's
817 // builtintab, Src/Modules/files.c:816-824; PATH has no
818 // /bin/zf_rm). The previous zf_-strip silently ran the
819 // system binary instead.
820 let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
821 return status;
822 }
823 // c:Src/Modules/stat.c:637-638 — zsh/stat registers BOTH `stat`
824 // and `zstat`. `zstat` is in the module_bound 127-gate above (no
825 // /usr/bin/zstat exists), but the bare `stat` name must FALL
826 // THROUGH to PATH when zsh/stat isn't loaded — zsh -fc
827 // 'stat -f %Lp f' runs /usr/bin/stat, while bin_stat's parser
828 // rejects stat(1) flags ("bad option: -c"). Same fall-through
829 // shape as the zsh/files gate above.
830 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
831 && name == "stat"
832 && !crate::ported::module::MODULESTAB
833 .lock()
834 .map(|t| t.is_loaded("zsh/stat"))
835 .unwrap_or(false)
836 {
837 let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
838 return status;
839 }
840 // c:Src/exec.c:3050-3068 — builtin lookup hits `builtintab` (the
841 // merged table containing module-provided builtins). The previous
842 // port walked only the core `BUILTINS` slice, so per-module
843 // entries like `log` (Src/Modules/watch.c:693 `BUILTIN("log", …,
844 // bin_log, …)`) were registered into builtintab via
845 // createbuiltintable but never reached at dispatch — `log` fell
846 // through to PATH and ran `/usr/bin/log`. Bug #72 in docs/BUGS.md.
847 let tab = crate::ported::builtin::createbuiltintable();
848 if let Some(bn_static) = tab.get(name) {
849 let bn_ptr = *bn_static as *const _ as *mut _;
850 return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
851 }
852 1
853}
854
855/// Shadow-aware dispatch matching zsh's name-resolution order:
856/// alias → reserved word → **function (shadows builtin)** → builtin →
857/// external. All `BUILTIN_X` opcode handlers route through here so a
858/// user-defined `cd () { … }` (or `r`, `fc`, `which`, … anything in
859/// fusevm's name→opcode map) takes precedence over the C builtin —
860/// matching `Src/exec.c:execcmd_exec`'s dispatch at c:3050-3068.
861/// Without this, compile-time builtin resolution silently ignored
862/// user wrappers (e.g. ZPWR's `cd () { builtin cd "$@"; … }`).
863/// True for builtins that are bound by zsh/files's boot_/setup_
864/// chain (Src/Modules/files.c:806-824). These are the bare-name
865/// `mkdir`/`rm`/`mv`/`ln`/`chmod`/`chown`/`chgrp`/`sync`/`rmdir`
866/// AND their `zf_*` aliases at c:816-824. Without explicit
867/// `zmodload zsh/files`, the names fall through to PATH lookup
868/// (zsh's `type rm` reports `/bin/rm`). Bug #28.
869fn module_gated_files_builtin(name: &str) -> bool {
870 matches!(
871 name,
872 "mkdir"
873 | "rmdir"
874 | "rm"
875 | "mv"
876 | "ln"
877 | "chmod"
878 | "chown"
879 | "chgrp"
880 | "sync"
881 | "zf_mkdir"
882 | "zf_rmdir"
883 | "zf_rm"
884 | "zf_mv"
885 | "zf_ln"
886 | "zf_chmod"
887 | "zf_chown"
888 | "zf_chgrp"
889 | "zf_sync"
890 )
891}
892
893pub(crate) fn dispatch_builtin(name: &str, args: Vec<String>) -> i32 {
894 // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close any
895 // `>(cmd)` write ends owned by this command once it finishes
896 // (drops on every return path below).
897 let _psub_fds = PsubFdGuard;
898 // c:Src/exec.c — when any redirect in the current scope failed
899 // (e.g. noclobber blocked a `>` overwrite), zsh refuses to
900 // execute the command and exits with status 1. The Rust port
901 // still applied the command (writing to the /dev/null sink
902 // installed by host_apply_redirect's noclobber arm) so the
903 // success status overwrote the intended 1. Short-circuit here
904 // for builtins (the external-exec equivalent lives in
905 // ZshrsHost::exec).
906 let redir_failed = with_executor(|exec| {
907 let f = exec.redirect_failed;
908 exec.redirect_failed = false;
909 f
910 });
911 if redir_failed {
912 // c:Src/exec.c:4367-4386 — POSIX special-builtin escalation:
913 // a failed redirect on a PSPECIAL builtin (set, readonly,
914 // typeset, ...) under POSIX_BUILTINS is FATAL in a
915 // non-interactive shell (`exit(1)` at c:4383). The `command`
916 // prefix resets this (BINF_COMMAND, c:4369) — that path
917 // dispatches through bin_command, not here.
918 if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
919 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
920 && builtin_is_pspecial(name)
921 {
922 use std::sync::atomic::Ordering;
923 crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
924 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
925 }
926 return 1;
927 }
928 // c:Src/glob.c:1876-1880 NOMATCH path — when expand_glob() failed
929 // on a no-match glob, zsh aborts the simple command after zerr()
930 // printed "no matches found". In C, this works because zerr()
931 // sets ERRFLAG_ERROR (Src/utils.c) and execcmd_exec()
932 // (Src/exec.c:3050+) checks errflag before invoking the builtin
933 // table. Rust's builtin dispatch doesn't sit on the same errflag
934 // gate, so we explicitly consume the per-command glob-fail cell
935 // and short-circuit with status 1. Mirrors the external-path
936 // guard at host_exec_external (line 5167). Without this:
937 // `echo /never/*` would print empty (silently rolled back to ""
938 // by the empty glob expansion). Parity bug #13.
939 consume_tilde_globsubst_carrier();
940 let glob_failed = with_executor(|exec| {
941 let f = exec.current_command_glob_failed.get();
942 exec.current_command_glob_failed.set(false); // c:1879 cleanup
943 f
944 });
945 if glob_failed {
946 // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH zerr sets
947 // ERRFLAG_ERROR (via utils.c:184). For a BUILTIN command the
948 // expansion runs IN the shell process, so errflag stays set
949 // and the rest of the input aborts (zsh -fc 'echo /nope_*;
950 // echo after' prints nothing after the error — verified
951 // against zsh 5.9). The continue-after-nomatch behaviour
952 // belongs ONLY to externals: C forks BEFORE expansion there,
953 // so the child's zerr can't touch the parent's errflag (zsh
954 // -fc 'ls /nope_*; echo after' prints `after`) — that path's
955 // clear lives in fn exec / execute_external. Leave
956 // ERRFLAG_ERROR set here; BUILTIN_ERREXIT_CHECK trigger 4
957 // aborts the remaining script at the next command boundary.
958 return 1; // c:1880 — command aborted, status 1
959 }
960 // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the NOMATCH
961 // gate above: all of this command's globs failed silently (words
962 // dropped, badcshglob accumulated 1s and no 2s) → `no match`,
963 // skip the builtin, status 1. Like the NOMATCH path, ERRFLAG
964 // from zerr stays set for builtins so the rest of the script
965 // aborts (zsh -fc 'setopt cshnullglob; print *nope* x; print
966 // after' prints only the error — verified zsh 5.9.1).
967 if consume_badcshglob() {
968 // c:Src/exec.c:3380 — `lastval = 1;` so the shell's final
969 // exit status reflects the aborted command.
970 with_executor(|exec| exec.set_last_status(1));
971 return 1;
972 }
973 // c:Src/exec.c:4162-4295 — assignment-builtin (BINF_ASSIGN family:
974 // typeset / declare / local / export / readonly / integer / float /
975 // private) whose `name=value` postassign arg raised errflag while
976 // its RHS was preforked (PREFORK_ASSIGN, c:4239-4245) — the classic
977 // case is a math error in `typeset -F fv=$((1/0))`. The postassign
978 // loop `break`s on errflag (c:4243) and then `if (!errflag)
979 // execbuiltin(...)` (c:4287) SKIPS the builtin entirely, so `lastval`
980 // is left UNCHANGED from before the command (0 fresh, 1 after
981 // `false`). This differs from a PLAIN assignment `x=$((1/0))`, which
982 // goes through execsimple c:1375 `lv = errflag ? errflag : cmdoutval`
983 // → 1, and from a NON-assign builtin `print $((1/0))`, whose main
984 // args-prefork errflag lands on c:3760 `lastval = 1`. Only the
985 // assignment-BUILTIN postassign path preserves the prior status.
986 // Mirror it here: the fusevm reg_passthru dispatch still calls us
987 // with errflag set (unlike C's pre-invoke gate), so consume that
988 // state and return the prior LASTVAL instead of running the builtin.
989 {
990 use std::sync::atomic::Ordering;
991 let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
992 let ef = live & crate::ported::zsh_h::ERRFLAG_ERROR;
993 let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
994 // Only the SOFT recoverable error (math failure like `$((1/0))`,
995 // ERRFLAG_ERROR without ERRFLAG_HARD) preserves the prior status
996 // per c:4287. A HARD error (`${var?msg}`, which c:Src/subst.c
997 // OR's ERRFLAG_HARD onto errflag) is a script-abort that yields
998 // status 1 regardless of the prior status — leave that to the
999 // normal dispatch/abort path below (which returns 1 and keeps
1000 // ERRFLAG_HARD set for the downstream errexit gate).
1001 if ef != 0 && hard == 0 && builtin_is_assign_family(name) {
1002 // c:4287 — execbuiltin skipped; lastval unchanged.
1003 return crate::ported::builtin::LASTVAL.load(Ordering::Relaxed);
1004 }
1005 }
1006 if let Some(status) = try_user_fn_override(name, &args) {
1007 // c:Src/jobs.c:1748 waitonejob — canonical single-command
1008 // pipestats update via the no-procs else-branch.
1009 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1010 let mut synth = crate::ported::zsh_h::job::default();
1011 crate::ported::jobs::waitonejob(&mut synth);
1012 return status;
1013 }
1014 // c:Src/builtin.c:587 + Src/exec.c:3056 — a builtin disabled via
1015 // `disable <name>` has its `DISABLED` flag set in `builtintab`;
1016 // `builtintab->getnode` (the DISABLED-filtering accessor) returns
1017 // NULL for it at lookup time, so execcmd_exec falls through to
1018 // PATH lookup and runs the external. The Rust port stores the
1019 // disabled set in `BUILTINS_DISABLED`; the previous dispatcher
1020 // only checked the immutable `createbuiltintable` HashMap which
1021 // never reflects disablement — so `disable echo; echo hi` kept
1022 // running the bin_echo builtin. Bug #106 in docs/BUGS.md.
1023 //
1024 // dispatch_builtin (the high-level wrapper used by the BUILTIN_*
1025 // opcode handlers and reg_passthru! callsites) is the correct
1026 // gate: `dispatch_builtin_raw` is the low-level entry point
1027 // used by `bin_builtin` itself which MUST bypass the disabled
1028 // set (man zshbuiltins: `builtin name` runs the builtin
1029 // regardless of disable state). Place the check here so the
1030 // bypass path stays clean.
1031 let disabled = crate::ported::builtin::BUILTINS_DISABLED
1032 .lock()
1033 .map(|s| s.contains(name))
1034 .unwrap_or(false);
1035 if disabled {
1036 let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1037 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1038 let mut synth = crate::ported::zsh_h::job::default();
1039 crate::ported::jobs::waitonejob(&mut synth);
1040 return status;
1041 }
1042 // c:Src/Modules/files.c:806-814 — `mkdir`, `rm`, `mv`, `ln`, `chmod`,
1043 // `chown`, `chgrp`, `sync`, `rmdir` are bound by the `zsh/files`
1044 // module's boot_/setup_ chain. Without explicit `zmodload zsh/files`,
1045 // these bare names fall through to PATH (`/bin/rm`, `/usr/bin/chmod`,
1046 // etc.) in zsh; `type rm` reports `rm is /bin/rm`. The `zf_*`
1047 // aliases (`zf_rm`, `zf_chmod`, …) are bound by the same module
1048 // and gated the same way. Bug #28 in docs/BUGS.md.
1049 if module_gated_files_builtin(name) {
1050 if !crate::ported::module::MODULESTAB
1051 .lock()
1052 .unwrap()
1053 .is_loaded("zsh/files")
1054 {
1055 // PATH lookup uses the literal name. In --zsh parity mode
1056 // `zf_rm` must 127 like zsh -fc (no /bin/zf_rm); default
1057 // zshrs mode keeps the convenience zf_-strip so the alias
1058 // still reaches the system binary.
1059 let path_name = if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1060 name
1061 } else {
1062 name.strip_prefix("zf_").unwrap_or(name)
1063 };
1064 let status =
1065 with_executor(|exec| exec.execute_external(path_name, &args, &[])).unwrap_or(127);
1066 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1067 let mut synth = crate::ported::zsh_h::job::default();
1068 crate::ported::jobs::waitonejob(&mut synth);
1069 return status;
1070 }
1071 }
1072 // c:Src/exec.c:3997 `int q = queue_signal_level();`
1073 // c:Src/exec.c:4231 `dont_queue_signals();`
1074 // c:Src/exec.c:4243 `restore_queue_signals(q);`
1075 //
1076 // C runs EVERY builtin with signal queueing switched OFF. Two
1077 // consequences the zshrs port was missing:
1078 //
1079 // 1. `dont_queue_signals()` DRAINS the pending queue (it calls
1080 // run_queued_signals()), so a signal that arrived while an
1081 // enclosing scope held queue_signals() — doshfunc holds one
1082 // for the whole call, c:Src/exec.c:5835 — fires its trap at
1083 // the NEXT command boundary rather than at function exit.
1084 // 2. While the builtin runs, queueing stays off, so a signal the
1085 // builtin sends to itself (`kill -USR1 $$`) dispatches the
1086 // trap synchronously inside the builtin — which is why zsh
1087 // prints pre/trap/post for
1088 // `f() { print pre; kill -USR1 $$; print post }`.
1089 //
1090 // Without this bracket every trap raised inside a function was
1091 // deferred to the enclosing unqueue_signals() (i.e. script end).
1092 let q = crate::ported::signals_h::queue_signal_level(); // c:3997
1093 crate::ported::signals_h::dont_queue_signals(); // c:4231
1094 let status = dispatch_builtin_raw(name, args);
1095 crate::ported::signals_h::restore_queue_signals(q); // c:4243
1096 // c:Src/jobs.c:1748 waitonejob — canonical single-command pipestats update.
1097 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1098 let mut synth = crate::ported::zsh_h::job::default();
1099 crate::ported::jobs::waitonejob(&mut synth);
1100 // c:Src/exec.c:4367-4386 — done: tail. A PSPECIAL builtin that
1101 // raised errflag under POSIX_BUILTINS exits the non-interactive
1102 // shell with status 1 ("hard error in POSIX" — e.g. bin_dot's
1103 // zerrnam at Src/builtin.c:6133). Arm the deferred-exit pair so
1104 // the next ERREXIT_CHECK unwinds; EXIT_VAL=1 matches C's
1105 // hardcoded exit(1), NOT the builtin's own status (dot returns
1106 // 127 but POSIX exits 1).
1107 if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
1108 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
1109 && builtin_is_pspecial(name)
1110 && (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
1111 & crate::ported::zsh_h::ERRFLAG_ERROR)
1112 != 0
1113 {
1114 use std::sync::atomic::Ordering;
1115 crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
1116 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
1117 }
1118 status
1119}
1120
1121/// c:Src/zsh.h:1467 BINF_PSPECIAL — true when `name` is a POSIX
1122/// special builtin per the canonical builtin table flags
1123/// (Src/builtin.c:48-129: `.`, `:`, break, continue, declare, eval,
1124/// exit, export, float, integer, local, readonly, return, set,
1125/// shift, source, times, trap, typeset, unset).
1126fn builtin_is_pspecial(name: &str) -> bool {
1127 crate::ported::builtin::createbuiltintable()
1128 .get(name)
1129 .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_PSPECIAL) != 0)
1130 .unwrap_or(false)
1131}
1132
1133/// c:Src/zsh.h:1486 BINF_ASSIGN — the assignment-builtin family
1134/// (typeset / declare / local / export / readonly / integer / float /
1135/// private). Their `name=value` args are handled as postassigns
1136/// (c:Src/exec.c:4162-4295), whose errflag-abort skips execbuiltin and
1137/// preserves the prior `lastval`. Read the flag straight from the
1138/// builtin table (same pattern as `builtin_is_pspecial`).
1139fn builtin_is_assign_family(name: &str) -> bool {
1140 crate::ported::builtin::createbuiltintable()
1141 .get(name)
1142 .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_ASSIGN) != 0)
1143 .unwrap_or(false)
1144}
1145
1146// The former `install_exec_hooks()` fn-pointer registry is gone. Code
1147// under `src/ported/` now reaches `ShellExecutor` operations
1148// (array/assoc storage, script eval, function dispatch, command
1149// substitution) through the `crate::ported::exec::*` accessor wrappers,
1150// which resolve the live executor via `try_with_executor`
1151// (`CURRENT_EXECUTOR`). The bridge lives in exec.rs — the sanctioned
1152// fusevm-access exception — per `feedback_no_exec_script_from_ported` /
1153// `feedback_no_shellexecutor_in_ported`.
1154
1155/// Register all zsh builtins with the VM.
1156pub(crate) fn register_builtins(vm: &mut fusevm::VM) {
1157 // src/ported/ reaches the live executor (param store, function
1158 // dispatch, nested script/cmdsubst exec) through the
1159 // `crate::ported::exec::*` accessor wrappers, which read
1160 // `CURRENT_EXECUTOR` via `try_with_executor`. No install step is
1161 // needed: the executor is in scope for the duration of any VM run
1162 // (set by `ExecutorContext::enter`), so the wrappers resolve it
1163 // directly. (Replaces the former `exec_hooks` OnceLock fn-ptr
1164 // registry, now deleted.)
1165 // Engage fusevm's tiered JIT (block + tracing) so hot, fully-eligible
1166 // numeric chunks run in native code and — with the `jit-disk-cache`
1167 // feature (on by default) — persist that native code to
1168 // `~/.cache/fusevm-jit`, letting repeated zsh invocations skip Cranelift
1169 // codegen. fusevm gates the JIT on per-chunk eligibility and warms up by
1170 // an invocation threshold, falling back to the interpreter for any chunk
1171 // it cannot compile (e.g. host-builtin/`Extended` command dispatch), so
1172 // enabling it here never changes observable behaviour — it only caches
1173 // the numeric hot path. Idempotent: re-enabling on each VM is a no-op.
1174 vm.enable_tracing_jit();
1175 // Macro for builtins that user functions are allowed to shadow.
1176 // zsh dispatch order is alias → function → builtin; without the
1177 // try_user_fn_override probe a `cat() { ... }; cat` would silently
1178 // run the C builtin and ignore the user function.
1179 macro_rules! reg_overridable {
1180 ($vm:expr, $id:expr, $name:literal, $method:ident) => {
1181 $vm.register_builtin($id, |vm, argc| {
1182 let args = pop_args(vm, argc);
1183 // c:Src/exec.c getproc + Src/jobs.c deletefilelist —
1184 // close `>(cmd)` write ends owned by this command
1185 // once it finishes (shadows bypass dispatch_builtin
1186 // and ZshrsHost::exec, so they need their own guard:
1187 // `tee >(wc -c) </dev/null` left wc blocked).
1188 let _psub_fds = PsubFdGuard;
1189 if let Some(s) = try_user_fn_override($name, &args) {
1190 return Value::Status(s);
1191 }
1192 // c:Src/exec.c — redirect failure in the current
1193 // scope means the command must NOT run. coreutils
1194 // shadows (cat / head / tail / etc.) take a separate
1195 // dispatch path from dispatch_builtin, so they need
1196 // their own gate. Without this `cat <&3` after a
1197 // closed-fd diagnostic still ran the shadow and
1198 // overwrote $? from the forced 1.
1199 let redir_failed = with_executor(|exec| {
1200 let f = exec.redirect_failed;
1201 exec.redirect_failed = false;
1202 f
1203 });
1204 if redir_failed {
1205 return Value::Status(1);
1206 }
1207 // `[builtins].coreutils_shadows = off` in
1208 // ~/.zshrs/zshrs.toml (or `ZSHRS_NO_COREUTILS_SHADOWS=1`
1209 // env override) bypasses the in-process shadow and
1210 // fork-execs the real /bin/X. Safety valve for any
1211 // script that hits an edge-case divergence between
1212 // the zshrs shadow and system coreutils. Cached
1213 // after first call, so the hot path is one atomic
1214 // load per shadowed-builtin invocation.
1215 if !crate::daemon_presence::coreutils_shadows_enabled() {
1216 return Value::Status(exec_system_command($name, &args));
1217 }
1218 let status = with_executor(|exec| exec.$method(&args));
1219 Value::Status(status)
1220 });
1221 };
1222 }
1223
1224 // Pure-passthru builtin: pops args, routes to canonical
1225 // `dispatch_builtin(name, args)` (which goes via execbuiltin →
1226 // BUILTINS[name] → bin_X). No pre/post bridge work. Used by
1227 // ~25 handlers that were 4-line copy-paste boilerplate.
1228 macro_rules! reg_passthru {
1229 ($vm:expr, $id:expr, $name:literal) => {
1230 $vm.register_builtin($id, |vm, argc| {
1231 Value::Status(dispatch_builtin($name, pop_args(vm, argc)))
1232 });
1233 };
1234 }
1235
1236 // Core builtins
1237 vm.register_builtin(BUILTIN_CD, |vm, argc| {
1238 let args = pop_args(vm, argc);
1239 if let Some(s) = try_user_fn_override("cd", &args) {
1240 return Value::Status(s);
1241 }
1242 let status = dispatch_builtin("cd", args);
1243 // c:Src/builtin.c:1258 — `callhookfunc("chpwd", NULL, 1, NULL)`
1244 // after cd succeeds. The canonical port at
1245 // src/ported/utils.rs:1532 handles both the `chpwd` shfunc
1246 // dispatch AND the `chpwd_functions` array walk.
1247 if status == 0 {
1248 crate::ported::utils::callhookfunc("chpwd", None, 1, std::ptr::null_mut());
1249 }
1250 Value::Status(status)
1251 });
1252
1253 vm.register_builtin(BUILTIN_PWD, |vm, argc| {
1254 let args = pop_args(vm, argc);
1255 if let Some(s) = try_user_fn_override("pwd", &args) {
1256 return Value::Status(s);
1257 }
1258 // Route through the canonical execbuiltin path so the `rLP`
1259 // optstr at BUILTINS["pwd"] is parsed into `ops`.
1260 let status = dispatch_builtin("pwd", args);
1261 Value::Status(status)
1262 });
1263
1264 vm.register_builtin(BUILTIN_ECHO, |vm, argc| {
1265 let args = pop_args(vm, argc);
1266 if let Some(s) = try_user_fn_override("echo", &args) {
1267 return Value::Status(s);
1268 }
1269 // Update `$_` to the last arg before running. C zsh sets
1270 // zunderscore in execcmd_exec for every simple command,
1271 // including builtins.
1272 crate::ported::params::set_zunderscore(&args);
1273 let status = dispatch_builtin("echo", args);
1274 Value::Status(status)
1275 });
1276
1277 vm.register_builtin(BUILTIN_PRINT, |vm, argc| {
1278 let args = pop_args(vm, argc);
1279 if let Some(s) = try_user_fn_override("print", &args) {
1280 return Value::Status(s);
1281 }
1282 crate::ported::params::set_zunderscore(&args);
1283 let status = dispatch_builtin("print", args);
1284 Value::Status(status)
1285 });
1286
1287 reg_passthru!(vm, BUILTIN_PRINTF, "printf");
1288 reg_passthru!(vm, BUILTIN_EXPORT, "export");
1289 reg_passthru!(vm, BUILTIN_UNSET, "unset");
1290 // `source` (Src/builtin.c c:116) wired to bin_dot via BUILTINS.
1291 reg_passthru!(vm, BUILTIN_SOURCE, "source");
1292 reg_passthru!(vm, BUILTIN_DOT, ".");
1293 reg_passthru!(vm, BUILTIN_LOGOUT, "logout");
1294
1295 vm.register_builtin(BUILTIN_EXIT, |vm, argc| {
1296 let args = pop_args(vm, argc);
1297 let status = dispatch_builtin("exit", args);
1298 Value::Status(status)
1299 });
1300
1301 vm.register_builtin(BUILTIN_RETURN, |vm, argc| {
1302 let args = pop_args(vm, argc);
1303 // zsh: bare `return` (no arg) returns with the status of
1304 // the most recently executed command — `false; return`
1305 // returns 1, not 0. Direct port of zsh's bin_break/RETURN.
1306 // The executor's `last_status` is stale here (synced at
1307 // statement boundaries, not after each VM op), so read
1308 // the live `vm.last_status` instead.
1309 let live_status = vm.last_status;
1310 let status = {
1311 // Sync canonical LASTVAL to the VM's view BEFORE
1312 // bin_break("return") reads it for the no-arg fallback.
1313 with_executor(|exec| exec.set_last_status(live_status));
1314 dispatch_builtin("return", args)
1315 };
1316 Value::Status(status)
1317 });
1318
1319 vm.register_builtin(BUILTIN_TRUE, |vm, argc| {
1320 let args = pop_args(vm, argc);
1321 if let Some(s) = try_user_fn_override("true", &args) {
1322 return Value::Status(s);
1323 }
1324 // c:Src/exec.c:1257 — zsh sets `zunderscore` AT THE END of
1325 // each command (the `if (!noerrs)` block runs `zsfree(prev_argv0); …;
1326 // zunderscore = …`). For no-arg `true`, $_ becomes the
1327 // command name itself. Set DIRECTLY (not via pending_underscore)
1328 // so the NEXT command's argv-expansion of `$_` reads "true",
1329 // not the stale prior value — pending_underscore is consumed
1330 // by pop_args which runs AFTER argv expansion, too late.
1331 // c:Src/exec.c:1257 — `zunderscore = …` at end-of-command.
1332 // With args, $_ = args.last(). Without args, $_ = command name.
1333 // Write DIRECTLY to the canonical zunderscore static (the
1334 // underscoregetfn at params.rs:7003 reads from there); the
1335 // paramtab "_" slot is shadowed by lookup_special_var so
1336 // set_scalar on it has no effect on `$_` reads.
1337 if args.is_empty() {
1338 crate::ported::params::set_zunderscore(&["true".to_string()]);
1339 } else {
1340 crate::ported::params::set_zunderscore(&args);
1341 }
1342 // Route through canonical execbuiltin so PS4 xtrace fires
1343 // via the c:442 printprompt4 path.
1344 Value::Status(dispatch_builtin("true", args))
1345 });
1346 vm.register_builtin(BUILTIN_FALSE, |vm, argc| {
1347 let args = pop_args(vm, argc);
1348 if let Some(s) = try_user_fn_override("false", &args) {
1349 return Value::Status(s);
1350 }
1351 // Direct set; see BUILTIN_TRUE above for rationale.
1352 if args.is_empty() {
1353 crate::ported::params::set_zunderscore(&["false".to_string()]);
1354 } else {
1355 crate::ported::params::set_zunderscore(&args);
1356 }
1357 // Route through canonical execbuiltin — see BUILTIN_TRUE
1358 // above for the same rationale (xtrace + fast-path removal).
1359 let status = dispatch_builtin("false", args);
1360 Value::Status(status)
1361 });
1362 vm.register_builtin(BUILTIN_COLON, |vm, argc| {
1363 let args = pop_args(vm, argc);
1364 // Direct set; see BUILTIN_TRUE above for rationale.
1365 if args.is_empty() {
1366 crate::ported::params::set_zunderscore(&[":".to_string()]);
1367 } else {
1368 crate::ported::params::set_zunderscore(&args);
1369 }
1370 let status = dispatch_builtin(":", args);
1371 Value::Status(status)
1372 });
1373
1374 vm.register_builtin(BUILTIN_TEST, |vm, argc| {
1375 let args = pop_args(vm, argc);
1376 // Distinguish `[ … ]` from `test …` by sniffing the trailing
1377 // `]` — `[` requires it (c:Src/builtin.c:7241), `test` rejects
1378 // it. The compile path emits BUILTIN_TEST for both, so the
1379 // dispatch name carries the `[` vs `test` semantic for
1380 // execbuiltin's funcid (BIN_BRACKET=21 vs BIN_TEST=20). Without
1381 // this, bin_test's `if func == BIN_BRACKET` arm (which pops
1382 // the trailing `]`) never fired for `[` calls, so the `]`
1383 // leaked into evalcond as a positional and silently changed
1384 // the result. Bug surfaced via test_test_dashdash_unknown_condition.
1385 let name = if args.last().map(|s| s.as_str()) == Some("]") {
1386 "["
1387 } else {
1388 "test"
1389 };
1390 let status = dispatch_builtin(name, args);
1391 Value::Status(status)
1392 });
1393
1394 // Variable declaration. `local` (Src/builtin.c bin_local) handles
1395 // the scope chain (`pm->old = oldpm` at Src/params.c:1137 inside
1396 // createparam, `pm->level = locallevel` at Src/builtin.c:2576).
1397 // `typeset` / `declare` are aliases — fusevm maps both to
1398 // BUILTIN_TYPESET; compile_zsh special-cases `declare` to keep
1399 // the `declare:` error prefix.
1400 reg_passthru!(vm, BUILTIN_LOCAL, "local");
1401 reg_passthru!(vm, BUILTIN_TYPESET, "typeset");
1402
1403 reg_passthru!(vm, BUILTIN_DECLARE, "declare");
1404 reg_passthru!(vm, BUILTIN_READONLY, "readonly");
1405 reg_passthru!(vm, BUILTIN_INTEGER, "integer");
1406 reg_passthru!(vm, BUILTIN_FLOAT, "float");
1407 reg_passthru!(vm, BUILTIN_READ, "read");
1408 // c:Bug #504 — fusevm reserves BUILTIN_MAPFILE for the bash
1409 // mapfile/readarray builtins. Neither exists in zsh; in --zsh
1410 // parity mode the dispatch must emit "command not found" + rc=127
1411 // matching zsh's external-command-lookup miss. The previous wiring
1412 // left BUILTIN_MAPFILE unregistered, so fusevm's VM treated the op
1413 // as a no-op rc=0 — `mapfile` (and `readarray`) silently succeeded
1414 // in --zsh mode. The host gate in `dispatch_builtin_raw` never
1415 // fired because the compile path emitted `Op::CallBuiltin(31, ..)`
1416 // directly. Register the slot so the gate runs (or a future
1417 // non-zsh mode can wire in a real impl).
1418 vm.register_builtin(fusevm::shell_builtins::BUILTIN_MAPFILE, |vm, argc| {
1419 let args = pop_args(vm, argc);
1420 // The fusevm name→id map collapses both `mapfile` and
1421 // `readarray` to the same opcode; pick the right diagnostic
1422 // by sniffing the user's actual invocation. The xtrace ARGS
1423 // push earlier records the cmd-prefix as the bottom of the
1424 // popped argv, but `args` here excludes the prefix — so we
1425 // can't recover the user-typed name from the stack. Default
1426 // to `mapfile` (the more-common spelling); both produce
1427 // identical diagnostics in any case.
1428 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1429 eprintln!("zsh:1: command not found: mapfile");
1430 let _ = args;
1431 return Value::Status(127);
1432 }
1433 // Non-zsh modes (bash drop-in) get a passthru to the canonical
1434 // dispatcher in case a future port adds a real mapfile builtin
1435 // — until then the rc=1 unknown-command default applies.
1436 Value::Status(dispatch_builtin("mapfile", args))
1437 });
1438 reg_passthru!(vm, BUILTIN_BREAK, "break");
1439 reg_passthru!(vm, BUILTIN_CONTINUE, "continue");
1440 reg_passthru!(vm, BUILTIN_SHIFT, "shift");
1441
1442 vm.register_builtin(BUILTIN_EVAL, |vm, argc| {
1443 // Direct port of `bin_eval(UNUSED(char *nam), char **argv, UNUSED(Options ops), UNUSED(int func))` body from Src/builtin.c:6151:
1444 // `if (!*argv) return 0;`
1445 // `prog = parse_string(zjoin(argv, ' ', 1), 1);`
1446 // `execode(prog, 1, 0, "eval");`
1447 // The execode invocation lives here (not in the canonical
1448 // free-fn) because it must run through the bytecode VM's
1449 // current executor — the same VM that's mid-dispatch.
1450 let mut args = pop_args(vm, argc);
1451 // c:Src/builtin.c:407-411 — generic `--` end-of-options
1452 // strip applied by `execbuiltin` for builtins that have
1453 // NULL optstr AND no BINF_HANDLES_OPTS. `eval` qualifies
1454 // (Src/builtin.c:65 `BUILTIN("eval", BINF_PSPECIAL, ...,
1455 // NULL, NULL)`). The BUILTIN_EVAL fast-path bypasses
1456 // execbuiltin, so we mirror the strip inline. Bug #319.
1457 if args.first().is_some_and(|s| s == "--") {
1458 args.remove(0);
1459 }
1460 if args.is_empty() {
1461 return Value::Status(0); // c:6160
1462 }
1463 let src = args.join(" "); // c:6166
1464 // c:Src/builtin.c:6164-6165 — `if (!ineval) scriptname =
1465 // "(eval)";`. Diagnostics emitted while the eval body runs
1466 // (command-not-found, parse errors, etc.) use scriptname as
1467 // the source-context prefix. Without setting it here the
1468 // BUILTIN_EVAL fast-path leaked the outer "zsh" prefix
1469 // through, breaking the `(eval):N:` convention zsh uses
1470 // for in-eval errors. Bug #420.
1471 let oscriptname = crate::ported::utils::scriptname_get();
1472 crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
1473 // Recursion backstop — c:Src/jobs.c:1878-1884. zsh caps eval recursion
1474 // via its job table (every eval'd pipeline grabs a job slot; the table
1475 // caps at MAX_MAXJOBS → "job table full or recursion limit exceeded").
1476 // The fusevm runtime allocates no job per pipeline, and nested evals
1477 // push no funcstack frame (INEVAL suppression, c:6164), so eval nesting
1478 // is invisible to both the job table AND FUNCNEST/FUNCSTACK — runaway
1479 // `eval`-string recursion overflowed the 256 MB main-thread stack →
1480 // uncatchable SIGBUS. Track eval re-entry depth (the Rust proxy for
1481 // held job slots) and refuse at the same MAX_MAXJOBS ceiling.
1482 let eval_depth = crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| {
1483 let v = d.get() + 1;
1484 d.set(v);
1485 v
1486 });
1487 let mut status = if eval_depth >= crate::ported::jobs::MAX_MAXJOBS {
1488 crate::ported::utils::zerr("job table full or recursion limit exceeded");
1489 1
1490 } else {
1491 with_executor(|exec| {
1492 // c:6175 execode
1493 exec.execute_script(&src).unwrap_or(1)
1494 })
1495 };
1496 crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
1497 // c:Src/builtin.c:6211-6212 — `if (errflag && !lastval)
1498 // lastval = errflag;`
1499 // c:Src/builtin.c:6221 — `errflag &= ~ERRFLAG_ERROR;`
1500 // eval is a CONTAINMENT boundary: an error inside the eval
1501 // body (readonly reassign, bad assoc set, ${unset?msg}, …)
1502 // breaks the eval body's lists via errflag, then eval clears
1503 // the flag and returns lastval, and the CALLER's next list
1504 // runs. zsh 5.9: `eval 'assoc=(odd)'; echo "after $?"`
1505 // prints `after 1` in -c, script, and stdin contexts.
1506 {
1507 use std::sync::atomic::Ordering;
1508 let ef = crate::ported::utils::errflag.load(Ordering::Relaxed)
1509 & crate::ported::zsh_h::ERRFLAG_ERROR;
1510 if ef != 0 && status == 0 {
1511 status = ef; // c:6212 lastval = errflag
1512 }
1513 crate::ported::utils::errflag
1514 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
1515 }
1516 crate::ported::utils::set_scriptname(oscriptname);
1517 Value::Status(status)
1518 });
1519
1520 // `builtin foo args…`: precmd-modifier that forces builtin dispatch,
1521 // bypassing alias AND function lookup. Without this, `builtin cd /`
1522 // inside a user `cd () { … }` wrapper recurses (real-world ZPWR pattern).
1523 // Handler pops argc args from the stack, treats args[0] as the builtin
1524 // name, and dispatches the rest via `dispatch_builtin` → `execbuiltin`
1525 // → `bin_*` directly. No function/alias lookup happens.
1526 vm.register_builtin(BUILTIN_BUILTIN, |vm, argc| {
1527 let args = pop_args(vm, argc);
1528 let Some((name, rest)) = args.split_first() else {
1529 // `builtin` with no args → list builtins (zsh emits nothing,
1530 // exit 0). Match that behavior; the BIN_BUILTIN bin_* in C
1531 // does the same default-list-nothing.
1532 return Value::Status(0);
1533 };
1534 // zshrs extension builtins (daemon z* family: zd, zcache, zjob,
1535 // …) are dispatched by name via try_dispatch instead of living
1536 // in builtintab — but they ARE builtins, so the `builtin`
1537 // precommand must reach them (`builtin zd ping` errored
1538 // "no such builtin: zd" while bare `zd ping` worked).
1539 if crate::daemon::builtins::is_zshrs_builtin(name) {
1540 let argv: Vec<String> =
1541 std::iter::once(name.to_string()).chain(rest.iter().cloned()).collect();
1542 return Value::Status(
1543 crate::daemon::builtins::try_dispatch(name, &argv).unwrap_or(1),
1544 );
1545 }
1546 // c:Src/exec.c:3435-3436 — `builtin NAME` with NAME not in
1547 // builtintab emits `zwarn("no such builtin: %s", cmdarg)`
1548 // and returns 1. zshrs's dispatch_builtin_raw bare-returned 1
1549 // silently. Probe the table here so the diagnostic fires
1550 // before dispatch.
1551 let tab = crate::ported::builtin::createbuiltintable();
1552 if !tab.contains_key(name.as_str()) {
1553 eprintln!("zshrs:1: no such builtin: {}", name);
1554 return Value::Status(1);
1555 }
1556 // `builtin foo` MUST bypass function shadow — that's the whole
1557 // point of the prefix. Use the _raw helper, not the shadow-aware
1558 // one. Without this, `cd () { builtin cd "$@"; }` recurses.
1559 Value::Status(dispatch_builtin_raw(name, rest.to_vec()))
1560 });
1561
1562 // `command foo args…` — BINF_COMMAND prefix (Src/builtin.c:44). Zsh
1563 // semantic: bypass alias+function lookup, search builtin then $PATH.
1564 // Without this, `cd () { command cd "$@" }` would re-invoke the user
1565 // wrapper (same root cause as the `builtin` bug). Flags `-p`/`-v`/`-V`
1566 // route to bin_whence with BIN_COMMAND funcid; bare `command foo`
1567 // dispatches builtin if present, else external (no fork — direct
1568 // spawn via execute_external since zshrs is non-forking).
1569 // BUILTIN_COMMAND — `command [-p] [-v|-V] cmd args…` BIN_PREFIX
1570 // (Src/builtin.c:45). PURE PASSTHRU: prepend "command" and hand
1571 // to `exec::execcmd_compile_head` (the fusevm-bytecode-time head
1572 // resolver mirroring `Src/exec.c::execcmd_exec` precommand-modifier
1573 // walk at c:3104-3187). That helper already does the -p / -v / -V
1574 // option parsing, surfaces `has_command_vv` for the whence
1575 // redirect, and reports the dispatch shape (is_builtin vs external).
1576 vm.register_builtin(BUILTIN_COMMAND, |vm, argc| {
1577 let args = pop_args(vm, argc);
1578 let mut full = Vec::with_capacity(args.len() + 1);
1579 full.push("command".to_string());
1580 full.extend(args.clone());
1581 let dispatch =
1582 crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
1583 let post = &full[dispatch.precmd_skip..];
1584 // c:Src/builtin.c:4500 — `command -p` resets PATH for the
1585 // exec to the POSIX-defined default (`getconf PATH`), so
1586 // standard utilities resolve even when the caller has
1587 // emptied $PATH. zsh restores the original PATH after the
1588 // command returns. Mirror via a scoped env::set_var.
1589 //
1590 // command's OWN options end at the first non-flag arg —
1591 // everything after the command name belongs to IT. The
1592 // previous `.any()` scan over ALL args stole `-p` from
1593 // `command mkdir -p DIR` (zconvey.plugin.zsh:44), stripping
1594 // the flag before /bin/mkdir ran → "File exists" errors on
1595 // every re-source.
1596 let mut lead = 0usize;
1597 let mut dash_p = false;
1598 let mut kept_flags: Vec<String> = Vec::new();
1599 for a in post.iter() {
1600 let s = a.as_str();
1601 if s == "--" {
1602 lead += 1;
1603 break;
1604 }
1605 if s.starts_with('-')
1606 && s.len() >= 2
1607 && s[1..].chars().all(|c| c == 'p' || c == 'v' || c == 'V')
1608 {
1609 if s.contains('p') {
1610 dash_p = true;
1611 }
1612 // -v / -V drive the whence-style lookup downstream —
1613 // keep them in post (only the PATH-reset `p` is
1614 // consumed here).
1615 let rest: String = s[1..].chars().filter(|c| *c != 'p').collect();
1616 if !rest.is_empty() {
1617 kept_flags.push(format!("-{}", rest));
1618 }
1619 lead += 1;
1620 continue;
1621 }
1622 break;
1623 }
1624 let mut post: Vec<String> = {
1625 let mut v = kept_flags;
1626 v.extend(post[lead..].iter().cloned());
1627 v
1628 };
1629 // c:Src/exec.c:3176-3177 — `BINF_COMMAND` arm strips a single
1630 // leading `--` end-of-options marker.
1631 // `execcmd_compile_head` (src/ported/exec.rs:1042) performs
1632 // this removal on its LOCAL `preargs` Vec but doesn't surface
1633 // the modified args; the caller still sees `--` in `full` and
1634 // tried to dispatch it as the command name. Bug #251. Mirror
1635 // the C strip here so `command -- echo hi` and
1636 // `command -p -- echo hi` route correctly.
1637 if let Some(first) = post.first() {
1638 if first == "--" {
1639 post.remove(0);
1640 }
1641 }
1642 let post = post.as_slice();
1643 let _path_guard = if dash_p {
1644 let saved = env::var("PATH").ok();
1645 let default_path = std::process::Command::new("getconf")
1646 .arg("PATH")
1647 .output()
1648 .ok()
1649 .and_then(|o| String::from_utf8(o.stdout).ok())
1650 .map(|s| s.trim().to_string())
1651 .filter(|s| !s.is_empty())
1652 .unwrap_or_else(|| "/usr/bin:/bin:/usr/sbin:/sbin".to_string());
1653 env::set_var("PATH", &default_path);
1654 crate::ported::params::setsparam("PATH", &default_path);
1655 Some(saved)
1656 } else {
1657 None
1658 };
1659 struct PathGuard {
1660 saved: Option<String>,
1661 active: bool,
1662 }
1663 impl Drop for PathGuard {
1664 fn drop(&mut self) {
1665 if !self.active {
1666 return;
1667 }
1668 match self.saved.take() {
1669 Some(p) => {
1670 env::set_var("PATH", &p);
1671 crate::ported::params::setsparam("PATH", &p);
1672 }
1673 None => {
1674 env::remove_var("PATH");
1675 crate::ported::params::setsparam("PATH", "");
1676 }
1677 }
1678 }
1679 }
1680 let _restore = PathGuard {
1681 saved: _path_guard.unwrap_or(None),
1682 active: dash_p,
1683 };
1684 if dispatch.has_command_vv {
1685 // `-v` / `-V` → bin_whence with BIN_COMMAND funcid.
1686 let mut ops = options {
1687 ind: [0u8; MAX_OPS],
1688 args: Vec::new(),
1689 argscount: 0,
1690 argsalloc: 0,
1691 };
1692 let mut name_pos = 0usize;
1693 let mut flag_byte = b'v';
1694 for (i, a) in post.iter().enumerate() {
1695 if a.starts_with('-') && a.len() >= 2 {
1696 let body = &a.as_bytes()[1..];
1697 if body.contains(&b'V') {
1698 flag_byte = b'V';
1699 }
1700 name_pos = i + 1;
1701 } else {
1702 name_pos = i;
1703 break;
1704 }
1705 }
1706 ops.ind[flag_byte as usize] = 1;
1707 let whence_args: Vec<String> = post[name_pos..].to_vec();
1708 return Value::Status(crate::ported::builtin::bin_whence(
1709 "command",
1710 &whence_args,
1711 &ops,
1712 crate::ported::hashtable_h::BIN_COMMAND,
1713 ));
1714 }
1715 if dispatch.is_empty_command {
1716 return Value::Status(0);
1717 }
1718 let Some((name, rest)) = post.split_first() else {
1719 return Value::Status(0);
1720 };
1721 // c:Src/exec.c:3275-3278 — `execcmd_compile_head` cleared
1722 // hn for the BINF_COMMAND + !POSIXBUILTINS case, surfacing
1723 // is_builtin=false. Run as external. Under POSIXBUILTINS
1724 // dispatch.is_builtin would be true; honour it.
1725 let n = name.clone();
1726 let r = rest.to_vec();
1727 if dispatch.is_builtin
1728 && crate::ported::builtin::BUILTINS
1729 .iter()
1730 .any(|b| b.node.nam == n.as_str())
1731 {
1732 return Value::Status(dispatch_builtin_raw(&n, r));
1733 }
1734 Value::Status(with_executor(|exec| exec.execute_external(&n, &r, &[])).unwrap_or(127))
1735 });
1736
1737 // `exec cmd args…` — BINF_EXEC prefix (Src/builtin.c:45). Zsh
1738 // semantic: replace the current shell process with `cmd`. On Unix
1739 // this is `execvp(2)`; the call only returns on error. zshrs is
1740 // non-forking, so the shell process IS the calling process —
1741 // execvp here directly replaces it. Options `-a name` (override
1742 // argv[0]), `-c` (clean env), `-l` (login shell — prepend `-`)
1743 // ported minimally; advanced redirect-only `exec >file` is handled
1744 // upstream by compile_zsh and never reaches this handler.
1745 vm.register_builtin(BUILTIN_EXEC, |vm, argc| {
1746 let mut args = pop_args(vm, argc);
1747 let mut argv0_override: Option<String> = None;
1748 let mut clean_env = false;
1749 let mut login = false;
1750 let mut i = 0;
1751 // c:Src/builtin.c:1075-1080 — track if any flag was consumed.
1752 // `exec -c`, `exec -l`, `exec -a NAME` without a following
1753 // command emit "exec requires a command to execute" rc=1.
1754 // Bare `exec` (no args at all) is the silent-redirect-apply
1755 // form per POSIX.
1756 let mut saw_flag = false;
1757 while i < args.len() {
1758 let a = &args[i];
1759 if a == "--" {
1760 args.remove(i);
1761 break;
1762 }
1763 // c:Src/builtin.c:42 `BIN_PREFIX("-", BINF_DASH)`. A bare
1764 // `-` is its own BINF_PREFIX builtin (BINF_DASH flag —
1765 // "login shell, prepend `-` to argv[0]"). In the canonical
1766 // precmd-walk at Src/exec.c:3056-3091 a bare `-` after
1767 // `exec` is recognized AS a builtin and stripped from
1768 // preargs (precmd_skip++), accumulating BINF_DASH into
1769 // cflags. The fast-path here bypasses execcmd_compile_head,
1770 // so we mirror the strip locally: bare `-` → set login,
1771 // remove, continue. Without this `exec -` (with no command
1772 // following) tried to exec `-` as a literal command and
1773 // exited the shell. Bug #252.
1774 if a == "-" {
1775 saw_flag = true;
1776 login = true;
1777 args.remove(i);
1778 continue;
1779 }
1780 if !a.starts_with('-') || a.len() < 2 {
1781 break;
1782 }
1783 match a.as_str() {
1784 "-a" => {
1785 saw_flag = true;
1786 args.remove(i);
1787 if i < args.len() {
1788 argv0_override = Some(args.remove(i));
1789 }
1790 }
1791 "-c" => {
1792 saw_flag = true;
1793 clean_env = true;
1794 args.remove(i);
1795 }
1796 "-l" => {
1797 saw_flag = true;
1798 login = true;
1799 args.remove(i);
1800 }
1801 _ => {
1802 // c:Src/exec.c:3196-3208 — when an unrecognized
1803 // `-X`-style arg has NO following arg, the lexer's
1804 // IS_DASH walk hits the "no next node" branch at
1805 // c:3199 before the unknown-flag-letter switch at
1806 // c:3249, so the canonical message is "exec
1807 // requires a command to execute" rc=1 (verified vs
1808 // `/opt/homebrew/bin/zsh -fc 'exec --bad'`).
1809 // Consume the lone flag so the post-loop check
1810 // fires. When a following arg exists, leave the
1811 // unknown-flag arg in place — that arg becomes
1812 // the command name and execution proceeds.
1813 if args.len() == 1 {
1814 saw_flag = true;
1815 args.remove(i);
1816 continue;
1817 }
1818 break;
1819 }
1820 }
1821 }
1822 let Some(cmd) = args.first().cloned() else {
1823 if saw_flag {
1824 // c:Src/builtin.c:1078-1080 — flags consumed but no
1825 // command follows → "exec requires a command to
1826 // execute" rc=1.
1827 eprintln!("zshrs:1: exec requires a command to execute");
1828 return Value::Status(1);
1829 }
1830 // `exec` with no command + no redirects = no-op success.
1831 return Value::Status(0);
1832 };
1833 let rest: Vec<String> = args[1..].to_vec();
1834 let display_argv0 = match argv0_override {
1835 Some(a) => a,
1836 None => {
1837 if login {
1838 format!("-{}", cmd)
1839 } else {
1840 cmd.clone()
1841 }
1842 }
1843 };
1844 // c:Src/exec.c::execcmd — `exec funcname` runs the function
1845 // in-process as the shell's last act, then exits with the
1846 // function's status. zsh's dispatcher falls through from the
1847 // BINF_EXEC prefix into the normal Builtin/External/Function
1848 // resolution and only execvp's if the target ISN'T a
1849 // function. Bug #101 in docs/BUGS.md: zshrs's exec went
1850 // straight to execvp and errored `not found` for shell
1851 // functions.
1852 //
1853 // For both subshell and top-level contexts: dispatch through
1854 // the function/builtin lookup first; only fall through to
1855 // execvp/spawn if the name isn't shell-resolvable.
1856 let has_user_fn = with_executor(|exec| exec.functions_compiled.contains_key(&cmd));
1857 if has_user_fn {
1858 let status =
1859 with_executor(|exec| exec.dispatch_function_call(&cmd, &rest).unwrap_or(127));
1860 // Top-level `exec funcname` — exit the shell with the
1861 // function's status (mirrors C's "exec replaces shell as
1862 // last act"). Subshell `(exec funcname)` — return through
1863 // the EXIT_PENDING path so the subshell body aborts and
1864 // the parent resumes via subshell_end.
1865 let in_subshell_now = with_executor(|exec| !exec.subshell_snapshots.is_empty());
1866 if in_subshell_now {
1867 crate::ported::builtin::EXIT_VAL
1868 .store(status, std::sync::atomic::Ordering::Relaxed);
1869 crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
1870 return Value::Status(status);
1871 }
1872 std::process::exit(status);
1873 }
1874 // c:Src/exec.c — builtin path: `exec builtin` runs the
1875 // builtin in-process and exits.
1876 let bn_in_tab = crate::ported::builtin::createbuiltintable().contains_key(&cmd);
1877 if bn_in_tab {
1878 let status = dispatch_builtin_raw(&cmd, rest.clone());
1879 let in_subshell_now = with_executor(|exec| !exec.subshell_snapshots.is_empty());
1880 if in_subshell_now {
1881 crate::ported::builtin::EXIT_VAL
1882 .store(status, std::sync::atomic::Ordering::Relaxed);
1883 crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
1884 return Value::Status(status);
1885 }
1886 std::process::exit(status);
1887 }
1888 // c:Src/exec.c — `exec` inside a subshell (`(exec cmd)`)
1889 // replaces ONLY the subshell child process; the parent shell
1890 // continues. C zsh always forks for `(...)`, so the actual
1891 // execvp lands in the forked child. zshrs runs subshells via
1892 // a snapshot/restore pattern in the SAME process — calling
1893 // execvp here would replace the parent too. Bug #94 in
1894 // docs/BUGS.md.
1895 //
1896 // Detect subshell context via the non-empty
1897 // `subshell_snapshots` stack. When in a subshell: spawn the
1898 // command as a child, wait for it, then signal the subshell
1899 // body to abort (return Status(N) and the caller's
1900 // subshell_end will pop the snapshot and resume the parent).
1901 let in_subshell = with_executor(|exec| !exec.subshell_snapshots.is_empty());
1902 if in_subshell {
1903 let mut command = std::process::Command::new(&cmd);
1904 command.arg0(&display_argv0);
1905 command.args(&rest);
1906 if clean_env {
1907 command.env_clear();
1908 }
1909 // Queue signals across spawn+wait so the SIGCHLD reaper
1910 // can't reap this child before child.wait() does — see
1911 // ForegroundWaitGuard.
1912 let _wait_guard = ForegroundWaitGuard::enter();
1913 let status = match command.spawn() {
1914 Ok(mut child) => match child.wait() {
1915 Ok(s) => s.code().unwrap_or(127),
1916 Err(_) => 127,
1917 },
1918 Err(e) => {
1919 // c:Src/exec.c:797 — `zerr("%e: %s", lerrno, arg0)`
1920 // when arg0 contains `/`.
1921 // c:872-876 — when arg0 has no `/` (PATH search
1922 // path), C tracks the "good" errno
1923 // via `isgooderr`; if all PATH entries
1924 // were ENOENT-not-good, eno stays 0
1925 // and C emits `command not found: %s`
1926 // instead of strerror.
1927 // %e expands to strerror(errno) with the first
1928 // letter lowercased (unless errno == EIO; see
1929 // Src/utils.c:362-368). `zerr` prepends the
1930 // scriptname:lineno: prefix — matching zsh's
1931 // canonical `zsh:N: <errmsg>: <cmd>` pattern.
1932 // Previously emitted `zshrs: exec: {}: not found`
1933 // (wrong prefix, hardcoded message, missing
1934 // lineno). Bug #140 in docs/BUGS.md.
1935 let errno = e.raw_os_error().unwrap_or(libc::ENOENT);
1936 let has_slash = cmd.contains('/');
1937 if !has_slash && errno == libc::ENOENT {
1938 // c:876 — PATH search exhausted with no good
1939 // errno → `command not found: arg0`.
1940 crate::ported::utils::zerr(&format!("command not found: {}", cmd));
1941 } else {
1942 let mut errmsg = crate::ported::compat::strerror(errno);
1943 if errno != libc::EIO {
1944 if let Some(c) = errmsg.chars().next() {
1945 errmsg = format!(
1946 "{}{}",
1947 c.to_ascii_lowercase(),
1948 &errmsg[c.len_utf8()..]
1949 );
1950 }
1951 }
1952 crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
1953 }
1954 // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
1955 if errno == libc::EACCES || errno == libc::ENOEXEC {
1956 126
1957 } else {
1958 127
1959 }
1960 }
1961 };
1962 // Mark the subshell as exec-replaced so subsequent body
1963 // commands skip — mirrors the post-execvp "child process
1964 // is gone" reality in C. EXIT_PENDING + EXIT_VAL drive
1965 // the next ERREXIT_CHECK to unwind to the subshell-end
1966 // patch.
1967 crate::ported::builtin::EXIT_VAL.store(status, std::sync::atomic::Ordering::Relaxed);
1968 crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
1969 return Value::Status(status);
1970 }
1971 let mut command = std::process::Command::new(&cmd);
1972 command.arg0(&display_argv0);
1973 command.args(&rest);
1974 if clean_env {
1975 command.env_clear();
1976 }
1977 use std::os::unix::process::CommandExt;
1978 // `exec` returns the OS error iff exec(2) failed; on success
1979 // it never returns. Match zsh: print the error to stderr with
1980 // the `exec` prefix and exit 127 (cmd not found) or 126 (not
1981 // executable).
1982 let err = command.exec();
1983 // c:Src/exec.c:797 / c:872-876 — same format as in-subshell
1984 // branch. arg0-has-/ → `<strerror>: <cmd>`; arg0-no-/ +
1985 // ENOENT → `command not found: <cmd>`. Lowercase strerror
1986 // first letter unless EIO. Bug #140 in docs/BUGS.md.
1987 let errno = err.raw_os_error().unwrap_or(libc::ENOENT);
1988 let has_slash = cmd.contains('/');
1989 if !has_slash && errno == libc::ENOENT {
1990 crate::ported::utils::zerr(&format!("command not found: {}", cmd));
1991 } else {
1992 let mut errmsg = crate::ported::compat::strerror(errno);
1993 if errno != libc::EIO {
1994 if let Some(c) = errmsg.chars().next() {
1995 errmsg = format!("{}{}", c.to_ascii_lowercase(), &errmsg[c.len_utf8()..]);
1996 }
1997 }
1998 crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
1999 }
2000 // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
2001 let code = if errno == libc::EACCES || errno == libc::ENOEXEC {
2002 126
2003 } else {
2004 127
2005 };
2006 std::process::exit(code);
2007 });
2008
2009 reg_passthru!(vm, BUILTIN_LET, "let");
2010
2011 // Job control
2012 reg_passthru!(vm, BUILTIN_JOBS, "jobs");
2013 reg_passthru!(vm, BUILTIN_FG, "fg");
2014 reg_passthru!(vm, BUILTIN_BG, "bg");
2015 reg_passthru!(vm, BUILTIN_KILL, "kill");
2016 reg_passthru!(vm, BUILTIN_DISOWN, "disown");
2017 reg_passthru!(vm, BUILTIN_WAIT, "wait");
2018 reg_passthru!(vm, BUILTIN_SUSPEND, "suspend");
2019
2020 // History — `fc` / `history` / `r` all route to `bin_fc` (zsh
2021 // registers them as aliases of the same builtin per Src/builtin.c).
2022 reg_passthru!(vm, BUILTIN_FC, "fc");
2023 reg_passthru!(vm, BUILTIN_HISTORY, "history");
2024 reg_passthru!(vm, BUILTIN_R, "r");
2025
2026 // Aliases — alias is `BINF_MAGICEQUALS` per Src/builtin.c:50.
2027 // c:Src/exec.c:3298-3304 — when a builtin has BINF_MAGICEQUALS,
2028 // execcmd_exec sets esprefork = PREFORK_TYPESET and calls
2029 // `prefork(args, esprefork, NULL)` on the argv. prefork (subst.c:
2030 // 100) drives `filesub` on each word (c:133), which (c:677-686)
2031 // looks for the assignment Equals and runs `filesubstr` on the
2032 // VALUE side. That's how `alias bad===` triggers equalsubstr's
2033 // "= not found" via the inner Equals after the first `=`.
2034 //
2035 // The fusevm dispatch path doesn't go through execcmd_exec, so
2036 // BUILTIN_ALIAS previously passed args straight to bin_alias with
2037 // no expansion — `alias x=~/foo` stored literal `~/foo` (no tilde
2038 // expand), `alias bad===` stored a broken entry without firing
2039 // the "= not found" diagnostic. The prefork(PREFORK_TYPESET) runs
2040 // per arg word via BUILTIN_MAGIC_EQUALS_PREFORK ops that
2041 // compile_simple emits BEFORE the redirect scope opens (matching
2042 // c:3304 prefork-before-addfd order), so the dispatch here is a
2043 // plain passthrough — re-running prefork would double-fire the
2044 // "= not found" diagnostic.
2045 reg_passthru!(vm, BUILTIN_ALIAS, "alias");
2046 // c:Src/exec.c:3298-3304 — per-word magic-equals prefork; see the
2047 // const doc at BUILTIN_MAGIC_EQUALS_PREFORK. prefork's filesub
2048 // trigger (subst.c:678 `strchr(*namptr+1, Equals)`) looks for the
2049 // EQUALS TOKEN, not literal `=`. The fusevm path delivers args
2050 // already-untokenized, so re-tokenize each element via
2051 // `shtokenize` (the same call C's lexer makes implicitly when
2052 // assembling the word) so prefork sees Equals tokens at `=`
2053 // boundaries and Tilde tokens at `~` starts. After prefork
2054 // expands, untokenize for storage.
2055 vm.register_builtin(BUILTIN_MAGIC_EQUALS_PREFORK, |vm, _argc| {
2056 let raw = vm.pop();
2057 let inputs: Vec<String> = match raw {
2058 Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
2059 other => vec![other.to_str()],
2060 };
2061 let mut as_linklist: crate::ported::linklist::LinkList<String> = Default::default();
2062 for s in &inputs {
2063 let mut tokd = s.clone();
2064 crate::ported::glob::shtokenize(&mut tokd);
2065 as_linklist.push_back(tokd);
2066 }
2067 let mut rf = 0i32;
2068 crate::ported::subst::prefork(
2069 &mut as_linklist,
2070 crate::ported::zsh_h::PREFORK_TYPESET,
2071 &mut rf,
2072 );
2073 let mut expanded: Vec<String> = Vec::with_capacity(inputs.len());
2074 while let Some(s) = as_linklist.pop_front() {
2075 expanded.push(crate::ported::lex::untokenize(&s).to_string());
2076 }
2077 if expanded.len() == 1 {
2078 Value::str(expanded.into_iter().next().unwrap())
2079 } else {
2080 Value::Array(expanded.into_iter().map(Value::str).collect())
2081 }
2082 });
2083
2084 // Options. `setopt` (BIN_SETOPT=0) / `unsetopt` (BIN_UNSETOPT=1)
2085 // share bin_setopt (options.c:580) — funcid bit discriminates
2086 // the polarity via BUILTINS table entries.
2087 reg_passthru!(vm, BUILTIN_SET, "set");
2088 reg_passthru!(vm, BUILTIN_SETOPT, "setopt");
2089 reg_passthru!(vm, BUILTIN_UNSETOPT, "unsetopt");
2090
2091 vm.register_builtin(BUILTIN_SHOPT, |vm, argc| {
2092 let args = pop_args(vm, argc);
2093 let status = crate::extensions::ext_builtins::shopt(&args);
2094 Value::Status(status)
2095 });
2096
2097 reg_passthru!(vm, BUILTIN_EMULATE, "emulate");
2098 reg_passthru!(vm, BUILTIN_GETOPTS, "getopts");
2099 reg_passthru!(vm, BUILTIN_AUTOLOAD, "autoload");
2100 reg_passthru!(vm, BUILTIN_FUNCTIONS, "functions");
2101 reg_passthru!(vm, BUILTIN_TRAP, "trap");
2102 reg_passthru!(vm, BUILTIN_DIRS, "dirs");
2103 // pushd / popd dispatch through canonical bin_cd via execbuiltin
2104 // — the BUILTINS table at src/ported/builtin.rs:9298 wires
2105 // `pushd` to bin_cd with funcid=BIN_PUSHD, and `popd` similarly
2106 // with BIN_POPD. Without these reg_passthru lines the fusevm
2107 // BUILTIN_PUSHD/POPD opcodes had no handler installed, so the
2108 // emitted CallBuiltin(110, …) silently returned a no-op and the
2109 // dirstack/$dirstack/pwd all stayed unchanged.
2110 reg_passthru!(vm, BUILTIN_PUSHD, "pushd");
2111 reg_passthru!(vm, BUILTIN_POPD, "popd");
2112 // type / whence / where / which all route through `bin_whence`
2113 // (canonical port at `src/ported/builtin.rs:3734` of
2114 // `Src/builtin.c:3975`). Each gets its own opcode so funcid +
2115 // defopts come from the BUILTINS table entry — execbuiltin
2116 // applies them correctly via the module-level dispatch_builtin.
2117 reg_passthru!(vm, BUILTIN_WHENCE, "whence");
2118 reg_passthru!(vm, BUILTIN_TYPE, "type");
2119 reg_passthru!(vm, BUILTIN_WHICH, "which");
2120 reg_passthru!(vm, BUILTIN_WHERE, "where");
2121 reg_passthru!(vm, BUILTIN_HASH, "hash");
2122 reg_passthru!(vm, BUILTIN_REHASH, "rehash");
2123
2124 // `unhash`/`unalias`/`unfunction` share `bin_unhash` (Src/builtin.c
2125 // c:4350) but each carries its own funcid (BIN_UNHASH /
2126 // BIN_UNALIAS / BIN_UNFUNCTION) in the BUILTINS table.
2127 reg_passthru!(vm, BUILTIN_UNHASH, "unhash");
2128 vm.register_builtin(BUILTIN_UNALIAS, |vm, argc| {
2129 let args = pop_args(vm, argc);
2130 Value::Status(dispatch_builtin("unalias", args))
2131 });
2132 vm.register_builtin(BUILTIN_UNFUNCTION, |vm, argc| {
2133 let args = pop_args(vm, argc);
2134 Value::Status(dispatch_builtin("unfunction", args))
2135 });
2136
2137 // Completion
2138 vm.register_builtin(BUILTIN_COMPGEN, |vm, argc| {
2139 let args = pop_args(vm, argc);
2140 // c:Bug #475/#555 — `compgen` is a bash-only builtin. In
2141 // `--zsh` mode emit "command not found" matching zsh's
2142 // external-command lookup miss — UNLESS a user FUNCTION of
2143 // that name exists: zsh has no such builtin, so bashcompinit's
2144 // `compgen() {...}` definition wins the dispatch there. The
2145 // unconditional 127 shadowed it and broke every
2146 // bashcompinit-style completion file (zsh-more-completions
2147 // _msync/_gocomplete/_qshell/_cw), spraying "command not
2148 // found: complete" at every deferred compinit load.
2149 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2150 if crate::ported::utils::getshfunc("compgen").is_some() {
2151 let status = with_executor(|exec| exec.dispatch_function_call("compgen", &args))
2152 .unwrap_or(127);
2153 return Value::Status(status);
2154 }
2155 eprintln!("zsh:1: command not found: compgen");
2156 let _ = args;
2157 return Value::Status(127);
2158 }
2159 let status = with_executor(|exec| exec.builtin_compgen(&args));
2160 Value::Status(status)
2161 });
2162
2163 vm.register_builtin(BUILTIN_COMPLETE, |vm, argc| {
2164 let args = pop_args(vm, argc);
2165 // c:Bug #475 — `complete` is a bash-only builtin. Same gate +
2166 // user-function precedence as BUILTIN_COMPGEN above.
2167 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2168 if crate::ported::utils::getshfunc("complete").is_some() {
2169 let status = with_executor(|exec| exec.dispatch_function_call("complete", &args))
2170 .unwrap_or(127);
2171 return Value::Status(status);
2172 }
2173 eprintln!("zsh:1: command not found: complete");
2174 let _ = args;
2175 return Value::Status(127);
2176 }
2177 let status = with_executor(|exec| exec.builtin_complete(&args));
2178 Value::Status(status)
2179 });
2180
2181 reg_passthru!(vm, BUILTIN_COMPADD, "compadd");
2182 reg_passthru!(vm, BUILTIN_COMPSET, "compset");
2183
2184 // See the const's doc comment for the contract. Stack (bottom→top):
2185 // base, e1, …, eN — argc = N + 1.
2186 vm.register_builtin(BUILTIN_TYPESET_PAREN_PACK, |vm, argc| {
2187 let mut vals: Vec<Value> = Vec::with_capacity(argc as usize);
2188 for _ in 0..argc {
2189 vals.push(vm.pop());
2190 }
2191 vals.reverse();
2192 let mut it = vals.into_iter();
2193 let mut out = it.next().map(|v| v.to_str()).unwrap_or_default();
2194 for v in it {
2195 match v {
2196 // Array → splice items as separate elements (splat);
2197 // empty array contributes nothing (empty elision).
2198 Value::Array(items) => {
2199 for item in items {
2200 out.push('\u{1f}');
2201 out.push_str(&item.to_str());
2202 }
2203 }
2204 other => {
2205 out.push('\u{1f}');
2206 out.push_str(&other.to_str());
2207 }
2208 }
2209 }
2210 Value::str(out)
2211 });
2212
2213 vm.register_builtin(BUILTIN_TYPESET_PAREN_CLOSE, |vm, _argc| {
2214 let base = vm.pop().to_str();
2215 Value::str(format!("{}\u{1f})", base))
2216 });
2217
2218 vm.register_builtin(BUILTIN_COMPDEF, |vm, argc| {
2219 let args = pop_args(vm, argc);
2220 // ACTUALLY A ZSH FUNCTION: compdef is defined by `compinit`, it is
2221 // never a builtin. Without the completion system set up it is
2222 // command-not-found (127) in every mode — `zsh -f; compdef` prints
2223 // "command not found: compdef". A user/compsys `compdef` FUNCTION
2224 // (autoload compinit → compinit defines compdef) wins and runs the
2225 // fast native impl; otherwise it's command-not-found. Previously the
2226 // extension builtin ran in native mode (bare `compdef` → "I need
2227 // arguments"), diverging from zsh.
2228 // compinit installs a `compdef` function stub (see
2229 // NATIVE_COMPDEF_MARKER) purely so `${+functions[compdef]}` is
2230 // true; route that exact body to the fast native impl instead of
2231 // dispatching the stub. A genuine user/compsys compdef function
2232 // (any other body) still wins via try_user_fn_override below.
2233 let is_native_stub = crate::ported::hashtable::shfunctab_lock()
2234 .read()
2235 .ok()
2236 .and_then(|t| t.get("compdef").and_then(|shf| shf.body.clone()))
2237 .map(|b| b.trim() == crate::extensions::ext_builtins::NATIVE_COMPDEF_MARKER)
2238 .unwrap_or(false);
2239 if is_native_stub {
2240 return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
2241 }
2242 if let Some(s) = try_user_fn_override("compdef", &args) {
2243 return Value::Status(s);
2244 }
2245 if with_executor(|exec| exec.function_exists("compdef")) {
2246 return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
2247 }
2248 eprintln!("zsh:1: command not found: compdef");
2249 Value::Status(127)
2250 });
2251
2252 vm.register_builtin(BUILTIN_COMPINIT, |vm, argc| {
2253 let args = pop_args(vm, argc);
2254 // ACTUALLY A ZSH FUNCTION: compinit is a contrib FUNCTION (autoloaded
2255 // from $fpath), never a builtin. Without `autoload -Uz compinit` it is
2256 // command-not-found
2257 // (127) in every mode — `zsh -f; compinit` prints
2258 // "command not found: compinit". zshrs previously ran its builtin
2259 // unconditionally, so bare `compinit` succeeded. Gate on a compinit
2260 // function entry existing (which `autoload -Uz compinit` creates);
2261 // once the user has autoloaded/defined it, run zshrs's implementation.
2262 if !with_executor(|exec| exec.function_exists("compinit")) {
2263 eprintln!("zsh:1: command not found: compinit");
2264 let _ = args;
2265 return Value::Status(127);
2266 }
2267 Value::Status(with_executor(|exec| exec.builtin_compinit(&args)))
2268 });
2269
2270 vm.register_builtin(BUILTIN_CDREPLAY, |vm, argc| {
2271 let args = pop_args(vm, argc);
2272 Value::Status(with_executor(|exec| exec.builtin_cdreplay(&args)))
2273 });
2274
2275 // Zsh-specific
2276 reg_passthru!(vm, BUILTIN_ZSTYLE, "zstyle");
2277 reg_passthru!(vm, BUILTIN_ZMODLOAD, "zmodload");
2278 reg_passthru!(vm, BUILTIN_BINDKEY, "bindkey");
2279 reg_passthru!(vm, BUILTIN_ZLE, "zle");
2280 reg_passthru!(vm, BUILTIN_VARED, "vared");
2281 reg_passthru!(vm, BUILTIN_ZCOMPILE, "zcompile");
2282 reg_passthru!(vm, BUILTIN_ZFORMAT, "zformat");
2283 reg_passthru!(vm, BUILTIN_ZPARSEOPTS, "zparseopts");
2284 reg_passthru!(vm, BUILTIN_ZREGEXPARSE, "zregexparse");
2285
2286 // Resource limits
2287 reg_passthru!(vm, BUILTIN_ULIMIT, "ulimit");
2288 reg_passthru!(vm, BUILTIN_LIMIT, "limit");
2289 reg_passthru!(vm, BUILTIN_UNLIMIT, "unlimit");
2290 reg_passthru!(vm, BUILTIN_UMASK, "umask");
2291
2292 // Misc
2293 reg_passthru!(vm, BUILTIN_TIMES, "times");
2294
2295 vm.register_builtin(BUILTIN_CALLER, |vm, argc| {
2296 let args = pop_args(vm, argc);
2297 // c:Bug #475 — `caller` is a bash-only builtin. In `--zsh`
2298 // mode emit the canonical "command not found" diagnostic
2299 // and rc=127 matching zsh's external-command-lookup miss.
2300 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2301 eprintln!("zsh:1: command not found: caller");
2302 let _ = args;
2303 return Value::Status(127);
2304 }
2305 Value::Status(with_executor(|exec| exec.builtin_caller(&args)))
2306 });
2307
2308 vm.register_builtin(BUILTIN_HELP, |vm, argc| {
2309 let args = pop_args(vm, argc);
2310 // c:Bug #475 — `help` is a bash-only builtin. Same gate as
2311 // BUILTIN_CALLER above.
2312 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2313 eprintln!("zsh:1: command not found: help");
2314 let _ = args;
2315 return Value::Status(127);
2316 }
2317 Value::Status(with_executor(|exec| exec.builtin_help(&args)))
2318 });
2319
2320 reg_passthru!(vm, BUILTIN_ENABLE, "enable");
2321 reg_passthru!(vm, BUILTIN_DISABLE, "disable");
2322 reg_passthru!(vm, BUILTIN_TTYCTL, "ttyctl");
2323 reg_passthru!(vm, BUILTIN_SYNC, "sync");
2324 reg_passthru!(vm, BUILTIN_MKDIR, "mkdir");
2325 reg_passthru!(vm, BUILTIN_STRFTIME, "strftime");
2326
2327 vm.register_builtin(BUILTIN_ZSLEEP, |vm, argc| {
2328 Value::Status(crate::extensions::ext_builtins::zsleep(&pop_args(vm, argc)))
2329 });
2330
2331 reg_passthru!(vm, BUILTIN_ZSYSTEM, "zsystem");
2332
2333 // PCRE
2334 reg_passthru!(vm, BUILTIN_PCRE_COMPILE, "pcre_compile");
2335 reg_passthru!(vm, BUILTIN_PCRE_MATCH, "pcre_match");
2336 reg_passthru!(vm, BUILTIN_PCRE_STUDY, "pcre_study");
2337
2338 // Database (GDBM)
2339 reg_passthru!(vm, BUILTIN_ZTIE, "ztie");
2340 reg_passthru!(vm, BUILTIN_ZUNTIE, "zuntie");
2341 reg_passthru!(vm, BUILTIN_ZGDBMPATH, "zgdbmpath");
2342
2343 // Prompt
2344 vm.register_builtin(BUILTIN_PROMPTINIT, |vm, argc| {
2345 let args = pop_args(vm, argc);
2346 // ACTUALLY A ZSH FUNCTION: promptinit is a contrib FUNCTION
2347 // (autoloaded from $fpath), never a builtin. Command-not-found until
2348 // `autoload -Uz promptinit`; once autoloaded, run the native impl.
2349 if !with_executor(|exec| exec.function_exists("promptinit")) {
2350 eprintln!("zsh:1: command not found: promptinit");
2351 let _ = args;
2352 return Value::Status(127);
2353 }
2354 Value::Status(crate::extensions::ext_builtins::promptinit(&args))
2355 });
2356
2357 vm.register_builtin(BUILTIN_PROMPT, |vm, argc| {
2358 let args = pop_args(vm, argc);
2359 Value::Status(crate::extensions::ext_builtins::prompt(&args))
2360 });
2361
2362 // Async / Parallel (zshrs extensions)
2363 vm.register_builtin(BUILTIN_ASYNC, |vm, argc| {
2364 let args = pop_args(vm, argc);
2365 let status = with_executor(|exec| exec.builtin_async(&args));
2366 Value::Status(status)
2367 });
2368
2369 vm.register_builtin(BUILTIN_AWAIT, |vm, argc| {
2370 let args = pop_args(vm, argc);
2371 let status = with_executor(|exec| exec.builtin_await(&args));
2372 Value::Status(status)
2373 });
2374
2375 vm.register_builtin(BUILTIN_PMAP, |vm, argc| {
2376 let args = pop_args(vm, argc);
2377 let status = with_executor(|exec| exec.builtin_pmap(&args));
2378 Value::Status(status)
2379 });
2380
2381 vm.register_builtin(BUILTIN_PGREP, |vm, argc| {
2382 let args = pop_args(vm, argc);
2383 let status = with_executor(|exec| exec.builtin_pgrep(&args));
2384 Value::Status(status)
2385 });
2386
2387 vm.register_builtin(BUILTIN_PEACH, |vm, argc| {
2388 let args = pop_args(vm, argc);
2389 let status = with_executor(|exec| exec.builtin_peach(&args));
2390 Value::Status(status)
2391 });
2392
2393 vm.register_builtin(BUILTIN_BARRIER, |vm, argc| {
2394 let args = pop_args(vm, argc);
2395 let status = with_executor(|exec| exec.builtin_barrier(&args));
2396 Value::Status(status)
2397 });
2398
2399 // Intercept (AOP)
2400 vm.register_builtin(BUILTIN_INTERCEPT, |vm, argc| {
2401 let args = pop_args(vm, argc);
2402 let status = with_executor(|exec| exec.builtin_intercept(&args));
2403 Value::Status(status)
2404 });
2405
2406 vm.register_builtin(BUILTIN_INTERCEPT_PROCEED, |vm, argc| {
2407 let args = pop_args(vm, argc);
2408 let status = with_executor(|exec| exec.builtin_intercept_proceed(&args));
2409 Value::Status(status)
2410 });
2411
2412 // Debug / Profile
2413 vm.register_builtin(BUILTIN_DOCTOR, |vm, argc| {
2414 let args = pop_args(vm, argc);
2415 let status = with_executor(|exec| exec.builtin_doctor(&args));
2416 Value::Status(status)
2417 });
2418
2419 vm.register_builtin(BUILTIN_DBVIEW, |vm, argc| {
2420 let args = pop_args(vm, argc);
2421 let status = with_executor(|exec| exec.builtin_dbview(&args));
2422 Value::Status(status)
2423 });
2424
2425 vm.register_builtin(BUILTIN_PROFILE, |vm, argc| {
2426 let args = pop_args(vm, argc);
2427 let status = with_executor(|exec| exec.builtin_profile(&args));
2428 Value::Status(status)
2429 });
2430
2431 reg_passthru!(vm, BUILTIN_ZPROF, "zprof");
2432
2433 // ═══════════════════════════════════════════════════════════════════════
2434 // Coreutils builtins (anti-fork, gated by !posix_mode)
2435 //
2436 // All of these are routinely wrapped by user functions in real
2437 // dotfiles (zpwr, oh-my-zsh, etc.) — `cat() { ... }`, `ls() { ... }`,
2438 // `find() { ... }`. Each handler MUST consult try_user_fn_override
2439 // first (via reg_overridable!) so the user definition wins, matching
2440 // zsh's alias → function → builtin dispatch order.
2441 // ═══════════════════════════════════════════════════════════════════════
2442
2443 reg_overridable!(vm, BUILTIN_CAT, "cat", builtin_cat);
2444 reg_overridable!(vm, BUILTIN_HEAD, "head", builtin_head);
2445 reg_overridable!(vm, BUILTIN_TAIL, "tail", builtin_tail);
2446 reg_overridable!(vm, BUILTIN_WC, "wc", builtin_wc);
2447 reg_overridable!(vm, BUILTIN_BASENAME, "basename", builtin_basename);
2448 reg_overridable!(vm, BUILTIN_DIRNAME, "dirname", builtin_dirname);
2449 reg_overridable!(vm, BUILTIN_TOUCH, "touch", builtin_touch);
2450 reg_overridable!(vm, BUILTIN_REALPATH, "realpath", builtin_realpath);
2451 reg_overridable!(vm, BUILTIN_SORT, "sort", builtin_sort);
2452 reg_overridable!(vm, BUILTIN_FIND, "find", builtin_find);
2453 reg_overridable!(vm, BUILTIN_UNIQ, "uniq", builtin_uniq);
2454 reg_overridable!(vm, BUILTIN_CUT, "cut", builtin_cut);
2455 reg_overridable!(vm, BUILTIN_TR, "tr", builtin_tr);
2456 reg_overridable!(vm, BUILTIN_SEQ, "seq", builtin_seq);
2457 reg_overridable!(vm, BUILTIN_REV, "rev", builtin_rev);
2458 reg_overridable!(vm, BUILTIN_TEE, "tee", builtin_tee);
2459 reg_overridable!(vm, BUILTIN_SLEEP, "sleep", builtin_sleep);
2460 reg_overridable!(vm, BUILTIN_WHOAMI, "whoami", builtin_whoami);
2461 reg_overridable!(vm, BUILTIN_ID, "id", builtin_id);
2462
2463 reg_overridable!(vm, BUILTIN_HOSTNAME, "hostname", builtin_hostname);
2464 reg_overridable!(vm, BUILTIN_UNAME, "uname", builtin_uname);
2465 reg_overridable!(vm, BUILTIN_DATE, "date", builtin_date);
2466 reg_overridable!(vm, BUILTIN_MKTEMP, "mktemp", builtin_mktemp);
2467 // `cp` — zshrs extension (NOT in upstream zsh; upstream's
2468 // zsh/files module ships `ln`/`mv`/`rm`/`chmod`/`chown` but no
2469 // `cp`). In-process implementation in
2470 // `ext_builtins::cp_impl` — recursive copy with -r/-R, -f, -i,
2471 // -n, -p (chown + utimensat), -v. ID 263 is the first slot
2472 // past fusevm's built-in range (260-262) and before BUILTIN_MAX
2473 // (280).
2474 /// `BUILTIN_CP` constant.
2475 pub const BUILTIN_CP: u16 = 263;
2476 reg_overridable!(vm, BUILTIN_CP, "cp", builtin_cp);
2477
2478 // Pipeline execution — bytecode-native fork-per-stage. Pops N sub-chunk
2479 // indices, forks N children with stdin/stdout wired through N-1 pipes,
2480 // each child runs its stage's compiled bytecode and exits. Parent waits
2481 // and returns the last stage's status.
2482 //
2483 // Caveats: post-fork in a multi-threaded program, only async-signal-safe
2484 // ops are POSIX-safe. We violate this (running the bytecode VM after fork
2485 // touches mutexes like REGEX_CACHE). In practice, most pipeline stages
2486 // don't touch shared mutex state — externals fork/exec away, builtins do
2487 // pure I/O. Risks are bounded; if a stage does touch a held mutex, the
2488 // child deadlocks.
2489 vm.register_builtin(BUILTIN_RUN_PIPELINE, |vm, argc| {
2490 let n = argc as usize;
2491 if n == 0 {
2492 return Value::Status(0);
2493 }
2494
2495 // c:Src/exec.c — every pipeline stage forks from the current
2496 // shell state, so each stage observes the pre-pipeline $? until
2497 // it runs its own command. Stage sub-VMs start fresh with
2498 // last_status=0, so seed them with the parent's lastval; without
2499 // this `false; echo $? | cat` prints 0 instead of zsh's 1.
2500 let parent_status = vm.last_status;
2501
2502 // Pop N sub-chunk indices (LIFO → reverse to stage order)
2503 let mut indices: Vec<u16> = Vec::with_capacity(n);
2504 for _ in 0..n {
2505 indices.push(vm.pop().to_int() as u16);
2506 }
2507 indices.reverse();
2508
2509 // Clone each stage's sub-chunk
2510 let stages: Vec<fusevm::Chunk> = indices
2511 .iter()
2512 .filter_map(|&i| vm.chunk.sub_chunks.get(i as usize).cloned())
2513 .collect();
2514 if stages.len() != n {
2515 return Value::Status(1);
2516 }
2517
2518 // Single stage — no pipe, just run inline
2519 if n == 1 {
2520 let stage = stages.into_iter().next().unwrap();
2521 crate::fusevm_disasm::maybe_print_stdout("pipeline:single", &stage);
2522 let mut stage_vm = fusevm::VM::new(stage);
2523 stage_vm.last_status = parent_status;
2524 register_builtins(&mut stage_vm);
2525 let _ = stage_vm.run();
2526 return Value::Status(stage_vm.last_status);
2527 }
2528
2529 // Build N-1 pipes
2530 let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(n - 1);
2531 for _ in 0..n - 1 {
2532 let mut fds = [0i32; 2];
2533 if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
2534 // Cleanup any pipes we already created
2535 for (r, w) in &pipes {
2536 unsafe {
2537 libc::close(*r);
2538 libc::close(*w);
2539 }
2540 }
2541 return Value::Status(1);
2542 }
2543 pipes.push((fds[0], fds[1]));
2544 }
2545
2546 // zsh runs the LAST stage of a pipeline in the CURRENT shell
2547 // (not a forked child) so a trailing `read x` keeps its
2548 // assignment in the parent. Other shells (bash) fork every
2549 // stage. Honor zsh by leaving stage N-1 inline. Forks the
2550 // first N-1 stages with fork(); runs the last in this process
2551 // with stdin dup2'd to the last pipe's read end and stdout
2552 // restored after.
2553 let last_idx = n - 1;
2554 let stages_vec: Vec<fusevm::Chunk> = stages.into_iter().collect();
2555
2556 let mut child_pids: Vec<libc::pid_t> = Vec::with_capacity(n - 1);
2557 for (i, chunk) in stages_vec.iter().take(last_idx).enumerate() {
2558 match unsafe { libc::fork() } {
2559 -1 => {
2560 // fork failed — kill any children we already started
2561 for pid in &child_pids {
2562 unsafe { libc::kill(*pid, libc::SIGTERM) };
2563 }
2564 for (r, w) in &pipes {
2565 unsafe {
2566 libc::close(*r);
2567 libc::close(*w);
2568 }
2569 }
2570 return Value::Status(1);
2571 }
2572 0 => {
2573 // Reset SIGPIPE to default so a broken-pipe write
2574 // kills the child cleanly instead of triggering a
2575 // Rust println! panic. The parent shell ignores
2576 // SIGPIPE so it can handle EPIPE itself, but child
2577 // pipeline stages should die quietly when their
2578 // downstream stage closes early (e.g. `seq | head -3`).
2579 unsafe {
2580 libc::signal(libc::SIGPIPE, libc::SIG_DFL);
2581 }
2582 // c:Src/exec.c — pipeline children are forked
2583 // subshells; their EXIT trap context is reset so
2584 // the parent's `trap '...' EXIT` doesn't fire when
2585 // the child exits. Mirror by dropping EXIT from
2586 // the inherited traps_table inside the child.
2587 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
2588 t.remove("EXIT");
2589 }
2590 // c:Src/exec.c:2862 → 1219 — pipeline children run
2591 // entersubsh with ESUB_PGRP, which clears the job
2592 // table (clearjobtab, Src/jobs.c:1780). Without
2593 // this, `sleep 5 & jobs -p | wc -l` reports 1 in
2594 // the forked stage where zsh reports 0. The fork
2595 // already copy-isolates the statics, so mutating
2596 // them here can't leak to the parent.
2597 with_executor(|exec| {
2598 let monitor =
2599 crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
2600 crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
2601 });
2602 *crate::ported::jobs::THISJOB
2603 .get_or_init(|| std::sync::Mutex::new(-1))
2604 .lock()
2605 .unwrap() = -1;
2606 // c:Src/exec.c:3720-3724 — the stage's own fds go
2607 // onto 0/1 only AFTER its argument words have been
2608 // expanded (prefork c:3304 / globlist c:3702), so
2609 // park them and let the stage chunk's
2610 // BUILTIN_PIPE_FDS_INSTALL do the dup2 at the
2611 // C-faithful point. `print -rl -- c a b |
2612 // print -r -- "[$(cat)]" | cat` therefore prints
2613 // `[]` — the middle stage's `$(cat)` reads the
2614 // shell's stdin, not the pipe.
2615 let in_fd = if i > 0 { pipes[i - 1].0 } else { -1 };
2616 let out_fd = pipes[i].1;
2617 // (Pipe-output MULTIOS marking — c:Src/exec.c:3724 —
2618 // is emitted INTO the stage chunk by compile_pipe
2619 // via BUILTIN_PIPE_OUTPUT_MARK, gated on the stage's
2620 // top-level command actually carrying redirects, so
2621 // a nested `{ echo a > f; } | cat` body redirect
2622 // does not wrongly join the pipe.)
2623 // Close every pipe fd this stage doesn't need. The
2624 // two it does keep are closed by the install op
2625 // right after their dup2.
2626 for (r, w) in &pipes {
2627 unsafe {
2628 if *r != in_fd && *r != out_fd {
2629 libc::close(*r);
2630 }
2631 if *w != in_fd && *w != out_fd {
2632 libc::close(*w);
2633 }
2634 }
2635 }
2636 stage_fds_park(in_fd, out_fd);
2637
2638 // Run this stage's bytecode on a fresh VM
2639 crate::fusevm_disasm::maybe_print_stdout(
2640 &format!("pipeline:child:stage:{i}"),
2641 chunk,
2642 );
2643 let mut stage_vm = fusevm::VM::new(chunk.clone());
2644 stage_vm.last_status = parent_status;
2645 register_builtins(&mut stage_vm);
2646 let _ = stage_vm.run();
2647 // Flush any buffered output before exiting
2648 let _ = std::io::stdout().flush();
2649 let _ = std::io::stderr().flush();
2650 std::process::exit(stage_vm.last_status);
2651 }
2652 pid => {
2653 child_pids.push(pid);
2654 }
2655 }
2656 }
2657
2658 // Parent runs the LAST stage inline. Save stdin, park the last
2659 // pipe's read end for the chunk's BUILTIN_PIPE_FDS_INSTALL
2660 // (c:Src/exec.c:3722 `addfd(..., 0, input, 0, NULL)` — after
2661 // the stage's args are expanded, so `… | print -r -- "[$(cat)]"`
2662 // has its `$(cat)` read the shell's stdin, not the pipe), run
2663 // the chunk, restore stdin. Close every other pipe fd so the
2664 // producer side gets EOF when the last upstream stage exits.
2665 // Shell-internal save — keep it out of the script's fd range (movefd,
2666 // c:Src/exec.c:2425).
2667 let saved_stdin = unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD, 10) };
2668 let last_in_fd = if last_idx > 0 {
2669 pipes[last_idx - 1].0
2670 } else {
2671 -1
2672 };
2673 // Close all pipe fds in the parent except the one the last
2674 // stage still has to install. (Children already have their own
2675 // copies; the install op closes the read end after its dup2.)
2676 for (r, w) in &pipes {
2677 unsafe {
2678 if *r != last_in_fd {
2679 libc::close(*r);
2680 }
2681 libc::close(*w);
2682 }
2683 }
2684 let outer_stage_fds = stage_fds_park(last_in_fd, -1);
2685
2686 // Run the last stage's bytecode on a sub-VM with the host
2687 // wired up. The host points back at the executor so reads
2688 // (`read x`) update the parent's variables directly.
2689 let last_stage_status = {
2690 let last_chunk = stages_vec.into_iter().last().unwrap();
2691 crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
2692 let mut stage_vm = fusevm::VM::new(last_chunk);
2693 stage_vm.last_status = parent_status;
2694 register_builtins(&mut stage_vm);
2695 stage_vm.set_shell_host(Box::new(ZshrsHost));
2696 let _ = stage_vm.run();
2697 let _ = std::io::stdout().flush();
2698 let _ = std::io::stderr().flush();
2699 stage_vm.last_status
2700 };
2701
2702 // Reclaim the read end if the stage chunk never reached its
2703 // install op (an expansion error aborted it, or the stage was
2704 // a shape that dispatches without one), then restore the outer
2705 // stage's still-pending fds for a nested pipeline.
2706 let (leftover_in, _) = stage_fds_take();
2707 if leftover_in >= 0 {
2708 unsafe { libc::close(leftover_in) };
2709 }
2710 stage_fds_park(outer_stage_fds.0, outer_stage_fds.1);
2711
2712 // Restore stdin
2713 if saved_stdin >= 0 {
2714 unsafe {
2715 libc::dup2(saved_stdin, libc::STDIN_FILENO);
2716 libc::close(saved_stdin);
2717 }
2718 }
2719
2720 // Wait for all forked stages, capture per-stage statuses for PIPESTATUS.
2721 let mut pipestatus: Vec<i32> = Vec::with_capacity(n);
2722 for pid in child_pids {
2723 let mut status: i32 = 0;
2724 unsafe {
2725 libc::waitpid(pid, &mut status, 0);
2726 }
2727 let s = if libc::WIFEXITED(status) {
2728 libc::WEXITSTATUS(status)
2729 } else if libc::WIFSIGNALED(status) {
2730 128 + libc::WTERMSIG(status)
2731 } else {
2732 1
2733 };
2734 pipestatus.push(s);
2735 }
2736 // Append the in-parent last-stage status so `pipestatus` ends
2737 // with N entries (one per stage).
2738 pipestatus.push(last_stage_status);
2739 // Pipeline exit status: by default, the LAST stage's status.
2740 // With `setopt pipefail` (or `set -o pipefail`), use the
2741 // first non-zero stage status (so failures earlier in the
2742 // pipeline propagate even if the last stage succeeded).
2743 let pipefail_on = with_executor(|exec| opt_state_get("pipefail").unwrap_or(false));
2744 let last_status = if pipefail_on {
2745 pipestatus
2746 .iter()
2747 .copied()
2748 .rfind(|&s| s != 0)
2749 .or_else(|| pipestatus.last().copied())
2750 .unwrap_or(0)
2751 } else {
2752 *pipestatus.last().unwrap_or(&0)
2753 };
2754
2755 // c:Src/params.c:265,438 — only `pipestatus` (lowercase) is the
2756 // zsh special parameter; bash's `PIPESTATUS` doesn't exist in
2757 // zsh's special-params table. Prior port also populated
2758 // `PIPESTATUS` "for portability" — but that's a real divergence
2759 // from zsh: a script doing `[[ -z $PIPESTATUS ]]` to detect
2760 // zsh-vs-bash would mis-classify. Bug #64 in docs/BUGS.md.
2761 with_executor(|exec| {
2762 let strs: Vec<String> = pipestatus.iter().map(|s| s.to_string()).collect();
2763 exec.set_array("pipestatus".to_string(), strs);
2764 });
2765
2766 Value::Status(last_status)
2767 });
2768
2769 // Array→String join. Pops one value; if it's an Array (e.g. from Op::Glob),
2770 // joins string-coerced elements with a single space. Pass-through for
2771 // non-arrays so the op is safe to chain after any String-or-Array producer.
2772 // Scalar coercion of an assembled word: pop a Value; if it's an
2773 // Array (produced by a splice segment like `"$@"` / `"${arr[@]}"`),
2774 // IFS[0]-join it to a single scalar; a scalar passes through. This
2775 // is the assignment-context coercion C zsh applies in multsub when
2776 // the expansion is the RHS of a SCALAR assignment (Src/subst.c
2777 // c:3032 sepjoin under ssub) — `v="$@"` joins the positionals with
2778 // ${IFS[1]} rather than leaving an array whose splat would lose all
2779 // but the first element. Joins via sepjoin so a custom / empty IFS
2780 // is honored (not a hardcoded space).
2781 vm.register_builtin(BUILTIN_ARRAY_JOIN, |vm, _argc| {
2782 let val = vm.pop();
2783 match val {
2784 Value::Array(items) => {
2785 let strs: Vec<String> = items.iter().map(|v| v.to_str()).collect();
2786 Value::str(crate::ported::utils::sepjoin(&strs, None))
2787 }
2788 other => other,
2789 }
2790 });
2791
2792 // `cmd &` background execution. Compile_list emits this for any item
2793 // followed by ListOp::Amp: the job text + the cmd's sub-chunk index are
2794 // pushed, then this builtin pops both, looks up the chunk, forks. The
2795 // child detaches via setsid (so SIGINT to the foreground job doesn't kill
2796 // it), runs the bytecode on a fresh VM with builtins re-registered, exits
2797 // with the last status. The parent registers the job in the canonical
2798 // JOBTAB (c:Src/exec.c::execpline Z_ASYNC arm) and returns Status(0).
2799 vm.register_builtin(BUILTIN_RUN_BG, |vm, _argc| {
2800 // `&|` / `&!` set disown → the job is dropped from the table (no
2801 // `[N] pid` announcement, no `[N] done`), matching C exec.c:1752-1758.
2802 let disown = vm.pop().to_int() != 0;
2803 let sub_idx = vm.pop().to_int() as usize;
2804 let job_text = vm.pop().to_str();
2805 let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
2806 Some(c) => c,
2807 None => return Value::Status(1),
2808 };
2809
2810 match unsafe { libc::fork() } {
2811 -1 => Value::Status(1),
2812 0 => {
2813 // Child: detach and run.
2814 unsafe { libc::setsid() };
2815 crate::fusevm_disasm::maybe_print_stdout("background_job", &chunk);
2816 let mut bg_vm = fusevm::VM::new(chunk);
2817 register_builtins(&mut bg_vm);
2818 let _ = bg_vm.run();
2819 let _ = std::io::stdout().flush();
2820 let _ = std::io::stderr().flush();
2821 std::process::exit(bg_vm.last_status);
2822 }
2823 pid => {
2824 // Parent: record the PID into `$!` (most recent
2825 // backgrounded job's pid). zsh exposes this for any
2826 // script that needs `wait $!`. Also register the
2827 // bare-pid job so a no-args `wait` can synchronize.
2828 // c:Src/jobs.c:73 — `lastpid = pid;` after a
2829 // background fork. zshrs's `$!` getter
2830 // (params.rs::lookup_special_var "!") reads from
2831 // the same atomic, so a single store here is the
2832 // canonical writer.
2833 crate::ported::modules::clone::lastpid
2834 .store(pid, std::sync::atomic::Ordering::Relaxed);
2835 // c:Src/exec.c:1700 — `thisjob = newjob = initjob()`:
2836 // allocate the canonical jobtab slot. c:Src/exec.c:2950
2837 // zfork path → addproc(pid, text, 0, &bgtime, ...) hangs
2838 // the proc entry (with its display text) off the job.
2839 // c:Src/exec.c:1744-1746 — `clearoldjobtab();
2840 // jobtab[thisjob].stat |= STAT_NOSTTY;` then c:1758
2841 // `spawnjob()` promotes it to curjob (top-level shell
2842 // only), marks STAT_LOCKED and resets thisjob.
2843 {
2844 use crate::ported::jobs;
2845 use std::sync::Mutex;
2846 let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
2847 let idx = {
2848 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
2849 let idx = jobs::initjob(&mut tab); // c:exec.c:1700
2850 jobs::addproc(
2851 &mut tab[idx],
2852 pid,
2853 &job_text,
2854 false,
2855 Some(std::time::Instant::now()),
2856 -1,
2857 -1,
2858 ); // c:exec.c:2950 addproc
2859 tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
2860 idx
2861 };
2862 jobs::clearoldjobtab(); // c:exec.c:1744
2863 if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
2864 *tj = idx as i32;
2865 }
2866 if disown {
2867 // c:exec.c:1752-1755 — `pipecleanfilelist(...);
2868 // deletejob(jobtab + thisjob, 1); thisjob = -1;` — a
2869 // disowned job leaves the table entirely, so neither
2870 // spawnjob's `[N] pid` nor the later `[N] done` prints.
2871 // This is what keeps zinit-turbo's `… &|` completion
2872 // jobs silent (they load inside a `zle -F` handler).
2873 {
2874 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
2875 jobs::pipecleanfilelist(&mut tab[idx], false); // c:1753
2876 jobs::deletejob(&mut tab[idx], true); // c:1754
2877 }
2878 if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock()
2879 {
2880 *tj = -1; // c:1755
2881 }
2882 } else {
2883 jobs::spawnjob(); // c:exec.c:1758
2884 }
2885 }
2886 with_executor(|exec| {
2887 exec.jobs
2888 .add_pid_job(pid, job_text.clone(), JobState::Running);
2889 });
2890 Value::Status(0)
2891 }
2892 }
2893 });
2894
2895 // ── Indexed-array storage ─────────────────────────────────────────────
2896 //
2897 // Stack: pushed values then name (LAST). `arr=(a b c)` → 4 args
2898 // (a, b, c, arr). `arr=($(cmd))` → 2 args (FlatArray, arr).
2899 //
2900 // PURE PASSTHRU: pop name + values, dispatch to canonical
2901 // `setaparam` / `sethparam` (C port of `Src/params.c:3595/3602`).
2902 // assignaparam already handles PM_UNIQUE dedupe, type-flag flip,
2903 // PM_NAMEREF rejection, ASSPM_AUGMENT prepend, and createparam
2904 // for fresh names.
2905 vm.register_builtin(BUILTIN_SET_ARRAY, |vm, argc| {
2906 // `${~spec}` carrier: an assignment statement is a word-
2907 // pipeline boundary too — restore the user's GLOB_SUBST
2908 // before the NEXT word expands (`Z[d]=${~Z[d]}; print
2909 // ${options[globsubst]}` must read the user value).
2910 consume_tilde_globsubst_carrier();
2911 let n = argc as usize;
2912 let mut popped: Vec<Value> = Vec::with_capacity(n);
2913 for _ in 0..n {
2914 popped.push(vm.pop());
2915 }
2916 popped.reverse();
2917 if popped.is_empty() {
2918 return Value::Status(1);
2919 }
2920 let name = popped.pop().unwrap().to_str();
2921 let mut values: Vec<String> = Vec::new();
2922 for v in popped {
2923 flatten_array_value(v, &mut values);
2924 }
2925 let blocked = with_executor(|exec| {
2926 // Assoc init `typeset -A m; m=(k v k v ...)` — route to
2927 // canonical sethparam (Src/params.c:3602) which parses the
2928 // flat (k,v) pair list internally.
2929 if exec.assoc(&name).is_some() {
2930 // `[k]=v` / `[k]+=v` elements arrive from the compiler
2931 // as Marker / key / value triples (compile_zsh's port
2932 // of keyvalpairelement, c:Src/subst.c:49-79).
2933 let marker = crate::ported::zsh_h::Marker;
2934 let values = if values.iter().any(|e| e.starts_with(marker)) {
2935 // c:Src/params.c:3544-3560 — under ASSPM_KEY_VALUE
2936 // assocs strictly enforce `[key]=value`: every
2937 // stride-of-3 element must be a Marker. Mixing
2938 // plain pairs with kv triads is an error.
2939 let mut i = 0usize;
2940 while i < values.len() {
2941 if !values[i].starts_with(marker) {
2942 crate::ported::utils::zerr(
2943 "bad [key]=value syntax for associative array",
2944 );
2945 crate::ported::utils::errflag.fetch_or(
2946 crate::ported::zsh_h::ERRFLAG_ERROR,
2947 std::sync::atomic::Ordering::Relaxed,
2948 );
2949 exec.set_last_status(1);
2950 return true;
2951 }
2952 i += 3;
2953 }
2954 if values.len() % 3 != 0 {
2955 // c:Src/params.c:4124-4131 arrhashsetfn — a
2956 // truncated triad leaves an odd non-Marker
2957 // count → "bad set of key/value pairs".
2958 crate::ported::utils::zerr(
2959 "bad set of key/value pairs for associative array",
2960 );
2961 crate::ported::utils::errflag.fetch_or(
2962 crate::ported::zsh_h::ERRFLAG_ERROR,
2963 std::sync::atomic::Ordering::Relaxed,
2964 );
2965 exec.set_last_status(1);
2966 return true;
2967 }
2968 // c:Src/params.c:4136-4168 arrhashsetfn — whole
2969 // assignment builds a FRESH table; a `Marker +`
2970 // triad (`[k]+=v`) appends to the value inserted
2971 // EARLIER IN THIS SAME LITERAL (assignstrvalue
2972 // with eltflags=ASSPM_AUGMENT against the new ht),
2973 // so `h=([k]=a [k]+=b)` yields "ab". Resolve the
2974 // appends here, then hand flat pairs to sethparam.
2975 let mut order: Vec<String> = Vec::new();
2976 let mut map: std::collections::HashMap<String, String> =
2977 std::collections::HashMap::new();
2978 for ch in values.chunks(3) {
2979 let elt_append = ch[0].chars().nth(1) == Some('+');
2980 let k = ch[1].clone();
2981 let v = ch[2].clone();
2982 let nv = if elt_append {
2983 format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
2984 } else {
2985 v
2986 };
2987 if !map.contains_key(&k) {
2988 order.push(k.clone());
2989 }
2990 map.insert(k, nv);
2991 }
2992 order
2993 .into_iter()
2994 .flat_map(|k| {
2995 let v = map.get(&k).cloned().unwrap_or_default();
2996 [k, v]
2997 })
2998 .collect()
2999 } else {
3000 values
3001 };
3002 // Odd-count rejection lives in the canonical chain:
3003 // sethparam → setarrvalue (c:3651/c:2920) →
3004 // arrhashsetfn's zerr "bad set of key/value pairs"
3005 // (Src/params.c:4128-4131). zerr sets ERRFLAG_ERROR,
3006 // which aborts the remaining list at the next command
3007 // boundary (BUILTIN_ERREXIT_CHECK trigger 4) —
3008 // matching `zsh -fc 'typeset -A m; m=(odd); print x'`
3009 // printing nothing after the error. Like C's sethparam
3010 // (c:3652-3653 returns v->pm regardless), the Rust
3011 // port returns Some on the odd-count path — the
3012 // failure travels via errflag, so check BOTH.
3013 let pre_err = crate::ported::utils::errflag
3014 .load(std::sync::atomic::Ordering::Relaxed)
3015 & crate::ported::zsh_h::ERRFLAG_ERROR;
3016 let res = crate::ported::params::sethparam(&name, values.clone());
3017 let now_err = crate::ported::utils::errflag
3018 .load(std::sync::atomic::Ordering::Relaxed)
3019 & crate::ported::zsh_h::ERRFLAG_ERROR;
3020 if res.is_none() || (pre_err == 0 && now_err != 0) {
3021 // c:Src/exec.c:2632-2633 addvars — `if
3022 // (!assignaparam(name, arr, myflags)) lastval = 1;`
3023 // — failed assignment sets lastval so the errflag
3024 // abort exits 1 (init.c loop() breaks, zsh_main
3025 // returns lastval).
3026 exec.set_last_status(1);
3027 return true;
3028 }
3029 #[cfg(feature = "recorder")]
3030 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3031 let ctx = exec.recorder_ctx();
3032 let attrs = exec.recorder_attrs_for(&name);
3033 let mut pairs: Vec<(String, String)> = Vec::with_capacity(values.len() / 2);
3034 let mut iter = values.iter().cloned();
3035 while let Some(k) = iter.next() {
3036 if let Some(v) = iter.next() {
3037 pairs.push((k, v));
3038 }
3039 }
3040 crate::recorder::emit_assoc_assign(&name, pairs, attrs, false, ctx);
3041 }
3042 return false;
3043 }
3044 // Indexed-array: setaparam (Src/params.c:3766) wraps
3045 // assignaparam with ASSPM_WARN — handles PM_UNIQUE dedupe,
3046 // type-flag flip, PM_READONLY rejection.
3047 //
3048 // `[k]=v` elements arrive as Marker / key / value triples
3049 // (compile_zsh's keyvalpairelement port). Mirror
3050 // c:Src/exec.c:2552-2553 — `if (prefork_ret &
3051 // PREFORK_KEY_VALUE) myflags |= ASSPM_KEY_VALUE;` — so
3052 // assignaparam runs its kv-resolution block (sparse fill
3053 // for PM_ARRAY, c:3447-3541; strict-triad enforcement for
3054 // special PM_HASHED targets like `options`, c:3544-3560).
3055 let values = values;
3056 let has_kv = values
3057 .iter()
3058 .any(|e| e.starts_with(crate::ported::zsh_h::Marker));
3059 // The tied-array mirror to a PM_TIED scalar
3060 // (`typeset -T PATH path`) lives canonically in
3061 // setarrvalue's dispatch in C zsh; until that wires
3062 // through assignaparam, mirror here so PATH stays in sync
3063 // after `path=(/x)`.
3064 //
3065 // The mirrored value must be the array AS STORED, which for a
3066 // PM_UNIQUE tie means deduped. c:4066-4076 arrsetfn fixes the
3067 // order:
3068 // if (pm->node.flags & PM_UNIQUE) uniqarray(x);
3069 // pm->u.arr = x;
3070 // if (pm->ename && x) arrfixenv(pm->ename, x);
3071 // — the dedupe happens FIRST, so the scalar publishes the same
3072 // list the array holds and the two halves of a tie always agree.
3073 // Mirroring the raw `values` broke exactly that:
3074 // typeset -U path; path=(/a /b /a)
3075 // $path → /a /b (right)
3076 // $PATH → /a:/b:/a (wrong; zsh gives /a:/b)
3077 // i.e. `typeset -U path`, the standard PATH-dedup idiom in
3078 // essentially every .zshrc. assignaparam's own arrfixenv does not
3079 // rescue it: that call is gated on the param having a gsu_a wired,
3080 // and `path` has none.
3081 //
3082 // The dedupe is applied here rather than by reading the array back
3083 // after assignaparam, because this mirror must stay BEFORE it.
3084 // `exec.set_scalar` is heavier than C's arrfixenv — arrfixenv only
3085 // rewrites the environment string, while set_scalar re-derives the
3086 // ARRAY from the scalar. Running it afterwards makes `path=()`
3087 // publish PATH="" and then re-split that back into a one-element
3088 // `path=("")`, where zsh leaves 0 elements.
3089 if let Some((scalar_name, sep)) = exec.tied_array_to_scalar.get(&name).cloned() {
3090 let uniq = crate::ported::params::paramtab()
3091 .read()
3092 .ok()
3093 .and_then(|t| t.get(&name).map(|p| p.node.flags))
3094 .map(|f| (f as u32 & crate::ported::zsh_h::PM_UNIQUE) != 0)
3095 .unwrap_or(false);
3096 let mirror = if uniq {
3097 crate::ported::params::simple_arrayuniq(values.clone()) // c:4068
3098 } else {
3099 values.clone()
3100 };
3101 exec.set_scalar(scalar_name, mirror.join(&sep)); // c:4074-4075
3102 }
3103 // c:Src/exec.c:2632-2633 addvars — `if (!assignaparam(...))
3104 // lastval = 1;` — a failed assignment (bad subscript, bad
3105 // [key]=value syntax, readonly) exits 1 and the errflag
3106 // abort stops the remaining list. Track errflag pre/post
3107 // like the assoc branch above.
3108 let kv_flag = if has_kv {
3109 crate::ported::zsh_h::ASSPM_KEY_VALUE
3110 } else {
3111 0
3112 };
3113 let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3114 & crate::ported::zsh_h::ERRFLAG_ERROR;
3115 let res = crate::ported::params::assignaparam(
3116 &name,
3117 values.clone(),
3118 crate::ported::zsh_h::ASSPM_WARN | kv_flag,
3119 );
3120 let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3121 & crate::ported::zsh_h::ERRFLAG_ERROR;
3122 if res.is_none() && pre_err == 0 && now_err != 0 {
3123 exec.set_last_status(1);
3124 return true;
3125 }
3126 #[cfg(feature = "recorder")]
3127 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3128 let ctx = exec.recorder_ctx();
3129 let attrs = exec.recorder_attrs_for(&name);
3130 emit_path_or_assign(&name, &values, attrs, false, &ctx);
3131 }
3132 false
3133 });
3134 let status = if blocked { 1 } else { 0 };
3135 // c:Src/jobs.c:1748-1757 waitonejob — in C an array-assignment
3136 // simple command goes through execpline → waitjobs; with no
3137 // procs the else-branch stores `pipestats[0] = lastval;
3138 // numpipestats = 1`. Bare SCALAR assignments never create a
3139 // job (no waitjobs), so this clobber is array/assoc-assignment
3140 // specific: `false|true; x=(1 2); echo $pipestatus` → `0` in
3141 // zsh while `x=1` preserves `1 0`. Bug #373.
3142 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
3143 let mut synth = crate::ported::zsh_h::job::default();
3144 crate::ported::jobs::waitonejob(&mut synth);
3145 Value::Status(status)
3146 });
3147 // `arr+=(d e f)` — array append. Same calling conventions as SET_ARRAY.
3148 //
3149 // PURE PASSTHRU shape: pop name + values, dispatch through the
3150 // canonical assoc / array setter. assignaparam's ASSPM_AUGMENT
3151 // flag handles the C-source-equivalent "preserve prior value"
3152 // semantics; for now we read the current array, extend with
3153 // new values, write through set_array (which routes to
3154 // setaparam → assignaparam where PM_UNIQUE dedupe lands).
3155 vm.register_builtin(BUILTIN_APPEND_ARRAY, |vm, argc| {
3156 let n = argc as usize;
3157 let mut popped: Vec<Value> = Vec::with_capacity(n);
3158 for _ in 0..n {
3159 popped.push(vm.pop());
3160 }
3161 popped.reverse();
3162 if popped.is_empty() {
3163 return Value::Status(1);
3164 }
3165 let name = popped.pop().unwrap().to_str();
3166 let mut values: Vec<String> = Vec::new();
3167 for v in popped {
3168 flatten_array_value(v, &mut values);
3169 }
3170 let blocked = with_executor(|exec| -> bool {
3171 // Assoc append `m+=(k1 v1 ...)`: merge the (k,v) pairs into
3172 // the existing map and write back via canonical sethparam
3173 // (Src/params.c:3602). The canonical C path would go
3174 // assignaparam(ASSPM_AUGMENT) → arrhashsetfn(ASSPM_AUGMENT)
3175 // at Src/params.c:3850, but the zshrs port of
3176 // arrhashsetfn doesn't yet implement value-storage
3177 // (pending Param.u_hash backend wireup) — until that
3178 // lands, do the augment + write here so the storage
3179 // actually mutates.
3180 if exec.assoc(&name).is_some() {
3181 // `[k]=v` / `[k]+=v` elements arrive as Marker / key /
3182 // value triples (compile_zsh's port of
3183 // keyvalpairelement, c:Src/subst.c:49-79).
3184 let marker = crate::ported::zsh_h::Marker;
3185 let mut map = exec.assoc(&name).unwrap_or_default();
3186 if values.iter().any(|e| e.starts_with(marker)) {
3187 // c:Src/params.c:3544-3560 — strict triad rule.
3188 let mut i = 0usize;
3189 while i < values.len() {
3190 if !values[i].starts_with(marker) {
3191 crate::ported::utils::zerr(
3192 "bad [key]=value syntax for associative array",
3193 );
3194 crate::ported::utils::errflag.fetch_or(
3195 crate::ported::zsh_h::ERRFLAG_ERROR,
3196 std::sync::atomic::Ordering::Relaxed,
3197 );
3198 exec.set_last_status(1);
3199 return true;
3200 }
3201 i += 3;
3202 }
3203 if values.len() % 3 != 0 {
3204 // c:Src/params.c:4124-4131 — odd pair count.
3205 crate::ported::utils::zerr(
3206 "bad set of key/value pairs for associative array",
3207 );
3208 crate::ported::utils::errflag.fetch_or(
3209 crate::ported::zsh_h::ERRFLAG_ERROR,
3210 std::sync::atomic::Ordering::Relaxed,
3211 );
3212 exec.set_last_status(1);
3213 return true;
3214 }
3215 // c:Src/params.c:4133-4168 arrhashsetfn with
3216 // ASSPM_AUGMENT — ht = the EXISTING table, so
3217 // `[k]+=v` appends to the current value
3218 // (assignstrvalue eltflags=ASSPM_AUGMENT,
3219 // c:4144-4150) and `[k]=v` overwrites.
3220 for ch in values.chunks(3) {
3221 let elt_append = ch[0].chars().nth(1) == Some('+');
3222 let k = ch[1].clone();
3223 let v = ch[2].clone();
3224 let nv = if elt_append {
3225 format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
3226 } else {
3227 v
3228 };
3229 map.insert(k, nv);
3230 }
3231 } else {
3232 let mut it = values.iter().cloned();
3233 while let Some(k) = it.next() {
3234 if let Some(v) = it.next() {
3235 map.insert(k, v);
3236 }
3237 }
3238 }
3239 exec.set_assoc(name, map);
3240 return false;
3241 }
3242 // Indexed-array append `arr+=(d e f)` — route directly
3243 // through canonical assignaparam with ASSPM_AUGMENT
3244 // (`Src/params.c:3570-3585` append-on-array branch).
3245 // assignaparam reads the prior array internally and
3246 // appends the new values, so the bridge no longer needs
3247 // to pre-concat manually. Marker triples from `[k]=v`
3248 // elements add ASSPM_KEY_VALUE (c:Src/exec.c:2552-2553)
3249 // so the kv sparse-fill block (c:Src/params.c:3447-3541)
3250 // resolves them against the existing elements.
3251 let kv_flag = if values
3252 .iter()
3253 .any(|e| e.starts_with(crate::ported::zsh_h::Marker))
3254 {
3255 crate::ported::zsh_h::ASSPM_KEY_VALUE
3256 } else {
3257 0
3258 };
3259 let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3260 & crate::ported::zsh_h::ERRFLAG_ERROR;
3261 let res = crate::ported::params::assignaparam(
3262 &name,
3263 values.clone(),
3264 crate::ported::zsh_h::ASSPM_AUGMENT | kv_flag,
3265 );
3266 let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3267 & crate::ported::zsh_h::ERRFLAG_ERROR;
3268 if res.is_none() && pre_err == 0 && now_err != 0 {
3269 // c:Src/exec.c:2632-2633 — failed assignment → lastval 1.
3270 exec.set_last_status(1);
3271 return true;
3272 }
3273 #[cfg(feature = "recorder")]
3274 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3275 let ctx = exec.recorder_ctx();
3276 let attrs = exec.recorder_attrs_for(&name);
3277 emit_path_or_assign(&name, &values, attrs, true, &ctx);
3278 }
3279 // Tied-scalar mirror — TODO faithful: should live in
3280 // setarrvalue's gsu dispatch once boot_ paramtab wiring
3281 // lands (Task #16). Re-read the canonical post-augment
3282 // array so the joined scalar matches.
3283 let tied_scalar = exec.tied_array_to_scalar.get(&name).cloned();
3284 if let Some((scalar_name, sep)) = tied_scalar {
3285 let merged = exec.array(&name).unwrap_or_default();
3286 let joined = merged.join(&sep);
3287 exec.set_scalar(scalar_name.clone(), joined.clone());
3288 let _ = crate::ported::params::zputenv(&format!("{}={}", &scalar_name, &joined));
3289 // c:Src/params.c:5354
3290 }
3291 false
3292 });
3293 // c:Src/jobs.c:1748-1757 waitonejob — `arr+=(...)` is an
3294 // array-assignment simple command and clobbers pipestats to
3295 // `[lastval]` exactly like `arr=(...)` above. Bug #373.
3296 let status = if blocked { 1 } else { 0 };
3297 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
3298 let mut synth = crate::ported::zsh_h::job::default();
3299 crate::ported::jobs::waitonejob(&mut synth);
3300 Value::Status(status)
3301 });
3302 // `name[@]=(...)` / `name[*]=(...)` — whole-array SET with the assoc
3303 // guard (c:Src/params.c:3324-3327). Stack: [v0..vn, name].
3304 vm.register_builtin(BUILTIN_SET_ARRAY_AT, |vm, argc| {
3305 let (name, values) = pop_array_args_with_name(vm, argc);
3306 let status = with_executor(|exec| {
3307 if exec.assoc(&name).is_some() {
3308 // c:Src/params.c:3324-3327 — `[@]` (any slice) on a
3309 // PM_HASHED target is an error.
3310 crate::ported::utils::zerr(&format!(
3311 "{}: attempt to set slice of associative array",
3312 name
3313 ));
3314 crate::ported::utils::errflag.fetch_or(
3315 crate::ported::zsh_h::ERRFLAG_ERROR,
3316 std::sync::atomic::Ordering::Relaxed,
3317 );
3318 exec.set_last_status(1);
3319 return 1;
3320 }
3321 exec.set_array(name, values); // whole replace (c:3528 setarrvalue)
3322 0
3323 });
3324 Value::Status(status)
3325 });
3326 // `name[@]+=(...)` / `name[*]+=(...)` — whole-array APPEND (push) with
3327 // the same assoc guard.
3328 vm.register_builtin(BUILTIN_APPEND_ARRAY_AT, |vm, argc| {
3329 let (name, values) = pop_array_args_with_name(vm, argc);
3330 let status = with_executor(|exec| {
3331 if exec.assoc(&name).is_some() {
3332 crate::ported::utils::zerr(&format!(
3333 "{}: attempt to set slice of associative array",
3334 name
3335 ));
3336 crate::ported::utils::errflag.fetch_or(
3337 crate::ported::zsh_h::ERRFLAG_ERROR,
3338 std::sync::atomic::Ordering::Relaxed,
3339 );
3340 exec.set_last_status(1);
3341 return 1;
3342 }
3343 let mut cur = exec.array(&name).unwrap_or_default();
3344 cur.extend(values);
3345 exec.set_array(name, cur); // c:3511-3528 AUGMENT on array → push
3346 0
3347 });
3348 Value::Status(status)
3349 });
3350 vm.register_builtin(BUILTIN_RUN_SELECT, |vm, argc| {
3351 if argc < 2 {
3352 return Value::Status(1);
3353 }
3354 let n = argc as usize;
3355 let mut popped: Vec<Value> = Vec::with_capacity(n);
3356 for _ in 0..n {
3357 popped.push(vm.pop());
3358 }
3359 // popped: [sub_idx, name, word_N, ..., word_1] (popping from top)
3360 let sub_idx_val = popped.remove(0);
3361 let name_val = popped.remove(0);
3362 // c:Src/loop.c — `select` flattens Array values (from `$@`,
3363 // `${arr[@]}`, etc.) into the menu. Without per-element
3364 // splice, `select x do ... done` (bare, iterating $@)
3365 // collapsed all positionals into one joined entry.
3366 let mut words: Vec<String> = Vec::new();
3367 for v in popped.into_iter().rev() {
3368 match v {
3369 Value::Array(items) => {
3370 for item in items {
3371 words.push(item.to_str());
3372 }
3373 }
3374 other => words.push(other.to_str()),
3375 }
3376 }
3377
3378 let sub_idx = sub_idx_val.to_int() as usize;
3379 let name = name_val.to_str();
3380
3381 // c:Src/loop.c:248-252 — `if (!args || empty(args)) {
3382 // state->pc = end; ... return 0; }`. An empty option list
3383 // skips the body entirely; without this gate the prompt loop
3384 // runs indefinitely (or twice on the EOF stdin case before
3385 // exiting). Bug #401.
3386 if words.is_empty() {
3387 return Value::Status(0);
3388 }
3389
3390 let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
3391 Some(c) => c,
3392 None => return Value::Status(1),
3393 };
3394
3395 let prompt =
3396 with_executor(|exec| exec.scalar("PROMPT3").unwrap_or_else(|| "?# ".to_string()));
3397
3398 let stdin = std::io::stdin();
3399 let mut reader = stdin.lock();
3400 let mut last_status: i32 = 0;
3401
3402 // c:Src/loop.c:264 — `more = selectlist(args, 0);` renders the menu
3403 // ONCE, BEFORE the selection loop. C reprints it ONLY when the user
3404 // enters an EMPTY line (c:290, inside the inner read loop) — never per
3405 // body iteration. This was a single conflated loop that re-rendered at
3406 // the top of every pass, so `printf "1\n2\n" | select x in a b; do
3407 // print $x; done` redrew the list before each prompt where zsh prints
3408 // it once.
3409 //
3410 // (`selectlist` is also ported at src/ported/loop.rs:127, but that copy
3411 // derives its row budget from adjustlines()/adjustcolumns() — an ioctl
3412 // on fd 1 — and so renders nothing when stdout is not a tty, which is
3413 // exactly this path. Keeping the working inline render here.)
3414 let render_menu = || {
3415 // Direct port of zsh's selectlist from
3416 // src/zsh/Src/loop.c:347-409. Layout is column-major
3417 // ("down columns, then across") — NOT row-major. With
3418 // 6 items in 3 cols zsh produces:
3419 // 1 3 5
3420 // 2 4 6
3421 // The previous Rust impl walked row-major which
3422 // produced 1 2 3 / 4 5 6 (visually similar but wrong
3423 // for prompts that mention ordering and breaks scripts
3424 // that rely on column count == ceil(N/rows)).
3425 //
3426 // C variable mapping:
3427 // ct -> word count (n)
3428 // longest -> max item width + 1, then plus digits-of-ct
3429 // fct -> column count
3430 // fw -> per-column width
3431 // colsz -> row count = ceil(ct / fct)
3432 // t1 -> row index, walks 0..colsz
3433 // ap -> item pointer; advances by colsz to step
3434 // DOWN a column.
3435 let term_width: usize = env::var("COLUMNS")
3436 .ok()
3437 .and_then(|v| v.parse().ok())
3438 .unwrap_or(80);
3439 let ct = words.len();
3440 // loop.c:354-363 — find longest item width.
3441 let mut longest = 1usize;
3442 for w in &words {
3443 let aplen = w.chars().count();
3444 if aplen > longest {
3445 longest = aplen;
3446 }
3447 }
3448 // loop.c:365-367 — `longest++` then add digits of `ct`.
3449 longest += 1;
3450 let mut t0 = ct;
3451 while t0 > 0 {
3452 t0 /= 10;
3453 longest += 1;
3454 }
3455 // loop.c:369-373 — fct = (cols - 1) / (longest + 3); if
3456 // 0, fct = 1; else fw = (cols - 1) / fct.
3457 let raw_fct = (term_width.saturating_sub(1)) / (longest + 3);
3458 let (fct, fw) = if raw_fct == 0 {
3459 (1, longest + 3)
3460 } else {
3461 (raw_fct, (term_width.saturating_sub(1)) / raw_fct)
3462 };
3463 // loop.c:374 — colsz = (ct + fct - 1) / fct.
3464 let colsz = ct.div_ceil(fct);
3465 // loop.c:375-395 — for each row t1, walk down columns.
3466 for t1 in 0..colsz {
3467 let mut ap_idx = t1;
3468 while ap_idx < ct {
3469 let w = &words[ap_idx];
3470 let n = ap_idx + 1;
3471 let _ = write!(std::io::stderr(), "{}) {}", n, w);
3472 let mut t2 = w.chars().count() + 2;
3473 let mut t3 = n;
3474 while t3 > 0 {
3475 t2 += 1;
3476 t3 /= 10;
3477 }
3478 // Pad to fw (loop.c:389-390).
3479 while t2 < fw {
3480 let _ = write!(std::io::stderr(), " ");
3481 t2 += 1;
3482 }
3483 ap_idx += colsz;
3484 }
3485 let _ = writeln!(std::io::stderr());
3486 }
3487 };
3488 render_menu(); // c:264 — once, before the loop
3489
3490 'select: loop {
3491 // c:266-290 — inner read loop: prompt and read until a NON-EMPTY
3492 // line arrives; each empty line reprints the menu and re-reads.
3493 let trimmed = loop {
3494 let _ = write!(std::io::stderr(), "{}", prompt);
3495 let _ = std::io::stderr().flush();
3496
3497 let mut line = String::new();
3498 match reader.read_line(&mut line) {
3499 Ok(0) => {
3500 // c:277-285 — EOF (user pressed Ctrl+D): REPLY="",
3501 // a newline to stderr, then leave the construct.
3502 with_executor(|exec| {
3503 exec.set_scalar("REPLY".to_string(), String::new());
3504 });
3505 let _ = writeln!(std::io::stderr());
3506 let _ = std::io::stderr().flush();
3507 break 'select;
3508 }
3509 Ok(_) => {}
3510 Err(_) => break 'select,
3511 }
3512 let t = line.trim_end_matches(['\n', '\r'][..].as_ref()).to_string();
3513 // c:288-289 — `if (*str) break;`
3514 if !t.is_empty() {
3515 break t;
3516 }
3517 // c:290 — `more = selectlist(args, more);` on an empty line.
3518 render_menu();
3519 };
3520 // c:291 `setsparam("REPLY", ztrdup(str));` — REPLY is set once the
3521 // inner loop yields a non-empty line. An empty line never reaches
3522 // here: c:290 reprints and re-reads instead.
3523 with_executor(|exec| {
3524 exec.set_scalar("REPLY".to_string(), trimmed.clone());
3525 });
3526
3527 // c:293 `i = atoi(str);` — atoi(3) reads a LEADING integer:
3528 // optional blanks, optional sign, then digits, ignoring whatever
3529 // trails, and yields 0 when there are no digits at all.
3530 // `parse::<usize>()` is strict and rejected `1 2` / `1abc` / `+2`
3531 // / ` 1`, so a reply with anything after the number selected
3532 // NOTHING where zsh selects the leading number's item.
3533 let i: i64 = {
3534 let b = trimmed.as_bytes();
3535 let mut p = 0;
3536 while p < b.len() && (b[p] == b' ' || b[p] == b'\t') {
3537 p += 1;
3538 }
3539 let neg = p < b.len() && b[p] == b'-';
3540 if p < b.len() && (b[p] == b'-' || b[p] == b'+') {
3541 p += 1;
3542 }
3543 let mut v: i64 = 0;
3544 while p < b.len() && b[p].is_ascii_digit() {
3545 v = v.saturating_mul(10).saturating_add((b[p] - b'0') as i64);
3546 p += 1;
3547 }
3548 if neg {
3549 -v
3550 } else {
3551 v
3552 }
3553 };
3554 // c:294-301 — `if (!i) str = "";` else walk i-1 nodes and take
3555 // that word; running off the end leaves "". A NEGATIVE i walks
3556 // until the list is exhausted (`n && i`), which also lands on "".
3557 let chosen = if i <= 0 {
3558 String::new()
3559 } else {
3560 words.get((i - 1) as usize).cloned().unwrap_or_default()
3561 };
3562
3563 with_executor(|exec| {
3564 exec.set_scalar(name.clone(), chosen);
3565 });
3566
3567 // Reset canonical BREAKS/CONTFLAG before running the body
3568 // so a stale value from a sibling construct doesn't leak in.
3569 crate::ported::builtin::BREAKS.store(0, SeqCst);
3570 crate::ported::builtin::CONTFLAG.store(0, SeqCst);
3571
3572 // c:Src/loop.c — `select` increments LOOPS for the body so
3573 // `break` / `continue` inside the body see loops > 0 and
3574 // don't emit `not in while, until, select, or repeat loop`.
3575 // Mirrors execwhile/execrepeat's `LOOPS.fetch_add` pattern.
3576 // The decrement happens after the body call so a body that
3577 // explicitly returns / errors still leaves the counter
3578 // balanced for the next iteration.
3579 crate::ported::builtin::LOOPS.fetch_add(1, SeqCst);
3580
3581 crate::fusevm_disasm::maybe_print_stdout("select:body", &chunk);
3582 let mut body_vm = fusevm::VM::new(chunk.clone());
3583 register_builtins(&mut body_vm);
3584 let _ = body_vm.run();
3585 last_status = body_vm.last_status;
3586
3587 crate::ported::builtin::LOOPS.fetch_sub(1, SeqCst);
3588
3589 // Drain the canonical BREAKS/CONTFLAG counters. Mirrors
3590 // loop.c:529-534's `if (breaks) { breaks--; if (breaks ||
3591 // !contflag) break; contflag = 0; }` drain pattern.
3592 // The legacy `BREAK_SELECT=1` env-var sentinel is still
3593 // honored for backward compat.
3594 let break_legacy = with_executor(|exec| {
3595 let v = exec.scalar("BREAK_SELECT");
3596 exec.unset_scalar("BREAK_SELECT");
3597 v.map(|s| s != "0" && !s.is_empty()).unwrap_or(false)
3598 });
3599 use std::sync::atomic::Ordering::SeqCst;
3600 let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
3601 if breaks > 0 {
3602 let cont = crate::ported::builtin::CONTFLAG.load(SeqCst);
3603 crate::ported::builtin::BREAKS.fetch_sub(1, SeqCst);
3604 if breaks - 1 > 0 || cont == 0 {
3605 break;
3606 }
3607 crate::ported::builtin::CONTFLAG.store(0, SeqCst);
3608 continue;
3609 }
3610 if break_legacy {
3611 break;
3612 }
3613 }
3614
3615 Value::Status(last_status)
3616 });
3617
3618 // Magic special-parameter assoc lookup. Synthesizes values from
3619 // shell state for zsh's shell-introspection assocs:
3620 // commands, aliases, galiases, saliases, dis_aliases, dis_galiases,
3621 // dis_saliases, functions, dis_functions, builtins, dis_builtins,
3622 // reswords, options, parameters, jobtexts, jobdirs, jobstates,
3623 // nameddirs, userdirs, modules.
3624 // Returns None if `name` isn't a recognized magic name.
3625
3626 // `${arr[idx]}` — pop name, then idx_str. zsh is 1-based for positive
3627 // indices; we honor that. `@`/`*` return the whole array as Value::Array
3628 // so Op::Exec splice produces N argv slots. For `${foo[key]}` where foo
3629 // is an assoc, the idx is a string key — we check assoc_arrays first
3630 // when the idx isn't `@`/`*` and the name has an assoc binding.
3631 // BUILTIN_ARRAY_INDEX — `${name[idx]}` paramsubst dispatch.
3632 // PURE PASSTHRU: pops the idx + name, hands the canonical
3633 // `${name[idx]}` form to `subst::paramsubst` (C port of
3634 // `Src/subst.c::paramsubst`). All subscript-flag dispatch
3635 // ((I)pat / (R)pat / (i)/(r)/(K)/(k), range slices `[N,M]`,
3636 // negative indices, magic-assoc shape lookup, DQ-join collapse)
3637 // lives inside paramsubst → fetchvalue → getarg in params.rs.
3638 //
3639 // Outer-flag dispatch (`(@)` / `(@k)` / `(v)NAME[(I)pat]` / etc.)
3640 // routes through BUILTIN_BRIDGE_BRACE_ARRAY at the compile path
3641 // (canonical paramsubst flag parser owns dispatch at Src/subst.c:2147+),
3642 // so BUILTIN_ARRAY_INDEX receives clean name+key with no sentinel
3643 // prefixes.
3644 vm.register_builtin(BUILTIN_ARRAY_INDEX, |vm, _argc| {
3645 let idx = vm.pop().to_str();
3646 let name = vm.pop().to_str();
3647 array_index_lookup(&name, &idx)
3648 });
3649 // BUILTIN_ARRAY_INDEX_UNBRACED — bare `$name[idx]` (no braces).
3650 // Same subscript dispatch as BUILTIN_ARRAY_INDEX when KSHARRAYS
3651 // is unset, but under KSHARRAYS the UNBRACED form does NOT
3652 // subscript at all:
3653 // c:Src/subst.c:2800-2802 — fetchvalue's bracket-parse arg is
3654 // `(unset(KSHARRAYS) || inbrace) ? 1 : -1`; -1 inhibits
3655 // subscript parsing for the bare form under KSHARRAYS.
3656 // c:Src/subst.c:2867 — the bracket-consuming loop only runs
3657 // `while (v || ((inbrace || (unset(KSHARRAYS) && vunset)) &&
3658 // isbrack(*s)))` — bare + KSHARRAYS leaves `[...]` as literal
3659 // trailing text.
3660 // The bare `$name` expands (first element for identifier-named
3661 // arrays per c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`),
3662 // the literal `[idx]` (+ any literal suffix) joins the last word,
3663 // and the word undergoes filename generation: `[...]` is a glob
3664 // char class, so unquoted it hits the c:Src/glob.c:1873-1886
3665 // nomatch/nullglob dispatch (reused via exec.expand_glob).
3666 // Operands: [name, idx, suffix, quoted] — `quoted` set when the
3667 // word carries DQ markers (no filename generation in DQ; zsh 5.9:
3668 // `setopt ksharrays; a=(x y z); print "$a[0]"` → `x[0]`).
3669 // Verbatim zsh 5.9 ground truth for the unquoted form:
3670 // `setopt ksharrays; a=(x y z); print -- $a[0]` →
3671 // stderr `zsh:1: no matches found: x[0]`, rc=1, empty stdout.
3672 vm.register_builtin(BUILTIN_ARRAY_INDEX_UNBRACED, |vm, _argc| {
3673 let quoted = vm.pop().to_str() == "1";
3674 let suffix = vm.pop().to_str();
3675 let idx = vm.pop().to_str();
3676 let name = vm.pop().to_str();
3677 if !crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
3678 let v = array_index_lookup(&name, &idx);
3679 if suffix.is_empty() {
3680 return v;
3681 }
3682 // Mirrors the previous compile-shape `ARRAY_INDEX +
3683 // Op::Concat` exactly: Concat stringifies via as_str_cow
3684 // (fusevm value.rs:132-146, arrays join with " ").
3685 return Value::str(format!("{}{}", v.to_str(), suffix));
3686 }
3687 // KSHARRAYS bare form: no subscript. Bare-`$name` words +
3688 // literal `[idx]suffix` glued onto the last word.
3689 let mut words = ksharrays_bare_words(&name);
3690 let last = format!("{}[{}]{}", words.pop().unwrap_or_default(), idx, suffix);
3691 if quoted {
3692 // DQ context — no filename generation, bracket text stays
3693 // literal.
3694 words.push(last);
3695 return Value::str(words.join(" "));
3696 }
3697 // c:Src/glob.c:1873-1886 — expand_glob handles nullglob /
3698 // NOMATCH (zerr "no matches found" + errflag + the
3699 // current_command_glob_failed cell consumed at the command
3700 // dispatch boundary) / literal passthrough for glob-free text.
3701 let matches = with_executor(|exec| exec.expand_glob(&last));
3702 let mut out: Vec<Value> = words.into_iter().map(Value::str).collect();
3703 out.extend(matches.into_iter().map(Value::str));
3704 if out.len() == 1 {
3705 return out.pop().unwrap();
3706 }
3707 Value::Array(out)
3708 });
3709 // BUILTIN_ASSOC_HAS_KEY — `${(k)assoc[name]}` key-existence query.
3710 // Pops [assoc_name, key]; returns key (Str) if present in the
3711 // assoc, empty Str otherwise. Mirrors zsh's `${(k)h[name]}`
3712 // documented semantics in zshparam(1) "Parameter Expansion Flags".
3713 // Distinct from BUILTIN_ARRAY_INDEX (which returns the VALUE) and
3714 // from `${+h[name]}` (which returns "0"/"1"). Bug #145.
3715 vm.register_builtin(BUILTIN_ASSOC_HAS_KEY, |vm, _argc| {
3716 let key = vm.pop().to_str();
3717 let name = vm.pop().to_str();
3718 // c:Src/params.c getindex — the subscript text is substituted
3719 // (singsub) before the lookup; `${(k)H[$k]}` must resolve $k.
3720 // The compiler hands this opcode the RAW subscript text, so a
3721 // dynamic key arrived literally ("$k") and matched nothing.
3722 // singsub is identity for plain keys.
3723 let key = if key.contains('$') || key.contains('`') || key.contains('\u{8c}') {
3724 crate::ported::subst::singsub(&key)
3725 } else {
3726 key
3727 };
3728 // c:Src/params.c:3131 gethkparam covers ordinary PM_HASHED
3729 // paramtab entries only. Special/magic hashes (`parameters`,
3730 // `options`, … — the zsh/parameter module's partab-backed
3731 // params, Src/Modules/parameter.c) aren't in that storage, so
3732 // a None here doesn't mean "no such assoc". Route those
3733 // through paramsubst, whose assoc materialization handles the
3734 // magic hashes and whose getarg port (c:Src/params.c:1591 +
3735 // Src/subst.c:2922) returns the KEY for `(k)` on a plain
3736 // subscript. zsh 5.9: `${(k)parameters[PATH]}` → "PATH".
3737 match crate::ported::params::gethkparam(&name) {
3738 Some(keys) => {
3739 if keys.iter().any(|k| k == &key) {
3740 Value::str(key)
3741 } else {
3742 Value::str("")
3743 }
3744 }
3745 None => paramsubst_to_value(&format!("${{(k){}[{}]}}", name, key)),
3746 }
3747 });
3748 vm.register_builtin(BUILTIN_BRIDGE_BRACE_ARRAY, |vm, _argc| {
3749 // Inner body of `${(...)...}` (already stripped of `${`/`}` by
3750 // the caller). The compiler optionally prefixes Qstring
3751 // (\u{8c}) to signal "expanded in DQ context" — strip it
3752 // here and bump in_dq_context for the paramsubst call so the
3753 // SUB_ZIP and other qt-aware paths fire.
3754 let body = vm.pop().to_str();
3755 let (dq, inner) = if let Some(rest) = body.strip_prefix('\u{8c}') {
3756 (true, rest.to_string())
3757 } else {
3758 (false, body)
3759 };
3760 if dq {
3761 with_executor(|exec| exec.in_dq_context += 1);
3762 }
3763 let v = paramsubst_to_value(&format!("${{{}}}", inner));
3764 if dq {
3765 with_executor(|exec| exec.in_dq_context -= 1);
3766 }
3767 v
3768 });
3769
3770 // BUILTIN_PARAM_FLAG — `${(flags)name}` paramsubst dispatch.
3771 // PURE PASSTHRU: pops sentinel-tagged flags + name, hands the
3772 // canonical `${(flags)name}` form to `subst::paramsubst` (C port
3773 // of `Src/subst.c::paramsubst`). The bridge does no flag
3774 // walking, no DQ-context branching, no array/scalar shape
3775 // selection — all of that lives inside paramsubst. Compile-time
3776 // context (DQ / scalar-assign-RHS) flows through executor cells
3777 // (in_dq_context, in_scalar_assign) bumped by BUILTIN_EXPAND_TEXT.
3778 vm.register_builtin(BUILTIN_PARAM_FLAG, |vm, _argc| {
3779 let flags = vm.pop().to_str();
3780 let name = vm.pop().to_str();
3781 let body = format!("${{({}){}}}", flags, name);
3782 paramsubst_to_value(&body)
3783 });
3784
3785 // `foo[key]=val` — single-key set on an assoc array. Stack: [name, key, value].
3786 // PURE PASSTHRU: assignsparam with `name[key]` form (C port of
3787 // `Src/params.c::assignsparam` subscript path at c:3210-3231)
3788 // already does the indexed-array vs assoc decision, PM_HASHED
3789 // auto-vivification, numeric-subscript bounds handling, and
3790 // PM_READONLY rejection.
3791 vm.register_builtin(BUILTIN_SET_ASSOC, |vm, _argc| {
3792 // `${~spec}` carrier: an assignment statement is a word-
3793 // pipeline boundary too — restore the user's GLOB_SUBST
3794 // before the NEXT word expands (`Z[d]=${~Z[d]}; print
3795 // ${options[globsubst]}` must read the user value).
3796 consume_tilde_globsubst_carrier();
3797 // argc 4 = compile flagged the subscript as DYNAMIC (`H[$k]`):
3798 // an EXPANDED-empty key is then a legal assoc key (C's
3799 // assignsparam isident gate sees the raw `$k` text and the
3800 // empty key stores at getindex time — zinit's
3801 // ZINIT_SICE[$1…$2] relies on it). argc 3 = source-literal
3802 // key; `H[]` stays the "not an identifier" error.
3803 let key_is_dynamic = if _argc == 4 {
3804 vm.pop().to_int() != 0
3805 } else {
3806 false
3807 };
3808 let value = vm.pop().to_str();
3809 let key = vm.pop().to_str();
3810 let name = vm.pop().to_str();
3811 if key_is_dynamic && key.is_empty() {
3812 with_executor(|exec| {
3813 let _ = exec;
3814 });
3815 // Mirror assignsparam's PM_HASHED tail directly (the
3816 // textual `name[]` reconstruction can't pass isident).
3817 if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
3818 let entry = store.entry(name.clone()).or_default();
3819 let newval = if let Some(old) = entry.get("") {
3820 // `+=` arrives pre-concatenated by the compile
3821 // read-modify-write; plain `=` overwrites.
3822 let _ = old;
3823 value.clone()
3824 } else {
3825 value.clone()
3826 };
3827 entry.insert(String::new(), newval);
3828 }
3829 return Value::Status(0);
3830 }
3831 with_executor(|exec| {
3832 #[cfg(feature = "recorder")]
3833 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3834 let ctx = exec.recorder_ctx();
3835 let attrs = exec.recorder_attrs_for(&name);
3836 crate::recorder::emit_assoc_assign(
3837 &name,
3838 vec![(key.clone(), value.clone())],
3839 attrs,
3840 true,
3841 ctx,
3842 );
3843 }
3844 let _ = exec;
3845 });
3846 // Build `name[key]=value` shape for assignsparam's subscript
3847 // dispatch. Arith-evaluate numeric subscripts on an existing
3848 // indexed array (`a[i+1]=v` form) before handing off — the
3849 // canonical port currently only handles literal int / string
3850 // keys, so pre-resolve here.
3851 let resolved_key = with_executor(|exec| {
3852 let is_indexed = exec.array(&name).is_some();
3853 let is_assoc = exec.assoc(&name).is_some();
3854 let is_scalar = !is_indexed && !is_assoc && exec.scalar(&name).is_some();
3855 // c:Src/params.c::getindex — `(i)pat` / `(I)pat` / `(R)pat`
3856 // / `(r)pat` subscript flags on an indexed array LHS resolve
3857 // to a numeric index (first / last match of pat). On a
3858 // SCALAR LHS the same flags resolve to a CHAR position
3859 // (1-based first/last match of pat in the scalar string)
3860 // for the c:2748+ char-splice assignment. zshrs's
3861 // read-form `${a[(i)pat]}` already implements both shapes;
3862 // the LHS assignment path silently stored the literal
3863 // "(i)pat" as an assoc key (for scalar: auto-vivified to
3864 // PM_HASHED via the assignsparam unknown-subscript
3865 // fallback). Bug #293 (array) / scalar sibling.
3866 //
3867 // Detect the `(flags)pat` shape and resolve to a numeric
3868 // index before assignsparam.
3869 if is_indexed || is_scalar {
3870 if let Some(rest) = key.strip_prefix('(') {
3871 if let Some(close) = rest.find(')') {
3872 let flags = &rest[..close];
3873 let pat = &rest[close + 1..];
3874 if !flags.is_empty()
3875 && flags
3876 .chars()
3877 .all(|c| matches!(c, 'I' | 'R' | 'i' | 'r' | 'n' | 'e'))
3878 {
3879 // Resolve via the array's contents.
3880 if let Some(arr) = exec.array(&name) {
3881 let return_index = true; // LHS write — index needed
3882 let down = flags.contains('I') || flags.contains('R');
3883 let exact = flags.contains('e');
3884 let iter: Box<dyn Iterator<Item = (usize, &String)>> = if down {
3885 Box::new(arr.iter().enumerate().rev())
3886 } else {
3887 Box::new(arr.iter().enumerate())
3888 };
3889 let mut found: Option<usize> = None;
3890 for (idx, elem) in iter {
3891 let matched = if exact {
3892 elem == pat
3893 } else {
3894 crate::ported::pattern::patcompile(
3895 &{
3896 let mut __pat_tok = (pat).to_string();
3897 crate::ported::glob::tokenize(&mut __pat_tok);
3898 __pat_tok
3899 },
3900 crate::ported::zsh_h::PAT_HEAPDUP as i32,
3901 None,
3902 )
3903 .map_or(false, |p| crate::ported::pattern::pattry(&p, elem))
3904 };
3905 if matched {
3906 found = Some(idx);
3907 break;
3908 }
3909 }
3910 let _ = return_index;
3911 // (i)/(r) return 1-based index of match,
3912 // arr.len()+1 (or 1 for I/R) on miss
3913 // per zsh docs. We mirror the read-form
3914 // semantics from subst.rs.
3915 let idx_1based = match found {
3916 Some(i) => (i + 1) as i64,
3917 None => (arr.len() + 1) as i64,
3918 };
3919 return idx_1based.to_string();
3920 }
3921 // Scalar LHS — resolve to a CHAR position
3922 // (1-based first/last match of pat in the
3923 // string). c:Src/params.c:1411-1418 — the
3924 // scalar path returns the char index from
3925 // sliding-window pattern match against
3926 // pm.u_str. Same algorithm as the read-form
3927 // at subst.rs:5283-5306. Bug (scalar
3928 // sibling of #293): `a=hello; a[(I)l]=X`
3929 // previously auto-vivified `a` into
3930 // PM_HASHED with key "(I)l" instead of
3931 // splicing X at the last 'l' position
3932 // (yielding "helXo").
3933 if is_scalar {
3934 let s = exec.scalar(&name).unwrap_or_default();
3935 let s_chars: Vec<char> = s.chars().collect();
3936 let n = s_chars.len();
3937 let want_last = flags.contains('I') || flags.contains('R');
3938 let exact = flags.contains('e');
3939 let mut found: Option<usize> = None;
3940 'outer: for start in 0..=n {
3941 let lengths: Box<dyn Iterator<Item = usize>> = if want_last {
3942 Box::new((1..=(n - start)).rev())
3943 } else {
3944 Box::new(1..=(n - start))
3945 };
3946 for len in lengths {
3947 let cand: String =
3948 s_chars[start..start + len].iter().collect();
3949 let matched = if exact {
3950 cand == pat
3951 } else {
3952 crate::ported::pattern::patcompile(
3953 &{
3954 let mut __pat_tok = (pat).to_string();
3955 crate::ported::glob::tokenize(&mut __pat_tok);
3956 __pat_tok
3957 },
3958 crate::ported::zsh_h::PAT_HEAPDUP as i32,
3959 None,
3960 )
3961 .map_or(false, |p| {
3962 crate::ported::pattern::pattry(&p, &cand)
3963 })
3964 };
3965 if matched {
3966 found = Some(start);
3967 if !want_last {
3968 break 'outer;
3969 }
3970 break;
3971 }
3972 }
3973 }
3974 // (I)/(R): scan again to find LAST.
3975 if want_last {
3976 let mut last_found: Option<usize> = found;
3977 for start in (0..=n).rev() {
3978 for len in 1..=(n - start) {
3979 let cand: String =
3980 s_chars[start..start + len].iter().collect();
3981 let matched = if exact {
3982 cand == pat
3983 } else {
3984 crate::ported::pattern::patcompile(
3985 &{
3986 let mut __pat_tok = (pat).to_string();
3987 crate::ported::glob::tokenize(
3988 &mut __pat_tok,
3989 );
3990 __pat_tok
3991 },
3992 crate::ported::zsh_h::PAT_HEAPDUP as i32,
3993 None,
3994 )
3995 .map_or(false, |p| {
3996 crate::ported::pattern::pattry(&p, &cand)
3997 })
3998 };
3999 if matched {
4000 last_found = Some(start);
4001 break;
4002 }
4003 }
4004 if last_found.is_some() && last_found.unwrap() >= start {
4005 break;
4006 }
4007 }
4008 found = last_found;
4009 }
4010 let idx_1based = match found {
4011 Some(i) => (i + 1) as i64,
4012 // (i) miss → len+1 (one past end).
4013 None => (n + 1) as i64,
4014 };
4015 return idx_1based.to_string();
4016 }
4017 }
4018 }
4019 }
4020 }
4021 if is_indexed && key.trim().parse::<i64>().is_err() {
4022 crate::ported::math::mathevali(&crate::ported::subst::singsub(&key))
4023 .map(|n| n.to_string())
4024 .unwrap_or(key.clone())
4025 } else {
4026 key.clone()
4027 }
4028 });
4029 // c:Src/params.c getindex — C parses the subscript from the
4030 // TOKENIZED source word, so a `]`/`}` that arrived via `$key`
4031 // expansion is plain data and can never terminate the
4032 // subscript. The textual `name[key]` rebuild below re-parses
4033 // the FLAT string, where an expanded `]` splits the key at the
4034 // first bracket (`c[$k]=5` with k='x]y' stored key "x" and
4035 // spilled junk — zpwr expandstats died on the spill in a later
4036 // math expr). For a PM_HASHED target the compile-time split
4037 // already isolated the exact key: store it directly via the
4038 // canonical hashed storage (same mechanism as the
4039 // dynamic-empty-key arm above / assignsparam's PM_HASHED
4040 // tail), with the readonly guard assignsparam would apply.
4041 let target_flags = with_executor(|exec| exec.param_flags(&name));
4042 // PM_SPECIAL exclusion: the zsh/parameter magic assocs
4043 // (functions / aliases / galiases / saliases / options / …)
4044 // have per-key setfns with SIDE EFFECTS — `functions[x]=body`
4045 // must parse the body into shfunctab (Src/Modules/
4046 // parameter.c:296 setfunction), `aliases[x]=v` must write
4047 // aliastab. The direct hashed-storage store below silently
4048 // swallowed those: zinit's tmp-subst wrappers
4049 // (`functions[autoload]=':zinit-tmp-subst-autoload "$@";'`)
4050 // never became real functions, so every
4051 // `.zinit-tmp-subst-off` spammed `unfunction: no such hash
4052 // table element: autoload/compdef/bindkey/…`. Route specials
4053 // through assignsparam's canonical per-name arms instead.
4054 if (target_flags as u32 & crate::ported::zsh_h::PM_HASHED) != 0
4055 && (target_flags as u32 & crate::ported::zsh_h::PM_SPECIAL) == 0
4056 {
4057 if (target_flags as u32 & crate::ported::zsh_h::PM_READONLY) != 0 {
4058 crate::ported::utils::zerr(&format!("read-only variable: {}", name));
4059 return Value::Status(1);
4060 }
4061 if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
4062 store
4063 .entry(name.clone())
4064 .or_default()
4065 .insert(resolved_key.clone(), value.clone());
4066 }
4067 return Value::Status(0);
4068 }
4069 let subscripted = format!("{}[{}]", name, resolved_key);
4070 crate::ported::params::assignsparam(&subscripted, &value, crate::ported::zsh_h::ASSPM_WARN);
4071 Value::Status(0)
4072 });
4073
4074 // Brace expansion. Routes through executor.xpandbraces (already
4075 // implemented for the pre-fusevm executor). Returns Value::Array.
4076 // BUILTIN_ARRAY_DROP_EMPTY — filter out empty Value::Str entries
4077 // from a Value::Array on the stack. Used by `for x in $@` /
4078 // `for x in $*` unquoted forms which drop empty positionals
4079 // (POSIX-like) but do NOT IFS-split each element internally
4080 // (zsh-specific — scalar word splitting is off by default).
4081 // Distinct from BUILTIN_WORD_SPLIT which routes through
4082 // multsub PREFORK_SPLIT (full IFS-split). Bug #166.
4083 vm.register_builtin(BUILTIN_ARRAY_DROP_EMPTY, |vm, _argc| {
4084 let v = vm.pop();
4085 match v {
4086 Value::Array(items) => {
4087 let filtered: Vec<Value> = items
4088 .into_iter()
4089 .filter(|x| !x.to_str().is_empty())
4090 .collect();
4091 Value::Array(filtered)
4092 }
4093 Value::Str(s) if s.is_empty() => Value::Array(Vec::new()),
4094 other => other,
4095 }
4096 });
4097
4098 // BUILTIN_QUOTEDZPUTS — re-wrap top-of-stack scalar via the
4099 // canonical quotedzputs (Src/utils.c:6464). Non-printable bytes
4100 // come back as `$'…'` C-string form so the cond xtrace prefix
4101 // line preserves the source-quoting form for `[[ -n $'\C-[OP' ]]`
4102 // instead of leaking raw ESC + "OP" bytes through the terminal.
4103 vm.register_builtin(BUILTIN_QUOTEDZPUTS, |vm, _argc| {
4104 let s = vm.pop().to_str();
4105 Value::str(crate::ported::utils::quotedzputs(&s))
4106 });
4107
4108 // BUILTIN_QUOTE_TOKENIZED_OUTPUT — char-aware mirror of
4109 // c:Src/exec.c:2114 `quote_tokenized_output`. The canonical
4110 // port at exec::quote_tokenized_output operates on bytes
4111 // (zsh's metafied encoding); zshrs strings are UTF-8 so
4112 // `\u{87}` Star is `[0xC2, 0x87]`, and a byte walk writes
4113 // 0xC2 raw (invalid UTF-8 lead → U+FFFD on lossy decode).
4114 // Walk by char and dispatch the same switch the byte port
4115 // uses, but with the token chars matching the UTF-8 form.
4116 vm.register_builtin(BUILTIN_QUOTE_TOKENIZED_OUTPUT, |vm, _argc| {
4117 let s = vm.pop().to_str();
4118 let mut out = String::with_capacity(s.len());
4119 let chars: Vec<char> = s.chars().collect();
4120 let mut i = 0;
4121 while i < chars.len() {
4122 let c = chars[i];
4123 // c:2120 — Meta-quoted byte: emit `*++s ^ 32`.
4124 // In UTF-8 strings Meta is `\u{83}`; the next char is
4125 // the metafied payload.
4126 if c == '\u{83}' {
4127 if let Some(&n) = chars.get(i + 1) {
4128 if (n as u32) < 0x80 {
4129 out.push(((n as u8) ^ 32) as char);
4130 } else {
4131 out.push(n);
4132 }
4133 i += 2;
4134 continue;
4135 }
4136 i += 1;
4137 continue;
4138 }
4139 // c:2124 — Nularg: skip.
4140 if c == '\u{a1}' {
4141 i += 1;
4142 continue;
4143 }
4144 // c:2128-2143 — ASCII specials get backslash-prefixed
4145 // then fall through to emit the literal char.
4146 match c {
4147 '\\' | '<' | '>' | '(' | '|' | ')' | '^' | '#' | '~' | '[' | ']' | '*' | '?'
4148 | '$' | ' ' => {
4149 out.push('\\');
4150 out.push(c);
4151 i += 1;
4152 continue;
4153 }
4154 '\t' => {
4155 out.push_str("$'\\t'");
4156 i += 1;
4157 continue;
4158 }
4159 '\n' => {
4160 out.push_str("$'\\n'");
4161 i += 1;
4162 continue;
4163 }
4164 '\r' => {
4165 out.push_str("$'\\r'");
4166 i += 1;
4167 continue;
4168 }
4169 '=' => {
4170 if i == 0 {
4171 out.push('\\');
4172 }
4173 out.push(c);
4174 i += 1;
4175 continue;
4176 }
4177 _ => {}
4178 }
4179 // c:2163 — `if (itok(*s)) putc(ztokens[*s - Pound]);`
4180 // Map zsh token chars (`\u{84}`..`\u{a1}` range, the
4181 // ones the lexer emits for `#$^*()…`) back to their
4182 // source ASCII via the `ztokens` table.
4183 let cp = c as u32;
4184 if (0x84..=0xa1).contains(&cp) {
4185 let idx = (cp - 0x84) as usize;
4186 let ztokens = crate::ported::lex::ztokens.as_bytes();
4187 if idx < ztokens.len() {
4188 out.push(ztokens[idx] as char);
4189 i += 1;
4190 continue;
4191 }
4192 }
4193 out.push(c);
4194 i += 1;
4195 }
4196 Value::str(out)
4197 });
4198
4199 // BUILTIN_WORD_SPLIT — `${=var}` IFS-split runtime.
4200 // PURE PASSTHRU: route through canonical `subst::multsub` with
4201 // PREFORK_SPLIT flag (C port of `Src/subst.c::multsub` at c:544
4202 // — the IFS-split walker with whitespace-vs-non-whitespace
4203 // gating, quote-aware parsing, and empty-field handling).
4204 vm.register_builtin(BUILTIN_WORD_SPLIT, |vm, _argc| {
4205 let s = vm.pop().to_str();
4206 let (_joined, parts, _isarr, _flags) =
4207 crate::ported::subst::multsub(&s, crate::ported::zsh_h::PREFORK_SPLIT);
4208 // Empty single-string special case → empty Array (drop empty arg).
4209 if parts.len() == 1 && parts[0].is_empty() {
4210 return Value::Array(Vec::new());
4211 }
4212 nodes_to_value(parts)
4213 });
4214
4215 // BUILTIN_FORCE_SPLIT — `${=name}` / SH_WORD_SPLIT forced split.
4216 // c:Src/subst.c:3920-3928 —
4217 // if (force_split && !isarr) {
4218 // aval = sepsplit(val, spsep, 0, 1);
4219 // if (!aval || !aval[0]) val = dupstring("");
4220 // else if (!aval[1]) val = aval[0];
4221 // else isarr = nojoin ? 1 : 2;
4222 // }
4223 // with spsep == NULL for the `=` flag, so sepsplit falls through to
4224 // Src/utils.c:3711 spacesplit(s, allownull=0). See BUILTIN_FORCE_SPLIT's
4225 // doc comment for the empty-field rule and the argc contract.
4226 vm.register_builtin(BUILTIN_FORCE_SPLIT, |vm, argc| {
4227 let s = vm.pop().to_str();
4228 let keep_empties = argc == 1;
4229 // c:3921 — `sepsplit(val, spsep, 0, 1)`; spsep NULL → spacesplit.
4230 let raw = crate::ported::utils::sepsplit(&s, None, false);
4231 // c:Src/subst.c:36 `char nulstring[] = {Nularg, '\0'};` — spacesplit
4232 // emits this for an empty field delimited by IFS-NON-whitespace
4233 // (c:Src/utils.c:3732 / :3752); it survives prefork's empty-node
4234 // delete and remnulargs (c:Src/glob.c:3649) turns it back into "".
4235 // A plain "" field (c:3734 / :3757) is what a skipped run of
4236 // IFS-WHITESPACE leaves behind, and prefork DOES delete that one.
4237 let nulstring = crate::ported::zsh_h::Nularg.to_string();
4238 let mut out: Vec<String> = Vec::with_capacity(raw.len());
4239 for w in raw {
4240 if w == nulstring {
4241 out.push(String::new());
4242 } else if w.is_empty() {
4243 if keep_empties {
4244 out.push(String::new());
4245 }
4246 } else {
4247 out.push(w);
4248 }
4249 }
4250 if out.is_empty() {
4251 // c:3922-3923 — `if (!aval || !aval[0]) val = dupstring("");`:
4252 // the split produced nothing, so the value is the empty SCALAR.
4253 // Quoted, that is one empty word (c:4465 `if (qt && !*y) y =
4254 // dupstring(nulstring);` → `a=( "${=v}" )` has one element);
4255 // unquoted, prefork deletes it and the word vanishes.
4256 if keep_empties {
4257 return Value::str(String::new());
4258 }
4259 note_empty_is_scalar(true);
4260 return Value::Array(Vec::new());
4261 }
4262 if out.len() == 1 {
4263 // c:3924 — `else if (!aval[1]) val = aval[0];` — a one-field
4264 // split stays a SCALAR (this is why `${#${(f)v}}` counts
4265 // characters when the split yields a single line).
4266 return Value::str(out.into_iter().next().unwrap());
4267 }
4268 // c:3927 — `isarr = nojoin ? 1 : 2;`
4269 Value::Array(out.into_iter().map(Value::str).collect())
4270 });
4271
4272 vm.register_builtin(BUILTIN_BRACE_EXPAND, |vm, _argc| {
4273 // c:Src/glob.c::xpandbraces — brace expansion runs per word.
4274 // When the upstream produced an array (e.g. `${a:e}` splat),
4275 // expand braces on each element separately so the splat
4276 // survives. `pop().to_str()` would join with space and lose
4277 // the array shape. Parity bug #28 cousin: the BRACE_EXPAND
4278 // emit always fires for any word containing `{` (including
4279 // `${...}` param-expansion braces), so its collapse hit even
4280 // pure-paramsubst args.
4281 let raw = vm.pop();
4282 // c:Src/options.c — `no_brace_expand` (negated braceexpand)
4283 // disables brace expansion entirely. When set, `{a,b}` stays
4284 // literal. Mirror by short-circuiting xpandbraces; pass the
4285 // input through unchanged.
4286 let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
4287 let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
4288 let inputs: Vec<String> = match raw {
4289 Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
4290 other => vec![other.to_str()],
4291 };
4292 if !brace_expand {
4293 return nodes_to_value(inputs);
4294 }
4295 let mut out: Vec<String> = Vec::with_capacity(inputs.len());
4296 for s in inputs {
4297 for w in crate::ported::glob::xpandbraces(&s, brace_ccl) {
4298 out.push(w);
4299 }
4300 }
4301 nodes_to_value(out)
4302 });
4303
4304 // `*(qual)` glob qualifier filter. Stack: [pattern, qualifier].
4305 // Pattern is glob-expanded normally, then each result is filtered by the
4306 // qualifier predicate. Common qualifiers:
4307 // . — regular files only
4308 // / — directories only
4309 // @ — symlinks
4310 // x — executable
4311 // r/w/x — readable/writable/executable
4312 // N — nullglob (no error if no match)
4313 // L+N / L-N — size > N / size < N (in bytes)
4314 // mh-N / mh+N — modified within N hours / older than N hours
4315 // md-N / md+N — modified within N days / older than N days
4316 // on/On — sort by name asc/desc (default)
4317 // oL/OL — sort by length
4318 // om/Om — sort by mtime
4319 // Pop a scalar pattern, run expand_glob, push Value::Array. Used
4320 // by the segment-concat compile path for `$D/*`-style words.
4321 vm.register_builtin(BUILTIN_GLOB_EXPAND, |vm, _argc| {
4322 // c:Src/glob.c:1872 — honour `setopt noglob` / `noglob CMD`
4323 // precommand. When the option is on, the word stays literal
4324 // (zsh skips the glob expansion entirely). Without this, the
4325 // segment-fast-path BUILTIN_GLOB_EXPAND fired even after
4326 // `noglob` set the option, so `noglob echo *.xyz` saw the
4327 // NOMATCH error instead of the literal pass-through.
4328 let raw = vm.pop();
4329 let noglob =
4330 opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
4331 glob_expand_word_value(raw, noglob)
4332 });
4333 // Redirect-target variant of BUILTIN_GLOB_EXPAND. c:Src/glob.c:
4334 // 2161-2167 xpandredir — `prefork(&fake, isset(MULTIOS) ? 0 :
4335 // PREFORK_SINGLE, NULL)` then "Globbing is only done for
4336 // multios.": a redirect target word is only globbed when the
4337 // MULTIOS option is set. With it unset, `echo hi > *.txt`
4338 // creates the literal file `*.txt`, and `wc -c < *.txt` errors
4339 // "no such file or directory: *.txt". Bug #36 follow-up in
4340 // docs/BUGS.md.
4341 vm.register_builtin(BUILTIN_REDIR_GLOB_EXPAND, |vm, _argc| {
4342 let raw = vm.pop();
4343 let noglob =
4344 opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
4345 let multios = opt_state_get("multios").unwrap_or(true);
4346 glob_expand_word_value(raw, noglob || !multios)
4347 });
4348 // Clear the default-word glob-pending carrier before the word's
4349 // expansion runs, so a flag set by a prior word never leaks in.
4350 vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB_RESET, |_vm, _argc| {
4351 crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| c.set(false));
4352 Value::Status(0)
4353 });
4354 // After the word is assembled, run filename generation ONLY if the
4355 // default/alternate paramsubst arm flagged a source-glob default
4356 // (DEFAULT_WORD_GLOB_PENDING). Otherwise pass the word through
4357 // literally — a parameter VALUE must not glob. c:Src/subst.c globlist.
4358 vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB, |vm, _argc| {
4359 let raw = vm.pop();
4360 let pending = crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| {
4361 let v = c.get();
4362 c.set(false); // read + clear
4363 v
4364 });
4365 if !pending {
4366 return raw;
4367 }
4368 let noglob =
4369 opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
4370 glob_expand_word_value(raw, noglob)
4371 });
4372
4373 // `break`/`continue` from a sub-VM body. The compile path emits
4374 // these when the keyword appears at chunk top-level (no enclosing
4375 // for/while in the current chunk's patch lists). Outer-loop
4376 // builtins (BUILTIN_RUN_SELECT and any future loop-via-builtin
4377 // construct) drain canonical BREAKS/CONTFLAG after each iteration.
4378 //
4379 // Writes match `bin_break`'s c:5836+ pattern:
4380 // continue: contflag = 1; breaks++ (Src/builtin.c::bin_break)
4381 // break: breaks++
4382 vm.register_builtin(BUILTIN_SET_BREAK, |_vm, _argc| {
4383 use std::sync::atomic::Ordering::SeqCst;
4384 crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
4385 Value::Status(0)
4386 });
4387 vm.register_builtin(BUILTIN_SET_CONTINUE, |_vm, _argc| {
4388 use std::sync::atomic::Ordering::SeqCst;
4389 crate::ported::builtin::CONTFLAG.store(1, SeqCst);
4390 crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
4391 Value::Status(0)
4392 });
4393
4394 // `break N`/`continue N` with a RUNTIME level count. Pops [count,
4395 // name]; math-evaluates count (c:builtin.c:5811 `mathevali`); on
4396 // count <= 0 emits `argument is not positive: N` via zerrnam (sets
4397 // errflag → abort, c:5813) and pushes Int(0) (matches no jump-table
4398 // entry → control falls through to the errflag abort). Otherwise
4399 // pushes Int(count) for the compiled jump table to dispatch on.
4400 vm.register_builtin(BUILTIN_BREAK_COUNT_VALIDATE, |vm, _argc| {
4401 let name = vm.pop().to_str();
4402 let count_s = vm.pop().to_str();
4403 let count = crate::ported::math::mathevali(&count_s).unwrap_or(0);
4404 if count <= 0 {
4405 crate::ported::utils::zerrnam(&name, &format!("argument is not positive: {count}"));
4406 return Value::Int(0);
4407 }
4408 Value::Int(count)
4409 });
4410
4411 // `${arr[*]}` — join array elements with the first IFS char into
4412 // a single string. Matches zsh: in DQ context this preserves the
4413 // join; in array context too the result is one Value::Str.
4414 // Set or clear a shell option directly. Used by `noglob CMD ...`
4415 // precommand wrapping — the compiler emits SET_RAW_OPT to flip the
4416 // option ON before compiling the inner words and OFF after, so glob
4417 // expansion of the inner args sees the temporary state.
4418 vm.register_builtin(BUILTIN_SET_RAW_OPT, |vm, _argc| {
4419 let on = vm.pop().to_int() != 0;
4420 let opt = vm.pop().to_str();
4421 // Pure passthru: canonical port lives in
4422 // src/ported/options.rs::opt_state_set_via_alias and
4423 // handles negation-alias resolution per c:Src/options.c.
4424 crate::ported::options::opt_state_set_via_alias(&opt, on);
4425 Value::Status(0)
4426 });
4427
4428 // c:Src/options.c GLOB_SUBST — runtime glob expansion of
4429 // substituted words. Pop a Value (Str or Array); when
4430 // GLOB_SUBST is ON, run expand_glob on each string element;
4431 // when OFF, pass through unchanged. Bug #119 in docs/BUGS.md.
4432 vm.register_builtin(BUILTIN_GLOB_SUBST_EXPAND, |vm, _argc| {
4433 let raw = vm.pop();
4434 let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
4435 if !glob_subst {
4436 return raw;
4437 }
4438 // Collect input strings (Str → vec![s]; Array → multiple).
4439 let inputs: Vec<String> = match raw {
4440 Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
4441 other => vec![other.to_str()],
4442 };
4443 // Run expand_glob on each. Empty matches collapse to a
4444 // single literal pass-through to mirror nullglob-off default.
4445 let mut out: Vec<String> = Vec::with_capacity(inputs.len());
4446 for pattern in inputs {
4447 // c:Src/subst.c — GLOB_SUBST subjects the value to the FULL
4448 // filename-generation pipeline: `filesub` (tilde/`=` expansion)
4449 // BEFORE globbing (prefork runs filesub then globlist). zshrs
4450 // globbed but skipped filesub, so `${~x}` / `setopt globsubst`
4451 // left `~/foo` un-expanded. filesubstr matches the Tilde TOKEN,
4452 // so shtokenize the value first (`~`→Tilde, glob metas active),
4453 // run filesub, then untokenize the surviving glob metas back to
4454 // raw for expand_glob (which re-tokenizes internally).
4455 let pattern = if pattern.contains('~') || pattern.contains('=') {
4456 let mut tok = pattern.clone();
4457 crate::ported::glob::shtokenize(&mut tok);
4458 let fs = crate::ported::subst::filesub(&tok, 0);
4459 crate::ported::lex::untokenize(&fs)
4460 } else {
4461 pattern
4462 };
4463 let matches = with_executor(|exec| exec.expand_glob(&pattern));
4464 if matches.is_empty() {
4465 // No match: keep the literal (like nullglob off).
4466 out.push(pattern);
4467 } else {
4468 for m in matches {
4469 out.push(m);
4470 }
4471 }
4472 }
4473 if out.len() == 1 {
4474 Value::str(out.into_iter().next().unwrap())
4475 } else {
4476 Value::Array(out.into_iter().map(Value::str).collect())
4477 }
4478 });
4479
4480 // c:Src/math.c:337 — `getmathparam` for ArithCompiler pre-load.
4481 // Pop a variable name, return its math-coerced value. Mirrors
4482 // the routing in math::getmathparam: try i64, then f64, then
4483 // recursive arith-eval, else 0. Bug #118 in docs/BUGS.md.
4484 vm.register_builtin(BUILTIN_GET_MATH_VAR, |vm, _argc| {
4485 let name = vm.pop().to_str();
4486 let raw = crate::ported::params::getsparam(&name).unwrap_or_default();
4487 // Empty / unset → 0.
4488 if raw.is_empty() {
4489 return Value::Int(0);
4490 }
4491 // Direct int / float parse.
4492 if let Ok(n) = raw.parse::<i64>() {
4493 return Value::Int(n);
4494 }
4495 if let Ok(f) = raw.parse::<f64>() {
4496 return Value::Float(f);
4497 }
4498 // Recursive arith eval (matches getmathparam fallback at
4499 // Src/math.c:337). If that fails too, return 0 — C's
4500 // mathevall returns 0 with errflag set on parse failure.
4501 match crate::ported::math::mathevali(&raw) {
4502 Ok(n) => Value::Int(n),
4503 Err(_) => Value::Int(0),
4504 }
4505 });
4506
4507 // c:Src/options.c GLOB_SUBST + Src/cond.c:552 cond_match.
4508 // Pop pattern string; when GLOB_SUBST is OFF, escape every glob
4509 // metachar with `\` so the downstream StrMatch + patcompile
4510 // treat them as literals (matching C's tokenization-based
4511 // gate). When GLOB_SUBST is ON, pass through unchanged.
4512 // See BUILTIN_GLOB_SUBST_GUARD docs above for full rationale.
4513 vm.register_builtin(BUILTIN_GLOB_SUBST_GUARD, |vm, _argc| {
4514 let p = vm.pop().to_str();
4515 let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
4516 if glob_subst {
4517 return Value::str(p);
4518 }
4519 let mut out = String::with_capacity(p.len() * 2);
4520 for c in p.chars() {
4521 match c {
4522 '*' | '?' | '[' | ']' | '(' | ')' | '|' | '<' | '>' | '#' | '^' | '~' | '\\' => {
4523 out.push('\\');
4524 out.push(c);
4525 }
4526 _ => out.push(c),
4527 }
4528 }
4529 Value::str(out)
4530 });
4531
4532 vm.register_builtin(BUILTIN_ARRAY_JOIN_STAR, |vm, _argc| {
4533 let name = vm.pop().to_str();
4534 let (joined, ifs_full, in_dq) = with_executor(|exec| {
4535 // c:Src/params.c — `"$*"` joins by IFS[0]. zsh
4536 // distinguishes IFS=unset (→ default `" "`) from
4537 // IFS="" (→ EMPTY separator → fields concatenate).
4538 // chars().next() collapsed both into the default, so
4539 // IFS="" was treated as IFS=" ".
4540 let ifs_full = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
4541 let sep = ifs_full
4542 .chars()
4543 .next()
4544 .map(|c| c.to_string())
4545 .unwrap_or_default();
4546 let in_dq = exec.in_dq_context > 0;
4547 let joined = if name == "@" || name == "*" || name == "argv" {
4548 exec.pparams().join(&sep)
4549 } else if let Some(assoc_map) = exec.assoc(&name) {
4550 // c:Src/params.c — assoc-splat values for
4551 // `"${h[@]}"` / `"${h[*]}"`. Bug #109 in
4552 // docs/BUGS.md.
4553 assoc_map.values().cloned().collect::<Vec<_>>().join(&sep)
4554 } else if let Some(arr) = exec.array(&name) {
4555 arr.join(&sep)
4556 } else {
4557 exec.get_variable(&name)
4558 };
4559 (joined, ifs_full, in_dq)
4560 });
4561 // c:Src/subst.c — UNQUOTED `${name[*]}` (or `$*`) goes
4562 // through the canonical "join via IFS[0], then word-split
4563 // via IFS" pipeline. The fast-path bypassed paramsubst
4564 // entirely so it never word-split, producing one joined
4565 // string instead of N argv entries. Bug #428.
4566 //
4567 // In QUOTED (`"${name[*]}"`) context, the result IS a
4568 // single scalar — return it as Str without splitting.
4569 if in_dq {
4570 return Value::str(joined);
4571 }
4572 if joined.is_empty() {
4573 return Value::Array(Vec::new());
4574 }
4575 // IFS word-split — every IFS char is a separator. Empty
4576 // resulting fields are dropped (the canonical
4577 // "remove empty unquoted words" pass from
4578 // Src/subst.c::prefork c:184-187).
4579 let parts: Vec<String> = joined
4580 .split(|c: char| ifs_full.contains(c))
4581 .filter(|s| !s.is_empty())
4582 .map(String::from)
4583 .collect();
4584 if parts.is_empty() {
4585 Value::Array(Vec::new())
4586 } else if parts.len() == 1 {
4587 Value::str(parts.into_iter().next().unwrap())
4588 } else {
4589 Value::Array(parts.into_iter().map(Value::str).collect())
4590 }
4591 });
4592
4593 vm.register_builtin(BUILTIN_ARRAY_ALL, |vm, _argc| {
4594 let name = vm.pop().to_str();
4595 // c:Src/params.c:2027-2029 — a `[@]`/`[*]` subscript sets
4596 // SCANPM_ISVAR_AT, i.e. `isarr != 0` (c:2915). An empty result is
4597 // therefore an empty ARRAY, and plan9 deletes the whole word
4598 // (c:4362) rather than keeping the surrounding text.
4599 note_empty_is_scalar(false);
4600 with_executor(|exec| {
4601 // Special positional names — splice the positional list.
4602 if name == "@" || name == "*" || name == "argv" {
4603 return Value::Array(exec.pparams().iter().map(Value::str).collect());
4604 }
4605 // c:Src/Modules/parameter.c — funcstack/funcfiletrace/
4606 // funcsourcetrace/functrace are PM_ARRAY|PM_READONLY
4607 // specials backed by the canonical FUNCSTACK Vec.
4608 // `${funcstack[@]}` inside a function call should splat
4609 // the innermost-first names; without this branch the
4610 // runtime fell to the scalar fallback (get_variable
4611 // returns empty for these specials) and `[@]` came out
4612 // empty. Bug #276 in docs/BUGS.md. Mirrors the parallel
4613 // arrays_get handler at src/ported/subst.rs ~10685.
4614 // c:Src/Modules/datetime.c:256 — `epochtime` PM_ARRAY|
4615 // PM_READONLY backed by getcurrenttime(). Same parallel
4616 // arrangement as the FUNCSTACK-backed specials below.
4617 if name == "epochtime" {
4618 let arr = crate::ported::modules::datetime::getcurrenttime();
4619 return Value::Array(arr.into_iter().map(Value::str).collect());
4620 }
4621 if matches!(
4622 name.as_str(),
4623 "funcstack" | "funcfiletrace" | "funcsourcetrace" | "functrace"
4624 ) {
4625 // Route the three trace arrays through the canonical
4626 // ported getfns (Src/Modules/parameter.c:648/:679/:711)
4627 // — the previous inline copy emitted wrong shapes
4628 // (bare filename for funcfiletrace, `name:lineno` for
4629 // functrace instead of `caller:lineno`); same dedup as
4630 // the parallel arrays_get handler in subst.rs.
4631 let vals: Vec<String> = match name.as_str() {
4632 "funcstack" => crate::ported::modules::parameter::FUNCSTACK
4633 .lock()
4634 .map(|f| f.iter().rev().map(|fs| fs.name.clone()).collect())
4635 .unwrap_or_default(),
4636 "funcfiletrace" => crate::ported::modules::parameter::funcfiletracegetfn(
4637 std::ptr::null_mut(),
4638 ),
4639 "funcsourcetrace" => crate::ported::modules::parameter::funcsourcetracegetfn(
4640 std::ptr::null_mut(),
4641 ),
4642 _ => crate::ported::modules::parameter::functracegetfn(std::ptr::null_mut()),
4643 };
4644 return Value::Array(vals.into_iter().map(Value::str).collect());
4645 }
4646 // c:Src/params.c — `${assoc[@]}` enumerates VALUES (per
4647 // params.c:1696-1750 hashparam splat). Check assoc
4648 // storage BEFORE the scalar fallback so an associative
4649 // array named X resolves `${X[@]}` to the values, not
4650 // empty. Bug #109 in docs/BUGS.md: `${h[@]}` on an
4651 // assoc routed through BUILTIN_ARRAY_ALL, which only
4652 // consulted `exec.array(name)` (the indexed-array map)
4653 // — that lookup missed for assocs, fell through to
4654 // `get_variable("h")` (also empty for an assoc-only
4655 // name), and returned `Array(vec![])`. zsh's expected
4656 // behavior is to enumerate values.
4657 if let Some(assoc_map) = exec.assoc(&name) {
4658 return Value::Array(
4659 assoc_map.values().cloned().map(Value::str).collect(),
4660 );
4661 }
4662 match exec.array(&name) {
4663 Some(v) => Value::Array(v.iter().map(Value::str).collect()),
4664 None => {
4665 // Fall back to scalar lookup. zsh (unlike bash)
4666 // does NOT IFS-split a scalar variable in a for
4667 // list — `for w in $scalar` iterates ONCE with the
4668 // scalar value. Word-splitting requires either
4669 // sh_word_split option or explicit `${(s.,.)scalar}`.
4670 let val = exec.get_variable(&name);
4671 if val.is_empty() && !exec.has_scalar(&name) && env::var(&name).is_err() {
4672 // c:Src/subst.c:3480-3485 — `${arr[@]}` on a genuinely
4673 // UNSET parameter under NO_UNSET is a "parameter not set"
4674 // error (vunset > 0 && unset(UNSET)), exactly like the
4675 // scalar `$arr`, the `${arr[*]}` splat, and `${arr[1]}`
4676 // — all of which already fire it via GET_VAR. The `[@]`
4677 // splat path returned an empty array silently, so
4678 // `setopt NO_UNSET; print "${arr[@]}"` exited 0 where zsh
4679 // exits 1. A DECLARED-but-empty array (`arr=()`) resolves
4680 // to `Some(vec![])` above and never reaches here, so it
4681 // still splats to nothing without erroring — matching zsh.
4682 if opt_state_get("nounset").unwrap_or(false) {
4683 crate::ported::utils::zerr(&format!("{}: parameter not set", name));
4684 crate::ported::utils::errflag.fetch_or(
4685 crate::ported::zsh_h::ERRFLAG_ERROR,
4686 std::sync::atomic::Ordering::Relaxed,
4687 );
4688 exec.set_last_status(1);
4689 }
4690 // c:Src/subst.c:3480-3485 — an UNSET parameter takes the
4691 // `vunset` arm: `val = dupstring("")` with isarr left at
4692 // 0. That is a SCALAR empty, so plan9 keeps the
4693 // surrounding text (`setopt rcexpandparam;
4694 // print -r -- "[${unset[@]}]"` → `[]`), unlike a
4695 // DECLARED-but-empty array (`arr=()`, matched by the
4696 // `Some(vec![])` arm above), which sets isarr and gets
4697 // the word deleted at c:4362.
4698 note_empty_is_scalar(true);
4699 Value::Array(vec![])
4700 } else if opt_state_get("shwordsplit").unwrap_or(false) {
4701 // c:3921 `aval = sepsplit(val, spsep, 0, 1)` — same
4702 // splitter as `${=name}` (Src/utils.c:3711 spacesplit),
4703 // not a naive `split().filter(non-empty)`: only the
4704 // IFS-WHITESPACE-derived empty fields are elided; the
4705 // `nulstring` ones an IFS-NON-whitespace separator makes
4706 // survive (c:Src/subst.c:36).
4707 let nulstring = crate::ported::zsh_h::Nularg.to_string();
4708 let parts: Vec<Value> =
4709 crate::ported::utils::sepsplit(&val, None, false)
4710 .into_iter()
4711 .filter_map(|w| {
4712 if w == nulstring {
4713 Some(Value::str(String::new()))
4714 } else if w.is_empty() {
4715 None // c:184-187 prefork uremnode
4716 } else {
4717 Some(Value::str(w))
4718 }
4719 })
4720 .collect();
4721 Value::Array(parts)
4722 } else {
4723 Value::Array(vec![Value::str(val)])
4724 }
4725 }
4726 }
4727 })
4728 });
4729
4730 // BUILTIN_ARRAY_FLATTEN(N): pops N values, flattens one level of Array
4731 // nesting, pushes the resulting Array AND its length as a separate Int.
4732 // The two-value return shape lets the caller (for-loop compile path)
4733 // SetSlot the length before SetSlot'ing the array, without re-deriving
4734 // the length from the array via a second builtin call.
4735 // `coproc [name] { body }` — bidirectional pipe to backgrounded body.
4736 // Stack discipline (top first): [name (str, "" for default), sub_idx (int)].
4737 // On success: parent's `executor.arrays[name]` becomes [write_fd, read_fd]
4738 // and Status(0) is returned. The caller writes to the child's stdin via
4739 // write_fd, reads its stdout via read_fd, and closes both when done.
4740 //
4741 // Bash's coproc convention is `${NAME[0]}` = read_fd, `${NAME[1]}` =
4742 // write_fd. We follow that: arrays[name] = [read_fd_str, write_fd_str].
4743 vm.register_builtin(BUILTIN_RUN_COPROC, |vm, _argc| {
4744 let sub_idx = vm.pop().to_int() as usize;
4745 let job_text = vm.pop().to_str();
4746 let raw_name = vm.pop().to_str();
4747 let name = if raw_name.is_empty() {
4748 "COPROC".to_string()
4749 } else {
4750 raw_name
4751 };
4752 let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
4753 Some(c) => c,
4754 None => return Value::Status(1),
4755 };
4756
4757 // c:Src/exec.c:1710-1712 — starting a new coproc closes the
4758 // previous one's fds FIRST:
4759 // if (coprocin >= 0) { zclose(coprocin); zclose(coprocout); }
4760 // The old coproc child then sees EOF on its stdin and exits on
4761 // its own schedule (its job-table entry stays until it's
4762 // reaped) — zsh does NOT deletejob it here. This is also what
4763 // makes the `exec 4<&p; coproc exit; read -u4` EOF idiom work:
4764 // the replacement coproc closes the shell's write end to the
4765 // old one.
4766 {
4767 use std::sync::atomic::Ordering;
4768 let old_in = crate::ported::modules::clone::coprocin.load(Ordering::Relaxed);
4769 if old_in >= 0 {
4770 let old_out = crate::ported::modules::clone::coprocout.load(Ordering::Relaxed);
4771 unsafe {
4772 libc::close(old_in);
4773 if old_out >= 0 {
4774 libc::close(old_out);
4775 }
4776 }
4777 crate::ported::modules::clone::coprocin.store(-1, Ordering::Relaxed);
4778 crate::ported::modules::clone::coprocout.store(-1, Ordering::Relaxed);
4779 }
4780 }
4781
4782 // (parent_read ← child_stdout)
4783 let mut p2c = [0i32; 2]; // parent writes, child reads
4784 let mut c2p = [0i32; 2]; // child writes, parent reads
4785 if unsafe { libc::pipe(p2c.as_mut_ptr()) } < 0 {
4786 return Value::Status(1);
4787 }
4788 if unsafe { libc::pipe(c2p.as_mut_ptr()) } < 0 {
4789 unsafe {
4790 libc::close(p2c[0]);
4791 libc::close(p2c[1]);
4792 }
4793 return Value::Status(1);
4794 }
4795 // c:Src/exec.c:5160 mpipe — both pipes' fds are moved above
4796 // the user-visible range (movefd → F_DUPFD ≥ 10) so the
4797 // coproc fds never collide with explicit user fds like
4798 // `exec 3>&p`.
4799 for fd in p2c.iter_mut().chain(c2p.iter_mut()) {
4800 *fd = crate::ported::utils::movefd(*fd);
4801 }
4802
4803 match unsafe { libc::fork() } {
4804 -1 => {
4805 unsafe {
4806 libc::close(p2c[0]);
4807 libc::close(p2c[1]);
4808 libc::close(c2p[0]);
4809 libc::close(c2p[1]);
4810 }
4811 Value::Status(1)
4812 }
4813 0 => {
4814 // Child: stdin from p2c[0], stdout to c2p[1]. Close all
4815 // unused fds. setsid so SIGINT to fg doesn't hit us.
4816 unsafe {
4817 libc::dup2(p2c[0], libc::STDIN_FILENO);
4818 libc::dup2(c2p[1], libc::STDOUT_FILENO);
4819 libc::close(p2c[0]);
4820 libc::close(p2c[1]);
4821 libc::close(c2p[0]);
4822 libc::close(c2p[1]);
4823 libc::setsid();
4824 }
4825 crate::fusevm_disasm::maybe_print_stdout("coproc:child", &chunk);
4826 let mut co_vm = fusevm::VM::new(chunk);
4827 register_builtins(&mut co_vm);
4828 let _ = co_vm.run();
4829 let _ = std::io::stdout().flush();
4830 let _ = std::io::stderr().flush();
4831 std::process::exit(co_vm.last_status);
4832 }
4833 pid => {
4834 // Parent: close child ends, store [read_fd, write_fd] in NAME.
4835 unsafe {
4836 libc::close(p2c[0]);
4837 libc::close(c2p[1]);
4838 }
4839 let read_fd = c2p[0];
4840 let write_fd = p2c[1];
4841 with_executor(|exec| {
4842 exec.unset_scalar(&name);
4843 exec.set_array(name, vec![read_fd.to_string(), write_fd.to_string()]);
4844 });
4845 // c:Src/exec.c — `coprocin`/`coprocout` are the
4846 // canonical globals that bin_read's `-p` arm
4847 // (Src/builtin.c:6510) and bin_print's `-p` arm
4848 // (Src/builtin.c:4827) read to find the
4849 // coprocess fds. The Rust port has the atomic
4850 // declarations at src/ported/modules/clone.rs:262
4851 // but the coproc-launch path never updated them,
4852 // so `read -p` / `print -p` always errored with
4853 // "-p: no coprocess" even when a coproc was
4854 // running. Bug #388 in docs/BUGS.md. Update them
4855 // here so the canonical builtins find the live
4856 // pipe.
4857 crate::ported::modules::clone::coprocin
4858 .store(read_fd, std::sync::atomic::Ordering::Relaxed);
4859 crate::ported::modules::clone::coprocout
4860 .store(write_fd, std::sync::atomic::Ordering::Relaxed);
4861 // c:Src/exec.c:1725 — `fdtable[coprocin] =
4862 // fdtable[coprocout] = FDT_UNUSED;`: the two kept ends
4863 // are user-reachable (via `>&p` / `<&p`), so they drop
4864 // the FDT_INTERNAL mark movefd gave them.
4865 crate::ported::utils::fdtable_set(read_fd, crate::ported::zsh_h::FDT_UNUSED);
4866 crate::ported::utils::fdtable_set(write_fd, crate::ported::zsh_h::FDT_UNUSED);
4867 // c:Src/exec.c:2837 — `lastpid = (zlong) pid;`. zsh
4868 // sets the `$!` global to the coproc child's PID so
4869 // subsequent `$!` reads return it. The Rust port at
4870 // exec.rs:6773 mirrors this for regular background
4871 // jobs but the coproc launch path was missing the
4872 // assignment, leaving `$!` at 0 after `coproc cmd`.
4873 crate::ported::modules::clone::lastpid
4874 .store(pid, std::sync::atomic::Ordering::Relaxed);
4875 // c:Src/exec.c:1700-1758 — the coproc rides the SAME
4876 // Z_ASYNC job-table path as `cmd &`: `thisjob = newjob
4877 // = initjob()` (c:1700), addproc hangs the pid+text
4878 // proc entry off the job, `jobtab[thisjob].stat |=
4879 // STAT_NOSTTY` (c:1746), `clearoldjobtab()` (c:1744)
4880 // and `spawnjob()` (c:1758) promote it to curjob. This
4881 // is what makes `jobs` list the coproc as
4882 // `[1] + running cat` and `kill %1` resolve it.
4883 // Mirrors the BUILTIN_RUN_BG parent arm exactly.
4884 {
4885 use crate::ported::jobs;
4886 use std::sync::Mutex;
4887 let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
4888 let idx = {
4889 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
4890 let idx = jobs::initjob(&mut tab); // c:exec.c:1700
4891 jobs::addproc(
4892 &mut tab[idx],
4893 pid,
4894 &job_text,
4895 false,
4896 Some(std::time::Instant::now()),
4897 -1,
4898 -1,
4899 );
4900 tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
4901 idx
4902 };
4903 jobs::clearoldjobtab(); // c:exec.c:1744
4904 if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
4905 *tj = idx as i32;
4906 }
4907 jobs::spawnjob(); // c:exec.c:1758
4908 }
4909 with_executor(|exec| {
4910 exec.jobs
4911 .add_pid_job(pid, job_text.clone(), JobState::Running);
4912 });
4913 Value::Status(0)
4914 }
4915 }
4916 });
4917
4918 vm.register_builtin(BUILTIN_ARRAY_FLATTEN, |vm, argc| {
4919 let n = argc as usize;
4920 let start = vm.stack.len().saturating_sub(n);
4921 let raw: Vec<Value> = vm.stack.drain(start..).collect();
4922 let mut flat: Vec<Value> = Vec::with_capacity(raw.len());
4923 for v in raw {
4924 match v {
4925 Value::Array(items) => flat.extend(items),
4926 other => flat.push(other),
4927 }
4928 }
4929 let len = flat.len() as i64;
4930 // Push the array first; the Int(len) becomes the builtin's return
4931 // value (which CallBuiltin already pushes). Caller consumes in
4932 // reverse: SetSlot(len_slot) pops Int, SetSlot(arr_slot) pops Array.
4933 vm.push(Value::Array(flat));
4934 Value::Int(len)
4935 });
4936
4937 // Shell variable get/set — routes through executor.variables so nested
4938 // VMs (function calls) and tree-walker callers see the same storage.
4939 // GET_VAR / GET_VAR_DQ share one body via `get_var_impl`; the only
4940 // difference is `force_dq`, which the compiler sets for QUOTED simple
4941 // reads (`"$name"`) so an array's empty elements are preserved (the
4942 // `in_dq_context` runtime flag is 0 for these compiler-direct reads).
4943 fn get_var_impl(vm: &mut fusevm::VM, argc: u8, force_dq: bool) -> Value {
4944 let args = pop_args(vm, argc);
4945 let name = args.into_iter().next().unwrap_or_default();
4946 let live_status = vm.last_status;
4947 // `$@` and `$*` need splice semantics — return Value::Array of
4948 // positional params so for-loop's BUILTIN_ARRAY_FLATTEN spreads them
4949 // and pop_args splits them into argv slots. zsh's `"$@"` bslashquote-each-
4950 // word semantics matches: each pos-param becomes its own arg.
4951 // Same for arrays accessed by name (e.g. `$arr` in some contexts).
4952 //
4953 // vm.last_status is authoritative: `subshell_end` now returns
4954 // Some(status) and fusevm's `Op::SubshellEnd` writes it into
4955 // vm.last_status, so a deferred subshell `exit N` is visible
4956 // here. Suppressing this sync (as an older revision did, back
4957 // when the host hook returned nothing) made LASTVAL win over
4958 // any status the VM set AFTER SubshellEnd — which dropped the
4959 // `!` negation of `Src/exec.c:1979-1980`
4960 // if ((slflags & WC_SUBLIST_NOT) && !errflag && !retflag)
4961 // lastval = !lastval;
4962 // for `! (exit 7)` (emit_negate_status' SetStatus updated
4963 // vm.last_status, then `$?` read the stale LASTVAL=7).
4964 let sync_status = |exec: &mut ShellExecutor| {
4965 exec.set_last_status(live_status);
4966 };
4967 if name == "@" || name == "*" {
4968 // Quoting decides empty-word retention (c:Src/subst.c:
4969 // 184-187): the COMPILE site knows it and emits
4970 // BUILTIN_ARRAY_DROP_EMPTY after this read for the
4971 // unquoted form only — in_dq_context is NOT a valid
4972 // discriminator here (the quoted "$@" fast path emits
4973 // GET_VAR directly without an EXPAND_TEXT wrapper).
4974 return with_executor(|exec| {
4975 sync_status(exec);
4976 Value::Array(exec.pparams().iter().map(Value::str).collect())
4977 });
4978 }
4979 // RC_EXPAND_PARAM: when the option is set and `name` refers to
4980 // an array, return Value::Array so the enclosing word's
4981 // BUILTIN_CONCAT_DISTRIBUTE distributes element-wise. Without
4982 // the option, arrays still join to a space-separated scalar
4983 // (zsh's default unquoted-array-as-scalar semantics).
4984 let rc_expand = with_executor(|exec| opt_state_get("rcexpandparam").unwrap_or(false));
4985 // c:Src/subst.c — under KSHARRAYS a bare `$name` (no [@]/[*] subscript;
4986 // this GET_VAR path only handles the bare form) is element 1 ONLY — a
4987 // scalar. RC_EXPAND_PARAM then has a single value to distribute, so
4988 // `$acc` → "p1", NOT the whole array. Skip the whole-array rc_expand
4989 // shortcut when KSHARRAYS is set and fall through to the normal path,
4990 // which applies the element-1 collapse. Without this gate,
4991 // `setopt KSH_ARRAYS rc_expand_param; print -r -- $acc` splatted every
4992 // element while zsh prints just "p1".
4993 let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
4994 if rc_expand && !ksh_arrays {
4995 let arr_val = with_executor(|exec| {
4996 sync_status(exec);
4997 exec.array(&name)
4998 });
4999 if let Some(arr) = arr_val {
5000 // c:4245 — a real array reference (`isarr != 0`). An empty one
5001 // takes plan9's word-removal path (c:4362), so clear the
5002 // scalar bit; a preceding empty-SCALAR expansion in the same
5003 // word would otherwise leave it set and keep the word alive
5004 // (`empty=''; e=(); a=("$empty"); print -rl -- x$e y`).
5005 note_empty_is_scalar(false);
5006 return Value::Array(arr.into_iter().map(Value::str).collect());
5007 }
5008 }
5009 // Magic-assoc fallback FIRST — `${aliases}` / `${functions}`
5010 // / `${commands}` / etc. should return the value list per
5011 // zsh's bare-assoc semantics. Without this, those names fell
5012 // through to `get_variable` which is empty (they live in
5013 // separate executor tables, not `assoc_arrays`). Return as
5014 // a Value::Array so `arr=(${aliases})` distributes into
5015 // multiple elements, matching zsh's array-context word
5016 // splitting for assoc-bare references.
5017 let magic_vals = with_executor(|exec| {
5018 sync_status(exec);
5019 // Canonical PARTAB dispatch (Src/Modules/parameter.c:2235-
5020 // 2298 + SPECIALPMDEFs in mapfile/terminfo/termcap/system/
5021 // zleparameter): PARTAB_ARRAY entries → whole-array getfn;
5022 // PARTAB entries → scan keys + per-key getpm/scanpm fn
5023 // pointers.
5024 let _ = exec;
5025 if let Some(values) = partab_array_get(&name) {
5026 Some(values)
5027 } else if let Some(keys) = partab_scan_keys(&name) {
5028 Some(
5029 keys.iter()
5030 .map(|k| partab_get(&name, k).unwrap_or_default())
5031 .collect::<Vec<_>>(),
5032 )
5033 } else {
5034 None
5035 }
5036 });
5037 if let Some(vals) = magic_vals {
5038 // Distinguish "name IS a magic-assoc with no entries"
5039 // (return Array(empty)) from "name is unknown — fall
5040 // through to get_variable".
5041 // c:Src/params.c:2293-2296 — KSHARRAYS bare reference
5042 // collapses to the FIRST element in scan order
5043 // (`v->end = 1, v->isarr = 0`). For `options` the scan
5044 // order is optiontab bucket order (OPTIONTAB), so zsh 5.9
5045 // prints `off` (posixargzero) for
5046 // `setopt ksharrays; print $options`.
5047 if opt_state_get("ksharrays").unwrap_or(false) {
5048 return Value::str(vals.into_iter().next().unwrap_or_default());
5049 }
5050 return Value::Array(vals.into_iter().map(Value::str).collect());
5051 }
5052 // Indexed-array path: return Value::Array so pop_args splats
5053 // each element into its own argv slot. Direct port of zsh's
5054 // unquoted `$arr` semantics — each element becomes a separate
5055 // word in command-arg position.
5056 //
5057 // DQ context exception: inside `"...$arr..."`, zsh joins with
5058 // the first char of $IFS (default space) so the DQ word stays
5059 // a single argv slot. Detect via in_dq_context (bumped by
5060 // BUILTIN_EXPAND_TEXT mode 1) and return the joined scalar.
5061 // Direct port of Src/subst.c:1759-1813 nojoin/sepjoin: in DQ
5062 // (qt=1) without explicit `(@)`, sepjoin runs and the result
5063 // is one word.
5064 let arr_assoc_data = with_executor(|exec| {
5065 sync_status(exec);
5066 let in_dq = force_dq || exec.in_dq_context > 0;
5067 // KSH_ARRAYS: bare `$arr` returns ONLY arr[0] (zero-
5068 // based first-element-only semantics). Direct port of
5069 // Src/params.c getstrvalue's KSH_ARRAYS gate which
5070 // returns aval[0] instead of the whole array.
5071 let ksh_arrays = opt_state_get("ksharrays").unwrap_or(false);
5072 if let Some(arr) = exec.array(&name) {
5073 if ksh_arrays {
5074 return Some((vec![arr.first().cloned().unwrap_or_default()], in_dq));
5075 }
5076 return Some((arr.clone(), in_dq));
5077 }
5078 if exec.assoc(&name).is_some() {
5079 // c:Src/params.c:2351-2358 — under KSH EMULATION a bare
5080 // `$assoc` is `${assoc[0]}` (a KEY-"0" lookup), so it is
5081 // EMPTY unless the hash actually has a key "0". This is
5082 // EMULATION-gated, not KSHARRAYS-option-gated: `emulate -L
5083 // ksh; typeset -A h=(a 1 b 2); print $h` is empty, whereas
5084 // `setopt ksharrays; …; print $h` collapses to the bucket-
5085 // first value below.
5086 if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
5087 let v = crate::ported::subst::assoc_get(&name)
5088 .and_then(|m| m.get("0").cloned())
5089 .unwrap_or_default();
5090 return Some((vec![v], in_dq));
5091 }
5092 // c:Src/hashtable.c scanhashtable — a bare `$assoc` joins its
5093 // VALUES in zsh hash-BUCKET order (the same order `(k)`/`(v)`
5094 // enumerate), NOT sorted or insertion order. `assoc_get`
5095 // rebuilds zsh's bucket layout; use it so `$as` matches
5096 // `${(v)as}` (`as=(zebra 9 apple 1)` → `9 1`, not the
5097 // alphabetical `1 9`). Under KSHARRAYS the bare form collapses
5098 // to the bucket-FIRST value (`9`), matching zsh.
5099 let values: Vec<String> = crate::ported::subst::assoc_get(&name)
5100 .map(|m| m.values().cloned().collect())
5101 .unwrap_or_default();
5102 if ksh_arrays {
5103 return Some((vec![values.into_iter().next().unwrap_or_default()], in_dq));
5104 }
5105 return Some((values, in_dq));
5106 }
5107 None
5108 });
5109 if let Some((items, in_dq)) = arr_assoc_data {
5110 // c:Src/subst.c:184-187 — prefork's `else if (!keep)
5111 // uremnode(list, node)`: UNQUOTED expansion drops empty
5112 // list nodes before they reach argv, so `a=(y '' x);
5113 // print -- $a` passes TWO args in zsh (`y x`), while the
5114 // quoted "${a[@]}" splat keeps the empty slot. The
5115 // paramsubst splat path already does this (Bug #578
5116 // retain); this GET_VAR fast path bypassed it and leaked
5117 // empty argv slots (visible double-space, wrong arg
5118 // counts in `for`/`print -l`).
5119 let items: Vec<String> = if in_dq {
5120 items
5121 } else {
5122 items.into_iter().filter(|s| !s.is_empty()).collect()
5123 };
5124 if in_dq {
5125 // c:Src/utils.c:3936-3945 sepjoin default-sep rule:
5126 // set-but-empty IFS joins with "" (`IFS=""; echo
5127 // "$arr"` concatenates); only unset / space-leading
5128 // IFS yields " ". The previous get_variable read
5129 // couldn't distinguish unset from set-empty.
5130 return Value::str(crate::ported::utils::sepjoin(&items, None));
5131 }
5132 // c:4245 — a real array reference: `isarr != 0`, so an empty
5133 // one takes plan9's word-removal path, not the scalar path.
5134 note_empty_is_scalar(false);
5135 return Value::Array(items.into_iter().map(Value::str).collect());
5136 }
5137 let (val, in_dq, is_known) = with_executor(|exec| {
5138 sync_status(exec);
5139 let v = exec.get_variable(&name);
5140 // For nounset detection: a name is "known" when it has a
5141 // paramtab/array/assoc/env entry. Special chars ($?, $#,
5142 // $@, $*, $-, $$, $!, $_, $0) always count as known
5143 // regardless of value. Pure-digit positional params
5144 // count as known iff index <= $# (set -- has populated
5145 // that slot). c:Src/subst.c:1689 — NOUNSET fires on
5146 // unset positional param too: `set --; echo "$1"` with
5147 // nounset must diagnose.
5148 let is_special_single = name.len() == 1
5149 && matches!(
5150 name.chars().next().unwrap(),
5151 '?' | '#' | '@' | '*' | '-' | '$' | '!' | '_' | '0'
5152 );
5153 let is_pure_digit = !name.is_empty() && name.chars().all(|c| c.is_ascii_digit());
5154 let positional_known = if is_pure_digit {
5155 let idx: usize = name.parse().unwrap_or(0);
5156 if idx == 0 {
5157 true // $0 always set
5158 } else {
5159 idx <= exec.pparams().len()
5160 }
5161 } else {
5162 false
5163 };
5164 let known = !v.is_empty()
5165 || name.is_empty()
5166 || is_special_single
5167 || positional_known
5168 || crate::ported::params::paramtab()
5169 .read()
5170 .ok()
5171 .map(|t| t.contains_key(&name))
5172 .unwrap_or(false)
5173 || env::var(&name).is_ok();
5174 (v, force_dq || exec.in_dq_context > 0, known)
5175 });
5176 // c:Src/subst.c:1689 — NO_UNSET / nounset: reading an unset
5177 // parameter fires "parameter not set" diagnostic and aborts
5178 // the substitution. Direct port of the noerrs gate at c:1689
5179 // (zerr + errflag). Matches `set -u` POSIX semantics.
5180 if !is_known && opt_state_get("nounset").unwrap_or(false) {
5181 crate::ported::utils::zerr(&format!("{}: parameter not set", name));
5182 crate::ported::utils::errflag.fetch_or(
5183 crate::ported::zsh_h::ERRFLAG_ERROR,
5184 std::sync::atomic::Ordering::Relaxed,
5185 );
5186 with_executor(|exec| exec.set_last_status(1));
5187 return Value::str("");
5188 }
5189 // Empty unquoted scalar → drop the arg (zsh "remove empty
5190 // unquoted words" rule). Returning empty Value::Array makes
5191 // pop_args contribute zero items. DQ context keeps the empty
5192 // string so "$a" stays a single empty arg. Direct port of
5193 // subst.c's elide-empty pass.
5194 if val.is_empty() && !in_dq {
5195 // c:1650-1656 / c:4437 — a SCALAR parameter has `isarr == 0`,
5196 // so it never reaches plan9's word-removal at c:4362. Flag the
5197 // empty Array below as a scalar so `setopt rcexpandparam;
5198 // v=; print -rl -- x$v y` still emits `x` (only an empty
5199 // ARRAY deletes the word).
5200 note_empty_is_scalar(true);
5201 return Value::Array(Vec::new());
5202 }
5203 // c:Src/subst.c:1759 SH_WORD_SPLIT — when shwordsplit is set and
5204 // we're in unquoted command-arg position (not DQ), split scalar
5205 // value on IFS into multiple words. Matches BUILTIN_ARRAY_ALL's
5206 // shwordsplit arm (fusevm_bridge.rs:2200). Without this, bare
5207 // `$s` in `print $s` stayed a single arg even with the option
5208 // set, breaking POSIX-style scalar word-splitting.
5209 if !in_dq && opt_state_get("shwordsplit").unwrap_or(false) {
5210 // c:1705 — `spbreak = (pf_flags & PREFORK_SHWORDSPLIT) && !qt`,
5211 // then c:3902 `force_split = !ssub && (spbreak || spsep)` and
5212 // c:3921 `aval = sepsplit(val, spsep, 0, 1)`. SH_WORD_SPLIT runs
5213 // the SAME splitter as `${=name}`, so route it through the same
5214 // port. The previous `split(|c| ifs.contains(c)).filter(non-empty)`
5215 // dropped every empty field, but spacesplit (Src/utils.c:3711)
5216 // only elides the ones a run of IFS-WHITESPACE produces — an
5217 // IFS-NON-whitespace separator preserves them as `nulstring`.
5218 // `IFS=x; v=xaxbx; setopt shwordsplit; print -rl -- $v` is four
5219 // words in zsh (``, a, b, ``), not two.
5220 let raw = crate::ported::utils::sepsplit(&val, None, false); // c:3921
5221 let nulstring = crate::ported::zsh_h::Nularg.to_string(); // c:36
5222 let parts: Vec<Value> = raw
5223 .into_iter()
5224 .filter_map(|w| {
5225 if w == nulstring {
5226 Some(Value::str(String::new()))
5227 } else if w.is_empty() {
5228 // c:184-187 — prefork deletes the truly-empty node.
5229 None
5230 } else {
5231 Some(Value::str(w))
5232 }
5233 })
5234 .collect();
5235 if parts.is_empty() {
5236 // c:3922 — `val = dupstring("")`: an empty SCALAR, not an
5237 // empty array (see EMPTY_EXPANSION_IS_SCALAR).
5238 note_empty_is_scalar(true);
5239 return Value::Array(Vec::new());
5240 } else if parts.len() == 1 {
5241 // c:3924 — `else if (!aval[1]) val = aval[0];`
5242 return parts.into_iter().next().unwrap();
5243 } else {
5244 return Value::Array(parts); // c:3927
5245 }
5246 }
5247 Value::str(val)
5248 }
5249 vm.register_builtin(BUILTIN_GET_VAR, |vm, argc| get_var_impl(vm, argc, false));
5250 vm.register_builtin(BUILTIN_GET_VAR_DQ, |vm, argc| get_var_impl(vm, argc, true));
5251
5252 // `name+=val` (no parens) — runtime dispatch:
5253 // - if `name` is in `arrays` → push `val` as new element
5254 // - if `name` is in `assoc_arrays` → refuse (zsh errors here)
5255 // - else → scalar concat (existing behavior)
5256 // Stack: [name, value].
5257 vm.register_builtin(BUILTIN_APPEND_SCALAR_OR_PUSH, |vm, argc| {
5258 let args = pop_args(vm, argc);
5259 let mut iter = args.into_iter();
5260 let name = iter.next().unwrap_or_default();
5261 let value = iter.next().unwrap_or_default();
5262 with_executor(|exec| {
5263 // Array form: `arr+=elem` pushes a single element.
5264 // Routes through canonical assignaparam(name, [value],
5265 // ASSPM_AUGMENT) — Src/params.c:3357 c:3402-3412 augment
5266 // path prepends prior scalar / appends to existing array.
5267 if exec.array(&name).is_some() {
5268 // c:Src/params.c — under KSHARRAYS a bare array name
5269 // addresses element 0 (ksh), so `a+=X` (scalar augment)
5270 // CONCATENATES onto the first element ("firstlast second"),
5271 // it does NOT push a new element. C routes scalar `+=`
5272 // through assignsparam (which targets the elem-0 value);
5273 // zshrs's APPEND_SCALAR_OR_PUSH would otherwise push.
5274 if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
5275 let mut arr = exec.array(&name).unwrap_or_default();
5276 if arr.is_empty() {
5277 arr.push(value.clone());
5278 } else {
5279 arr[0] = format!("{}{}", arr[0], value);
5280 }
5281 exec.set_array(name.clone(), arr);
5282 #[cfg(feature = "recorder")]
5283 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5284 let ctx = exec.recorder_ctx();
5285 let attrs = exec.recorder_attrs_for(&name);
5286 emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
5287 }
5288 return;
5289 }
5290 let _ = crate::ported::params::assignaparam(
5291 &name,
5292 vec![value.clone()],
5293 crate::ported::zsh_h::ASSPM_AUGMENT,
5294 );
5295 #[cfg(feature = "recorder")]
5296 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5297 let ctx = exec.recorder_ctx();
5298 let attrs = exec.recorder_attrs_for(&name);
5299 emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
5300 }
5301 return;
5302 }
5303 if exec.assoc(&name).is_some() {
5304 eprintln!("zshrs: {}: cannot use += on assoc without (key val)", name);
5305 return;
5306 }
5307 // Scalar / integer / float form: route through canonical
5308 // assignsparam(name, value, ASSPM_AUGMENT) which
5309 // dispatches PM_TYPE — PM_SCALAR concats, PM_INTEGER
5310 // arith-adds (c:2775-2778), PM_FLOAT float-adds.
5311 let _ = crate::ported::params::assignsparam(
5312 &name,
5313 &value,
5314 crate::ported::zsh_h::ASSPM_AUGMENT,
5315 );
5316 #[cfg(feature = "recorder")]
5317 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5318 let ctx = exec.recorder_ctx();
5319 let attrs = exec.recorder_attrs_for(&name);
5320 // Re-read the canonical value via get_variable for the
5321 // recorder bundle (assignsparam may have transformed it
5322 // through integer/float arithmetic).
5323 let final_val = exec.get_variable(&name);
5324 let lower = name.to_ascii_lowercase();
5325 if matches!(
5326 lower.as_str(),
5327 "path" | "fpath" | "manpath" | "module_path" | "cdpath"
5328 ) {
5329 emit_path_or_assign(&name, std::slice::from_ref(&final_val), attrs, true, &ctx);
5330 } else {
5331 crate::recorder::emit_assign_typed(&name, &final_val, attrs, ctx);
5332 }
5333 }
5334 });
5335 Value::Status(0)
5336 });
5337
5338 // BUILTIN_SET_VAR — `name=value` runtime scalar assignment.
5339 // PURE PASSTHRU: hand to canonical `setsparam` (C port of
5340 // `Src/params.c::setsparam`). That walks assignsparam →
5341 // assignstrvalue which already does:
5342 // - readonly rejection (zerr + errflag at c:2701)
5343 // - PM_INTEGER math evaluation (mathevali at c:3590)
5344 // - PM_EFLOAT / PM_FFLOAT float coercion (c:3608)
5345 // - PM_LOWER / PM_UPPER case fold (via setstrvalue)
5346 // - GSU special-param dispatch (homesetfn / ifssetfn / etc.)
5347 // - allexport env mirror via the PM_EXPORTED setfn
5348 //
5349 // Bridge-only concerns kept here:
5350 // - inline_env_stack (zsh `X=foo cmd` scoped env)
5351 // - recorder emission (PFA-SMR)
5352 // - vm.last_status propagation for `a=$(cmd)` exit-code chaining
5353 // Sets the GLOB_ASSIGN-eligibility flag consumed by the NEXT BUILTIN_SET_VAR.
5354 // Emitted only when a scalar-assign RHS had an unquoted glob token. Takes no
5355 // args; its pushed return is discarded by a following Op::Pop.
5356 vm.register_builtin(BUILTIN_MARK_GLOB_ELIGIBLE, |_vm, _argc| {
5357 SET_VAR_GLOB_ELIGIBLE.with(|c| c.set(true));
5358 fusevm::Value::Int(0)
5359 });
5360 vm.register_builtin(BUILTIN_SET_VAR, |vm, argc| {
5361 // `${~spec}` carrier: an assignment statement is a word-
5362 // pipeline boundary too — restore the user's GLOB_SUBST
5363 // before the NEXT word expands (`Z[d]=${~Z[d]}; print
5364 // ${options[globsubst]}` must read the user value).
5365 consume_tilde_globsubst_carrier();
5366 // Snapshot the raw Values BEFORE pop_args's to_str
5367 // flattening — needed to distinguish Int (arith assignment,
5368 // integer-typed param) from Str (scalar assignment).
5369 let mut raw_values: Vec<fusevm::Value> = Vec::with_capacity(argc as usize);
5370 for _ in 0..argc {
5371 raw_values.push(vm.pop());
5372 }
5373 raw_values.reverse();
5374 let name = raw_values.first().map(|v| v.to_str()).unwrap_or_default();
5375 let value_raw = raw_values.get(1).cloned();
5376 let value = value_raw.as_ref().map(|v| v.to_str()).unwrap_or_default();
5377 // c:Src/params.c — when the bytecode hands us an Int value
5378 // (only the arith assignment paths emit this — `(( X = N ))`
5379 // is the canonical site), route through setiparam so the
5380 // param ends up PM_INTEGER + inherits the math layer's
5381 // `lastbase` for display formatting (`(( X = 16#ff ));
5382 // echo \$X` → `16#FF`). Scalar `X=val` and `$((expr))`
5383 // assignments still take the setsparam path below.
5384 let int_assign = matches!(value_raw, Some(fusevm::Value::Int(_)));
5385 let float_assign = matches!(value_raw, Some(fusevm::Value::Float(_)));
5386 let mut assign_failed = false;
5387 with_executor(|exec| {
5388 // c:Src/params.c assignsparam — PM_READONLY rejection
5389 // BEFORE any env mutation. The inline-env-prefix path
5390 // (`X=2 env`) called env::set_var unconditionally before
5391 // the readonly check fired in setsparam, so the OS env
5392 // got X=2 even though the assignment errored. env then
5393 // inherited the polluted env from fork, leaking the
5394 // attempted override past the readonly guard. Mirror
5395 // C's order: readonly check → zerr → bail; only mutate
5396 // env when the assignment is admissible. Bug #551
5397 // (security-relevant).
5398 if exec.is_readonly_param(&name) {
5399 crate::ported::utils::zerr(&format!("read-only variable: {}", name));
5400 return;
5401 }
5402 // Inline-assignment frame tracking (`X=foo cmd` reverts on
5403 // command return).
5404 if !exec.inline_env_stack.is_empty() {
5405 let prev_var = crate::ported::params::getsparam(&name);
5406 let prev_env = env::var(&name).ok();
5407 exec.inline_env_stack
5408 .last_mut()
5409 .unwrap()
5410 .push((name.clone(), prev_var, prev_env));
5411 let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value));
5412 // c:Src/params.c:5354
5413 }
5414 // Canonical setsparam handles readonly, integer math, case
5415 // fold, GSU dispatch. For Int values (arith assigns) route
5416 // through setiparam so the param is PM_INTEGER + inherits
5417 // the math layer's lastbase for display formatting. For
5418 // Float (arith assigns producing MN_FLOAT) route through
5419 // setnparam so the param is PM_FFLOAT — `(( b = a * 2 ))`
5420 // with scalar `a="3.14"` should create b as typeset -F,
5421 // not a scalar holding "6.28".
5422 if int_assign {
5423 if let Some(fusevm::Value::Int(i)) = value_raw {
5424 crate::ported::params::setiparam(&name, i);
5425 } else {
5426 assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
5427 }
5428 } else if float_assign {
5429 if let Some(fusevm::Value::Float(f)) = value_raw {
5430 // ArithCompiler returns Value::Float whenever any
5431 // operand came through Str (BUILTIN_GET_VAR yields
5432 // Value::Str even for integer-shaped scalars). To
5433 // avoid forcing every `(( b = a + 3 ))` to PM_FFLOAT
5434 // when `a="5"` (integer-shaped), detect integer-
5435 // valued floats and route through setiparam instead.
5436 // True floats (non-integral) reach setnparam →
5437 // PM_FFLOAT so `typeset -p b` shows `typeset -F …`.
5438 if f.fract() == 0.0 && f.is_finite() && f.abs() <= i64::MAX as f64 {
5439 crate::ported::params::setiparam(&name, f as i64);
5440 } else {
5441 let mnval = crate::ported::math::mnumber {
5442 l: 0,
5443 d: f,
5444 type_: crate::ported::math::MN_FLOAT,
5445 };
5446 crate::ported::params::setnparam(&name, mnval);
5447 }
5448 } else {
5449 assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
5450 }
5451 } else {
5452 // c:Src/exec.c:2554-2567 — GLOB_ASSIGN. When the
5453 // `globassign` option is on and the scalar RHS is a glob
5454 // pattern, glob it and recreate the parameter as a scalar
5455 // (≤1 match) or array (>1) — csh-style assignment. The
5456 // bridge hands `value` UNTOKENIZED, so re-tokenize
5457 // (shtokenize) before haswilds/globlist; zsh's wordcode
5458 // value arrives pre-tokenized via `htok`. The
5459 // `isset(GLOBASSIGN)` gate is first and cheap (option off
5460 // by default), so the common path is unchanged.
5461 let mut globbed = false;
5462 // Only glob the RHS when the compiler flagged an UNQUOTED glob
5463 // token in the literal wordcode (SET_VAR_GLOB_ELIGIBLE). zsh's
5464 // GLOB_ASSIGN (Src/exec.c:2554) globs literal patterns only —
5465 // `x="/tmp/*"`, `x='/tmp/*'`, `x=$param`, `x=$(cmd)` all assign
5466 // verbatim. The value arrives here untokenized (DQ-wrapped by
5467 // the compiler), so this compile-time flag is the only surviving
5468 // signal of whether the pattern was quote-protected.
5469 let glob_eligible = SET_VAR_GLOB_ELIGIBLE.with(|c| c.replace(false));
5470 if glob_eligible && crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBASSIGN) {
5471 let mut tv = value.clone();
5472 crate::ported::glob::shtokenize(&mut tv);
5473 if crate::ported::pattern::haswilds(&tv) {
5474 // Committed to the glob path: never fall back to
5475 // assigning the literal pattern (zsh errors on
5476 // no-match instead).
5477 globbed = true;
5478 // globlist tokenizes its input internally (for
5479 // haswilds + glob_path) and prints the ORIGINAL
5480 // string verbatim in its "no matches found" error,
5481 // so feed it the UNtokenized value — passing the
5482 // tokenized form would leak the Star/Quest token
5483 // bytes into the error message.
5484 let mut ll: crate::ported::linklist::LinkList<String> = Default::default();
5485 ll.push_back(value.clone());
5486 crate::ported::subst::globlist(&mut ll, 0); // c:2556
5487 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
5488 == 0
5489 {
5490 let matches: Vec<String> = ll
5491 .nodes
5492 .iter()
5493 .map(|s| crate::ported::lex::untokenize(s).to_string())
5494 .collect();
5495 crate::ported::params::unsetparam(&name); // c:2562
5496 if matches.len() <= 1 {
5497 let v = matches.into_iter().next().unwrap_or_default();
5498 assign_failed =
5499 crate::ported::params::setsparam(&name, &v).is_none();
5500 } else {
5501 crate::ported::params::setaparam(&name, matches);
5502 }
5503 }
5504 // errflag set → globlist already reported
5505 // "no matches found"; leave the param unassigned
5506 // to match zsh's abort.
5507 }
5508 }
5509 // c:Src/exec.c addvars — a NULL return from
5510 // assignsparam (e.g. nameref resolving out of scope,
5511 // createparam refusal at c:1108-1118) fails the
5512 // assignment with status 1.
5513 if !globbed {
5514 assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
5515 }
5516 }
5517 // PM_EXPORTED / allexport env mirror — read AFTER setsparam
5518 // so the flag bit reflects any GSU setfn side-effects.
5519 let allexport = opt_state_get("allexport").unwrap_or(false);
5520 let already_exported =
5521 (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
5522 if allexport || already_exported {
5523 let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value));
5524 // c:Src/params.c:5354
5525 }
5526 #[cfg(feature = "recorder")]
5527 if crate::recorder::is_enabled()
5528 && exec.local_scope_depth == 0
5529 && !matches!(
5530 name.as_str(),
5531 "PPID" | "LINENO" | "ZSH_ARGZERO" | "argv0" | "ARGC" | "?" | "_" | "RANDOM"
5532 )
5533 {
5534 let ctx = exec.recorder_ctx();
5535 let attrs = exec.recorder_attrs_for(&name);
5536 crate::recorder::emit_assign_typed(&name, &value, attrs, ctx);
5537 }
5538 });
5539 Value::Status(vm.last_status)
5540 });
5541
5542 // c:Src/exec.c execfor → Src/params.c:6362 setloopvar — the
5543 // for-loop variable bind. Distinct from BUILTIN_SET_VAR because a
5544 // PM_NAMEREF loop variable REBINDS (new refname) instead of
5545 // assigning through the resolved chain.
5546 vm.register_builtin(BUILTIN_SET_LOOP_VAR, |vm, argc| {
5547 let args = pop_args(vm, argc);
5548 let name = args.first().cloned().unwrap_or_default();
5549 let value = args.get(1).cloned().unwrap_or_default();
5550 if crate::vm_helper::is_nameref(&name) {
5551 let ef_before =
5552 crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
5553 crate::ported::params::setloopvar(&name, &value); // c:6362
5554 let ef_after = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
5555 if (ef_after & crate::ported::utils::ERRFLAG_ERROR) != 0 && ef_after != ef_before {
5556 // zerr fired (read-only reference / invalid self
5557 // reference) — abort the loop, status 1 (C errflag).
5558 vm.last_status = 1;
5559 return Value::Bool(false);
5560 }
5561 return Value::Bool(true);
5562 }
5563 // Plain loop var — canonical scalar path (same shape as
5564 // BUILTIN_SET_VAR's setsparam arm).
5565 with_executor(|exec| {
5566 if exec.is_readonly_param(&name) {
5567 crate::ported::utils::zerr(&format!("read-only variable: {}", name));
5568 return;
5569 }
5570 crate::ported::params::setsparam(&name, &value);
5571 let allexport = opt_state_get("allexport").unwrap_or(false);
5572 let already_exported =
5573 (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
5574 if allexport || already_exported {
5575 let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value));
5576 // c:Src/params.c:5354
5577 }
5578 });
5579 Value::Bool(true)
5580 });
5581
5582 // Pre-compiled function registration — used by compile_zsh.rs's
5583 // FuncDef path. Stack: [name, base64-bincode-of-Chunk]. We decode
5584 // the base64, deserialize the Chunk, and store directly in
5585 // executor.functions_compiled. Bypasses the ShellCommand JSON layer.
5586 // BUILTIN_VAR_EXISTS — `[[ -v name ]]` set-test.
5587 // PURE PASSTHRU: build `${+name}` and route through canonical
5588 // `subst::paramsubst` which returns "1" for set / "0" for unset
5589 // (C port of `Src/subst.c::paramsubst` plus-prefix arm).
5590 // paramsubst handles all the shapes the 48-line hand-roll did:
5591 // - bare scalar / array / assoc
5592 // - subscripted `a[N]` / `h[key]`
5593 // - positional params (any digit-only name)
5594 // - env-var fallback (`HOME` set via getsparam → lookup_special_var)
5595 vm.register_builtin(BUILTIN_VAR_EXISTS, |vm, _argc| {
5596 let name = vm.pop().to_str();
5597 // c:Src/cond.c:361 `case 'v': return !issetvar(left)`. `-v` is
5598 // NOT `${+name}` — issetvar (params.c:751) additionally rejects
5599 // trailing chars after the parsed name/subscript (`arr[3]extra`,
5600 // nested `arr[2][1]`) and validates array-slice bounds (an
5601 // out-of-range `(i)`-not-found index is "unset"). `${+}` is
5602 // lenient and reported those as set.
5603 Value::Bool(crate::ported::params::issetvar(&name) != 0)
5604 });
5605
5606 // `time { compound; ... }` — runs the sub-chunk and prints elapsed
5607 // wall-clock time. zsh's full `time` also tracks user/system CPU via
5608 // getrusage on the *child*; we approximate via wall-time only since
5609 // the sub-chunk runs in-process (no fork). Output format matches
5610 // `time simple-cmd` (already implemented elsewhere via exectime).
5611 vm.register_builtin(BUILTIN_TIME_SUBLIST, |vm, argc| {
5612 let sub_idx = vm.pop().to_int() as usize;
5613 // c:Src/jobs.c:1028-1029 — `pn->text` arg to printtime. argc==2
5614 // means the compiler also pushed a desc string (bug #66 fix);
5615 // older callers with argc==1 push only sub_idx and we synthesize
5616 // an empty desc for backward compat with cached bytecode that
5617 // predates the desc-threading patch.
5618 let desc = if argc >= 2 {
5619 vm.pop().to_str().to_string()
5620 } else {
5621 String::new()
5622 };
5623 let chunk_opt = vm.chunk.sub_chunks.get(sub_idx).cloned();
5624 let Some(chunk) = chunk_opt else {
5625 return Value::Status(0);
5626 };
5627 // c:Src/jobs.c:1968 — `getrusage(RUSAGE_CHILDREN, &ti)` before
5628 // and after the timed sublist gives accurate per-stage user/sys
5629 // CPU. Wall-time-only approximation (0.7×/0.1× fudge factors)
5630 // produced bogus user/sys columns and ignored TIMEFMT. Bug #66
5631 // in docs/BUGS.md.
5632 let ru_before: libc::rusage = unsafe {
5633 let mut r: libc::rusage = std::mem::zeroed();
5634 libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
5635 r
5636 };
5637 // c:Src/jobs.c — zsh's `time` reports only for JOBS (forked
5638 // work). Builtins/brace-groups/functions run in the shell
5639 // process with no job, so `zsh -fc 'time true'` emits NOTHING.
5640 // Snapshot the fork-event counter; report only if the timed
5641 // body forked (external command or subshell).
5642 let forks_before = crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed);
5643 let start = Instant::now();
5644 crate::fusevm_disasm::maybe_print_stdout("time_sublist", &chunk);
5645 let mut sub_vm = fusevm::VM::new(chunk);
5646 register_builtins(&mut sub_vm);
5647 let _ = sub_vm.run();
5648 let status = sub_vm.last_status;
5649 let elapsed = start.elapsed();
5650 let ru_after: libc::rusage = unsafe {
5651 let mut r: libc::rusage = std::mem::zeroed();
5652 libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
5653 r
5654 };
5655 // Delta children rusage = timed work's CPU.
5656 let mut delta = ru_after;
5657 let sub = |a: libc::timeval, b: libc::timeval| -> libc::timeval {
5658 let mut sec = a.tv_sec - b.tv_sec;
5659 let mut usec = a.tv_usec as i64 - b.tv_usec as i64;
5660 if usec < 0 {
5661 sec -= 1;
5662 usec += 1_000_000;
5663 }
5664 libc::timeval {
5665 tv_sec: sec,
5666 tv_usec: usec as libc::suseconds_t,
5667 }
5668 };
5669 delta.ru_utime = sub(ru_after.ru_utime, ru_before.ru_utime);
5670 delta.ru_stime = sub(ru_after.ru_stime, ru_before.ru_stime);
5671 let ti = crate::ported::zsh_h::timeinfo::from_rusage(&delta);
5672 // c:Src/jobs.c:808-809 — `s = getsparam("TIMEFMT"); s ||
5673 // DEFAULT_TIMEFMT`. Honor user-set TIMEFMT, fall back to the
5674 // canonical default.
5675 let fmt = crate::ported::params::getsparam("TIMEFMT")
5676 .unwrap_or_else(|| crate::ported::zsh_system_h::DEFAULT_TIMEFMT.to_string());
5677 // c:Src/jobs.c:768 `desc` arg — for the `time { sublist }` /
5678 // `time simple-cmd` keyword path, zsh passes the sublist's
5679 // source text (used by %J via printtime). The compiler now
5680 // threads the rendered source text through as the desc operand
5681 // (compile_zsh.rs Time arm, argc==2 form). Bug #66.
5682 // c:Src/jobs.c — no job, no report (see forks_before above).
5683 let forked = crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed)
5684 != forks_before;
5685 if forked {
5686 let line = crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
5687 eprintln!("{}", line);
5688 }
5689 Value::Status(status)
5690 });
5691
5692 // `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocator.
5693 // Stack: [path, varid, op_byte]. Opens path with the appropriate mode
5694 // and stores the resulting fd number in $varid as a string. We use
5695 // a high starting fd (10+) by allocating then dup'ing — matches zsh's
5696 // "fresh fd >= 10" promise so subsequent commands don't collide on
5697 // stdin/out/err.
5698 vm.register_builtin(BUILTIN_OPEN_NAMED_FD, |vm, _argc| {
5699 use std::sync::atomic::Ordering;
5700 let op_byte = vm.pop().to_int() as u8;
5701 let varid = vm.pop().to_str();
5702 let path = vm.pop().to_str();
5703 // Param introspection used by both the open and close forms.
5704 let param_flags = crate::ported::params::paramtab()
5705 .read()
5706 .ok()
5707 .and_then(|t| t.get(&varid).map(|p| p.node.flags));
5708 let param_readonly = param_flags
5709 .map(|f| (f & crate::ported::zsh_h::PM_READONLY as i32) != 0)
5710 .unwrap_or(false);
5711 // `{varid}>&-` / `{varid}<&-` — REDIR_CLOSE with varid.
5712 // Direct port of Src/exec.c:3805-3850.
5713 if matches!(
5714 op_byte,
5715 b if b == fusevm::op::redirect_op::DUP_WRITE
5716 || b == fusevm::op::redirect_op::DUP_READ
5717 ) {
5718 let n = path.trim_start_matches('&');
5719 if n == "-" {
5720 let val = with_executor(|exec| exec.scalar(&varid)).unwrap_or_default();
5721 let fd1 = val.parse::<i32>();
5722 // c:3811-3816 — bad=1: parameter doesn't contain an fd.
5723 let Ok(fd1) = fd1 else {
5724 crate::ported::utils::zwarn(&format!(
5725 "parameter {} does not contain a file descriptor",
5726 varid
5727 ));
5728 with_executor(|exec| exec.redirect_failed = true);
5729 return Value::Status(1);
5730 };
5731 // c:3813-3814 — bad=2: readonly parameter.
5732 if param_readonly {
5733 crate::ported::utils::zwarn(&format!(
5734 "can't close file descriptor from readonly parameter {}",
5735 varid
5736 ));
5737 with_executor(|exec| exec.redirect_failed = true);
5738 return Value::Status(1);
5739 }
5740 // c:3830-3835 — bad=3: fd >= 10 marked FDT_INTERNAL.
5741 if fd1 >= 10
5742 && fd1 <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
5743 && crate::ported::utils::fdtable_get(fd1) == crate::ported::zsh_h::FDT_INTERNAL
5744 {
5745 crate::ported::utils::zwarn(&format!(
5746 "file descriptor {} used by shell, not closed",
5747 fd1
5748 ));
5749 with_executor(|exec| exec.redirect_failed = true);
5750 return Value::Status(1);
5751 }
5752 // c:3870-3873 — close; report failure (varid form
5753 // always reports, unlike bare `N>&-`).
5754 if crate::ported::utils::zclose(fd1) < 0 {
5755 crate::ported::utils::zwarn(&format!(
5756 "failed to close file descriptor {}: {}",
5757 fd1,
5758 std::io::Error::last_os_error()
5759 ));
5760 return Value::Status(1);
5761 }
5762 return Value::Status(0);
5763 }
5764 // `{varid}>&N` — dup N to a fresh fd >= 10, store in varid.
5765 if let Ok(src) = n.parse::<i32>() {
5766 if param_readonly {
5767 crate::ported::utils::zwarn(&format!(
5768 "can't allocate file descriptor to readonly parameter {}",
5769 varid
5770 ));
5771 with_executor(|exec| exec.redirect_failed = true);
5772 return Value::Status(1);
5773 }
5774 let dup = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
5775 if dup < 0 {
5776 crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src));
5777 with_executor(|exec| exec.redirect_failed = true);
5778 return Value::Status(1);
5779 }
5780 // c:2404-2412 addfd varid arm — movefd + FDT_EXTERNAL.
5781 let final_fd = crate::ported::utils::movefd(dup);
5782 crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
5783 with_executor(|exec| {
5784 exec.set_scalar(varid, final_fd.to_string());
5785 });
5786 return Value::Status(0);
5787 }
5788 return Value::Status(1);
5789 }
5790 // `{varid}<<HERE` / `{varid}<<<str` — op byte 255 (zshrs-side
5791 // contract with compile_redir; fusevm's redirect_op stops at
5792 // 8). C path: gethere/getherestr write the body to a temp
5793 // file (Src/exec.c:4660-4682), then addfd's varid arm moves
5794 // the read fd >= 10, marks FDT_EXTERNAL and sets the param
5795 // (c:2402-2412). `path` carries the BODY text here.
5796 if op_byte == 255 {
5797 if param_readonly {
5798 crate::ported::utils::zwarn(&format!(
5799 "can't allocate file descriptor to readonly parameter {}",
5800 varid
5801 ));
5802 with_executor(|exec| exec.redirect_failed = true);
5803 return Value::Status(1);
5804 }
5805 let body = format!("{}\n", path.trim_end_matches('\n'));
5806 let mut tmpl: Vec<u8> = b"/tmp/zshrs_hd_XXXXXX\0".to_vec();
5807 let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
5808 if write_fd < 0 {
5809 crate::ported::utils::zwarn(&format!(
5810 "can't create temp file for here document: {}",
5811 std::io::Error::last_os_error()
5812 ));
5813 return Value::Status(1);
5814 }
5815 let bytes = body.as_bytes();
5816 let mut off = 0;
5817 while off < bytes.len() {
5818 let n = unsafe {
5819 libc::write(
5820 write_fd,
5821 bytes[off..].as_ptr() as *const libc::c_void,
5822 bytes.len() - off,
5823 )
5824 };
5825 if n <= 0 {
5826 unsafe { libc::close(write_fd) };
5827 return Value::Status(1);
5828 }
5829 off += n as usize;
5830 }
5831 unsafe { libc::close(write_fd) };
5832 let read_fd =
5833 unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
5834 unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
5835 if read_fd < 0 {
5836 return Value::Status(1);
5837 }
5838 let final_fd = crate::ported::utils::movefd(read_fd);
5839 if final_fd < 0 {
5840 return Value::Status(1);
5841 }
5842 crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
5843 with_executor(|exec| {
5844 exec.set_scalar(varid, final_fd.to_string());
5845 });
5846 return Value::Status(0);
5847 }
5848 // Open form: `{varid}>file` etc.
5849 // c:Src/exec.c:2177-2215 checkclobberparam — gate BEFORE open.
5850 if param_readonly {
5851 // c:2191-2197
5852 crate::ported::utils::zwarn(&format!(
5853 "can't allocate file descriptor to readonly parameter {}",
5854 varid
5855 ));
5856 with_executor(|exec| exec.redirect_failed = true);
5857 return Value::Status(1);
5858 }
5859 // c:2199-2213 — NO_CLOBBER refuses to overwrite a parameter
5860 // already holding an OPEN fd (decimal value, fdtable says
5861 // FDT_EXTERNAL).
5862 if !isset(crate::ported::zsh_h::CLOBBER) && op_byte != fusevm::op::redirect_op::CLOBBER {
5863 if let Some(val) = with_executor(|exec| exec.scalar(&varid)) {
5864 if let Ok(fd) = val.parse::<i32>() {
5865 if fd >= 0
5866 && fd <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
5867 && crate::ported::utils::fdtable_get(fd)
5868 == crate::ported::zsh_h::FDT_EXTERNAL
5869 {
5870 crate::ported::utils::zwarn(&format!(
5871 "can't clobber parameter {} containing file descriptor {}",
5872 varid, fd
5873 ));
5874 with_executor(|exec| exec.redirect_failed = true);
5875 return Value::Status(1);
5876 }
5877 }
5878 }
5879 }
5880 let path_c = match CString::new(path.clone()) {
5881 Ok(c) => c,
5882 Err(_) => return Value::Status(1),
5883 };
5884 let flags = match op_byte {
5885 b if b == fusevm::op::redirect_op::READ => libc::O_RDONLY,
5886 b if b == fusevm::op::redirect_op::WRITE || b == fusevm::op::redirect_op::CLOBBER => {
5887 libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC
5888 }
5889 b if b == fusevm::op::redirect_op::APPEND => {
5890 libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND
5891 }
5892 b if b == fusevm::op::redirect_op::READ_WRITE => libc::O_RDWR | libc::O_CREAT,
5893 _ => return Value::Status(1),
5894 };
5895 let fd = unsafe { libc::open(path_c.as_ptr(), flags, 0o666) };
5896 if fd < 0 {
5897 return Value::Status(1);
5898 }
5899 // c:2404-2412 addfd varid arm — `fd1 = movefd(fd2);
5900 // fdtable[fd1] = FDT_EXTERNAL; setiparam(varid, fd1);`.
5901 // FDT_EXTERNAL (not INTERNAL): the user owns this fd — the
5902 // NO_CLOBBER gate above and `{fd}>&-` close both key off it.
5903 let final_fd = crate::ported::utils::movefd(fd);
5904 if final_fd < 0 {
5905 crate::ported::utils::zerr(&format!(
5906 "cannot move fd {}: {}",
5907 fd,
5908 std::io::Error::last_os_error()
5909 ));
5910 return Value::Status(1);
5911 }
5912 crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
5913 let _ = Ordering::Relaxed;
5914 with_executor(|exec| {
5915 exec.set_scalar(varid, final_fd.to_string());
5916 });
5917 Value::Status(0)
5918 });
5919
5920 // BUILTIN_SET_TRY_BLOCK_ERROR — capture the try-block's exit
5921 // status into `__zshrs_try_block_saved_status` (a scratch
5922 // scalar) so the always-arm can later restore it. Also set
5923 // `TRY_BLOCK_ERROR` per zsh semantics: it stays at -1 unless
5924 // the try-block fired an explicit error (errflag), per
5925 // c:Src/exec.c execlist's WC_TRYBLOCK arm.
5926 vm.register_builtin(BUILTIN_SET_TRY_BLOCK_ERROR, |vm, _argc| {
5927 use std::sync::atomic::Ordering;
5928 let vm_status = vm.last_status;
5929 // c:Src/exec.c WC_TRYBLOCK — the always-arm runs with a
5930 // clean escape state. Snapshot RETFLAG / BREAKS / CONTFLAG /
5931 // EXIT_PENDING here and clear them; RESTORE_TRY_BLOCK_STATUS
5932 // re-applies them at always-arm exit so the propagation jump
5933 // emitted by compile_zsh fires correctly.
5934 let ret_save = crate::ported::builtin::RETFLAG.swap(0, Ordering::Relaxed); // c:769-770
5935 let brk_save = crate::ported::builtin::BREAKS.swap(0, Ordering::Relaxed); // c:771-772
5936 let cont_save = crate::ported::builtin::CONTFLAG.swap(0, Ordering::Relaxed); // c:773-774
5937 let exit_save = crate::ported::builtin::EXIT_PENDING.swap(0, Ordering::Relaxed);
5938 // c:Src/loop.c:762-763 — `save_try_errflag = try_errflag;
5939 // save_try_interrupt = try_interrupt;`. Restored at c:778-779
5940 // by RESTORE_TRY_BLOCK_STATUS so a nested try block doesn't
5941 // clobber the enclosing one's `$TRY_BLOCK_ERROR`.
5942 let try_err_save = crate::ported::r#loop::try_errflag.load(Ordering::Relaxed); // c:762
5943 let try_int_save = crate::ported::r#loop::try_interrupt.load(Ordering::Relaxed); // c:763
5944 TRY_ESCAPE_SAVE.with(|s| {
5945 s.borrow_mut().push((
5946 ret_save,
5947 brk_save,
5948 cont_save,
5949 exit_save,
5950 try_err_save,
5951 try_int_save,
5952 ));
5953 });
5954 // c:Src/loop.c:764-766 — `try_errflag = (zlong)(errflag &
5955 // ERRFLAG_ERROR); try_interrupt = (zlong)((errflag &
5956 // ERRFLAG_INT) ? 1 : 0);`. Both are the RAW FLAG BITS, not the
5957 // try-list's exit status: ERRFLAG_ERROR is 1 (zsh.h:2972), so
5958 // `$TRY_BLOCK_ERROR` is 1-or-0 in zsh regardless of what the
5959 // failing command's `$?` was. The try-list's status is carried
5960 // separately in `__zshrs_try_block_saved_status`.
5961 let live_errflag = crate::ported::utils::errflag.load(Ordering::Relaxed);
5962 let try_err = (live_errflag & crate::ported::zsh_h::ERRFLAG_ERROR) as i64; // c:765
5963 let try_int = if (live_errflag & crate::ported::zsh_h::ERRFLAG_INT) != 0 {
5964 1i64
5965 } else {
5966 0i64
5967 }; // c:766
5968 crate::ported::r#loop::try_errflag.store(try_err, Ordering::Relaxed); // c:765
5969 crate::ported::r#loop::try_interrupt.store(try_int, Ordering::Relaxed); // c:766
5970 // c:Src/loop.c:755 — `endval = lastval ? lastval : errflag;`.
5971 // The status of the WHOLE `{…} always {…}` construct, captured
5972 // BEFORE the always-list runs (exectry returns it at c:801) and
5973 // deliberately including the errflag fallback: a try-list that
5974 // failed with `lastval == 0` but raised errflag still reports 1.
5975 let endval = if vm_status != 0 {
5976 vm_status
5977 } else {
5978 live_errflag
5979 }; // c:755
5980 with_executor(|exec| {
5981 // flags=0 (not setsparam's ASSPM_WARN): VM-internal scratch —
5982 // must never surface as a WARN_CREATE_GLOBAL diagnostic inside
5983 // a user function running `{...} always {...}` (f-sy-h's
5984 // `_zsh_highlight` does exactly that under warncreateglobal).
5985 crate::ported::params::assignsparam(
5986 "__zshrs_try_block_saved_status",
5987 &endval.to_string(),
5988 0,
5989 );
5990 let _ = exec;
5991 // Mirror into paramtab so `${parameters[TRY_BLOCK_ERROR]}`
5992 // and the PM_INTEGER `u_val` shadow agree with the atomic
5993 // the special-var getter reads. (setsparam → intsetfn's
5994 // TRY_BLOCK_ERROR arm re-stores the same value.)
5995 exec.set_scalar("TRY_BLOCK_ERROR".to_string(), try_err.to_string());
5996 exec.set_scalar("TRY_BLOCK_INTERRUPT".to_string(), try_int.to_string());
5997 });
5998 // c:Src/loop.c:768 — `errflag = 0;` ("We need to reset all
5999 // errors to allow the block to execute"). C clears the WHOLE
6000 // word, not just ERRFLAG_ERROR.
6001 crate::ported::utils::errflag.store(0, Ordering::Relaxed); // c:768
6002 Value::Status(0)
6003 });
6004
6005 // BUILTIN_BEGIN_INLINE_ENV / END_INLINE_ENV — wrap an
6006 // inline-assignment-prefixed command (`X=foo Y=bar cmd`):
6007 // BEGIN pushes a save frame; SET_VAR fires for each assign and
6008 // ALSO env::set_var's the value (visible to cmd's child); the
6009 // command runs; END pops the frame and restores both shell-var
6010 // and process-env state. Direct port of zsh's addvars() →
6011 // execute_simple → restore-after-exec contract.
6012 vm.register_builtin(BUILTIN_BEGIN_INLINE_ENV, |_vm, _argc| {
6013 with_executor(|exec| {
6014 exec.inline_env_stack.push(Vec::new());
6015 });
6016 Value::Status(0)
6017 });
6018 vm.register_builtin(BUILTIN_END_INLINE_ENV, |_vm, _argc| {
6019 with_executor(|exec| {
6020 if let Some(frame) = exec.inline_env_stack.pop() {
6021 for (name, prev_var, prev_env) in frame.into_iter().rev() {
6022 match prev_var {
6023 Some(v) => {
6024 exec.set_scalar(name.clone(), v);
6025 }
6026 None => {
6027 exec.unset_scalar(&name);
6028 }
6029 }
6030 match prev_env {
6031 Some(v) => env::set_var(&name, &v),
6032 None => env::remove_var(&name),
6033 }
6034 }
6035 }
6036 });
6037 Value::Status(0)
6038 });
6039 // c:Src/exec.c:3969-3976 — bare-exec assignment epilogue: see the
6040 // const's doc block. POSIX_BUILTINS → assignments persist (pop the
6041 // frame, discard the saved state); otherwise → restore_params
6042 // (same walk as END_INLINE_ENV).
6043 vm.register_builtin(BUILTIN_EXEC_INLINE_ENV_DONE, |_vm, _argc| {
6044 let persist = isset(crate::ported::zsh_h::POSIXBUILTINS);
6045 with_executor(|exec| {
6046 if let Some(frame) = exec.inline_env_stack.pop() {
6047 if persist {
6048 return; // c:3971 — no save/restore under POSIX_BUILTINS
6049 }
6050 for (name, prev_var, prev_env) in frame.into_iter().rev() {
6051 match prev_var {
6052 Some(v) => {
6053 exec.set_scalar(name.clone(), v);
6054 }
6055 None => {
6056 exec.unset_scalar(&name);
6057 }
6058 }
6059 match prev_env {
6060 Some(v) => env::set_var(&name, &v),
6061 None => env::remove_var(&name),
6062 }
6063 }
6064 }
6065 });
6066 Value::Status(0)
6067 });
6068
6069 // BUILTIN_RESTORE_TRY_BLOCK_STATUS — emitted at the end of an
6070 // `always` arm. Per zshmisc, the exit status of the entire
6071 // `{ try } always { finally }` construct is the try-list's
6072 // status, regardless of what happens in the always-list (the
6073 // exception is `return`/`exit` inside always, which short-
6074 // circuits and the cleanup is the only thing that runs). So
6075 // restore TRY_BLOCK_ERROR unconditionally — the always-list's
6076 // exit status is discarded for the construct.
6077 vm.register_builtin(BUILTIN_RESTORE_TRY_BLOCK_STATUS, |_vm, _argc| {
6078 use std::sync::atomic::Ordering;
6079 // c:Src/loop.c:801 — `return endval;`. The construct's exit
6080 // status is the try-list's (captured at c:755 by
6081 // SET_TRY_BLOCK_ERROR), never the always-list's.
6082 let saved = with_executor(|exec| {
6083 exec.scalar("__zshrs_try_block_saved_status")
6084 .and_then(|s| s.parse::<i32>().ok())
6085 .unwrap_or(0)
6086 });
6087 // c:Src/exec.c:1375 — `lastval = lv;` on the exectry return.
6088 // The always-list's own commands left their status in LASTVAL
6089 // (`always { : }` → 0); without this store the errflag re-raise
6090 // below aborts the shell with the always-list's 0 instead of
6091 // the try-list's failure status.
6092 crate::ported::builtin::LASTVAL.store(saved, Ordering::Relaxed); // c:1375
6093 // c:Src/loop.c:774-777 — the error RE-RAISE. This is the
6094 // whole point of TRY_BLOCK_ERROR being writable:
6095 //
6096 // if (try_errflag) errflag |= ERRFLAG_ERROR;
6097 // else errflag &= ~ERRFLAG_ERROR;
6098 // if (try_interrupt) errflag |= ERRFLAG_INT;
6099 // else errflag &= ~ERRFLAG_INT;
6100 //
6101 // SET_TRY_BLOCK_ERROR cleared errflag (c:768) so the always-arm
6102 // could run; the try-block's error is PARKED in `try_errflag`
6103 // and re-raised HERE unless the always-arm zeroed it
6104 // (`TRY_BLOCK_ERROR=0`, the documented swallow idiom — routed
6105 // to the atomic by intsetfn's IPDEF6 arm, params.rs).
6106 //
6107 // zshrs used to just drop the parked error, so
6108 // `f() { { typeset -r ro=1; ro=2 } always { … }; print reached }`
6109 // kept running and exited 0, where zsh aborts f with status 1.
6110 let te = crate::ported::r#loop::try_errflag.load(Ordering::Relaxed); // c:774
6111 let ti = crate::ported::r#loop::try_interrupt.load(Ordering::Relaxed); // c:776
6112 if te != 0 {
6113 crate::ported::utils::errflag
6114 .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed); // c:775
6115 } else {
6116 crate::ported::utils::errflag
6117 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed); // c:777
6118 }
6119 if ti != 0 {
6120 crate::ported::utils::errflag
6121 .fetch_or(crate::ported::zsh_h::ERRFLAG_INT, Ordering::Relaxed); // c:779
6122 } else {
6123 crate::ported::utils::errflag
6124 .fetch_and(!crate::ported::zsh_h::ERRFLAG_INT, Ordering::Relaxed); // c:781
6125 }
6126 // Re-apply the escape flags captured by SET_TRY_BLOCK_ERROR.
6127 // If the always-arm itself fired return/break/continue/exit,
6128 // its handler already overwrote the canonical atomics; let
6129 // those win — the always-arm's own escape always takes
6130 // priority over the try-block's deferred one.
6131 if let Some((ret, brk, cont, exit_p, try_err_save, try_int_save)) =
6132 TRY_ESCAPE_SAVE.with(|s| s.borrow_mut().pop())
6133 {
6134 // c:Src/loop.c:782-783 — `try_errflag = save_try_errflag;
6135 // try_interrupt = save_try_interrupt;`
6136 crate::ported::r#loop::try_errflag.store(try_err_save, Ordering::Relaxed); // c:782
6137 crate::ported::r#loop::try_interrupt.store(try_int_save, Ordering::Relaxed);
6138 // c:783
6139 if crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) == 0 {
6140 crate::ported::builtin::RETFLAG.store(ret, Ordering::Relaxed);
6141 }
6142 if crate::ported::builtin::BREAKS.load(Ordering::Relaxed) == 0 {
6143 crate::ported::builtin::BREAKS.store(brk, Ordering::Relaxed);
6144 }
6145 if crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed) == 0 {
6146 crate::ported::builtin::CONTFLAG.store(cont, Ordering::Relaxed);
6147 }
6148 if crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) == 0 {
6149 crate::ported::builtin::EXIT_PENDING.store(exit_p, Ordering::Relaxed);
6150 }
6151 }
6152 Value::Status(saved)
6153 });
6154
6155 // `[[ -r/-w/-x file ]]` — the cond path must use access(2) (the
6156 // C-faithful doaccess), NOT fusevm's generic Op::TestFile which only
6157 // checks existence for -r/-w (so a `chmod 000` file read as readable;
6158 // C02cond.ztst:13). Stack: [path, mode]; mode is the access(2) bit
6159 // (R_OK=4, W_OK=2, X_OK=1). Mirrors cond.rs:232/238/267 `doaccess`.
6160 vm.register_builtin(BUILTIN_COND_ACCESS, |vm, _argc| {
6161 let mode = vm.pop().to_int() as i32;
6162 let path = vm.pop().to_str();
6163 Value::Bool(crate::ported::cond::doaccess(&path, mode) != 0)
6164 });
6165
6166 vm.register_builtin(BUILTIN_IS_TTY, |vm, _argc| {
6167 let fd_str = vm.pop().to_str();
6168 let fd: i32 = fd_str.trim().parse().unwrap_or(-1);
6169 let is_tty = if fd < 0 {
6170 false
6171 } else {
6172 unsafe { libc::isatty(fd) != 0 }
6173 };
6174 Value::Bool(is_tty)
6175 });
6176
6177 // c:Src/exec.c:4918/5040/5069 — a process substitution used inside a
6178 // `[[ … ]]` cond operand errors "process substitution %s cannot be
6179 // used here" (getoutputfile/getproc run with thisjob == -1). Emitted
6180 // by the compiler in place of ProcessSubIn/Out when in_cond_operand.
6181 vm.register_builtin(BUILTIN_PROCSUB_COND_ERROR, |_vm, _argc| {
6182 let cmd = _vm.pop().to_str();
6183 crate::ported::utils::zerr(&format!(
6184 "process substitution {} cannot be used here",
6185 cmd
6186 ));
6187 // c:getoutputfile returns NULL with errflag set → the enclosing
6188 // statement aborts (empty stdout, exit 1), rather than the cond
6189 // merely evaluating false.
6190 crate::ported::utils::errflag.fetch_or(
6191 crate::ported::zsh_h::ERRFLAG_ERROR,
6192 std::sync::atomic::Ordering::Relaxed,
6193 );
6194 with_executor(|exec| exec.set_last_status(1));
6195 _vm.last_status = 1;
6196 Value::str("")
6197 });
6198
6199 // Set $LINENO before executing the next statement. Direct
6200 // port of zsh's `lineno` global tracking from Src/input.c
6201 // (`if ((inbufflags & INP_LINENO) || !strin) && c == '\n')
6202 // lineno++;`). The compiler emits one of these before each
6203 // top-level pipe in `compile_sublist`, carrying the line
6204 // number captured by the parser at `ZshPipe.lineno`. Pops
6205 // [n], updates `$LINENO` in the variable table.
6206 vm.register_builtin(BUILTIN_SET_LINENO, |vm, _argc| {
6207 let n = vm.pop().to_int();
6208 // c:Src/exec.c:lineno = N — direct write to the param's
6209 // u_val. Cannot go through setsparam because LINENO carries
6210 // PM_READONLY (so `(t)LINENO` reads `integer-readonly-special`
6211 // per zsh); setsparam → assignstrvalue's PM_READONLY guard
6212 // would reject the internal write. C zsh handles this via the
6213 // PM_SPECIAL GSU vtable's setfn callback which bypasses the
6214 // generic readonly check; the Rust port writes the canonical
6215 // field directly instead.
6216 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
6217 if let Some(pm) = tab.get_mut("LINENO") {
6218 pm.u_val = n;
6219 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
6220 }
6221 }
6222 // Mirror to the file-static `lineno` (utils.c:121) that
6223 // zerrmsg reads at utils.c:301 for the `:N: msg` prefix.
6224 crate::ported::utils::set_lineno(n as i32);
6225 // Also drive lex::LEX_LINENO — zerrmsg (utils.rs:376) reads
6226 // THAT counter for the `name:N:` prefix. C zsh interleaves
6227 // parse and execute per top-level list, so its single
6228 // `lineno` global serves both; zshrs compiles the whole
6229 // script before running, leaving LEX_LINENO parked at EOF.
6230 // Without this write, every runtime zwarn/zerr reported the
6231 // script's LAST line instead of the failing statement's.
6232 crate::ported::lex::set_lineno(n as u64);
6233 // DAP hook — checks breakpoints / step mode / pause-request
6234 // for the line we just landed on. O(1) no-op when DAP is off
6235 // (single atomic load on a OnceLock). Inside `--dap` mode
6236 // this is the call that blocks the executor on a Condvar
6237 // until the IDE sends `continue`. Mirrors strykelang's
6238 // `debugger.should_stop(line) → debugger.prompt(...)` flow.
6239 crate::extensions::dap::check_line(n as u32);
6240 Value::Status(0)
6241 });
6242
6243 // Direct port of Src/prompt.c:1623 cmdpush. Token is a `CS_*`
6244 // value (zsh.h:2775-2806) emitted by compile_zsh around each
6245 // compound command (if/while/[[…]]/((…))/$(…)) and consumed by
6246 // `%_` in PS4 / prompt expansion.
6247 vm.register_builtin(BUILTIN_CMD_PUSH, |vm, _argc| {
6248 let token = vm.pop().to_int() as u8;
6249 // Route through canonical cmdpush (Src/prompt.c:1623). The
6250 // prompt expander reads from the file-static `CMDSTACK` at
6251 // `prompt.rs:2006`, not `exec.cmd_stack` — without this,
6252 // `%_` in PS4 saw an empty stack during xtrace.
6253 if (token as i32) < crate::ported::zsh_h::CS_COUNT {
6254 crate::ported::prompt::cmdpush(token);
6255 }
6256 Value::Status(0)
6257 });
6258
6259 // Direct port of Src/prompt.c:1631 cmdpop.
6260 vm.register_builtin(BUILTIN_CMD_POP, |_vm, _argc| {
6261 crate::ported::prompt::cmdpop();
6262 Value::Status(0)
6263 });
6264
6265 vm.register_builtin(BUILTIN_OPTION_SET, |vm, _argc| {
6266 let name = vm.pop().to_str();
6267 // Direct port of `optison(char *name, char *s)` at Src/cond.c:502 — `[[ -o NAME ]]`
6268 // reads through the same `opts[]` array that `setopt NAME`
6269 // writes via `dosetopt`. Earlier code read a duplicate Executor
6270 // HashMap which never saw `bin_setopt`'s writes (those land in
6271 // `OPTS_LIVE` via `opt_state_set`). Routing through the canonical
6272 // C port restores the single-store invariant: one `opts[]`,
6273 // shared between setopt/unsetopt and `[[ -o ]]`.
6274 let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
6275 match r {
6276 0 => Value::Bool(true), // c:cond.c:520 set
6277 1 => Value::Bool(false), // c:cond.c:518/520 unset
6278 _ => {
6279 // c:cond.c:514 — unknown option. optison already emitted the
6280 // diagnostic (via zwarn, now that fromtest=NULL for
6281 // `[[ -o ]]`); re-printing here double-emitted for
6282 // `[[ ! -o bad ]]` / `[[ -o a || -o b ]]`.
6283 Value::Bool(false)
6284 }
6285 }
6286 });
6287 // Tri-state `-o` for compile_cond's direct status path. Returns
6288 // 0 / 1 / 3 as a Value::Int that compile_cond consumes via
6289 // Op::SetStatus. Mirrors zsh's `[[ -o invalid ]]` returning $?=3.
6290 vm.register_builtin(BUILTIN_OPTION_CHECK_TRISTATE, |vm, _argc| {
6291 let name = vm.pop().to_str();
6292 let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
6293 // optison itself prints the diagnostic via zwarnnam when r=3
6294 // and POSIXBUILTINS is unset (the canonical path). Don't
6295 // double-emit here. r is already 0/1/3.
6296 Value::Int(r as i64)
6297 });
6298
6299 // BUILTIN_PARAM_FILTER — `${var:#pat}` / `${var:|name}` etc.
6300 // PURE PASSTHRU: rebuild `${name:#pat}` and route to paramsubst.
6301 vm.register_builtin(BUILTIN_PARAM_FILTER, |vm, _argc| {
6302 let pattern = vm.pop().to_str();
6303 let name = vm.pop().to_str();
6304 let body = format!("${{{}:#{}}}", name, pattern);
6305 paramsubst_to_value(&body)
6306 });
6307
6308 // `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()`
6309 // — subscripted-array assign with array RHS. Stack pushed by
6310 // compile_assign as: [elem0, elem1, …, elemN-1, name, key].
6311 vm.register_builtin(BUILTIN_SET_SUBSCRIPT_RANGE, |vm, argc| {
6312 let n = argc as usize;
6313 let mut popped: Vec<Value> = Vec::with_capacity(n);
6314 for _ in 0..n {
6315 popped.push(vm.pop());
6316 }
6317 popped.reverse();
6318 if popped.len() < 3 {
6319 return Value::Status(1);
6320 }
6321 // c:Src/params.c:3511-3526 — trailing append flag. The ARRAY
6322 // subscript path (`a[N]+=(v)` / `a[lo,hi]+=(v)`) sets it so the
6323 // AUGMENT transform below collapses the range to an empty range
6324 // after the slice end and inserts ONLY the new value. The scalar
6325 // path pre-concats the old slice (ARRAY_INDEX+Concat) and passes
6326 // 0, so it keeps plain-replace semantics.
6327 let append = popped.pop().map_or(false, |v| v.to_str() == "1");
6328 let key = popped.pop().unwrap().to_str();
6329 let name = popped.pop().unwrap().to_str();
6330 let mut values: Vec<String> = Vec::new();
6331 for v in popped {
6332 match v {
6333 Value::Array(items) => {
6334 for it in items {
6335 values.push(it.to_str());
6336 }
6337 }
6338 other => values.push(other.to_str()),
6339 }
6340 }
6341 // c:Src/params.c:3383-3389 — a subscripted ARRAY assignment to an
6342 // associative array is an error, whatever the subscript looks like:
6343 // if (v && PM_TYPE(v->pm->node.flags) == PM_HASHED) {
6344 // unqueue_signals();
6345 // zerr("%s: attempt to set slice of associative array",
6346 // v->pm->node.nam);
6347 // freearray(val);
6348 // errflag |= ERRFLAG_ERROR;
6349 // return NULL;
6350 // }
6351 // assignaparam (params.rs) ports this, but a single-key `h[k]=(1 2)`
6352 // never reaches it: the VM lowers subscripted assignment to this
6353 // builtin instead. The comma form `h[a,b]=(1 2)` DID error — it takes a
6354 // different route — so the gap looked like a subscript-parsing quirk
6355 // when it was really "the check lives on a path this form doesn't
6356 // take". Untreated, the assignment was SILENTLY DISCARDED: rc=0 and
6357 // `${h[k]}` still read its old value.
6358 //
6359 // Only array-valued assignment is rejected; `h[k]=x` is a scalar
6360 // element store and stays legal.
6361 {
6362 let is_hashed = crate::ported::params::paramtab()
6363 .read()
6364 .ok()
6365 .and_then(|t| {
6366 t.get(&name)
6367 .map(|pm| crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32) == crate::ported::zsh_h::PM_HASHED)
6368 })
6369 .unwrap_or(false);
6370 if is_hashed {
6371 crate::ported::utils::zerr(&format!(
6372 "{name}: attempt to set slice of associative array" // c:3385
6373 ));
6374 crate::ported::utils::errflag.fetch_or(
6375 crate::ported::zsh_h::ERRFLAG_ERROR,
6376 std::sync::atomic::Ordering::Relaxed,
6377 ); // c:3387
6378 return Value::Status(1); // c:3388
6379 }
6380 }
6381
6382 with_executor(|exec| {
6383 // Parse subscript: slice `lo,hi` or single index `i`.
6384 // setarrvalue (Src/params.c:2895) expects 1-based start/
6385 // end inclusive where start==end means replace one
6386 // element. Negative bounds translate to len+n+1 (1-based).
6387 //
6388 // c:Src/params.c — the END side accepts 0 as a valid value
6389 // that signals "insert BEFORE start position" (the canonical
6390 // `a[N,N-1]=val` prepend / mid-insert idiom). Bug #275 in
6391 // docs/BUGS.md: the previous Rust port clamped end up to 1,
6392 // collapsing `a[1,0]=(X Y)` into `a[1,1]=(X Y)` which
6393 // OVERWRITES position 1 instead of prepending. Provide two
6394 // translators — start_translate clamps to 1 (1-based);
6395 // end_translate keeps 0 intact so the splice in
6396 // setarrvalue (start_idx=0..end_idx=0) inserts at the front.
6397 // Bug #589: for scalars (no array), use the scalar's char
6398 // count as `len` so negative-index translation (`a[2,-1]`)
6399 // computes against the actual string length, not 0.
6400 let len = exec
6401 .array(&name)
6402 .map(|a| a.len() as i64)
6403 .or_else(|| {
6404 crate::ported::params::paramtab().read().ok().and_then(|t| {
6405 t.get(&name).and_then(|pm| {
6406 if crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
6407 == crate::ported::zsh_h::PM_SCALAR
6408 {
6409 pm.u_str.as_ref().map(|s| s.chars().count() as i64)
6410 } else {
6411 None
6412 }
6413 })
6414 })
6415 })
6416 .unwrap_or(0);
6417 let start_translate = |raw: i64| -> i32 {
6418 if raw < 0 {
6419 (len + raw + 1).max(1) as i32
6420 } else {
6421 raw.max(1) as i32
6422 }
6423 };
6424 let end_translate = |raw: i64| -> i32 {
6425 if raw < 0 {
6426 (len + raw + 1).max(0) as i32
6427 } else {
6428 raw.max(0) as i32
6429 }
6430 };
6431 // c:Src/params.c — KSH_ARRAYS option flips array subscripts
6432 // from 1-based to 0-based. setarrvalue expects 1-based
6433 // inclusive bounds, so under KSH_ARRAYS we shift positive
6434 // inputs by +1 before translation. Negative bounds left
6435 // alone (count from end). Sibling of #610/#611/#612.
6436 // Bug #613.
6437 let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
6438 let ksh_shift = |raw: i64| -> i64 {
6439 if ksh_arrays && raw >= 0 {
6440 raw + 1
6441 } else {
6442 raw
6443 }
6444 };
6445 // c:Src/params.c getindex — subscript bounds are MATH
6446 // expressions, not bare integers: `a[(( ${#a}+1 ))]=(x)`,
6447 // `a[n+1]=(x)`. Plain `parse::<i64>()` returned 0 on any
6448 // arithmetic subscript, which the `i == 0` guard turned into
6449 // a silent no-op (computed-index append never landed). Parse
6450 // the literal fast-path first, then fall back to mathevali
6451 // (which handles `(( ))` grouping, var refs, and operators).
6452 let eval_bound = |s: &str| -> i64 {
6453 let t = s.trim();
6454 t.parse::<i64>()
6455 .ok()
6456 .or_else(|| crate::ported::math::mathevali(t).ok())
6457 .unwrap_or(0)
6458 };
6459 let (start, end) = if let Some((s_str, e_str)) = key.split_once(',') {
6460 let s = ksh_shift(eval_bound(s_str));
6461 let e = ksh_shift(eval_bound(e_str));
6462 (start_translate(s), end_translate(e))
6463 } else {
6464 let i = ksh_shift(eval_bound(&key));
6465 if i == 0 {
6466 return;
6467 }
6468 let n = start_translate(i);
6469 (n, n)
6470 };
6471 // c:Src/params.c:3518-3520 (assignaparam ASSPM_AUGMENT) — a
6472 // subscripted `+=` to an array does NOT prepend the old slice;
6473 // it collapses the range to an EMPTY range positioned right
6474 // AFTER the slice end (`v->start = v->end--`) and splices in
6475 // ONLY the new value: `a[2]+=(d)` on (a b c) → (a b d c);
6476 // `a[2,3]+=(x)` on (1 2 3 4) → (1 2 3 x 4). In setarrvalue's
6477 // 1-based convention here that means start = end+1 (so
6478 // start_idx == end_idx == end → splice arr[end..end]).
6479 let (start, end) = if append && end > 0 {
6480 (end + 1, end)
6481 } else {
6482 (start, end)
6483 };
6484 // c:Src/params.c:392-430 IPDEF9("argv"/"@"/"*", &pparams) —
6485 // the positional parameters live in the `pparams` vector, NOT
6486 // paramtab, so a subscript splice (`argv[2]=(X Y Z)`,
6487 // `2=(X Y Z)`) must read/write pparams. Splice a synthetic
6488 // array param holding the current positionals via the
6489 // canonical setarrvalue, then store the result back to
6490 // pparams — mirroring assignaparam's argv/@/* special-case
6491 // (params.rs:6937) for the whole-array form.
6492 if name == "argv" || name == "@" || name == "*" {
6493 let mut pm = {
6494 crate::ported::params::createparam(
6495 &name,
6496 crate::ported::zsh_h::PM_ARRAY as i32,
6497 );
6498 crate::ported::params::paramtab()
6499 .write()
6500 .ok()
6501 .and_then(|mut t| t.remove(&name))
6502 };
6503 if let Some(ref mut p) = pm {
6504 p.u_arr = Some(exec.pparams());
6505 }
6506 let mut v = crate::ported::zsh_h::value {
6507 pm,
6508 arr: Vec::new(),
6509 scanflags: 0,
6510 valflags: 0,
6511 start,
6512 end,
6513 };
6514 crate::ported::params::setarrvalue(&mut v, values);
6515 let result = v.pm.and_then(|p| p.u_arr).unwrap_or_default();
6516 exec.set_pparams(result);
6517 return;
6518 }
6519 // Route through canonical setarrvalue (Src/params.c:2895).
6520 // It handles PM_READONLY rejection, PM_HASHED slice-error,
6521 // PM_ARRAY splice + bounds clamp + padding (c:2980+).
6522 let taken = match crate::ported::params::paramtab().write() {
6523 Ok(mut tab) => tab.remove(&name),
6524 Err(_) => None,
6525 };
6526 // c:Src/exec.c:2640 / getvalue(…, 1) — a subscript assignment to
6527 // a NONEXISTENT parameter auto-creates it. getindex/fetchvalue
6528 // with the create flag calls createparam(name, PM_ARRAY) so the
6529 // splice has an array to write into; `unset u; u[1,2]=(a z)`
6530 // then yields the array (a z). Without this, setarrvalue saw
6531 // v.pm == None and silently stored nothing. The single-index
6532 // scalar-value path (SET_ASSOC/SET_ARRAY_AT) already vivifies;
6533 // this brings the range/array-value path to parity.
6534 let taken = taken.or_else(|| {
6535 crate::ported::params::createparam(&name, crate::ported::zsh_h::PM_ARRAY as i32);
6536 crate::ported::params::paramtab()
6537 .write()
6538 .ok()
6539 .and_then(|mut t| t.remove(&name))
6540 });
6541 // c:Src/params.c:2748+ — PM_SCALAR with subscript range
6542 // SPLICES the value into the scalar's char string. Bug
6543 // #589: zshrs's slice handler always called setarrvalue,
6544 // erroring "attempt to assign array value to non-array"
6545 // for `a=hello; a[2,3]=XYZ`. Detect PM_SCALAR and route
6546 // through assignstrvalue (which does scalar splice via
6547 // the PM_SCALAR arm at params.rs:3709-3789).
6548 let is_scalar = taken.as_ref().map_or(false, |pm| {
6549 crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
6550 == crate::ported::zsh_h::PM_SCALAR
6551 });
6552 let mut v = crate::ported::zsh_h::value {
6553 pm: taken,
6554 arr: Vec::new(),
6555 scanflags: 0,
6556 valflags: 0,
6557 start,
6558 end,
6559 };
6560 if is_scalar {
6561 // Scalar splice — concat values, route through
6562 // assignstrvalue which dispatches by PM_TYPE.
6563 // start_translate returns 1-based positions; assignstrvalue's
6564 // PM_SCALAR arm at params.rs:3735+ expects 0-based start
6565 // (chars before start are kept) and 0-based end-exclusive
6566 // (chars from end are kept). Convert: start-=1.
6567 if v.start > 0 {
6568 v.start -= 1;
6569 }
6570 let val: String = values.join("");
6571 crate::ported::params::assignstrvalue(Some(&mut v), Some(val), 0);
6572 } else {
6573 crate::ported::params::setarrvalue(&mut v, values);
6574 }
6575 // Write the mutated Param back to paramtab — setarrvalue
6576 // mutated v.pm in-place; the prior `tab.remove(&name)` at
6577 // the top of this handler took ownership, so we re-insert
6578 // here. setarrvalue + this re-insert IS the canonical
6579 // store (Src/params.c:2895). No further mirror needed.
6580 if let Some(pm) = v.pm {
6581 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
6582 tab.insert(name, pm);
6583 }
6584 }
6585 });
6586 Value::Status(0)
6587 });
6588
6589 // BUILTIN_CONCAT_SPLICE — word-segment concat for an expansion whose
6590 // ARRAY shape survives into the word (`${arr[@]}`, `$@`, `${(@)a}`,
6591 // `${=v}`, slices). c:Src/subst.c:4245 `if (isarr)` gates the two
6592 // emit shapes and c:1663 `int plan9 = isset(RCEXPANDPARAM);` picks
6593 // between them at RUNTIME — so the option, not the compile-time
6594 // segment shape, decides splice-vs-cross-product here.
6595 vm.register_builtin(BUILTIN_CONCAT_SPLICE, |vm, _argc| {
6596 let rhs = vm.pop();
6597 let lhs = vm.pop();
6598 if plan9_active() {
6599 return concat_plan9(lhs, rhs);
6600 }
6601 concat_splice(lhs, rhs)
6602 });
6603
6604 // BUILTIN_CONCAT_DISTRIBUTE — word-segment concat. With
6605 // rcexpandparam (zsh option), distributes element-wise (cartesian
6606 // product). Default mode: joins arrays with IFS first char to a
6607 // single scalar before concat, matching zsh's default unquoted
6608 // and DQ semantics. Direct port of Src/subst.c sepjoin path
6609 // (line ~1813) which gates element-vs-join on the rc_expand_param
6610 // option, defaulting to join.
6611 // BUILTIN_CONCAT_DISTRIBUTE_FORCED — same shape as
6612 // CONCAT_DISTRIBUTE, but always cartesian-distributes when one
6613 // side is Array. Used for compile-time-detected explicit
6614 // distribution forms (`${^arr}` etc.) where the source flag
6615 // overrides the rcexpandparam option default.
6616 // `${^arr}` — RC_EXPAND_PARAM forced on by the flag. concat_plan9 carries
6617 // both halves of C's plan9 block: the c:4316-4350 cartesian emit AND the
6618 // c:4362 `uremnode` word deletion for an empty array. The DISTRIBUTE_FORCED
6619 // handler below cannot be reused: it is shared with `${(@)a}` / `${(f)v}` /
6620 // `${a[@]}`, which KEEP the word on empty (`x${(@)a}y` → `xy`).
6621 vm.register_builtin(BUILTIN_CONCAT_PLAN9, |vm, _argc| {
6622 let rhs = vm.pop();
6623 let lhs = vm.pop();
6624 concat_plan9(lhs, rhs)
6625 });
6626
6627 // `${^^arr}` — RC_EXPAND_PARAM forced OFF (c:2553-2555 `plan9 = 0`). Every
6628 // other concat builtin re-checks plan9_active(), which is the OPTION, so
6629 // under `setopt rcexpandparam` they cross-product regardless of the flag.
6630 // Go straight to concat_splice — C's non-plan9 path (c:4366-4437).
6631 vm.register_builtin(BUILTIN_CONCAT_SPLICE_NOPLAN9, |vm, _argc| {
6632 let rhs = vm.pop();
6633 let lhs = vm.pop();
6634 concat_splice(lhs, rhs)
6635 });
6636
6637 vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE_FORCED, |vm, _argc| {
6638 let rhs = vm.pop();
6639 let lhs = vm.pop();
6640 match (lhs, rhs) {
6641 (Value::Array(la), Value::Array(ra)) => {
6642 if ra.is_empty() {
6643 return Value::Array(la);
6644 }
6645 if la.is_empty() {
6646 return Value::Array(ra);
6647 }
6648 let mut out = Vec::with_capacity(la.len() * ra.len());
6649 for a in &la {
6650 let a_s = a.as_str_cow();
6651 for b in &ra {
6652 let b_s = b.as_str_cow();
6653 let mut s = String::with_capacity(a_s.len() + b_s.len());
6654 s.push_str(&a_s);
6655 s.push_str(&b_s);
6656 out.push(Value::str(s));
6657 }
6658 }
6659 Value::Array(out)
6660 }
6661 (Value::Array(la), rhs_scalar) => {
6662 // An EMPTY array contributes nothing to a concatenated
6663 // word — the surrounding scalar text survives. zsh:
6664 // `x${^a}y` (a=()) / `x${(P)scalar-empty}y` → "xy", NOT
6665 // a dropped word. Without this, a `(P)` indirect to an
6666 // unset/empty scalar (which nodes_to_value collapses to
6667 // Value::Array([]) for standalone-removal semantics)
6668 // cartesian-dropped the whole word — p10k's
6669 // `typeset -g _$2=${(P)2}` then arrived as a bare
6670 // `typeset` and dumped every parameter (~217× → 19 MB
6671 // terminal flood → startup hang).
6672 if la.is_empty() {
6673 return rhs_scalar;
6674 }
6675 let r = rhs_scalar.as_str_cow();
6676 let out: Vec<Value> = la
6677 .into_iter()
6678 .map(|a| {
6679 let a_s = a.as_str_cow();
6680 let mut s = String::with_capacity(a_s.len() + r.len());
6681 s.push_str(&a_s);
6682 s.push_str(&r);
6683 Value::str(s)
6684 })
6685 .collect();
6686 Value::Array(out)
6687 }
6688 (lhs_scalar, Value::Array(ra)) => {
6689 // Symmetric empty-array-contributes-nothing rule; see
6690 // the (Array, scalar) arm above.
6691 if ra.is_empty() {
6692 return lhs_scalar;
6693 }
6694 let l = lhs_scalar.as_str_cow();
6695 let out: Vec<Value> = ra
6696 .into_iter()
6697 .map(|b| {
6698 let b_s = b.as_str_cow();
6699 let mut s = String::with_capacity(l.len() + b_s.len());
6700 s.push_str(&l);
6701 s.push_str(&b_s);
6702 Value::str(s)
6703 })
6704 .collect();
6705 Value::Array(out)
6706 }
6707 (lhs_s, rhs_s) => {
6708 let l = lhs_s.as_str_cow();
6709 let r = rhs_s.as_str_cow();
6710 let mut s = String::with_capacity(l.len() + r.len());
6711 s.push_str(&l);
6712 s.push_str(&r);
6713 Value::str(s)
6714 }
6715 }
6716 });
6717
6718 vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE, |vm, argc| {
6719 let rhs = vm.pop();
6720 let lhs = vm.pop();
6721 // c:Src/subst.c:4245 `if (isarr)` — an unquoted array embedded
6722 // in a word ALWAYS emits one word per element, never a scalar
6723 // join. The shape (splice vs plan9 cross-product) is chosen at
6724 // RUNTIME by c:1663 `int plan9 = isset(RCEXPANDPARAM);`, exactly
6725 // as BUILTIN_CONCAT_SPLICE does. The only extra case DISTRIBUTE
6726 // handles is the DQ context: the compiler emits
6727 // CallBuiltin(BUILTIN_CONCAT_DISTRIBUTE, 1) when the parent word
6728 // is DQ-wrapped (compile_zsh.rs parent_is_dq), and inside DQ
6729 // `"pre${arr}post"` joins via $IFS[0] to a single scalar
6730 // regardless of the option (c:Src/subst.c:1650-1656 isarr
6731 // comment). The default UNQUOTED path emits argc=2 (lhs + rhs).
6732 // Bug #246 in docs/BUGS.md.
6733 if argc == 1 {
6734 // DQ context: join any Array side to scalar via sepjoin's
6735 // IFS default. c:Src/utils.c:3936-3945 — set-but-empty IFS
6736 // joins with "" (`IFS=""; echo "x$*y"` → `xabcy`); only
6737 // unset / space-leading IFS yields " ".
6738 let join_arr = |arr: Vec<Value>| -> String {
6739 let strs: Vec<String> = arr.iter().map(|v| v.as_str_cow().into_owned()).collect();
6740 crate::ported::utils::sepjoin(&strs, None)
6741 };
6742 let l = match lhs {
6743 Value::Array(a) => join_arr(a),
6744 other => other.as_str_cow().into_owned(),
6745 };
6746 let r = match rhs {
6747 Value::Array(a) => join_arr(a),
6748 other => other.as_str_cow().into_owned(),
6749 };
6750 let mut s = String::with_capacity(l.len() + r.len());
6751 s.push_str(&l);
6752 s.push_str(&r);
6753 return Value::str(s);
6754 }
6755 // Unquoted plain `${arr}`: same runtime dispatch as
6756 // BUILTIN_CONCAT_SPLICE — c:4245 `if (isarr)` always distributes
6757 // one word per element; c:1663 picks splice (default, first/last
6758 // sticking, c:4366-4437) vs plan9 cross-product (c:4316-4365).
6759 // concat_splice / concat_plan9 both honor EMPTY_EXPANSION_IS_SCALAR
6760 // so the p10k `${(P)2}` empty-array word-removal semantics survive.
6761 if plan9_active() {
6762 return concat_plan9(lhs, rhs);
6763 }
6764 concat_splice(lhs, rhs)
6765 });
6766
6767 // `[[ a -ef b ]]` — same-inode test. Resolves both paths via fs::metadata
6768 // (follows symlinks the way zsh's -ef does) and compares (dev, inode).
6769 // Returns false on any I/O error (path missing, permission denied, etc.).
6770 vm.register_builtin(BUILTIN_SAME_FILE, |vm, _argc| {
6771 let b = vm.pop().to_str();
6772 let a = vm.pop().to_str();
6773 let same = match (fs::metadata(&a), fs::metadata(&b)) {
6774 (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
6775 _ => false,
6776 };
6777 Value::Bool(same)
6778 });
6779
6780 // `[[ -c path ]]` — character device.
6781 vm.register_builtin(BUILTIN_IS_CHARDEV, |vm, _argc| {
6782 let path = vm.pop().to_str();
6783 let result = fs::metadata(&path)
6784 .map(|m| m.file_type().is_char_device())
6785 .unwrap_or(false);
6786 Value::Bool(result)
6787 });
6788 // `[[ -b path ]]` — block device.
6789 vm.register_builtin(BUILTIN_IS_BLOCKDEV, |vm, _argc| {
6790 let path = vm.pop().to_str();
6791 let result = fs::metadata(&path)
6792 .map(|m| m.file_type().is_block_device())
6793 .unwrap_or(false);
6794 Value::Bool(result)
6795 });
6796 // `[[ -p path ]]` — FIFO (named pipe).
6797 vm.register_builtin(BUILTIN_IS_FIFO, |vm, _argc| {
6798 let path = vm.pop().to_str();
6799 let result = fs::metadata(&path)
6800 .map(|m| m.file_type().is_fifo())
6801 .unwrap_or(false);
6802 Value::Bool(result)
6803 });
6804 // `[[ -S path ]]` — socket.
6805 vm.register_builtin(BUILTIN_IS_SOCKET, |vm, _argc| {
6806 let path = vm.pop().to_str();
6807 let result = fs::symlink_metadata(&path)
6808 .map(|m| m.file_type().is_socket())
6809 .unwrap_or(false);
6810 Value::Bool(result)
6811 });
6812
6813 // `[[ -k path ]]` / `-u` / `-g` — sticky / setuid / setgid bit.
6814 vm.register_builtin(BUILTIN_HAS_STICKY, |vm, _argc| {
6815 let path = vm.pop().to_str();
6816 let result = fs::metadata(&path)
6817 .map(|m| m.permissions().mode() & libc::S_ISVTX as u32 != 0)
6818 .unwrap_or(false);
6819 Value::Bool(result)
6820 });
6821 vm.register_builtin(BUILTIN_HAS_SETUID, |vm, _argc| {
6822 let path = vm.pop().to_str();
6823 let result = fs::metadata(&path)
6824 .map(|m| m.permissions().mode() & libc::S_ISUID as u32 != 0)
6825 .unwrap_or(false);
6826 Value::Bool(result)
6827 });
6828 vm.register_builtin(BUILTIN_HAS_SETGID, |vm, _argc| {
6829 let path = vm.pop().to_str();
6830 let result = fs::metadata(&path)
6831 .map(|m| m.permissions().mode() & libc::S_ISGID as u32 != 0)
6832 .unwrap_or(false);
6833 Value::Bool(result)
6834 });
6835 vm.register_builtin(BUILTIN_OWNED_BY_USER, |vm, _argc| {
6836 let path = vm.pop().to_str();
6837 let euid = unsafe { libc::geteuid() };
6838 let result = fs::metadata(&path)
6839 .map(|m| m.uid() == euid)
6840 .unwrap_or(false);
6841 Value::Bool(result)
6842 });
6843 vm.register_builtin(BUILTIN_OWNED_BY_GROUP, |vm, _argc| {
6844 let path = vm.pop().to_str();
6845 let egid = unsafe { libc::getegid() };
6846 let result = fs::metadata(&path)
6847 .map(|m| m.gid() == egid)
6848 .unwrap_or(false);
6849 Value::Bool(result)
6850 });
6851
6852 // `[[ -N path ]]` — file's access time is NOT newer than its
6853 // modification time (zsh man: "true if file exists and its
6854 // access time is not newer than its modification time"). Used
6855 // by zsh's mailbox-watching code. The semantic is `atime <=
6856 // mtime` (equivalent to `mtime >= atime`) — equal counts as
6857 // true, which a strict `mtime > atime` check missed for newly
6858 // created files where both stamps are identical.
6859 vm.register_builtin(BUILTIN_FILE_MODIFIED_SINCE_ACCESS, |vm, _argc| {
6860 let path = vm.pop().to_str();
6861 let result = fs::metadata(&path)
6862 .map(|m| m.atime() <= m.mtime())
6863 .unwrap_or(false);
6864 Value::Bool(result)
6865 });
6866
6867 // `[[ a -nt b ]]` — true if `a`'s mtime is strictly later than `b`'s.
6868 // BOTH files must exist; if either is missing the result is false.
6869 // (Earlier behavior was bash's "missing == infinitely-old"; zsh
6870 // strictly requires both files to exist.)
6871 vm.register_builtin(BUILTIN_FILE_NEWER, |vm, _argc| {
6872 let b = vm.pop().to_str();
6873 let a = vm.pop().to_str();
6874 // Use SystemTime modified() for nanosecond precision —
6875 // MetadataExt::mtime() returns seconds only, so two files
6876 // touched within the same second compared equal even when
6877 // 500ms apart. zsh tracks ns and uses `>=` for ties (touching
6878 // a then b in quick succession should still report b newer).
6879 let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
6880 let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
6881 let result = match (ta, tb) {
6882 (Some(ta), Some(tb)) => ta > tb,
6883 _ => false,
6884 };
6885 Value::Bool(result)
6886 });
6887
6888 // `[[ a -ot b ]]` — mirror of -nt. Same both-must-exist contract.
6889 vm.register_builtin(BUILTIN_FILE_OLDER, |vm, _argc| {
6890 let b = vm.pop().to_str();
6891 let a = vm.pop().to_str();
6892 let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
6893 let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
6894 let result = match (ta, tb) {
6895 (Some(ta), Some(tb)) => ta < tb,
6896 _ => false,
6897 };
6898 Value::Bool(result)
6899 });
6900
6901 // `set -e` / `setopt errexit` post-command check. Compiler emits
6902 // this after each top-level command's SetStatus (skipped inside
6903 // conditionals/pipelines/&&||/`!`). If errexit is on AND the last
6904 // command exited non-zero AND it's not a `return` from a function,
6905 // exit the shell with that status.
6906 // `set -x` / `setopt xtrace` — print each command before it runs.
6907 // The compiler emits this BEFORE the actual builtin/external call
6908 // with the command's literal text as a single string arg. We
6909 // print to stderr if xtrace is on. Honors `$PS4` (default `+ `).
6910 //
6911 // ── XTRACE flow control ────────────────────────────────────────
6912 // Mirror of C zsh's `doneps4` flag in execcmd_exec (Src/exec.c).
6913 // When an assignment trace fires (XTRACE_ASSIGN), it emits PS4
6914 // and sets this flag so the subsequent XTRACE_ARGS skips its own
6915 // PS4 emission — the assignment + command end up on the SAME
6916 // line: `<PS4>a=1 echo hello\n`. XTRACE_ARGS / XTRACE_NEWLINE
6917 // reset the flag after emitting the trailing `\n`.
6918 vm.register_builtin(BUILTIN_XTRACE_IS_ON, |_vm, _argc| {
6919 // Push live xtrace state. Caller pairs this with JumpIfFalse
6920 // to skip the trace-string-building block when xtrace is off,
6921 // avoiding side-effectful operand re-evaluation. Bug #159 in
6922 // docs/BUGS.md.
6923 let on = with_executor(|_| opt_state_get("xtrace").unwrap_or(false));
6924 Value::Int(if on { 1 } else { 0 })
6925 });
6926
6927 vm.register_builtin(BUILTIN_XTRACE_LINE, |vm, _argc| {
6928 let cmd_text = vm.pop().to_str();
6929 // Sync exec.last_status with the live vm.last_status BEFORE
6930 // the next command runs. Direct port of the zsh exec.c
6931 // contract — `$?` reads the exit status of the *most recent*
6932 // command. XTRACE_LINE is emitted by the compiler BEFORE
6933 // every simple command, so it's the natural sync point.
6934 let live = vm.last_status;
6935 with_executor(|exec| {
6936 exec.set_last_status(live);
6937 });
6938 // C zsh emits xtrace for `(( … ))` / `[[ … ]]` / `case` /
6939 // `if/while/until/for/repeat` head expressions via
6940 // `printprompt4(); fprintf(xtrerr, "%s\n", expr)` at
6941 // Src/exec.c:5240 (math), c:5286 (cond), c:4117 (for), etc.
6942 // The compiler emits BUILTIN_XTRACE_LINE only at those
6943 // construct boundaries (compile_arith / compile_cond /
6944 // compile_if / compile_while / compile_for / compile_case);
6945 // simple commands route to BUILTIN_XTRACE_ARGS instead. So
6946 // this handler always emits when xtrace is on — no prefix-
6947 // string heuristic.
6948 let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
6949 if on {
6950 let already = XTRACE_DONE_PS4.with(|f| f.get());
6951 if !already {
6952 printprompt4();
6953 }
6954 // c:exec.c:5240/5286 — `fprintf(xtrerr, "%s\n", expr)`. Buffer
6955 // the line + newline, flush once (single write).
6956 xtrerr_fputs(&cmd_text);
6957 xtrerr_fputs("\n");
6958 xtrerr_flush();
6959 XTRACE_DONE_PS4.with(|f| f.set(false));
6960 }
6961 Value::Status(0)
6962 });
6963
6964 // BUILTIN_XTRACE_ARRAY_LINE — xtrace line for an `arr=(...)` / `arr+=(...)`
6965 // assignment. Stack on entry: [array, prefix] (argc = 2); pops prefix
6966 // ("name=( " / "name+=( "), then the whole assembled Value::Array. Direct
6967 // port of c:Src/exec.c::addvars:2624-2632, guarded on the live xtrace
6968 // state like C's `if (xtr)`: prints `prefix qz(e0) qz(e1) … ) ` with each
6969 // element shell-quoted (quotedzputs). Replaces the former one-VM-slot-per-
6970 // element trace, which overflowed next_slot (u16) on large literals.
6971 vm.register_builtin(BUILTIN_XTRACE_ARRAY_LINE, |vm, _argc| {
6972 let prefix = vm.pop().to_str();
6973 let arr = vm.pop();
6974 let live = vm.last_status;
6975 with_executor(|exec| {
6976 exec.set_last_status(live);
6977 });
6978 let on = with_executor(|_exec| opt_state_get("xtrace").unwrap_or(false));
6979 if on {
6980 let already = XTRACE_DONE_PS4.with(|f| f.get());
6981 if !already {
6982 printprompt4();
6983 }
6984 let mut line = String::with_capacity(prefix.len() + 16);
6985 line.push_str(&prefix);
6986 if let Value::Array(items) = arr {
6987 for it in items {
6988 line.push_str(&crate::ported::utils::quotedzputs(&it.to_str()));
6989 line.push(' ');
6990 }
6991 }
6992 line.push_str(") ");
6993 line.push('\n');
6994 xtrerr_fputs(&line);
6995 xtrerr_flush();
6996 XTRACE_DONE_PS4.with(|f| f.set(false));
6997 }
6998 Value::Status(0)
6999 });
7000
7001 // BUILTIN_MAKE_ARRAY_COUNTED — pop a count Int (top), then pop that many
7002 // values below it, and push them as one Value::Array (bottom-of-group
7003 // first). Same result as Op::MakeArray(N) but N comes from the stack as an
7004 // i64, dodging MakeArray's u16 operand cap. The compiler emits this only
7005 // when a literal `arr=(...)` has more than u16::MAX elements.
7006 vm.register_builtin(BUILTIN_MAKE_ARRAY_COUNTED, |vm, _argc| {
7007 let count = vm.pop().to_int().max(0) as usize;
7008 let mut items: Vec<Value> = Vec::with_capacity(count);
7009 for _ in 0..count {
7010 items.push(vm.pop());
7011 }
7012 items.reverse();
7013 Value::Array(items)
7014 });
7015
7016 // Like XTRACE_LINE but reads the top `argc - 1` values from the
7017 // VM stack WITHOUT consuming them (peek), then pops a prefix
7018 // string at the top. Joins prefix + peeked args with spaces using
7019 // zsh's quotedzputs-equivalent quoting. Direct port of
7020 // Src/exec.c:2055-2066 — emit AFTER expansion, with each arg
7021 // shell-quoted, so `for i in a b; echo for $i` traces as
7022 // `echo for a` / `echo for b`, not `echo for $i`.
7023 //
7024 // Stack contract on entry: [arg1, arg2, ..., argN, prefix].
7025 // Pops prefix; peeks argN..arg1 below. argc = N + 1.
7026 vm.register_builtin(BUILTIN_XTRACE_ARGS, |vm, argc| {
7027 let prefix = vm.pop().to_str();
7028 let live = vm.last_status;
7029 with_executor(|exec| {
7030 exec.set_last_status(live);
7031 });
7032 let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
7033 if on {
7034 let n_args = argc.saturating_sub(1) as usize;
7035 let len = vm.stack.len();
7036 // c:Src/exec.c:2055 — argv is the POST-expansion word
7037 // list, so an arg that expanded to multiple words splats
7038 // into multiple trace tokens AND an arg that expanded to
7039 // zero words (empty unquoted `${UNSET}`) emits nothing.
7040 // pop_args (line 6243) already does this splat for the
7041 // real handler; mirror the same Array → splat / empty →
7042 // drop logic here so xtrace renders `echo ${UNSET}` as
7043 // `echo` (zsh) instead of `echo ''` (the previous
7044 // single-arg stringify path returned "" and then
7045 // quotedzputs wrapped it in `''`).
7046 let arg_strs: Vec<String> = if n_args > 0 && len >= n_args {
7047 let mut out = Vec::new();
7048 for v in &vm.stack[len - n_args..] {
7049 match v {
7050 Value::Array(items) => {
7051 for item in items {
7052 out.push(quotedzputs(&item.to_str()));
7053 }
7054 }
7055 other => out.push(quotedzputs(&other.to_str())),
7056 }
7057 }
7058 out
7059 } else {
7060 Vec::new()
7061 };
7062 // Builtins dispatch through `execbuiltin` (Src/builtin.c:442)
7063 // which emits its own PS4 + name + args xtrace. To avoid
7064 // double-emission, skip our emission here when the first
7065 // arg is a known builtin with a registered HandlerFunc —
7066 // those go through execbuiltin and will trace themselves.
7067 // Externals + builtins-not-yet-routed-through-execbuiltin
7068 // keep our emission as a stand-in.
7069 let goes_through_execbuiltin = crate::ported::builtin::BUILTINS
7070 .iter()
7071 .any(|b| b.node.nam == prefix && b.handlerfunc.is_some());
7072 if !goes_through_execbuiltin {
7073 let line = if arg_strs.is_empty() {
7074 prefix
7075 } else {
7076 format!("{} {}", prefix, arg_strs.join(" "))
7077 };
7078 // Mirrors Src/exec.c:2055 xtrace emission. C does:
7079 // if (!doneps4) printprompt4();
7080 // ... emit args + spaces ...
7081 // fputc('\n', xtrerr); fflush(xtrerr);
7082 // printprompt4 + the args + `\n` all land in the xtrerr
7083 // buffer; the single fflush below writes the whole line in
7084 // one syscall so concurrent pipeline stages never
7085 // interleave (c:makecline:2122-2123).
7086 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
7087 if !already_ps4 {
7088 printprompt4();
7089 }
7090 xtrerr_fputs(&line);
7091 xtrerr_fputs("\n"); // c:2122 fputc('\n', xtrerr)
7092 xtrerr_flush(); // c:2123 fflush(xtrerr)
7093 }
7094 XTRACE_DONE_PS4.with(|f| f.set(false));
7095 }
7096 Value::Status(0)
7097 });
7098
7099 // BUILTIN_XTRACE_ASSIGN — direct port of the per-assignment
7100 // trace block at Src/exec.c:2517-2582. C body excerpt:
7101 // xtr = isset(XTRACE);
7102 // if (xtr) { printprompt4(); doneps4 = 1; }
7103 // while (assign) {
7104 // if (xtr) fprintf(xtrerr, "%s+=" or "%s=", name);
7105 // ... eval value into `val` ...
7106 // if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
7107 // ...
7108 // }
7109 //
7110 // Stack on entry: [..., name, value]. PEEKS both (they're left
7111 // on stack for SET_VAR to pop). Emits `name=<quoted-val> ` with
7112 // no newline; trailing `\n` comes from XTRACE_ARGS (cmd path)
7113 // or XTRACE_NEWLINE (assignment-only path).
7114 vm.register_builtin(BUILTIN_XTRACE_ASSIGN, |vm, _argc| {
7115 let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
7116 if on {
7117 // PEEK [..., name, value] — argc==2 by contract.
7118 let len = vm.stack.len();
7119 if len >= 2 {
7120 let name = vm.stack[len - 2].to_str();
7121 let value = vm.stack[len - 1].to_str();
7122 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
7123 if !already_ps4 {
7124 printprompt4();
7125 XTRACE_DONE_PS4.with(|f| f.set(true));
7126 }
7127 // C: `fprintf(xtrerr, "%s=", name)` then `quotedzputs
7128 // (val); fputc(' ', xtrerr);`. Append to the xtrerr buffer
7129 // (no newline / no flush — the line continues with the
7130 // command via XTRACE_ARGS, or ends at XTRACE_NEWLINE).
7131 xtrerr_fputs(&format!("{}={} ", name, quotedzputs(&value)));
7132 }
7133 }
7134 Value::Status(0)
7135 });
7136
7137 // BUILTIN_XTRACE_NEWLINE — emit trailing `\n` + flush iff a
7138 // prior XTRACE_ASSIGN this line already emitted PS4. Mirrors
7139 // C's `fputc('\n', xtrerr); fflush(xtrerr);` at exec.c:3398
7140 // (the assignment-only path through execcmd_exec).
7141 vm.register_builtin(BUILTIN_XTRACE_NEWLINE, |_vm, _argc| {
7142 let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
7143 if on {
7144 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
7145 if already_ps4 {
7146 xtrerr_fputs("\n"); // c:3398 fputc('\n', xtrerr)
7147 xtrerr_flush(); // c:3398 fflush(xtrerr)
7148 XTRACE_DONE_PS4.with(|f| f.set(false));
7149 }
7150 }
7151 Value::Status(0)
7152 });
7153
7154 // c:Src/exec.c WC_TRYBLOCK — post-always re-jump probes. Each
7155 // returns 1 + consumes the atomic when the corresponding
7156 // escape flag is set; the try-block compile pairs each with
7157 // a JumpIfFalse + Jump → outer scope's return / break /
7158 // continue patches.
7159 vm.register_builtin(BUILTIN_RETFLAG_CHECK, |_vm, _argc| {
7160 use std::sync::atomic::Ordering;
7161 let r = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
7162 if r != 0 {
7163 // Don't clear here — doshfunc owns the clear at c:6047
7164 // when the function unwinds. Leaving it set propagates
7165 // through nested `eval`/`source` callers correctly.
7166 Value::Int(1)
7167 } else {
7168 Value::Int(0)
7169 }
7170 });
7171 vm.register_builtin(BUILTIN_BREAKS_CHECK, |_vm, _argc| {
7172 use std::sync::atomic::Ordering;
7173 let b = crate::ported::builtin::BREAKS.load(Ordering::Relaxed);
7174 let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
7175 // `break` sets BREAKS but NOT CONTFLAG; `continue` sets both.
7176 // Filter out the continue path here so the two checks are
7177 // mutually exclusive.
7178 if b != 0 && c == 0 {
7179 // Consume BREAKS so the outer loop's break_patches
7180 // landing doesn't double-decrement.
7181 crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
7182 Value::Int(1)
7183 } else {
7184 Value::Int(0)
7185 }
7186 });
7187 vm.register_builtin(BUILTIN_CONTFLAG_CHECK, |_vm, _argc| {
7188 use std::sync::atomic::Ordering;
7189 let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
7190 if c != 0 {
7191 crate::ported::builtin::CONTFLAG.store(0, Ordering::Relaxed);
7192 crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
7193 Value::Int(1)
7194 } else {
7195 Value::Int(0)
7196 }
7197 });
7198 vm.register_builtin(BUILTIN_NOEXEC_CHECK, |_vm, _argc| {
7199 // c:Src/exec.c:1390 — `set -n` / `noexec` option: parse but
7200 // don't execute. Returns Int(1) when noexec is set so the
7201 // emit-side JumpIfTrue skips the statement body.
7202 if opt_state_get("noexec").unwrap_or(false) {
7203 return Value::Int(1);
7204 }
7205 // c:Src/exec.c:1390 — execlist's list-loop gate:
7206 // `while (wc_code(code) == WC_LIST && !breaks && !retflag
7207 // && !errflag)`
7208 // — once errflag is set, the NEXT sublist never starts, so
7209 // lastval survives untouched to the shell exit. Without this
7210 // prologue gate the follow-up statement RAN, its dispatch
7211 // saw errflag, returned 1, and SetStatus clobbered lastval —
7212 // `[[ x == [a- ]]; print rc=$?` exited 1 instead of zsh's 2
7213 // (the cond syntax error set lastval=2 per exec.c:5216-5221).
7214 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
7215 & crate::ported::zsh_h::ERRFLAG_ERROR)
7216 != 0
7217 {
7218 return Value::Int(1);
7219 }
7220 Value::Int(0)
7221 });
7222 vm.register_builtin(BUILTIN_DONETRAP_RESET, |_vm, _argc| {
7223 // c:Src/exec.c:1455 — `donetrap = 0;` at sublist start.
7224 // Reset before each top-level statement so the next
7225 // sublist's ERREXIT_CHECK fires the ZERR trap on its FIRST
7226 // non-zero command. Carries the "already fired" state
7227 // across function-call returns within the SAME outer
7228 // sublist (per C semantics — donetrap is process-global).
7229 // Bug #303 in docs/BUGS.md.
7230 crate::ported::exec::DONETRAP.store(0, std::sync::atomic::Ordering::Relaxed);
7231 Value::Status(0)
7232 });
7233
7234 // `[[ -z X ]]` / `[[ -n X ]]` — pop one Value, route through
7235 // canonical `src/ported/cond.rs::evalcond` so the actual
7236 // empty/non-empty test reuses the C-port at `cond.rs:270-271`
7237 // (`'n' => !arg.is_empty()`, `'z' => arg.is_empty()`).
7238 //
7239 // The Array→args conversion lives at the bridge because cond.rs
7240 // expects `&[&str]` (C `cond_str` signature equivalent). For
7241 // `"${arr[@]}"` in DQ context the splice yields `Value::Array`
7242 // — an empty array still expands to one implicit empty word
7243 // (per zsh's "${arr[@]}" splat preserving at least one slot
7244 // in cond context), so:
7245 // - Array(0) → ["-z", ""] → evalcond → 0 (true)
7246 // - Array(1) → ["-z", word] → evalcond → 0/1
7247 // - Array(2+) → ["-z", w1, w2, ...] → evalcond → 2 (parse
7248 // error: too many ops)
7249 // → coerced to false
7250 // - Str(s) → ["-z", s] → evalcond → 0/1
7251 //
7252 // Bug #185 in docs/BUGS.md.
7253 fn run_cond_str_empty(v: Value, op: &str) -> Value {
7254 let words: Vec<String> = match v {
7255 Value::Array(arr) => arr.into_iter().map(|x| x.to_str()).collect(),
7256 Value::Str(s) => vec![s.to_string()],
7257 other => vec![other.to_str()],
7258 };
7259 let mut args: Vec<&str> = vec![op];
7260 if words.is_empty() {
7261 args.push("");
7262 } else {
7263 args.extend(words.iter().map(|s| s.as_str()));
7264 }
7265 let opts: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
7266 let vars: std::collections::HashMap<String, String> = std::collections::HashMap::new();
7267 // c:Src/cond.c:62-66 — `evalcond` returns 0=true, 1=false,
7268 // 2=syntax-error. Coerce error to false (observable behavior
7269 // in zsh: `[[ -z a b ]]` errors and the test as a whole
7270 // returns non-zero).
7271 // `[[ ]]` dispatch — C's `evalcond(state, NULL)` calling convention.
7272 // `None` for from_test → mathevali integer-compare coercion path.
7273 let ret = crate::ported::cond::evalcond(&args, &opts, &vars, false, None);
7274 Value::Int(if ret == 0 { 1 } else { 0 })
7275 }
7276 vm.register_builtin(BUILTIN_COND_STR_EMPTY, |vm, _argc| {
7277 let v = vm.pop();
7278 run_cond_str_empty(v, "-z")
7279 });
7280 vm.register_builtin(BUILTIN_COND_STR_NONEMPTY, |vm, _argc| {
7281 let v = vm.pop();
7282 run_cond_str_empty(v, "-n")
7283 });
7284
7285 // `exec N<<<"str"` — herestring redirect to explicit fd, applied
7286 // permanently. Direct port of `Src/exec.c:4655 getherestr` +
7287 // `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)` at c:3766-
7288 // 3780 for the nullexec=1 bare-exec-redir path. Bug #205 in
7289 // docs/BUGS.md.
7290 vm.register_builtin(BUILTIN_EXEC_HERESTR_FD, |vm, _argc| {
7291 let fd = vm.pop().to_int() as i32;
7292 let content = vm.pop().to_str();
7293 // c:4671-4672 — append `\n` for "real" herestrings (not
7294 // heredoc-derived). zshrs's bare-exec path only fires for
7295 // the `<<<` syntax (REDIR_HERESTR), so always append.
7296 let body = format!("{}\n", content);
7297 // c:4673-4679 — gettempfile → write_loop → close → reopen
7298 // read-only → unlink. Rust equivalent via tempfile crate or
7299 // explicit O_TMPFILE; use mkstemp + unlink-immediately to
7300 // mirror C exactly.
7301 use std::ffi::CString;
7302 let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
7303 let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
7304 if write_fd < 0 {
7305 crate::ported::utils::zwarn(&format!(
7306 "can't create temp file for here document: {}",
7307 std::io::Error::last_os_error()
7308 ));
7309 return Value::Status(1);
7310 }
7311 // c:4675 — write_loop(fd, t, len)
7312 let bytes = body.as_bytes();
7313 let mut off = 0;
7314 while off < bytes.len() {
7315 let n = unsafe {
7316 libc::write(
7317 write_fd,
7318 bytes[off..].as_ptr() as *const libc::c_void,
7319 bytes.len() - off,
7320 )
7321 };
7322 if n <= 0 {
7323 unsafe { libc::close(write_fd) };
7324 return Value::Status(1);
7325 }
7326 off += n as usize;
7327 }
7328 unsafe { libc::close(write_fd) }; // c:4676
7329 // Path null-terminated by mkstemp; reopen for reading.
7330 let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
7331 // c:4678 — unlink immediately so the file disappears on
7332 // close, leaving only the fd reference.
7333 unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
7334 if read_fd < 0 {
7335 return Value::Status(1);
7336 }
7337 // c:3779 addfd → dup2 to target fd, close intermediate.
7338 let r = unsafe { libc::dup2(read_fd, fd) };
7339 unsafe { libc::close(read_fd) };
7340 if r < 0 {
7341 return Value::Status(1);
7342 }
7343 Value::Status(0)
7344 });
7345 // c:Src/exec.c:2418 + addfd splice — MULTIOS fan-out. Stack
7346 // layout pushed by compile_zsh's coalescing pass:
7347 // [target_1, op_byte_1, target_2, op_byte_2, …, target_N,
7348 // op_byte_N, fd]
7349 // argc = 2N + 1. Pops, opens every target, sets up a pipe +
7350 // splitter thread that reads pipe → writes every chunk to
7351 // every opened target, dup2's pipe-write-end onto fd. The
7352 // splitter is closed + joined by host_redirect_scope_end.
7353 // Bug #36 in docs/BUGS.md.
7354 vm.register_builtin(BUILTIN_MULTIOS_REDIRECT, |vm, argc| {
7355 if argc < 3 || argc % 2 == 0 {
7356 // Bad shape — bail.
7357 return Value::Status(1);
7358 }
7359 // Pop fd first (top of stack).
7360 let fd = vm.pop().to_int() as i32;
7361 // Then pop (op, target) pairs in reverse compile order. Keep
7362 // targets as Values — a glob-bearing target arrives as a
7363 // Value::Array of matches.
7364 let n_targets = ((argc - 1) / 2) as usize;
7365 let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_targets);
7366 for _ in 0..n_targets {
7367 let op_byte = vm.pop().to_int() as u8;
7368 let target = vm.pop();
7369 pairs.push((op_byte, target));
7370 }
7371 // Restore compile order (target_1 first).
7372 pairs.reverse();
7373
7374 // c:Src/glob.c:2195-2203 xpandredir — "Loop over matches,
7375 // duplicating the redirection for each file found": a glob
7376 // target with N matches becomes N members of the same multio
7377 // (`echo hi > *.txt` with two matches writes both files).
7378 let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
7379 for (op_byte, target) in pairs {
7380 match target {
7381 Value::Array(items) => {
7382 for item in items {
7383 entries.push((op_byte, item.to_str()));
7384 }
7385 }
7386 other => entries.push((op_byte, other.to_str())),
7387 }
7388 }
7389 if entries.is_empty() {
7390 return Value::Status(1);
7391 }
7392
7393 // c:Src/exec.c:2418 — `else if (!mfds[fd1] || unset(MULTIOS))`:
7394 // with MULTIOS unset every redirect takes the REPLACE path in
7395 // script order — each target is still opened (created /
7396 // truncated) and dup2'd over the fd, so the LAST one wins and
7397 // earlier files end up empty (`unsetopt multios; print x > a
7398 // > b` leaves `a` empty, `x` in `b`). host_apply_redirect is
7399 // exactly one replace step, noclobber gate included.
7400 let multios_on = opt_state_get("multios").unwrap_or(true);
7401 if !multios_on {
7402 with_executor(|exec| {
7403 for (op_byte, target) in &entries {
7404 exec.host_apply_redirect(fd as u8, *op_byte, target);
7405 if exec.redirect_failed {
7406 // c:Src/exec.c execerr — abort the remaining
7407 // redirect list on failure.
7408 break;
7409 }
7410 }
7411 });
7412 return Value::Status(0);
7413 }
7414
7415 if entries.len() == 1 {
7416 // Single member after splicing — a plain replace
7417 // (c:2418 new-multio arm). Route through
7418 // host_apply_redirect so the noclobber gate, the
7419 // pipeline-output split partial, and error handling all
7420 // apply exactly as for an un-bagged redirect.
7421 let (op_byte, target) = &entries[0];
7422 with_executor(|exec| {
7423 exec.host_apply_redirect(fd as u8, *op_byte, target);
7424 });
7425 return Value::Status(0);
7426 }
7427
7428 // c:Src/exec.c:3722-3724 — when this command's stdout IS the
7429 // pipeline output, C seeds mfds[1] with the pipe BEFORE
7430 // walking the redirect list, so the pipe is the multio's
7431 // first member (`print x >&1 > f | cat` sends `x` down the
7432 // pipe TWICE: once for the seed, once for the `>&1` dup).
7433 let pipe_seed = fd == 1
7434 && with_executor(|exec| {
7435 exec.pipe_output_scope
7436 .is_some_and(|d| d + 1 == exec.redirect_scope_stack.len())
7437 });
7438
7439 // Save current fd state for scope-end restoration — BEFORE
7440 // the first member's replace dup2 below.
7441 // c:Src/exec.c:2425 — `int fdN = movefd(fd1); save[fd1] = fdN;`. A SAVED
7442 // descriptor is shell state and must live above the script's fd range:
7443 // plain dup() returns the LOWEST free fd, which parked the saved stdout
7444 // on fd 3, so `print -u 3 -r -- X 2>/dev/null` wrote into the shell's own
7445 // saved descriptor and reported success where zsh says `bad file number`.
7446 // F_DUPFD with a floor of 10 is exactly what movefd does.
7447 let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
7448 if saved >= 0 {
7449 with_executor(|exec| {
7450 if let Some(top) = exec.redirect_scope_stack.last_mut() {
7451 top.push((fd, saved));
7452 } else {
7453 unsafe { libc::close(saved) };
7454 }
7455 });
7456 }
7457
7458 // Accumulate member fds in redirect order. c:Src/exec.c:
7459 // 2447-2480 addfd — the FIRST member REPLACES the fd
7460 // (c:2448-2450 `mfds[fd1]->ct=1; mfds[fd1]->fds[0]=fd1;`), so
7461 // a later numeric `>&N` self-dup resolves against the fd's
7462 // value at that point in the sequence: `print x > f >&1`
7463 // writes f TWICE; `print x >&1 > f` writes the ORIGINAL
7464 // stdout + f.
7465 let mut target_fds: Vec<i32> = Vec::with_capacity(entries.len() + 1);
7466 if pipe_seed {
7467 let p = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
7468 if p >= 0 {
7469 target_fds.push(p);
7470 }
7471 }
7472 let noclobber = opt_state_get("noclobber").unwrap_or(false)
7473 || !opt_state_get("clobber").unwrap_or(true);
7474 for (i, (op_byte, target)) in entries.iter().enumerate() {
7475 let open_result: std::io::Result<i32> = match *op_byte {
7476 r::DUP_WRITE | r::DUP_READ => {
7477 // Numeric `>&N` — dup the LIVE fd N (after any
7478 // earlier member's replace).
7479 match target.trim_start_matches('&').parse::<i32>() {
7480 Ok(src) => {
7481 let d = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
7482 if d >= 0 {
7483 Ok(d)
7484 } else {
7485 Err(std::io::Error::last_os_error())
7486 }
7487 }
7488 Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
7489 }
7490 }
7491 r::WRITE => {
7492 // c:Src/exec.c clobber_open — noclobber applies
7493 // to multio file targets too; failure aborts the
7494 // remaining redirect list (execerr), so `setopt
7495 // noclobber; touch a; print x > a > b` errors on
7496 // `a` and never creates `b`.
7497 let target_meta = std::fs::metadata(target).ok();
7498 let target_is_regular_file = target_meta
7499 .as_ref()
7500 .map(|m| m.file_type().is_file())
7501 .unwrap_or(false);
7502 // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY re-uses
7503 // an empty regular file under noclobber (same allowance
7504 // as the single-redirect path).
7505 let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
7506 && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
7507 if noclobber && target_is_regular_file && !clobber_empty_ok {
7508 eprintln!(
7509 "{}:{}: file exists: {}",
7510 shname(),
7511 crate::ported::lex::lineno(),
7512 target
7513 );
7514 for prev in &target_fds {
7515 unsafe {
7516 libc::close(*prev);
7517 }
7518 }
7519 with_executor(|exec| {
7520 exec.redirect_failed = true;
7521 });
7522 // Sink the upcoming command's output (mirrors
7523 // the single-redirect noclobber arm in
7524 // host_apply_redirect).
7525 if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
7526 let new_fd = file.into_raw_fd();
7527 unsafe {
7528 libc::dup2(new_fd, fd);
7529 libc::close(new_fd);
7530 }
7531 }
7532 return Value::Status(1);
7533 }
7534 fs::OpenOptions::new()
7535 .write(true)
7536 .create(true)
7537 .truncate(true)
7538 .open(target)
7539 .map(|f| f.into_raw_fd())
7540 }
7541 r::APPEND => fs::OpenOptions::new()
7542 .write(true)
7543 .create(true)
7544 .append(true)
7545 .open(target)
7546 .map(|f| f.into_raw_fd()),
7547 _ => fs::OpenOptions::new()
7548 .write(true)
7549 .create(true)
7550 .truncate(true)
7551 .open(target)
7552 .map(|f| f.into_raw_fd()),
7553 };
7554 match open_result {
7555 Ok(tfd) => {
7556 if i == 0 && !pipe_seed {
7557 // c:2448-2450 — first member replaces the fd.
7558 unsafe {
7559 libc::dup2(tfd, fd);
7560 }
7561 }
7562 target_fds.push(tfd);
7563 }
7564 Err(e) => {
7565 // c:Src/exec.c:3741 — `zwarn("%e: %s", errno, fname)`:
7566 // zwarning supplies the `name:LINE:` prefix with the
7567 // REAL current lineno; redir_errno_msg builds the `%e`
7568 // errno message (was a hardcoded ErrorKind match that
7569 // showed generic "redirect failed" for EROFS/etc.).
7570 let msg = redir_errno_msg(&e);
7571 crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
7572 // Close already-opened fds to avoid leaks.
7573 for prev in &target_fds {
7574 unsafe {
7575 libc::close(*prev);
7576 }
7577 }
7578 with_executor(|exec| {
7579 exec.redirect_failed = true;
7580 });
7581 return Value::Status(1);
7582 }
7583 }
7584 }
7585
7586 // Create the splitter pipe.
7587 let (read_end, write_end) = match os_pipe::pipe() {
7588 Ok(p) => p,
7589 Err(_) => {
7590 for f in &target_fds {
7591 unsafe {
7592 libc::close(*f);
7593 }
7594 }
7595 return Value::Status(1);
7596 }
7597 };
7598 let pipe_write_raw = AsRawFd::as_raw_fd(&write_end);
7599 // Spawn the splitter thread: read pipe → write every chunk
7600 // to every target fd. Each write inside the thread uses
7601 // libc::write directly on the raw fd (no Rust File ownership
7602 // so the splitter can close after EOF without racing main).
7603 let target_fds_for_thread = target_fds.clone();
7604 let handle = std::thread::spawn(move || {
7605 let mut r = read_end;
7606 let mut buf = [0u8; 8192];
7607 loop {
7608 match std::io::Read::read(&mut r, &mut buf) {
7609 Ok(0) => break,
7610 Ok(n) => {
7611 for &tfd in &target_fds_for_thread {
7612 let mut off = 0;
7613 while off < n {
7614 let w = unsafe {
7615 libc::write(
7616 tfd,
7617 buf[off..n].as_ptr() as *const libc::c_void,
7618 n - off,
7619 )
7620 };
7621 if w <= 0 {
7622 break;
7623 }
7624 off += w as usize;
7625 }
7626 }
7627 }
7628 Err(_) => break,
7629 }
7630 }
7631 // Close every target so file contents flush.
7632 for tfd in target_fds_for_thread {
7633 unsafe {
7634 libc::close(tfd);
7635 }
7636 }
7637 });
7638
7639 // Dup the pipe write-end onto the target fd; close the
7640 // original write_end so EOF arrives when host_redirect_scope_end
7641 // closes our tracked pipe_write_fd.
7642 let write_dup = unsafe { libc::fcntl(pipe_write_raw, libc::F_DUPFD, 10) };
7643 drop(write_end);
7644 if write_dup < 0 {
7645 return Value::Status(1);
7646 }
7647 unsafe {
7648 libc::dup2(write_dup, fd);
7649 libc::close(write_dup);
7650 }
7651 // Track the running splitter so scope-end can drain + join.
7652 // The "write_fd" we store is the user-visible fd (e.g. 1).
7653 // Closing that fd at scope-end isn't quite right; we need a
7654 // way to send EOF. Solution: track the write_dup we just
7655 // closed; instead keep a second dup for the close-on-end.
7656 // Shell-internal bookkeeping fd — above the script's range (movefd).
7657 let close_on_end = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
7658 with_executor(|exec| {
7659 if let Some(top) = exec.multios_scope_stack.last_mut() {
7660 top.push((close_on_end, handle));
7661 } else {
7662 // No scope — leak the dup; thread will keep running
7663 // until process exit. Should not happen because
7664 // host_redirect_scope_begin pushed a frame.
7665 unsafe { libc::close(close_on_end) };
7666 }
7667 });
7668 Value::Status(0)
7669 });
7670 // c:Src/exec.c:2418 input-arm — MULTIOS read fan-in. Stack
7671 // layout pushed by compile_zsh (mirrors the write side):
7672 // [source_1, op_1, source_2, op_2, …, source_N, op_N, fd]
7673 // argc = 2N + 1; op distinguishes file opens (READ) from numeric
7674 // `<&N` dups (DUP_READ); a glob source arrives as Value::Array
7675 // and splices into one member per match (c:Src/glob.c:2195-2203).
7676 // Opens every source, sets up a pipe + producer thread that
7677 // reads each source in order and writes to the pipe write-end,
7678 // then closes its write-end so the consumer gets EOF. dup2 the
7679 // pipe read-end onto fd. Bug #36 input side in docs/BUGS.md.
7680 vm.register_builtin(BUILTIN_MULTIOS_READ, |vm, argc| {
7681 if argc < 3 || argc % 2 == 0 {
7682 return Value::Status(1);
7683 }
7684 let fd = vm.pop().to_int() as i32;
7685 let n_sources = ((argc - 1) / 2) as usize;
7686 let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_sources);
7687 for _ in 0..n_sources {
7688 let op_byte = vm.pop().to_int() as u8;
7689 let source = vm.pop();
7690 pairs.push((op_byte, source));
7691 }
7692 pairs.reverse();
7693
7694 // Splice glob match arrays (c:Src/glob.c:2195-2203).
7695 let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
7696 for (op_byte, source) in pairs {
7697 match source {
7698 Value::Array(items) => {
7699 for item in items {
7700 entries.push((op_byte, item.to_str()));
7701 }
7702 }
7703 other => entries.push((op_byte, other.to_str())),
7704 }
7705 }
7706 if entries.is_empty() {
7707 return Value::Status(1);
7708 }
7709
7710 // c:Src/exec.c:2418 — `unset(MULTIOS)`: sequential replace,
7711 // last source wins (`unsetopt multios; cat < a < b` reads
7712 // only b; a is still opened — and errors still surface).
7713 let multios_on = opt_state_get("multios").unwrap_or(true);
7714 if !multios_on {
7715 with_executor(|exec| {
7716 for (op_byte, source) in &entries {
7717 exec.host_apply_redirect(fd as u8, *op_byte, source);
7718 if exec.redirect_failed {
7719 break;
7720 }
7721 }
7722 });
7723 return Value::Status(0);
7724 }
7725
7726 if entries.len() == 1 {
7727 // Single member after splicing — plain replace.
7728 let (op_byte, source) = &entries[0];
7729 with_executor(|exec| {
7730 exec.host_apply_redirect(fd as u8, *op_byte, source);
7731 });
7732 return Value::Status(0);
7733 }
7734
7735 // Save current fd state for scope-end restoration — BEFORE
7736 // the first member's replace dup2 below.
7737 // c:Src/exec.c:2425 — `int fdN = movefd(fd1); save[fd1] = fdN;`. A SAVED
7738 // descriptor is shell state and must live above the script's fd range:
7739 // plain dup() returns the LOWEST free fd, which parked the saved stdout
7740 // on fd 3, so `print -u 3 -r -- X 2>/dev/null` wrote into the shell's own
7741 // saved descriptor and reported success where zsh says `bad file number`.
7742 // F_DUPFD with a floor of 10 is exactly what movefd does.
7743 let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
7744 if saved >= 0 {
7745 with_executor(|exec| {
7746 if let Some(top) = exec.redirect_scope_stack.last_mut() {
7747 top.push((fd, saved));
7748 } else {
7749 unsafe { libc::close(saved) };
7750 }
7751 });
7752 }
7753
7754 // Open every source in redirect order; numeric `<&N` dups
7755 // resolve against the LIVE fd table. First member replaces
7756 // the fd (c:2448-2450) so later self-dups see it.
7757 let mut source_fds: Vec<i32> = Vec::with_capacity(entries.len());
7758 for (i, (op_byte, source)) in entries.iter().enumerate() {
7759 let open_result: std::io::Result<i32> = match *op_byte {
7760 r::DUP_READ | r::DUP_WRITE => match source.trim_start_matches('&').parse::<i32>() {
7761 Ok(src) => {
7762 let d = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
7763 if d >= 0 {
7764 Ok(d)
7765 } else {
7766 Err(std::io::Error::last_os_error())
7767 }
7768 }
7769 Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
7770 },
7771 _ => fs::File::open(source).map(|f| f.into_raw_fd()),
7772 };
7773 match open_result {
7774 Ok(tfd) => {
7775 if i == 0 {
7776 unsafe {
7777 libc::dup2(tfd, fd);
7778 }
7779 }
7780 source_fds.push(tfd);
7781 }
7782 Err(e) => {
7783 let msg = match e.kind() {
7784 std::io::ErrorKind::PermissionDenied => "permission denied",
7785 std::io::ErrorKind::NotFound => "no such file or directory",
7786 _ => "open failed",
7787 };
7788 // c:Src/exec.c:3741 — zwarn with real lineno prefix.
7789 crate::ported::utils::zwarn(&format!("{}: {}", msg, source));
7790 for prev in &source_fds {
7791 unsafe {
7792 libc::close(*prev);
7793 }
7794 }
7795 with_executor(|exec| {
7796 exec.redirect_failed = true;
7797 });
7798 return Value::Status(1);
7799 }
7800 }
7801 }
7802
7803 // Create the concatenator pipe.
7804 let (read_end, write_end) = match os_pipe::pipe() {
7805 Ok(p) => p,
7806 Err(_) => {
7807 for f in &source_fds {
7808 unsafe {
7809 libc::close(*f);
7810 }
7811 }
7812 return Value::Status(1);
7813 }
7814 };
7815 // dup the pipe read-end onto fd before spawning the
7816 // producer; close the original read_end so the consumer
7817 // (reading via fd) is the sole reference until scope-end.
7818 let read_dup = unsafe { libc::dup(AsRawFd::as_raw_fd(&read_end)) };
7819 drop(read_end);
7820 if read_dup < 0 {
7821 for f in &source_fds {
7822 unsafe {
7823 libc::close(*f);
7824 }
7825 }
7826 return Value::Status(1);
7827 }
7828 unsafe {
7829 libc::dup2(read_dup, fd);
7830 libc::close(read_dup);
7831 }
7832 // Spawn the producer.
7833 let source_fds_for_thread = source_fds.clone();
7834 let handle = std::thread::spawn(move || {
7835 let mut w = write_end;
7836 let mut buf = [0u8; 8192];
7837 for sfd in source_fds_for_thread {
7838 loop {
7839 let n = unsafe {
7840 libc::read(sfd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
7841 };
7842 if n <= 0 {
7843 break;
7844 }
7845 let n = n as usize;
7846 if std::io::Write::write_all(&mut w, &buf[..n]).is_err() {
7847 break;
7848 }
7849 }
7850 unsafe {
7851 libc::close(sfd);
7852 }
7853 }
7854 // Closing w (the write_end) at scope drop signals EOF
7855 // to the consumer.
7856 });
7857 with_executor(|exec| {
7858 // Track using a closed-write sentinel — the producer
7859 // owns write_end so we just need to join. Use -1 fd
7860 // marker meaning "no fd to close".
7861 if let Some(top) = exec.multios_scope_stack.last_mut() {
7862 top.push((-1, handle));
7863 } else {
7864 let _ = handle.join();
7865 }
7866 });
7867 Value::Status(0)
7868 });
7869 // c:Src/exec.c:3978-3986 — nullexec==1 marker. See the const's
7870 // doc block. Arg: 1 = entering a bare-exec redirect, 0 = leaving.
7871 vm.register_builtin(BUILTIN_EXEC_PERM_REDIRS, |vm, _argc| {
7872 let on = vm.pop().to_int() != 0;
7873 with_executor(|exec| exec.exec_redirs_permanent = on);
7874 Value::Status(0)
7875 });
7876 // Bare-exec redirect epilogue — see the const's doc block.
7877 // c:Src/exec.c:252-259 (execerr) + c:4367-4386 (done: POSIX gate).
7878 vm.register_builtin(BUILTIN_EXEC_REDIR_DONE, |vm, _argc| {
7879 use std::sync::atomic::Ordering;
7880 let failed = with_executor(|exec| {
7881 let f = exec.redirect_failed;
7882 exec.redirect_failed = false;
7883 f
7884 });
7885 if !failed {
7886 return Value::Status(0);
7887 }
7888 // c:255 — `redir_err = lastval = 1`.
7889 vm.last_status = 1;
7890 if isset(crate::ported::zsh_h::POSIXBUILTINS) && !isset(crate::ported::zsh_h::INTERACTIVE) {
7891 // c:4379-4383 — non-interactive POSIX fatal: exit(1).
7892 // In-process equivalent: arm EXIT_PENDING/EXIT_VAL so the
7893 // next BUILTIN_ERREXIT_CHECK (trigger 2) unwinds the
7894 // script with status 1 — same deferred-exit shape the
7895 // `exit` builtin uses inside subshell contexts.
7896 crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
7897 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
7898 }
7899 Value::Status(1)
7900 });
7901 // c:Src/exec.c:3722-3724 — see the const's doc block. No args.
7902 vm.register_builtin(BUILTIN_PIPE_OUTPUT_MARK, |_vm, _argc| {
7903 with_executor(|exec| exec.pipe_output_pending = true);
7904 Value::Status(0)
7905 });
7906 // c:Src/exec.c:3710-3724 — install this pipeline stage's fds.
7907 // /* Make a copy of stderr for xtrace output before redirecting */
7908 // fflush(xtrerr);
7909 // ...
7910 // /* Add pipeline input/output to mnodes */
7911 // if (input) addfd(forked, save, mfds, 0, input, 0, NULL);
7912 // if (output) addfd(forked, save, mfds, 1, output, 1, NULL);
7913 // Emitted into the stage chunk by compile_zsh.rs (after the arg
7914 // words' expansion ops, before the redirect scope), and fed by
7915 // BUILTIN_RUN_PIPELINE via `stage_fds_park`. Doing the dup2 HERE
7916 // rather than before the chunk runs is what makes a `$(...)` in a
7917 // stage's arguments see the shell's fd 0 instead of the pipe.
7918 vm.register_builtin(BUILTIN_PIPE_FDS_INSTALL, |vm, argc| {
7919 // Arg: `|&` merge-stderr flag (compile_zsh always passes it).
7920 let merge_stderr = pop_args(vm, argc)
7921 .first()
7922 .map(|s| s != "0" && !s.is_empty())
7923 .unwrap_or(false);
7924 let (in_fd, out_fd) = stage_fds_take();
7925 if in_fd < 0 && out_fd < 0 {
7926 return Value::Status(0);
7927 }
7928 // c:3711 `fflush(xtrerr)` — flush before the fds move, so
7929 // anything buffered from the expansion phase lands on the
7930 // ORIGINAL fd, not on the pipe.
7931 let _ = std::io::stdout().flush();
7932 let _ = std::io::stderr().flush();
7933 unsafe {
7934 if in_fd >= 0 {
7935 libc::dup2(in_fd, libc::STDIN_FILENO);
7936 if in_fd != libc::STDIN_FILENO {
7937 libc::close(in_fd);
7938 }
7939 }
7940 if out_fd >= 0 {
7941 libc::dup2(out_fd, libc::STDOUT_FILENO);
7942 if out_fd != libc::STDOUT_FILENO {
7943 libc::close(out_fd);
7944 }
7945 // `cmd |& next`: the `2>&1` C appends to cmd's redirect
7946 // list (walked at c:3730+, i.e. after this addfd), so
7947 // stderr follows the pipe, not the shell's stdout.
7948 if merge_stderr {
7949 libc::dup2(libc::STDOUT_FILENO, libc::STDERR_FILENO);
7950 }
7951 }
7952 }
7953 Value::Status(0)
7954 });
7955 // c:Src/exec.c — block-level redirect-failure gate. When a
7956 // compound command (`{ … } < file`, `( … ) > file`, etc.) has a
7957 // failing redirect (e.g. `< /nonexistent`), zsh skips the entire
7958 // body AND sets lastval to 1. The simple-command path's
7959 // redirect_failed check (line 215-221 above) only catches the
7960 // failure when a builtin dispatches and is consumed by that
7961 // single builtin call — so a multi-statement block kept running
7962 // its remaining statements after the redir error. Emit-side at
7963 // compile_zsh.rs::compile_command's Redirected arm pairs this
7964 // with a JumpIfTrue → WithRedirectsEnd to abandon the body.
7965 vm.register_builtin(BUILTIN_REDIRECT_FAILED_CHECK, |vm, _argc| {
7966 let failed = with_executor(|exec| {
7967 let f = exec.redirect_failed;
7968 exec.redirect_failed = false;
7969 f
7970 });
7971 if failed {
7972 vm.last_status = 1;
7973 Value::Int(1)
7974 } else {
7975 Value::Int(0)
7976 }
7977 });
7978 // c:Src/exec.c — drop-in replacement for fusevm's Op::Exec used by
7979 // the dynamic-first-word path (`$cmd`, `$(cmd)`, glob-named cmds).
7980 // fusevm's Op::Exec returns Value::Status(0) when post-expansion
7981 // argv is empty (vm.rs:1722) — that clobbers \$? for the
7982 // `\$(exit 1); echo \$?` case where the cmd-subst left
7983 // last_status = 1 but the empty expansion gets exec'd to 0.
7984 // Mirror C zsh: when the word list is empty after expansion,
7985 // \$? becomes whatever the inner cmd-subst's last_status is
7986 // (preserved here by returning Value::Status(last_status)).
7987 // c:Src/cond.c:308-316 — `if (!(pprog = patcompile(right, ...)))
7988 // { zwarnnam(fromtest, "bad pattern: %s", right); return 2; }`.
7989 // The cond path must NOT use str_match/glob_match_static: the
7990 // case-statement consumer of those follows Src/loop.c:667 zerr
7991 // semantics (errflag abort), while cond is a zwarn + status-2
7992 // soft failure. COND_BAD_PATTERN carries the 2 across the
7993 // Bool-shaped stack contract (so `!=`'s LogNot can't lose it).
7994 thread_local! {
7995 static COND_BAD_PATTERN: std::cell::Cell<bool> =
7996 const { std::cell::Cell::new(false) };
7997 }
7998 vm.register_builtin(BUILTIN_COND_STRMATCH, |vm, _argc| {
7999 let pat = vm.pop().to_str();
8000 let s = vm.pop().to_str();
8001 let mut pat_tok = pat.clone();
8002 crate::ported::glob::tokenize(&mut pat_tok);
8003 if crate::ported::pattern::patcompile(
8004 &pat_tok,
8005 crate::ported::zsh_h::PAT_STATIC as i32,
8006 None,
8007 )
8008 .is_none()
8009 {
8010 // c:314 — zwarnnam(fromtest, "bad pattern: %s", right).
8011 crate::ported::utils::zwarn(&format!("bad pattern: {}", pat));
8012 COND_BAD_PATTERN.with(|c| c.set(true));
8013 return Value::Bool(false);
8014 }
8015 // Match via the shared engine so `(#b)`/`(#m)` backref and
8016 // MATCH-variable population stays in one place.
8017 Value::Bool(crate::vm_helper::glob_match_static(&s, &pat))
8018 });
8019 vm.register_builtin(BUILTIN_COND_UNKNOWN, |vm, _argc| {
8020 // c:Src/cond.c:150-188 — `zwarnnam(fromtest, "unknown condition: %s",
8021 // name)` for a `-X` op with no matching cond module. Like a cond
8022 // syntax error it yields status 2 and aborts: arm COND_BAD_PATTERN so
8023 // the downstream BUILTIN_COND_STATUS_FROM_BOOL carries the 2 across the
8024 // Bool-shaped stack and runs the shared errflag+set_last_status(2)+abort
8025 // path (c:Src/exec.c:5216-5221). Returns Bool(false) as the operand.
8026 let op = vm.pop().to_str();
8027 crate::ported::utils::zerr(&format!("unknown condition: {}", op));
8028 COND_BAD_PATTERN.with(|c| c.set(true));
8029 Value::Bool(false)
8030 });
8031 vm.register_builtin(BUILTIN_COND_STATUS_FROM_BOOL, |vm, _argc| {
8032 // `${~pat}` / `${(P)~pat}` inside a `[[ … ]]` operand flips
8033 // GLOB_SUBST on via the tilde carrier so the pattern match sees
8034 // active metacharacters. In C that flag is prefork-scoped and
8035 // gone once the operand is consumed; zshrs restores it at the
8036 // next command-dispatch boundary, but a bare `[[ … ]]` has no
8037 // trailing assignment to trigger that — so globsubst leaked ON
8038 // into the NEXT command's word expansion, filename-generating a
8039 // scalar value it should not (p10k `_p9k_set_prompt`: line 45
8040 // `[[ … != ${(P)~disabled} ]]` leaked into line 46's
8041 // `local val=$arr[idx]`, whose glob-char-laden value then hit
8042 // "no matches found" and aborted the whole prompt build →
8043 // garbled 25-line prompt / interactive hang). Consume the
8044 // carrier here: this builtin ends EVERY `[[ … ]]`, and runs
8045 // after the operands (and their pattern match) are done.
8046 consume_tilde_globsubst_carrier();
8047 let ok = vm.pop().to_int() != 0;
8048 let bad = COND_BAD_PATTERN.with(|c| {
8049 let b = c.get();
8050 c.set(false);
8051 b
8052 });
8053 if bad {
8054 // c:Src/exec.c:5216-5221 — `stat = evalcond(...);
8055 // /* 2 indicates a syntax error. For compatibility,
8056 // turn this into a shell error. */
8057 // if (stat == 2) errflag |= ERRFLAG_ERROR;`
8058 // The errflag abort exits the script with lastval (2),
8059 // matching `zsh -fc '[[ x == [a- ]]; print rc=$?'`
8060 // printing nothing after the diagnostic and exiting 2.
8061 crate::ported::utils::errflag.fetch_or(
8062 crate::ported::zsh_h::ERRFLAG_ERROR,
8063 std::sync::atomic::Ordering::Relaxed,
8064 );
8065 with_executor(|exec| exec.set_last_status(2));
8066 return Value::Int(2); // c:Src/cond.c:316 `return 2;`
8067 }
8068 let status: i32 = if ok { 0 } else { 1 };
8069 // c:Src/exec.c:5216 — `lastval = evalcond(...)`: the conditional's
8070 // result IS the command's lastval, and c:Src/cond.c's evalcond
8071 // never inspects errflag while evaluating. So when a `[[ … ]]`
8072 // operand raised errflag (e.g. a nounset "parameter not set" zerr
8073 // on `${arr[99]}` under NO_UNSET), zsh STILL completes the test and
8074 // exits with the cond result; the errflag only aborts the FOLLOWING
8075 // commands. Sync the result to the executor's live lastval HERE —
8076 // the nounset site left it at a transient 1, and the next
8077 // BUILTIN_ERREXIT_CHECK reads the executor (not vm.last_status), so
8078 // without this sync `setopt NO_UNSET; [[ -z ${arr[99]} ]]` exited 1
8079 // instead of 0. The Op::SetStatus that follows sets vm.last_status;
8080 // this keeps the executor coherent with it before the abort check.
8081 with_executor(|exec| exec.set_last_status(status));
8082 Value::Int(status as i64)
8083 });
8084 vm.register_builtin(BUILTIN_USE_CMDOUTVAL_RESET, |_vm, _argc| {
8085 crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
8086 Value::Status(0)
8087 });
8088
8089 vm.register_builtin(BUILTIN_EXEC_DYNAMIC, |vm, argc| {
8090 let raw = pop_args(vm, argc);
8091 // Flatten Array entries into argv slots (matches fusevm
8092 // Op::Exec's flatten at vm.rs:1660-1665) so `${arr[@]}` /
8093 // splice expansions produce one argv slot per element.
8094 let args: Vec<String> = raw.into_iter().collect();
8095 // c:Src/subst.c paramsubst — when `${var:?msg}` or
8096 // `${var?msg}` set errflag, the expansion may produce empty
8097 // argv[0] which would fall into the EACCES/permission-denied
8098 // path below, masking the real paramsubst diagnostic with a
8099 // spurious "permission denied:" line and rc=126. Honour
8100 // errflag so the simple command ends with the paramsubst
8101 // error as the sole diagnostic, rc=1. Bug #86.
8102 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
8103 & crate::ported::zsh_h::ERRFLAG_ERROR)
8104 != 0
8105 {
8106 return Value::Status(1);
8107 }
8108 if args.is_empty() {
8109 // c:Src/exec.c:3442 — a command whose words expand to ZERO
8110 // words is a NULL command: `cmdoutval = use_cmdoutval ?
8111 // lastval : 0`. `use_cmdoutval` is set (below, in
8112 // BUILTIN_CMD_SUBST_TEXT) only when a command substitution
8113 // ran during this command's word expansion, so:
8114 // `false; $(exit 5)` → keep the subst status (5)
8115 // `false; $nonexistent` → reset to 0 (null command).
8116 // The previous port unconditionally kept `$?`, so
8117 // `false; $unset` wrongly stayed 1 (A01grammar.ztst:5).
8118 let keep =
8119 crate::ported::exec::use_cmdoutval.load(std::sync::atomic::Ordering::Relaxed) != 0;
8120 let status = if keep { vm.last_status } else { 0 };
8121 crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
8122 return Value::Status(status);
8123 }
8124 if args[0].is_empty() {
8125 // Explicit empty command word — exec returns EACCES.
8126 let script_name =
8127 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
8128 let lineno: u64 = with_executor(|exec| {
8129 exec.scalar("LINENO")
8130 .and_then(|s| s.parse::<u64>().ok())
8131 .unwrap_or(1)
8132 });
8133 eprintln!("{}:{}: permission denied: ", script_name, lineno);
8134 return Value::Status(126);
8135 }
8136 // AOP intercepts (zshrs extension, no C counterpart) — same
8137 // gate as host_exec_external (the static-head path): dynamic
8138 // command names (`cmd=/bin/echo; $cmd payload`) must consult
8139 // registered intercepts before dispatch, else `intercept
8140 // before /bin/echo ...` fires for the literal spelling but
8141 // not the variable one. run_intercepts runs before-advice
8142 // in-place and returns None to continue; Some(status) means
8143 // an around/after advice fully handled the command.
8144 let intercepted = with_executor(|exec| {
8145 if exec.intercepts.is_empty() {
8146 return None;
8147 }
8148 let full_cmd = if args.len() == 1 {
8149 args[0].clone()
8150 } else {
8151 args.join(" ")
8152 };
8153 let rest: Vec<String> = args[1..].to_vec();
8154 exec.run_intercepts(&args[0], &full_cmd, &rest)
8155 });
8156 if let Some(result) = intercepted {
8157 return Value::Status(result.unwrap_or(127));
8158 }
8159 // c:Src/exec.c:2900 execcmd_exec — canonical simple-command
8160 // dispatcher. Runs precmd-modifier walk (c:3013-3091), then
8161 // dispatches to execbuiltin (c:4233) / runshfunc (c:3431+) /
8162 // execute (c:4314) per the resolved head. zshrs's bytecode VM
8163 // expanded the args before reaching here; we feed them in via
8164 // eparams.args and let execcmd_exec do the rest exactly as C
8165 // does for static heads. Without this, `c=builtin; $c source X`
8166 // skipped the precmd walk and emitted "command not found:
8167 // builtin".
8168 let mut state = crate::ported::zsh_h::estate {
8169 prog: Box::<crate::ported::zsh_h::eprog>::default(),
8170 pc: 0,
8171 strs: None,
8172 strs_offset: 0,
8173 };
8174 let mut eparams = crate::ported::zsh_h::execcmd_params {
8175 args: Some(args),
8176 redir: None,
8177 beg: 0,
8178 varspc: None,
8179 assignspc: None,
8180 typ: crate::ported::zsh_h::WC_SIMPLE as i32,
8181 postassigns: 0,
8182 htok: 0,
8183 };
8184 // input/output=0 → no pipe redirection (use shell stdio
8185 // directly); `output != 0` at c:2988 forks immediately. last1=2
8186 // (c:Src/exec.c:2014 `last1 ? 1 : 2`): terminal pipe stage but
8187 // the shell IS needed afterward — the VM keeps executing
8188 // bytecode after this op. last1=1 would arm the fake-exec
8189 // optimization (c:3646-3651, gate at c:3662 `last1 != 1`),
8190 // making `execute()` execve THIS process for external heads:
8191 // `p=/bin/echo; $p hi; echo after` replaced the shell and
8192 // `after` never ran (D04parameter chunk 11 shell-killer).
8193 // c:Src/exec.c:1690-1700 — execpline's job frame: save thisjob
8194 // (`pj = thisjob`) and allocate the jobtab slot that
8195 // execcmd_fork's addproc (c:2853) hangs the child pid off.
8196 // Without a live thisjob, the fork at c:3662 (last1 != 1 →
8197 // external must fork) registers no proc, nothing waits, and
8198 // the child races the rest of the script.
8199 let pj = {
8200 use crate::ported::jobs;
8201 *jobs::THISJOB
8202 .get_or_init(|| std::sync::Mutex::new(-1))
8203 .lock()
8204 .unwrap_or_else(|e| e.into_inner())
8205 };
8206 let newjob = {
8207 use crate::ported::jobs;
8208 let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
8209 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
8210 jobs::initjob(&mut tab) // c:1700 `thisjob = newjob = initjob()`
8211 };
8212 {
8213 use crate::ported::jobs;
8214 *jobs::THISJOB
8215 .get_or_init(|| std::sync::Mutex::new(-1))
8216 .lock()
8217 .unwrap_or_else(|e| e.into_inner()) = newjob as i32;
8218 }
8219 crate::ported::exec::execcmd_exec(
8220 &mut state,
8221 &mut eparams,
8222 0, // input (c:2989)
8223 0, // output (c:2988)
8224 crate::ported::zsh_h::Z_SYNC as i32, // how
8225 2, // last1=2 — shell continues (c:2014)
8226 -1, // close_if_forked
8227 );
8228 // c:Src/exec.c:1828-1835 — execpline's Z_SYNC tail: waitjobs()
8229 // reaps the forked external. c:Src/jobs.c:487-495 + 551-552 —
8230 // the job's LAST proc sets lastval (0200|sig when signalled,
8231 // else WEXITSTATUS). Builtin/shfunc heads never forked (job
8232 // has no procs) — LASTVAL was already set by execbuiltin /
8233 // doshfunc inside execcmd_exec; skip the wait.
8234 {
8235 use crate::ported::jobs;
8236 let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
8237 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
8238 if jobs::hasprocs(&tab, newjob) {
8239 jobs::waitjobs(&mut tab, newjob); // c:1835
8240 if let Some(p) = tab[newjob].procs.last() {
8241 let val = if p.is_signaled() {
8242 0o200 | p.term_sig() // c:Src/jobs.c:489-490
8243 } else {
8244 p.exit_status() // c:Src/jobs.c:494
8245 };
8246 crate::ported::builtin::LASTVAL
8247 .store(val, std::sync::atomic::Ordering::Relaxed);
8248 }
8249 }
8250 // c:1977-1979 — `deletejob(jn, 0)` once done; c:1981
8251 // `thisjob = pj` restores the caller's job.
8252 if newjob < tab.len() {
8253 jobs::deletejob(&mut tab[newjob], false);
8254 }
8255 *jobs::THISJOB
8256 .get_or_init(|| std::sync::Mutex::new(-1))
8257 .lock()
8258 .unwrap_or_else(|e| e.into_inner()) = pj;
8259 }
8260 let status = crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
8261 let mut synth = crate::ported::zsh_h::job::default();
8262 crate::ported::jobs::waitonejob(&mut synth);
8263 Value::Status(status)
8264 });
8265 // c:Src/exec.c:3340-3364 — `< file` / `> file` with no command
8266 // word. Resolves NULLCMD/READNULLCMD at runtime then routes
8267 // through host_exec_external. Redirects are already applied by
8268 // the surrounding WithRedirectsBegin scope.
8269 vm.register_builtin(BUILTIN_NULLCMD_EXEC, |vm, argc| {
8270 let args = pop_args(vm, argc);
8271 let is_single_read = args
8272 .first()
8273 .map(|s| s != "0" && !s.is_empty())
8274 .unwrap_or(false);
8275 // c:Src/exec.c — when the surrounding redir-open failed
8276 // (e.g. `< /nonexistent`), zerr already printed the diag
8277 // and set redirect_failed. Don't invoke NULLCMD — return
8278 // status 1 like the wordcode path does.
8279 let redir_failed = with_executor(|exec| {
8280 let f = exec.redirect_failed;
8281 exec.redirect_failed = false;
8282 f
8283 });
8284 if redir_failed {
8285 crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
8286 return Value::Status(1);
8287 }
8288 let nullcmd = crate::ported::params::getsparam("NULLCMD");
8289 let nc_str = nullcmd.as_deref().unwrap_or("");
8290 let nc_empty = nc_str.is_empty();
8291 // c:3340-3344 — CSHNULLCMD or no NULLCMD set → diagnostic.
8292 if nc_empty || crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLCMD) {
8293 let script_name =
8294 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
8295 let lineno: u64 = with_executor(|exec| {
8296 exec.scalar("LINENO")
8297 .and_then(|s| s.parse::<u64>().ok())
8298 .unwrap_or(1)
8299 });
8300 eprintln!("{}:{}: redirection with no command", script_name, lineno);
8301 return Value::Status(1);
8302 }
8303 // c:3350 — SHNULLCMD → run `:`.
8304 let cmd: String = if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHNULLCMD) {
8305 ":".to_string()
8306 } else if is_single_read {
8307 // c:3354-3359 — single REDIR_READ + READNULLCMD set → readnullcmd.
8308 let rnc = crate::ported::params::getsparam("READNULLCMD");
8309 let rnc_str = rnc.as_deref().unwrap_or("");
8310 if !rnc_str.is_empty() {
8311 rnc_str.to_string()
8312 } else {
8313 nc_str.to_string() // c:3360-3363 fallback
8314 }
8315 } else {
8316 nc_str.to_string() // c:3360-3363
8317 };
8318 let status = with_executor(|exec| exec.host_exec_external(&[cmd]));
8319 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
8320 Value::Status(status)
8321 });
8322 // c:Src/exec.c:3342 — `zerr("redirection with no command")`.
8323 // Bare prefix-keyword (`builtin`, `command`, `exec`, `noglob`,
8324 // `nocorrect`) with a redirect but no command word. Emits the
8325 // canonical diagnostic via zerr (which sets errflag) and
8326 // returns Status(1). Bug #534.
8327 vm.register_builtin(BUILTIN_REDIR_NO_CMD, |_vm, _argc| {
8328 crate::ported::utils::zerr("redirection with no command");
8329 Value::Status(1)
8330 });
8331 vm.register_builtin(BUILTIN_DEBUG_TRAP, |vm, _argc| {
8332 // c:Src/signals.c:1245 dotrap(SIGDEBUG) — fires the DEBUG
8333 // trap body once per statement. The body sees the parent
8334 // shell's $? (LASTVAL). Guard against re-entry: commands
8335 // inside the DEBUG trap body would otherwise trigger
8336 // DEBUG_TRAP recursively → stack overflow. zsh guards via
8337 // its in_trap counter; we mirror with a thread-local Cell.
8338 //
8339 // c:Src/exec.c::trapcmd — before dotrap, the C source sets
8340 // `ZSH_DEBUG_CMD` to the about-to-run command text via
8341 // `dupstring(text)`. The trap body reads the parameter;
8342 // C unsets it after the trap returns. compile_list emits
8343 // the rendered statement text as the single arg here so the
8344 // shell-visible parameter reflects the command. Bug #263 in
8345 // docs/BUGS.md.
8346 let cmd_text = vm.pop().to_str();
8347 DEBUG_TRAP_REENTRY.with(|c| {
8348 if c.get() {
8349 return Value::Status(0);
8350 }
8351 // c:Src/exec.c:1423 — `if (sigtrapped[SIGDEBUG] &&
8352 // isset(DEBUGBEFORECMD) && !intrap)`. Bug #573: without
8353 // this gate, every sublist boundary called
8354 // setsparam("ZSH_DEBUG_CMD", ...) even when no DEBUG trap
8355 // was set, polluting the param table and (under
8356 // WARN_CREATE_GLOBAL) emitting a spurious
8357 // `scalar parameter ZSH_DEBUG_CMD created globally`
8358 // warning at every function call.
8359 //
8360 // Two trap registries exist (per signals.rs:1481-1511 dotrap):
8361 // - settrap path → sigtrapped[SIGDEBUG] bits set
8362 // - bin_trap path → traps_table["DEBUG"] populated, sigtrapped untouched
8363 // Mirror the dotrap dispatch decision: skip only when BOTH
8364 // are absent.
8365 let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
8366 let debug_trapped = crate::ported::signals::sigtrapped
8367 .lock()
8368 .map(|v| v.get(sig_debug).copied().unwrap_or(0))
8369 .unwrap_or(0);
8370 let debug_in_table = crate::ported::builtin::traps_table()
8371 .lock()
8372 .map(|t| t.contains_key("DEBUG"))
8373 .unwrap_or(false);
8374 if debug_trapped == 0 && !debug_in_table {
8375 return Value::Status(0);
8376 }
8377 c.set(true);
8378 // c:Src/exec.c — set ZSH_DEBUG_CMD scalar (PM_READONLY
8379 // is NOT set on ZSH_DEBUG_CMD, so the canonical
8380 // setsparam path is fine here — no direct paramtab
8381 // mutation needed).
8382 crate::ported::params::setsparam("ZSH_DEBUG_CMD", &cmd_text);
8383 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGDEBUG);
8384 // c:Src/exec.c::trapcmd — `unsetparam("ZSH_DEBUG_CMD")`
8385 // after the trap returns. Mirror that.
8386 crate::ported::params::unsetparam("ZSH_DEBUG_CMD");
8387 c.set(false);
8388 Value::Status(0)
8389 })
8390 });
8391
8392 // Fatal-only abort check emitted between the pipes of an `&&` / `||`
8393 // chain, where the full errexit check is suppressed. Mirrors ONLY the
8394 // errflag arm of BUILTIN_ERREXIT_CHECK below: an errflag abandons the
8395 // list in zsh, and no connector can consume it.
8396 vm.register_builtin(BUILTIN_FATAL_ABORT_CHECK, |vm, _argc| {
8397 use std::sync::atomic::Ordering;
8398 let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
8399 & crate::ported::zsh_h::ERRFLAG_ERROR)
8400 != 0;
8401 if !errflag_set || isset(crate::ported::zsh_h::INTERACTIVE) {
8402 return Value::Int(0);
8403 }
8404 // CONTINUE_ON_ERROR: clear and keep going, as the full check does.
8405 if isset(crate::ported::zsh_h::CONTINUEONERROR) {
8406 crate::ported::utils::errflag
8407 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
8408 return Value::Int(0);
8409 }
8410 // Abort the chain with the failing command's own status intact —
8411 // a cond syntax error left lastval=2 (c:Src/exec.c:5216-5221), and
8412 // that 2 is what zsh exits with. Reading the executor's live
8413 // lastval (not forcing 1) is the same rule the full check uses.
8414 vm.last_status = with_executor(|exec| exec.last_status());
8415 Value::Int(1)
8416 });
8417 vm.register_builtin(BUILTIN_ERREXIT_CHECK, |vm, _argc| {
8418 // Returns Value::Int(1) when the caller should jump to the
8419 // current scope's return-patch landing (subshell-end / func-
8420 // end / chunk-end). Returns Value::Int(0) otherwise. Emit
8421 // side at `emit_errexit_check` pairs this with a JumpIfTrue
8422 // → return_patches pattern so the caller can short-circuit.
8423 //
8424 // Four triggers:
8425 // 1. RETFLAG set by a nested `return` / `exit` (eval,
8426 // sourced file, called function). Unwind THIS scope so
8427 // the flag propagates outward until something clears it.
8428 // 2. EXIT_PENDING set (mostly subshell-context exits). Same
8429 // propagation logic.
8430 // 3. `set -e` + nonzero status — the classic errexit path.
8431 // 4. errflag set in non-interactive mode — readonly
8432 // reassign, bad redirect, parse error mid-expansion etc.
8433 // Aborts the script (c:Src/init.c loop()).
8434 use std::sync::atomic::Ordering;
8435 let retflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
8436 let exit_pending = crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed);
8437 // c:Src/exec.c:1571-1603 — `sublist_done:` runs the ZERR trap for
8438 // the sublist that just failed. It is NOT gated on retflag: C only
8439 // consults retflag at the TOP of the list loop (c:1370 `while
8440 // (wc_code(code) == WC_LIST && !breaks && !retflag && !errflag)`),
8441 // which stops the NEXT sublist — the current one still completes
8442 // its sublist_done. So `return 5` fires the ERR trap on its way out.
8443 //
8444 // zshrs's escape short-circuit below returns before ever reaching
8445 // the ZERR fire, so a `return N` inside a try-list skipped the trap:
8446 // f() { { return 5 } always { print fin } }; f
8447 // printed `fin / err=5` where zsh prints `err=5 / fin / err=5`.
8448 // (Plain `f() { return 5 }` matched by luck — the inner fire was
8449 // missing but the OUTER sublist fired instead, since doshfunc had
8450 // cleared retflag by then and DONETRAP was still 0.)
8451 //
8452 // `exit` is deliberately excluded: C's `exit` goes zexit() →
8453 // realexit(), leaving the process without ever reaching
8454 // sublist_done. Verified: `zsh -fc 'trap "print err" ERR; f(){ exit
8455 // 5 }; f'` prints nothing.
8456 if retflag != 0 && exit_pending == 0 {
8457 let last = vm.last_status;
8458 // c:1598-1603 — same DONETRAP gate as the non-escape path below.
8459 if last != 0 && crate::ported::exec::DONETRAP.load(Ordering::Relaxed) == 0 {
8460 // c:Src/signals.c:1085-1087 — `int obreaks = breaks; int
8461 // oretflag = retflag; int olastval = lastval;` and c:1220-1222
8462 // — `breaks += obreaks; retflag = oretflag;`. dotrapargs
8463 // brackets EVERY trap dispatch with this save/restore because
8464 // the trap body runs as a normal list and would otherwise
8465 // consume the caller's control-flow flags. That matters
8466 // exactly here: we are firing ZERR while retflag is SET, and a
8467 // FUNCTION-form trap (`TRAPZERR() { … }`) goes through
8468 // doshfunc, whose epilogue eats retflag outright
8469 // (c:Src/exec.c:6047-6052 `if (retflag) { retflag = 0; breaks
8470 // = funcsave->breaks; }`). Without the bracket the pending
8471 // `return 5` was swallowed by its own ERR trap and the
8472 // function ran on:
8473 // TRAPZERR() { print z }; f() { { return 2 } always { : }
8474 // print after }; f
8475 // printed `after`, where zsh returns from f.
8476 //
8477 // zshrs's `dotrap` inlines the dispatch and does not carry
8478 // dotrapargs' save/restore, so the bracket lives at this call
8479 // site. lastval is restored too (c:1087 / c:1213 `lastval =
8480 // olastval`) — the trap body's own commands must not become
8481 // the caller's `$?`.
8482 let obreaks = crate::ported::builtin::BREAKS.load(Ordering::Relaxed); // c:1085
8483 let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed); // c:1086
8484 let olastval = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1087
8485 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1601
8486 crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed); // c:1602
8487 crate::ported::builtin::BREAKS.store(obreaks, Ordering::Relaxed); // c:1220
8488 crate::ported::builtin::RETFLAG.store(oretflag, Ordering::Relaxed); // c:1222
8489 crate::ported::builtin::LASTVAL.store(olastval, Ordering::Relaxed); // c:1213
8490 }
8491 }
8492 if retflag != 0 || exit_pending != 0 {
8493 if exit_pending != 0 {
8494 // c:Src/builtin.c zexit — the deferred exit carries its
8495 // status in EXIT_VAL; sync it into the VM counter so
8496 // the top-level unwind reports it as the script's exit
8497 // (run_chunk returns vm.last_status). Without this, a
8498 // POSIX-fatal `.` failure exited 127 (bin_dot's return)
8499 // instead of C's exit(1) at Src/exec.c:4383.
8500 vm.last_status = crate::ported::builtin::EXIT_VAL.load(Ordering::Relaxed) & 0xFF;
8501 }
8502 return Value::Int(1);
8503 }
8504 let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
8505 & crate::ported::zsh_h::ERRFLAG_ERROR)
8506 != 0;
8507 // c:Src/init.c:1931 — `if (errflag && !interact &&
8508 // !isset(CONTINUEONERROR)) { errexit = 1; break; }` — with
8509 // CONTINUE_ON_ERROR set, the top-level do-while re-enters
8510 // loop() and the NEXT list runs instead of the shell exiting.
8511 // Clear the flag so the next statement starts clean (the
8512 // failed statement's lastval is already in place).
8513 if errflag_set
8514 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
8515 && crate::ported::zsh_h::isset(crate::ported::zsh_h::CONTINUEONERROR)
8516 {
8517 crate::ported::utils::errflag
8518 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
8519 return Value::Int(0);
8520 }
8521 if errflag_set && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE) {
8522 // c:Src/exec.c execlist — every enclosing list loop runs
8523 // `while (... && !errflag)`, so a set errflag breaks the
8524 // CURRENT scope and the check in the enclosing scope
8525 // breaks THAT one, all the way out. Leave errflag SET —
8526 // do NOT convert it to EXIT_PENDING: a process-exit
8527 // signal tunnels through the containment boundaries C
8528 // has, namely eval (Src/builtin.c:6221 `errflag &=
8529 // ~ERRFLAG_ERROR`), source (Src/init.c:1663 same), fork
8530 // boundaries (subshell/cmdsubst — child's errflag dies
8531 // with the child), and the interactive toplevel
8532 // (Src/init.c:139). Those boundaries clear errflag
8533 // themselves and execution continues past them; with
8534 // EXIT_PENDING armed here, `eval 'assoc=(odd)'; echo
8535 // after` aborted the whole script where zsh 5.9 prints
8536 // `after` (eval status 1). Bug #74's function case
8537 // (`f() { local -r x=5; x=10; }; f; echo after`) still
8538 // aborts: the function scope unwinds on THIS check, and
8539 // the caller's next ERREXIT_CHECK sees the still-set
8540 // errflag and unwinds too — exactly C's propagation.
8541 //
8542 // c:Src/init.c:234 — loop() BREAKS on errflag and
8543 // zsh_main exits with the UNTOUCHED lastval, NOT a
8544 // forced 1: `typeset -i x=3#8` (math error during the
8545 // assignment, before typeset sets a status) exits 0 in
8546 // zsh; a cond syntax error set lastval=2 (exec.c:5216-
8547 // 5221) and zsh exits 2; the readonly-reassign case
8548 // exits 1 because ITS lastval is 1. Sync the VM counter
8549 // from the executor's live lastval instead of
8550 // overwriting.
8551 vm.last_status = with_executor(|exec| exec.last_status());
8552 // c:Src/exec.c:1598-1603 — `sublist_done:` runs the ZERR trap
8553 // for the failed sublist BEFORE the enclosing list loop breaks
8554 // on errflag (`while (... && !errflag)` at c:1370). So an
8555 // errflag-setting command (readonly reassign, bad redirect)
8556 // must fire ZERR on its way out, exactly like the retflag
8557 // escape above and the non-escape fall-through below. Without
8558 // this the errflag early-return pre-empted the ZERR block
8559 // further down, so `TRAPZERR() { … }; typeset -r ro=1; ro=2`
8560 // aborted the script (correct) but never fired the trap. Same
8561 // DONETRAP gate + dotrapargs save/restore bracket
8562 // (c:signals.c:1085-1087 / 1213-1222) as the retflag branch:
8563 // a function-form TRAPZERR runs through doshfunc and would
8564 // otherwise consume the caller's breaks/retflag/lastval.
8565 let last = with_executor(|exec| exec.last_status());
8566 if last != 0 && crate::ported::exec::DONETRAP.load(Ordering::Relaxed) == 0 {
8567 let obreaks = crate::ported::builtin::BREAKS.load(Ordering::Relaxed); // c:1085
8568 let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed); // c:1086
8569 let olastval = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1087
8570 // c:Src/signals.c:1101 — dotrapargs returns early if errflag
8571 // is set, and c:1174/1205-1218 brackets the dispatch with
8572 // `traperr = errflag` … restore. The failing assignment left
8573 // errflag SET, so the trap body (`print zerr`) would itself
8574 // bail on the first op. Clear errflag across the dispatch so
8575 // the body runs, then restore it so the script still aborts.
8576 let oerrflag = crate::ported::utils::errflag.load(Ordering::Relaxed); // c:1174
8577 crate::ported::utils::errflag.store(0, Ordering::Relaxed);
8578 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1601
8579 crate::ported::utils::errflag.store(oerrflag, Ordering::Relaxed); // c:1216
8580 crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed); // c:1602
8581 crate::ported::builtin::BREAKS.store(obreaks, Ordering::Relaxed); // c:1220
8582 crate::ported::builtin::RETFLAG.store(oretflag, Ordering::Relaxed); // c:1222
8583 crate::ported::builtin::LASTVAL.store(olastval, Ordering::Relaxed); // c:1213
8584 }
8585 return Value::Int(1);
8586 }
8587 let last = vm.last_status;
8588 if last == 0 {
8589 return Value::Int(0);
8590 }
8591 // c:Src/exec.c:1598 `if (!this_noerrexit && !donetrap &&
8592 // !this_donetrap)` — gate the ZERR trap fire on DONETRAP so
8593 // an inner sublist (e.g. `false` inside a function) that
8594 // already fired ZERR doesn't fire it AGAIN at the outer
8595 // sublist's post-command check (after the function
8596 // returned non-zero). Bug #303 in docs/BUGS.md. DONETRAP
8597 // is reset at top-level statement boundaries via
8598 // BUILTIN_DONETRAP_RESET (compile_list emit at
8599 // compile_zsh.rs).
8600 let already_done = crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
8601 if !already_done {
8602 // c:Src/signals.c:1245 dotrap(SIGZERR) — canonical ZERR
8603 // trap dispatch. Fires whenever a command exits
8604 // non-zero.
8605 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR);
8606 // c:1602 — `donetrap = 1;` after firing.
8607 crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed);
8608 }
8609 // c:Src/exec.c:1605-1610 — compute errreturn / errexit.
8610 // errreturn = ERRRETURN && (INTERACTIVE || locallevel || sourcelevel)
8611 // && !(noerrexit & NOERREXIT_RETURN)
8612 // errexit = (ERREXIT || (ERRRETURN && !errreturn))
8613 // && !(noerrexit & NOERREXIT_EXIT)
8614 let no_err = crate::ported::exec::noerrexit.load(Ordering::Relaxed);
8615 let locallvl = crate::ported::params::locallevel.load(Ordering::Relaxed);
8616 let sourcelvl = crate::ported::init::sourcelevel.load(Ordering::Relaxed);
8617 let errreturn_opt = isset(crate::ported::zsh_h::ERRRETURN);
8618 let in_unwindable_scope =
8619 isset(crate::ported::zsh_h::INTERACTIVE) || locallvl != 0 || sourcelvl != 0;
8620 let errreturn = errreturn_opt
8621 && in_unwindable_scope
8622 && (no_err & crate::ported::zsh_h::NOERREXIT_RETURN) == 0;
8623 if errreturn {
8624 // c:1620-1623 — `retflag = 1; breaks = loops;` — unwind to
8625 // function boundary without exiting the shell.
8626 crate::ported::builtin::RETFLAG.store(1, Ordering::Relaxed);
8627 let loops = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
8628 crate::ported::builtin::BREAKS.store(loops, Ordering::Relaxed);
8629 return Value::Int(1);
8630 }
8631 let (errexit_on, in_subshell) = with_executor(|exec| {
8632 let on_canonical = isset(ERREXIT) || (errreturn_opt && !errreturn); // c:1608-1609
8633 let on_legacy = opt_state_get("errexit").unwrap_or(false);
8634 (
8635 (on_canonical || on_legacy) && (no_err & crate::ported::zsh_h::NOERREXIT_EXIT) == 0,
8636 !exec.subshell_snapshots.is_empty(),
8637 )
8638 });
8639 if !errexit_on {
8640 return Value::Int(0);
8641 }
8642 // c:Src/exec.c:1611-1618 — under ERR_EXIT a failing command exits the
8643 // whole shell via realexit() FROM THE POINT OF FAILURE, before any
8644 // enclosing `always` arm can run. zsh 5.9.2 (the reference) has no
8645 // `this_noerrexit` deferral, so at top-level / function scope the
8646 // faithful behavior is to process-exit here (zexit fires the SIGEXIT
8647 // trap and exits). This bypasses the always arm, fixing
8648 // `setopt errexit; { false } always { print A }` which wrongly ran the
8649 // always body: the deferred EXIT_PENDING routed the unwind through
8650 // always_entry (compile_zsh.rs re-points it there) and
8651 // SET_TRY_BLOCK_ERROR then cleared the pending exit so the body ran.
8652 if crate::ported::builtin::SUBSHELL_DEPTH.load(Ordering::Relaxed) == 0 {
8653 crate::ported::builtin::zexit(last, crate::ported::zsh_h::ZEXIT_NORMAL); // c:1618 realexit
8654 }
8655 // Subshell: zshrs runs subshells in-process, so it cannot process-exit
8656 // the whole shell here — defer to the subshell-end unwind.
8657 crate::ported::builtin::EXIT_VAL.store(last, Ordering::Relaxed);
8658 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
8659 let _ = in_subshell;
8660 Value::Int(1)
8661 });
8662
8663 // BUILTIN_ASSIGN_ONLY_STATUS — status of an assignment-only
8664 // simple command. c:Src/exec.c:3393-3396 (execcmd_exec, no
8665 // command word + varspc): `if (errflag) lastval = 1; else
8666 // lastval = cmdoutval;`; same shape at c:1322 (execsimple
8667 // WC_ASSIGN: `lv = (errflag ? errflag : cmdoutval)`) and
8668 // c:3977 (nullexec=2 redir variant). cmdoutval is the exit of
8669 // a `$()` that ran in an RHS (already in vm.last_status via
8670 // compile_assign's per-assign SetStatus), 0 otherwise. The
8671 // store goes to the canonical LASTVAL too — that IS C's single
8672 // `lastval` global; without it the errflag-abort path
8673 // (BUILTIN_ERREXIT_CHECK trigger 4) syncs vm.last_status from
8674 // a stale LASTVAL and `readonly r=1; r=2` exited 0, not 1.
8675 vm.register_builtin(BUILTIN_ASSIGN_ONLY_STATUS, |vm, _argc| {
8676 use std::sync::atomic::Ordering;
8677 let had_cmd_subst = vm.pop().to_int() != 0;
8678 let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
8679 & crate::ported::zsh_h::ERRFLAG_ERROR)
8680 != 0;
8681 // c:Src/exec.c addvars — `if (!pm) { lastval = 1; if
8682 // (!cmdoutval) cmdoutval = 1; }` (assignment-failed cheat).
8683 let assign_failed = ASSIGN_FAILED_FLAG.swap(false, std::sync::atomic::Ordering::Relaxed);
8684 let status = if errflag_set || assign_failed {
8685 1 // c:Src/exec.c:3394 `lastval = 1` / addvars cmdoutval=1
8686 } else if had_cmd_subst {
8687 vm.last_status // c:3396 `lastval = cmdoutval` (subst exit)
8688 } else {
8689 0 // c:3396 `lastval = cmdoutval` (cmdoutval = 0)
8690 };
8691 with_executor(|exec| exec.set_last_status(status));
8692 // c:Src/jobs.c deletefilelist — a `=(cmd)` temp file is bound to the
8693 // JOB of the command that created it and unlinked when that command
8694 // completes (Src/exec.c:5588 for the shfunc case; the simple-command
8695 // job's filelist likewise). An assignment-only command like
8696 // `f==(cmd)` has no consuming builtin/exec, so the PsubFdGuard that
8697 // cleans consuming commands never fires — the temp leaked and a later
8698 // `$(<$f)` / `[[ -f $f ]]` still saw it, where zsh deletes it at the
8699 // end of the assignment (verified: even `f==(x) && cat $f` fails).
8700 // Clean here so the assignment command is the temp's job boundary.
8701 close_pending_psub_fds();
8702 Value::Status(status)
8703 });
8704
8705 // `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
8706 // Pops [name, op_byte, rhs] (rhs popped first). Returns the modified
8707 // value as Value::Str. Handles unset/empty distinction (`:-` etc.
8708 // treat empty same as unset, matching POSIX).
8709 // BUILTIN_PARAM_DEFAULT_FAMILY — `${var-x}` / `${var:-x}` / `${var=x}` /
8710 // `${var:=x}` / `${var?x}` / `${var:?x}` / `${var+x}` / `${var:+x}`.
8711 // PURE PASSTHRU: pop name + op + rhs, reconstruct the canonical
8712 // brace expression, hand to `subst::paramsubst` (C port of
8713 // `Src/subst.c::paramsubst`). All "missing vs empty" gating,
8714 // nounset suppression, default-evaluation, and elide-empty-words
8715 // semantics live inside paramsubst.
8716 vm.register_builtin(BUILTIN_PARAM_DEFAULT_FAMILY, |vm, _argc| {
8717 let rhs = vm.pop().to_str();
8718 let op = vm.pop().to_int() as u8;
8719 let name = vm.pop().to_str();
8720 // op=8 is the `${+name}` set-test prefix form (distinct from the
8721 // `${name+rhs}` substitute-if-set suffix form which is op=7).
8722 // Per compile_zsh.rs::parse_param_modifier: the `+` is emitted as
8723 // a leading sigil and `rhs` is empty.
8724 let body = if op == 8 {
8725 format!("${{+{}}}", name)
8726 } else {
8727 let op_str = match op {
8728 0 => ":-",
8729 1 => ":=",
8730 2 => ":?",
8731 3 => ":+",
8732 4 => "-",
8733 5 => "=",
8734 6 => "?",
8735 7 => "+",
8736 _ => "-",
8737 };
8738 format!("${{{}{}{}}}", name, op_str, rhs)
8739 };
8740 paramsubst_to_value(&body)
8741 });
8742
8743 // `${var:offset[:length]}` — substring. Pops [name, offset, length].
8744 // length == -1 means "rest of string". Negative offset counts from end.
8745 // BUILTIN_PARAM_SUBSTRING — `${var:offset:length}` literal-int form.
8746 // PURE PASSTHRU: reconstruct `${name:offset:length}` and route
8747 // through `subst::paramsubst`. Length sentinel `i64::MIN` =
8748 // "no length given" (omit the `:length` portion).
8749 //
8750 // c:Src/subst.c:1571,3781 — `${name:-N}` is the colon-default
8751 // operator, NOT a substring with negative offset. zsh's lexical
8752 // rule disambiguates via a literal space: `${name: -N}` (space
8753 // before `-`) is the substring form. The reconstructed body MUST
8754 // preserve that space when offset < 0; otherwise paramsubst's
8755 // `:-` dispatch fires on the synthesized `${name:-N}` body and
8756 // returns N as the unset-default instead of slicing the last N
8757 // chars. Length-form `${name:-N:M}` has the same trap.
8758 vm.register_builtin(BUILTIN_PARAM_SUBSTRING, |vm, _argc| {
8759 let length = vm.pop().to_int();
8760 let offset = vm.pop().to_int();
8761 let name = vm.pop().to_str();
8762 let off_sep = if offset < 0 { " " } else { "" };
8763 let body = if length == i64::MIN {
8764 format!("${{{}:{}{}}}", name, off_sep, offset)
8765 } else {
8766 format!("${{{}:{}{}:{}}}", name, off_sep, offset, length)
8767 };
8768 paramsubst_to_value(&body)
8769 });
8770
8771 // BUILTIN_PARAM_SUBSTRING_EXPR — `${var:offset_expr[:length_expr]}` form.
8772 // PURE PASSTHRU: rebuild `${name:offset:length}` using the
8773 // expression text verbatim (paramsubst's offset/length
8774 // parser evaluates arith / param refs itself).
8775 //
8776 // c:Src/subst.c:1571,3781 — same `:-` disambiguation trap as
8777 // BUILTIN_PARAM_SUBSTRING. The expression text may itself start
8778 // with `-` (e.g. `${VAR:$((-1))}` arith resolves at the body-
8779 // assembly layer in some upstream paths, leaving `-1` in
8780 // off_expr). Insert a leading space when off_expr starts with
8781 // `-` so paramsubst's check_colon_subscript (subst.c:1571)
8782 // accepts the operand as a math expression instead of the
8783 // `:-` operator catching it.
8784 vm.register_builtin(BUILTIN_PARAM_SUBSTRING_EXPR, |vm, _argc| {
8785 let has_len = vm.pop().to_int() != 0;
8786 let len_expr = vm.pop().to_str();
8787 let off_expr = vm.pop().to_str();
8788 let name = vm.pop().to_str();
8789 let off_sep = if off_expr.starts_with('-') { " " } else { "" };
8790 let body = if has_len {
8791 format!("${{{}:{}{}:{}}}", name, off_sep, off_expr, len_expr)
8792 } else {
8793 format!("${{{}:{}{}}}", name, off_sep, off_expr)
8794 };
8795 paramsubst_to_value(&body)
8796 });
8797
8798 // `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}`
8799 // Pops [name, pattern, op_byte]. op: 0=`#` short-prefix, 1=`##` long,
8800 // 2=`%` short-suffix, 3=`%%` long. Glob-pattern matching via the
8801 // existing glob_match_static helper.
8802 // BUILTIN_PARAM_STRIP — `${var#pat}` / `${var##pat}` / `${var%pat}` /
8803 // `${var%%pat}`. PURE PASSTHRU: reconstruct the brace expression
8804 // and route through `subst::paramsubst`. (M)/(S) flags arrive
8805 // through SUB_FLAGS (already inside paramsubst's scope), so we
8806 // just clear the bridge-side cached read.
8807 vm.register_builtin(BUILTIN_PARAM_STRIP, |vm, _argc| {
8808 let _dq_flag = vm.pop().to_int() != 0;
8809 let op = vm.pop().to_int() as u8;
8810 let pattern = vm.pop().to_str();
8811 let name = vm.pop().to_str();
8812 let op_str = match op {
8813 0 => "#",
8814 1 => "##",
8815 2 => "%",
8816 3 => "%%",
8817 _ => "#",
8818 };
8819 let body = format!("${{{}{}{}}}", name, op_str, pattern);
8820 paramsubst_to_value(&body)
8821 });
8822
8823 // `$((expr))` — pops [expr_string], evaluates via MathEval which
8824 // honors integer-vs-float distinction (zsh-compatible). Returns
8825 // the result as Value::Str so it can be Concat'd into surrounding
8826 // word context.
8827 vm.register_builtin(BUILTIN_ARITH_EVAL, |vm, _argc| {
8828 // Pure path: evaluate expr, return string. errflag may be
8829 // set by arithsubst on math error; the caller decides
8830 // whether to clear it. For `(( ... ))` (math command) the
8831 // compile_arith path clears via BUILTIN_ARITH_CMD_FINISH;
8832 // for `$((... ))` (substitution inside another command)
8833 // errflag stays set so the surrounding command aborts —
8834 // matches c:Src/math.c "math errors propagate as errflag
8835 // through the containing word expansion".
8836 let expr = vm.pop().to_str();
8837 let result = crate::ported::subst::arithsubst(&expr, "", "");
8838 let _ = vm; // silence unused warning when no math error path mutates
8839 Value::str(result)
8840 });
8841
8842 // After-call hook used by compile_arith's `(( ... ))` path: when
8843 // arithsubst set errflag (math error), clear it and signal
8844 // status=2 in vm.last_status — matches zsh's c:exec.c arith-
8845 // failure: the math command exits 2 and the script continues.
8846 vm.register_builtin(BUILTIN_ARITH_CMD_FINISH, |vm, _argc| {
8847 use std::sync::atomic::Ordering;
8848 let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
8849 let err = live & crate::ported::zsh_h::ERRFLAG_ERROR;
8850 let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
8851 if err != 0 {
8852 // c:Src/subst.c:3344 — when `${var:?msg}` fires, errflag
8853 // is OR'd with ERRFLAG_HARD to signal a script-abort
8854 // error (vs a recoverable math error like `$((1/0))`).
8855 // Clear only the ERRFLAG_ERROR bit; preserve
8856 // ERRFLAG_HARD so the next ERREXIT_CHECK aborts the
8857 // script. Bug #193 in docs/BUGS.md.
8858 if hard != 0 {
8859 // Keep ERRFLAG_HARD AND ERRFLAG_ERROR set so the
8860 // script-abort gate downstream still fires.
8861 vm.last_status = 2;
8862 Value::Status(2)
8863 } else {
8864 crate::ported::utils::errflag
8865 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
8866 vm.last_status = 2;
8867 Value::Status(2)
8868 }
8869 } else {
8870 Value::Status(vm.last_status)
8871 }
8872 });
8873
8874 // `$(cmd)` — pops [cmd_string], routes through
8875 // run_command_substitution which performs an in-process pipe-capture.
8876 // Avoids the Op::CmdSubst sub-chunk word-emit bug
8877 // (`printf "a\nb"` produced "anb" via that path). Returns trimmed
8878 // output (trailing newlines stripped per POSIX cmd-sub semantics).
8879 vm.register_builtin(BUILTIN_CMD_SUBST_TEXT, |vm, _argc| {
8880 let cmd = vm.pop().to_str();
8881 // Inherit live $? into the inner shell so cmd-subst sees the
8882 // parent's most recent exit. Same rationale as the mode-3
8883 // backtick path above.
8884 let live_status = vm.last_status;
8885 let result = with_executor(|exec| {
8886 exec.set_last_status(live_status);
8887 exec.run_command_substitution(&cmd)
8888 });
8889 // Mirror run_command_substitution's exec.last_status side
8890 // effect into the VM's live counter so a containing
8891 // assignment's BUILTIN_SET_VAR — which reads vm.last_status
8892 // — sees the cmd-subst's exit. Without this, `a=$(false);
8893 // echo $?` reads stale 0 (vm.last_status was zeroed by
8894 // compile_assign's prelude SetStatus, and run_cmd_subst only
8895 // updated exec.last_status). Pull the value back through
8896 // exec since it owns the canonical post-subst record.
8897 let cs_status = with_executor(|exec| exec.last_status());
8898 vm.last_status = cs_status;
8899 // c:Src/exec.c — a command substitution running during a
8900 // command's word expansion makes its exit the status of an
8901 // otherwise-empty command (`$(exit 5)` → 5). Flag it so
8902 // BUILTIN_EXEC_DYNAMIC's null-command branch keeps `$?` instead
8903 // of resetting to 0.
8904 crate::ported::exec::use_cmdoutval.store(1, std::sync::atomic::Ordering::Relaxed);
8905 Value::str(result)
8906 });
8907
8908 // Text-based word expansion. Pops [preserved_text, mode_byte].
8909 // mode_byte:
8910 // 0 = Default — expand_string + xpandbraces + expand_glob
8911 // 1 = DoubleQuoted — strip outer `"…"`, expand_string only
8912 // (no brace, no glob — DQ semantics)
8913 // 2 = SingleQuoted — strip outer `'…'`, no expansion
8914 // (kept for symmetry; Snull early-return covers most SQ)
8915 // 3 = AltBackquote — strip backticks, run as cmd-sub
8916 // 7 = RedirTarget — same as Default but glob gated on MULTIOS
8917 // (c:Src/glob.c:2161-2167 xpandredir)
8918 // Single result → Value::str; multi → Value::Array.
8919 vm.register_builtin(BUILTIN_EXPAND_TEXT, |vm, _argc| {
8920 let mode = vm.pop().to_int() as u8;
8921 let text = vm.pop().to_str();
8922 // Sync vm.last_status → exec.last_status so cmd-subst (mode 3)
8923 // and any nested $? reads inside singsub see the live `$?`
8924 // from the most recent VM op. Without this, cmd-subst inside
8925 // arg-eval saw a stale exec.last_status that was zeroed at
8926 // the start of the current statement. Direct port of zsh's
8927 // pre-cmdsubst lastval propagation per Src/exec.c:4770.
8928 let live_status = vm.last_status;
8929 with_executor(|exec| exec.set_last_status(live_status));
8930 let result_value = with_executor(|exec| match mode {
8931 // Mode 1 = DoubleQuoted (argument context).
8932 // Mode 5 = DoubleQuoted in scalar-assignment context.
8933 // Both share the same DQ unescape pre-processing; mode 5
8934 // additionally bumps `in_scalar_assign` so subst_port's
8935 // paramsubst sees ssub=true and suppresses split flags
8936 // `(f)` / `(s:STR:)` / `(0)` per Src/subst.c:1759 +
8937 // Src/exec.c::addvars line 2546 (the PREFORK_SINGLE bit
8938 // C zsh sets when prefork-ing the assignment RHS).
8939 1 | 5 => {
8940 // DoubleQuoted: strip outer `"…"` if present. In DQ
8941 // context, `\` escapes the DQ-special chars `$`, `` ` ``,
8942 // `"`, `\`. zsh's expand_string expects the lexer's
8943 // `\0X` literal-marker for an already-escaped char, so
8944 // we pre-process: `\$` → `\0$`, `\\` → `\0\`, etc. Then
8945 // expand_string handles the rest.
8946 let inner = if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
8947 &text[1..text.len() - 1]
8948 } else {
8949 text.as_str()
8950 };
8951 // The lexer's dquote_parse (Src/lex.c) already tokenized
8952 // DQ contents: `$` → Qstring (\u{8c}), `\$`/`\\`/`\"`/
8953 // `` \` `` → Bnull (\u{9f}) + literal. Stringsubst /
8954 // multsub recognize these markers natively. We pass
8955 // `inner` through verbatim — no re-tokenization needed.
8956 let prepped: String = inner.to_string();
8957 // Tell parameter-flag application that we're inside
8958 // double quotes — array-only flags ((o), (O), (n),
8959 // (i), (M), (u)) must be no-ops here per zsh.
8960 exec.in_dq_context += 1;
8961 if mode == 5 {
8962 exec.in_scalar_assign += 1;
8963 }
8964 // Mode 1 = argv DQ word; mode 5 = scalar-assign RHS.
8965 // In C zsh, the corresponding prefork-on-list paths
8966 // are: argv → `prefork(argv_list, 0)` returns multi-
8967 // word LinkList (Src/exec.c::execcmd), assignment →
8968 // `prefork(rhs_list, PREFORK_SINGLE|PREFORK_ASSIGN)`
8969 // returns single-word (Src/exec.c::addvars line
8970 // 2546). zshrs's `multsub` (Src/subst.c:544) is the
8971 // multi-result variant; `singsub` (Src/subst.c:514)
8972 // asserts ≤1 node. Mode 5 keeps singsub; mode 1
8973 // switches to multsub so `"${(@)arr}"`/`"$@"`/
8974 // `"${arr[@]}"` in argv context emit multiple words
8975 // as the C path would.
8976 // c:Src/lex.c untokenize — the final argv pass C runs
8977 // on every expanded word (glob.c:1862 / exec.c) drops
8978 // the Nularg empty-word sentinel remnulargs left in
8979 // place and folds any remaining token chars. Without
8980 // it, quoted splits with empty pieces
8981 // ("${(s:|:)x}" on "|a|b|") leak U+00A1 into argv.
8982 let result_value = if mode == 5 {
8983 let out = crate::ported::subst::singsub(&prepped);
8984 Value::str(crate::ported::lex::untokenize(&out))
8985 } else {
8986 let (_first, nodes, _ms_ws, _ret) = crate::ported::subst::multsub(&prepped, 0);
8987 // c:Src/subst.c:655 — multsub returns Vec::new()
8988 // for zero-word results (quoted array splat that
8989 // resolved to empty array). Surface as
8990 // Value::Array(vec![]) so the downstream array
8991 // assignment / argv flattening sees ZERO args.
8992 // Previous Rust port returned Value::str("") which
8993 // surfaced as ONE empty arg. Bug #120 in
8994 // docs/BUGS.md.
8995 if nodes.is_empty() {
8996 Value::Array(Vec::new())
8997 } else if nodes.len() == 1 {
8998 Value::str(crate::ported::lex::untokenize(
8999 &nodes.into_iter().next().unwrap(),
9000 ))
9001 } else {
9002 Value::Array(
9003 nodes
9004 .into_iter()
9005 .map(|n| Value::str(crate::ported::lex::untokenize(&n)))
9006 .collect(),
9007 )
9008 }
9009 };
9010 if mode == 5 {
9011 exec.in_scalar_assign -= 1;
9012 }
9013 exec.in_dq_context -= 1;
9014 result_value
9015 }
9016 2 => {
9017 // SingleQuoted: pure literal, strip outer `'…'`.
9018 let inner = if text.len() >= 2 && text.starts_with('\'') && text.ends_with('\'') {
9019 &text[1..text.len() - 1]
9020 } else {
9021 text.as_str()
9022 };
9023 Value::str(inner.to_string())
9024 }
9025 3 => {
9026 // Backquote command sub: strip outer backticks.
9027 // Word-split the result on IFS when the surrounding
9028 // word is unquoted — zsh: `print -l \`echo a b c\``
9029 // emits one arg per word. The $(…) path applies the
9030 // same split via BUILTIN_WORD_SPLIT after capture; do
9031 // the equivalent here for the `…` form.
9032 let inner = if text.len() >= 2 && text.starts_with('`') && text.ends_with('`') {
9033 &text[1..text.len() - 1]
9034 } else {
9035 text.as_str()
9036 };
9037 // Apply the live VM status before running the inner
9038 // shell so the inherited $? matches zsh's lastval
9039 // propagation.
9040 exec.set_last_status(live_status);
9041 let captured = exec.run_command_substitution(inner);
9042 let trimmed = captured.trim_end_matches('\n');
9043 if exec.in_dq_context > 0 {
9044 Value::str(trimmed.to_string())
9045 } else {
9046 let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
9047 let parts: Vec<Value> = trimmed
9048 .split(|c: char| ifs.contains(c))
9049 .filter(|s| !s.is_empty())
9050 .map(|s| Value::str(s.to_string()))
9051 .collect();
9052 if parts.is_empty() {
9053 Value::str(String::new())
9054 } else if parts.len() == 1 {
9055 parts.into_iter().next().unwrap()
9056 } else {
9057 Value::Array(parts)
9058 }
9059 }
9060 }
9061 4 => {
9062 // HeredocBody: expand variables / command-subst / arith
9063 // but NOT glob or brace. Heredoc lines like `[42]` must
9064 // pass through verbatim — running them through the
9065 // default pipeline triggers NOMATCH on the literal.
9066 Value::str(crate::ported::subst::singsub(&text))
9067 }
9068 _ => {
9069 // Default (unquoted): the lexer's gettokstr already
9070 // tokenized backslash-escapes (`\$` → Bnull+$, etc).
9071 // Pass `text` through verbatim — multsub/stringsubst
9072 // recognize the markers natively. No bridge-side
9073 // re-tokenization needed.
9074 //
9075 // Mode 6 = unquoted RHS in scalar-assign context.
9076 // Pass PREFORK_ASSIGN so prefork's filesub colon-walk
9077 // fires per c:Src/exec.c:2546.
9078 let prepped: String = text.clone();
9079 if std::env::var("ZSHRS_TRACE_DEFP").is_ok() {
9080 eprintln!(
9081 "[TRACE_DEFP] text={:?} prepped={:?} mode={}",
9082 text, prepped, mode
9083 );
9084 }
9085 let pf_flags = if mode == 6 {
9086 crate::ported::zsh_h::PREFORK_ASSIGN
9087 } else {
9088 0
9089 };
9090 // c:Src/subst.c:544+ — `multsub(&prepped, 0)` is the
9091 // unquoted-argv equivalent of zsh's `prefork(list,
9092 // 0, NULL)` for a single-element list. Returns the
9093 // post-expansion node list (Vec<String>) so array-
9094 // shape results (e.g. `${a:e}`, `${a[@]}`,
9095 // `${(s::)str}`) splat into multiple argv words.
9096 // singsub() collapses to one string and discards the
9097 // splat — parity bug #28 (whole-array modifier).
9098 let (_first, nodes, _ms_ws, _ret) =
9099 crate::ported::subst::multsub(&prepped, pf_flags);
9100 if std::env::var("ZSHRS_TRACE_MULTSUB").is_ok() {
9101 eprintln!("[TRACE_MULTSUB] prepped={:?} nodes={:?}", prepped, nodes);
9102 }
9103 // c:Src/subst.c:166 — xpandbraces runs AFTER prefork's
9104 // substitution pass and BEFORE untokenize/glob. Per
9105 // word, scan for Inbrace TOKEN and expand. Words that
9106 // don't contain Inbrace TOKEN pass through unchanged.
9107 // Brace expansion is done here (inside the bridge
9108 // default arm) instead of via a post-EXPAND_TEXT
9109 // BRACE_EXPAND emit because untokenize (line below)
9110 // strips TOKEN bytes, after which the strict-TOKEN
9111 // xpandbraces gate would no longer match.
9112 let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
9113 // c:Src/options.c — `no_brace_expand` (negated
9114 // `braceexpand`) gates brace expansion entirely.
9115 // When off, `{a,b}` stays literal.
9116 let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
9117 let pre_brace: Vec<String> = if nodes.is_empty() {
9118 vec![String::new()]
9119 } else {
9120 nodes
9121 };
9122 let brace_expanded: Vec<String> = pre_brace
9123 .into_iter()
9124 .flat_map(|w| {
9125 if brace_expand && w.contains('\u{8f}') {
9126 crate::ported::glob::xpandbraces(&w, brace_ccl)
9127 } else {
9128 vec![w]
9129 }
9130 })
9131 .collect();
9132 // zsh stores the option as `glob` (default ON);
9133 // `setopt noglob` writes `glob=false`. Honor either
9134 // form so the dispatcher behaves the same as zsh.
9135 // Mode 7 = redirect-target word: glob only under
9136 // MULTIOS (c:Src/glob.c:2161-2167 xpandredir,
9137 // "Globbing is only done for multios.").
9138 let noglob = opt_state_get("noglob").unwrap_or(false)
9139 || opt_state_get("GLOB").map(|v| !v).unwrap_or(false)
9140 || !opt_state_get("glob").unwrap_or(true)
9141 || (mode == 7 && !opt_state_get("multios").unwrap_or(true));
9142 let parts: Vec<String> = brace_expanded
9143 .into_iter()
9144 .flat_map(|s| {
9145 // The lexer leaves glob metacharacters in their
9146 // META-encoded form: `*` → `\u{87}`, `?` →
9147 // `\u{86}`, `[` → `\u{91}`, etc. expand_string
9148 // doesn't untokenize them, so the literal-char
9149 // checks below (`s.contains('*')`) would miss
9150 // every real glob and skip expand_glob — that
9151 // bug let `echo *.toml` print the literal
9152 // `*.toml` because the META `\u{87}` never
9153 // matched the literal `*`. Untokenize once so
9154 // the metacharacter checks see the canonical
9155 // form. zsh's pattern.c expects `*` etc. as
9156 // bare chars at the glob layer.
9157 // c:Src/pattern.c:4306 haswilds on the still-
9158 // TOKENIZED word (pre-untokenize), matching C's
9159 // zglob entry gate (Src/glob.c:1230) which runs
9160 // on the lexer-tokenized string. haswilds
9161 // matches ONLY token codes: source-level
9162 // `*.toml` carries Star and fires; bare literal
9163 // `[`/`*`/`?` from `$'...'` decode, `:-`
9164 // default values, or nested-substitution
9165 // results were never shtokenize'd (C
9166 // subst.c:3231 sets globsubst=0 in the `:-`
9167 // arm) and stay literal — bug #625. Plain
9168 // multibyte text (`↔`) never matches a token
9169 // codepoint — bug #627.
9170 let is_glob_pre = !noglob && crate::ported::pattern::haswilds(&s);
9171 let s = crate::lex::untokenize(&s);
9172 // Skip glob expansion for assignment-shaped
9173 // words (`NAME=value`). zsh doesn't expand the
9174 // RHS of an assignment as a path glob unless
9175 // `setopt globassign` is set, and feeding such
9176 // words through expand_glob makes NOMATCH
9177 // (default ON) fire spuriously on
9178 // `integer i=2*3+1`, `path=*.rs`, etc.
9179 let is_assignment_shape = {
9180 let bytes = s.as_bytes();
9181 let mut i = 0;
9182 if !bytes.is_empty()
9183 && (bytes[0] == b'_' || bytes[0].is_ascii_alphabetic())
9184 {
9185 i += 1;
9186 while i < bytes.len()
9187 && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric())
9188 {
9189 i += 1;
9190 }
9191 i < bytes.len() && bytes[i] == b'='
9192 } else {
9193 false
9194 }
9195 };
9196 // Glob-trigger decision: pre-untokenize
9197 // haswilds_tokens_only result (computed above
9198 // before the untokenize that collapses META
9199 // tokens to their ASCII forms). The TOKEN-only
9200 // gate matches C `Src/pattern.c:4306-4376`
9201 // exactly — only Inbrack/Star/Quest/Inpar/Bar/
9202 // Inang/Pound/Hat token codes count as wild,
9203 // not their literal ASCII counterparts. Source-
9204 // level `*.toml` carries Star token so globs;
9205 // `$'…'`-decoded `[abc]` carries bare `[` so
9206 // stays literal. Bug #625.
9207 if is_glob_pre && !is_assignment_shape {
9208 exec.expand_glob(&s)
9209 } else if is_assignment_shape
9210 && crate::ported::zsh_h::isset(crate::ported::zsh_h::MAGICEQUALSUBST)
9211 {
9212 // c:Src/exec.c:3353 — when MAGIC_EQUAL_SUBST is set
9213 // on a non-typeset command, esprefork = PREFORK_TYPESET,
9214 // so every NAME=value arg runs through
9215 // filesub(PREFORK_TYPESET): the `~`/`=` after the
9216 // first `=` (and after each `:`) undergo filename
9217 // expansion. `print foo=~/bar` → `foo=$HOME/bar`.
9218 // filesubstr (subst.c:741) keys on the Tilde TOKEN,
9219 // not literal `~`; this `s` was already untokenized
9220 // above, so re-tokenize (as BUILTIN_MAGIC_EQUALS_PREFORK
9221 // does) before filesub, then untokenize the result.
9222 let mut tokd = s.clone();
9223 crate::ported::glob::shtokenize(&mut tokd);
9224 let exp = crate::ported::subst::filesub(
9225 &tokd,
9226 crate::ported::zsh_h::PREFORK_TYPESET,
9227 );
9228 vec![crate::lex::untokenize(&exp).to_string()]
9229 } else {
9230 vec![s]
9231 }
9232 })
9233 .collect();
9234 if parts.len() == 1 {
9235 let only = parts.into_iter().next().unwrap_or_default();
9236 // Empty unquoted expansion → drop the arg entirely
9237 // (zsh "remove empty unquoted words" rule). Returning
9238 // an empty Value::Array makes pop_args contribute zero
9239 // items. Direct port of subst.c's empty-elide pass at
9240 // the end of multsub which removes empty linknodes
9241 // from unquoted contexts. Quoted DQ/SQ paths (modes
9242 // 1/2/5) take separate arms above and always emit
9243 // Value::Str so the empty arg survives.
9244 //
9245 // c:Src/subst.c:4437 + 1650-1656 — a word CONTAINING a
9246 // quoted span never drops: `x"${v[-1]}"y` (v empty)
9247 // is the scalar "xy", and a standalone `"${v[-1]}"`
9248 // is ONE empty arg. The lexer marks DQ/SQ spans with
9249 // Dnull(\u{9e})/Snull(\u{9d})/Qstring(\u{8c})/
9250 // Bnull(\u{9f}); their presence in the SOURCE word
9251 // means qt semantics apply. Without this gate, zpwr's
9252 // global `setopt rc_expand_param` turned autopair's
9253 // `local lchar="${LBUFFER[-1]}"` (empty prompt +
9254 // backspace) into an ARGLESS `local` — the full
9255 // parameter-table dump the user saw per keystroke.
9256 if only.is_empty() {
9257 let word_has_quoted_span = text.chars().any(|c| {
9258 matches!(c, '\u{9e}' | '\u{9d}' | '\u{8c}' | '\u{9f}' | '"' | '\'')
9259 });
9260 if word_has_quoted_span {
9261 Value::str(String::new())
9262 } else {
9263 Value::Array(Vec::new())
9264 }
9265 } else {
9266 Value::str(only)
9267 }
9268 } else {
9269 Value::Array(parts.into_iter().map(Value::str).collect())
9270 }
9271 }
9272 });
9273 // Pull any inner cmd-subst (`` `cmd` `` via mode 3 or via
9274 // mode 0/6 multsub → getoutput, `$(cmd)` via the default
9275 // arm's multsub path, nested `$()`s reached through
9276 // stringsubst) back into vm.last_status so a containing
9277 // assignment's BUILTIN_SET_VAR — which reads vm.last_status —
9278 // sees the cmd-subst's exit. Without this, backtick
9279 // assignments (`a=\`false\`; echo $?`) reported 0 because the
9280 // ported LASTVAL update never reached the VM-side counter.
9281 let cs_status = with_executor(|exec| exec.last_status());
9282 vm.last_status = cs_status;
9283 result_value
9284 });
9285
9286 // `${#name}` — pops [name]. Returns the value's element count for
9287 // arrays (indexed and assoc) or character length for scalars.
9288 // BUILTIN_PARAM_LENGTH — `${#name}`. PURE PASSTHRU.
9289 vm.register_builtin(BUILTIN_PARAM_LENGTH, |vm, _argc| {
9290 let name = vm.pop().to_str();
9291 // PARAM_LENGTH's empty-result semantics differ from
9292 // paramsubst_to_value: 0 nodes → "0" (numeric length), not
9293 // empty array. paramsubst on `${#X}` always returns at least
9294 // one node in practice (the length string); the empty case
9295 // is defensive.
9296 let mut ret_flags: i32 = 0;
9297 let (_full, _pos, nodes) = crate::ported::subst::paramsubst(
9298 &format!("${{#{}}}", name),
9299 0,
9300 false,
9301 0i32,
9302 &mut ret_flags,
9303 );
9304 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
9305 with_executor(|exec| exec.set_last_status(1));
9306 }
9307 if nodes.is_empty() {
9308 Value::str("0")
9309 } else {
9310 nodes_to_value(nodes)
9311 }
9312 });
9313
9314 // `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
9315 // `${var/%pat/repl}` — Pops [name, pattern, replacement, op_byte].
9316 // op: 0=first, 1=all, 2=anchor-prefix (`/#`), 3=anchor-suffix (`/%`).
9317 // BUILTIN_PARAM_REPLACE — `${var/pat/repl}` / `${var//pat/repl}` /
9318 // `${var/#pat/repl}` / `${var/%pat/repl}`. PURE PASSTHRU.
9319 vm.register_builtin(BUILTIN_PARAM_REPLACE, |vm, _argc| {
9320 let dq_flag = vm.pop().to_int() != 0;
9321 let op = vm.pop().to_int() as u8;
9322 let repl = vm.pop().to_str();
9323 let pattern = vm.pop().to_str();
9324 let name = vm.pop().to_str();
9325 // DQ context: C's lexer marks every `$` inside double quotes
9326 // as the Qstring token (Src/lex.c dquote_parse) and keeps `'`
9327 // a plain char — so a DQ replacement's `$'…'` is LITERAL in
9328 // C (Src/subst.c:301 decodes only the tokenized Snull form;
9329 // `"${a/X/$'\0'}"` keeps the five chars `$'\0'`). The body
9330 // rebuilt below re-enters stringsubst as raw text, which
9331 // would mis-decode `$'…'` as ANSI-C; stamp the Qstring
9332 // marker on the repl's `$` so stringsubst sees the same DQ
9333 // signal C's tokens carry. The PATTERN side keeps decoding
9334 // (matches observed zsh: the pattern's `$'\0'` matches a
9335 // real NUL while the repl's stays literal).
9336 let repl = if dq_flag {
9337 repl.replace('$', "\u{8c}")
9338 } else {
9339 repl
9340 };
9341 // op encoding: 0 = first `/`, 1 = all `//`, 2 = anchor-prefix
9342 // `/#`, 3 = anchor-suffix `/%`. The brace form distinguishes
9343 // first-vs-all by single vs doubled slash, and anchored by
9344 // a `#` or `%` immediately after the slash(es).
9345 let body = match op {
9346 0 => format!("${{{}/{}/{}}}", name, pattern, repl),
9347 1 => format!("${{{}//{}/{}}}", name, pattern, repl),
9348 2 => format!("${{{}/#{}/{}}}", name, pattern, repl),
9349 3 => format!("${{{}/%{}/{}}}", name, pattern, repl),
9350 _ => format!("${{{}/{}/{}}}", name, pattern, repl),
9351 };
9352 // c:Src/subst.c:1625 — paramsubst's qt flag. The compiler
9353 // threads the word's DQ context onto the stack; dropping it
9354 // (the old `let _dq_flag`) ran the rebuilt body with qt=false
9355 // whenever the opcode fired outside an EXPAND_TEXT scope, so
9356 // DQ-only semantics inside the replacement (e.g. `$'` staying
9357 // literal per Src/subst.c:301 — `"${a/x/$'\t'q}"`) were lost.
9358 // Bump in_dq_context exactly like EXPAND_TEXT mode 1 so
9359 // paramsubst_to_value's qt probe sees the right context.
9360 if dq_flag {
9361 with_executor(|exec| exec.in_dq_context += 1);
9362 }
9363 let ret = paramsubst_to_value(&body);
9364 if dq_flag {
9365 with_executor(|exec| exec.in_dq_context -= 1);
9366 }
9367 ret
9368 });
9369
9370 vm.register_builtin(BUILTIN_REGISTER_COMPILED_FN, |vm, argc| {
9371 let args = pop_args(vm, argc);
9372 let mut iter = args.into_iter();
9373 let name = iter.next().unwrap_or_default();
9374 let body_b64 = iter.next().unwrap_or_default();
9375 let body_source = iter.next().unwrap_or_default();
9376 let line_base_str = iter.next().unwrap_or_default();
9377 let line_base: i64 = line_base_str.parse().unwrap_or(0);
9378 let bytes = base64_decode(&body_b64);
9379 let status = match bincode::deserialize::<fusevm::Chunk>(&bytes) {
9380 Ok(chunk) => with_executor(|exec| {
9381 // c:Src/exec.c:5383 — `shf->filename =
9382 // ztrdup(scriptfilename);` — the function's
9383 // definition-file is read from the canonical
9384 // file-scope `scriptfilename` global at compile
9385 // time, NOT from a per-executor struct field.
9386 // exec.scriptfilename is seeded once at
9387 // bins/zshrs.rs:1717 to the bin basename ("zsh")
9388 // and never updates on source/dot, so reading from
9389 // it left every user function's def_file as "zsh".
9390 // Route through scriptfilename_get() so source /
9391 // dot's set_scriptfilename calls propagate.
9392 let def_file = crate::ported::utils::scriptfilename_get()
9393 .or_else(|| exec.scriptfilename.clone());
9394 if !body_source.is_empty() {
9395 exec.function_source
9396 .insert(name.clone(), body_source.clone());
9397 }
9398 exec.function_line_base.insert(name.clone(), line_base);
9399 exec.function_def_file.insert(name.clone(), def_file);
9400 // PFA-SMR aspect: every `name() {}` / `function name { }`
9401 // funnels through here at compile time. Emit one record
9402 // with the function name + raw body source.
9403 #[cfg(feature = "recorder")]
9404 if crate::recorder::is_enabled() {
9405 let ctx = exec.recorder_ctx();
9406 let body = if body_source.is_empty() {
9407 None
9408 } else {
9409 Some(body_source.as_str())
9410 };
9411 crate::recorder::emit_function(&name, body, ctx);
9412 }
9413 // Mirror into canonical shfunctab so scanfunctions /
9414 // ${(k)functions} / functions builtin see user defs.
9415 // C: exec.c:funcdef → shfunctab->addnode(ztrdup(name),shf).
9416 if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
9417 let mut shf = crate::ported::hashtable::shfunc_with_body(&name, &body_source);
9418 // c:Src/exec.c:5409 — `shf->lineno = lineno;`. Use
9419 // the same max(1, line_base) clamp as the synth_shf
9420 // in vm_helper::dispatch_function_call. Bug #396.
9421 shf.lineno = std::cmp::max(1, line_base);
9422 tab.add(shf);
9423 }
9424 // c:Src/exec.c:5460-5475 — `TRAP<SIG>() { ... }` is the
9425 // function-named trap install. zsh detects the `TRAP`
9426 // prefix at func-def time and calls
9427 // `settrap(signum, NULL, ZSIG_FUNC)` so the next
9428 // dispatch of that signal routes to the named shfunc.
9429 // Bug #157 in docs/BUGS.md — fusevm_bridge's funcdef
9430 // opcode skipped this dispatch entirely, so TRAPEXIT /
9431 // TRAPUSR1 / TRAPZERR / TRAPDEBUG never fired.
9432 if name.len() > 4 && name.starts_with("TRAP") {
9433 if let Some(sn) = crate::ported::jobs::getsigidx(&name[4..]) {
9434 let _ = crate::ported::signals::settrap(
9435 sn,
9436 None,
9437 crate::ported::zsh_h::ZSIG_FUNC as i32,
9438 );
9439 }
9440 }
9441 exec.functions_compiled.insert(name, chunk);
9442 0
9443 }),
9444 Err(_) => 1,
9445 };
9446 Value::Status(status)
9447 });
9448
9449 // Wire the ShellHost so direct shell ops (Op::Glob, Op::TildeExpand,
9450 // Op::ExpandParam, Op::CmdSubst, Op::CallFunction, etc.) route through
9451 // ZshrsHost back into the executor.
9452 vm.set_shell_host(Box::new(ZshrsHost));
9453}
9454
9455impl ZshrsHost {
9456 /// True iff `c` can be a `(j:…:)` / `(s:…:)` delimiter — non-alphanumeric,
9457 /// non-underscore. Restricting to punctuation avoids `(jL)` consuming `L`
9458 /// as a delim instead of as the next flag.
9459 fn is_zsh_flag_delim(c: char) -> bool {
9460 !c.is_ascii_alphanumeric() && c != '_'
9461 }
9462}
9463
9464/// Shared `${name[idx]}` subscript dispatch for BUILTIN_ARRAY_INDEX
9465/// and the KSHARRAYS-unset arm of BUILTIN_ARRAY_INDEX_UNBRACED.
9466///
9467/// c:Src/subst.c subscript parsing — when paramsubst re-parses the
9468/// synthesized `${name[idx]}` body, characters like `'` `"` `\` `$`
9469/// etc. are LEXER-active inside the `[…]` and get reinterpreted
9470/// (quote-strip, paramsubst recursion, …). For PRE-EVALUATED key
9471/// strings (the dynamic-key fast path at compile_zsh.rs:3234 already
9472/// expanded `$k` via EXPAND_TEXT), the idx is a literal string that
9473/// must match the stored key byte-for-byte — no further
9474/// reinterpretation. Direct assoc lookup bypasses the lexer for this
9475/// case, avoiding the quote-strip bug where `h[a'b]` failed to
9476/// resolve because paramsubst's subscript lexer treated the `'` as a
9477/// quote. Bug #338. Only fires for simple assoc-name + non-flag idx
9478/// (no outer-flag sentinels, no `(…)` flag prefix on idx, no splat
9479/// operator). Other paths (slice, splat, flag-based search,
9480/// magic-assoc) still flow through paramsubst.
9481fn array_index_lookup(name: &str, idx: &str) -> Value {
9482 let idx_is_simple = !idx.starts_with('(') && idx != "@" && idx != "*" && !idx.contains(',');
9483 if idx_is_simple {
9484 // assoc_key_hit: single-lock O(1) probe — exec.assoc() clones
9485 // the WHOLE map per lookup (O(n), quadratic in shell loops).
9486 // When `name` IS an assoc, exact-key semantics apply to EVERY
9487 // plain key: hit → value, miss → empty (C `${assoc[missing]}`).
9488 // Never fall through to the textual `${name[key]}` rebuild —
9489 // keys carrying `{`/`}`/`[`/`]` (zsh-autopair probes
9490 // `${AUTOPAIR_LBOUNDS[$pair]}` with pair='{') re-parse as
9491 // broken syntax there ("failed to compile regex: repetition
9492 // quantifier…" + a `}` appended per keystroke).
9493 if let Some((_, v)) = crate::vm_helper::assoc_key_hit(name, idx) {
9494 return Value::str(v.unwrap_or_default());
9495 }
9496 }
9497 // c:Src/params.c:1449-1450 getindex — a leading `(e)`/`(E)` flag
9498 // group makes the subscript LITERAL (group consumed, exact key).
9499 // The textual rebuild below re-parses a FLAT `${name[(e)KEY]}`
9500 // string, so a `]` / `}` that arrived via `$key` expansion
9501 // terminates the subscript / brace early — "bad substitution" or
9502 // spilled-junk values (zpwr expandstats iterates alias keys
9503 // containing brackets). C never re-parses: getarg scans the
9504 // TOKENIZED source where expanded data brackets are inert. Do the
9505 // exact-match lookup directly against the assoc (plain or magic
9506 // alias tables); search groups ((r)/(i)/(k)/…) and other targets
9507 // keep the textual path.
9508 if let Some(rest) = idx.strip_prefix('(') {
9509 if let Some(close) = rest.find(')') {
9510 let grp = &rest[..close];
9511 if !grp.is_empty() && grp.chars().all(|ch| ch == 'e' || ch == 'E') {
9512 let key = &rest[close + 1..];
9513 if let Some(hit) = direct_assoc_key_get(name, key) {
9514 return Value::str(hit.unwrap_or_default());
9515 }
9516 }
9517 }
9518 }
9519 // Plain assoc key that the flat rebuild would mangle (`]` closes
9520 // the subscript, `}` closes the brace): direct lookup. On a miss
9521 // return empty — the textual fallback cannot represent the key.
9522 if (idx.contains(']') || idx.contains('}')) && !idx.starts_with('(') {
9523 if let Some(hit) = direct_assoc_key_get(name, idx) {
9524 return Value::str(hit.unwrap_or_default());
9525 }
9526 }
9527 let body = format!("${{{}[{}]}}", name, idx);
9528 paramsubst_to_value(&body)
9529}
9530
9531/// Exact-key read against an assoc-like target WITHOUT the textual
9532/// `${name[key]}` reparse (see array_index_lookup — expanded `]`/`}`
9533/// in keys break the flat form). `Some(hit)` when `name` is a target
9534/// this helper understands (plain assoc, or the alias magic assocs of
9535/// zsh/parameter — Src/Modules/parameter.c getpmalias family);
9536/// `None` = not direct-capable, caller keeps the textual path.
9537fn direct_assoc_key_get(name: &str, key: &str) -> Option<Option<String>> {
9538 use crate::ported::zsh_h::{ALIAS_GLOBAL, DISABLED};
9539 // c:Src/Modules/parameter.c:1247+ getpmalias / getpmgalias /
9540 // getpmsalias — each view filters its table by flags.
9541 let alias_view = |global: bool, suffix: bool, disabled: bool| -> Option<String> {
9542 let tab = if suffix {
9543 crate::ported::hashtable::sufaliastab_lock()
9544 } else {
9545 crate::ported::hashtable::aliastab_lock()
9546 };
9547 tab.read().ok().and_then(|t| {
9548 t.iter().find_map(|(k, a)| {
9549 let f = a.node.flags as u32;
9550 if k == key
9551 && ((f & ALIAS_GLOBAL as u32 != 0) == global || suffix)
9552 && ((f & DISABLED as u32 != 0) == disabled)
9553 {
9554 Some(a.text.clone())
9555 } else {
9556 None
9557 }
9558 })
9559 })
9560 };
9561 match name {
9562 "aliases" => Some(alias_view(false, false, false)),
9563 "galiases" => Some(alias_view(true, false, false)),
9564 "saliases" => Some(alias_view(false, true, false)),
9565 "dis_aliases" => Some(alias_view(false, false, true)),
9566 "dis_galiases" => Some(alias_view(true, false, true)),
9567 "dis_saliases" => Some(alias_view(false, true, true)),
9568 _ => {
9569 // Single-lock O(1) probe (see assoc_key_hit) — the previous
9570 // double exec.assoc() cloned the whole map twice per lookup.
9571 crate::vm_helper::assoc_key_hit(name, key).map(|(_, v)| v)
9572 }
9573 }
9574}
9575
9576/// KSHARRAYS bare-`$name` expansion words for the unbraced
9577/// no-subscript form (BUILTIN_ARRAY_INDEX_UNBRACED's KSHARRAYS arm).
9578///
9579/// - `@` / `*` stay the full positional list (the c:Src/params.c:
9580/// 2293-2296 first-element collapse is gated on
9581/// `itype_end(t, IIDENT, 1) != t` — an identifier-shaped name —
9582/// which `@`/`*` are not). The literal `[idx]` then joins the LAST
9583/// word, matching zsh 5.9: `setopt ksharrays; set -- p q;
9584/// print -- $@[0]` → `zsh:1: no matches found: q[0]`.
9585/// - Identifier-named arrays collapse to the FIRST element
9586/// (c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`).
9587/// - Assocs collapse to the first value in scan order; the `options`
9588/// magic assoc's scan order is `OPTIONTAB` bucket order (first key
9589/// `posixargzero`), matching zsh 5.9: `emulate sh -L;
9590/// print $options[posixargzero]` → `off[posixargzero]`.
9591/// - Scalars / unset names expand to their value / empty (zsh 5.9:
9592/// `setopt ksharrays; print -- $unsetvar[0]` →
9593/// `zsh:1: no matches found: [0]`).
9594fn ksharrays_bare_words(name: &str) -> Vec<String> {
9595 if name == "@" || name == "*" {
9596 return with_executor(|exec| exec.pparams());
9597 }
9598 // Magic special-parameter lookups first — mirrors the
9599 // BUILTIN_GET_VAR precedence (partab before executor tables).
9600 if let Some(vals) = crate::vm_helper::partab_array_get(name) {
9601 return vec![vals.into_iter().next().unwrap_or_default()];
9602 }
9603 if let Some(keys) = crate::vm_helper::partab_scan_keys(name) {
9604 let v = keys
9605 .first()
9606 .and_then(|k| crate::vm_helper::partab_get(name, k))
9607 .unwrap_or_default();
9608 return vec![v];
9609 }
9610 let arr_or_assoc = with_executor(|exec| {
9611 if let Some(arr) = exec.array(name) {
9612 // c:Src/params.c:2293-2296 — first element only.
9613 return Some(arr.first().cloned().unwrap_or_default());
9614 }
9615 if let Some(map) = exec.assoc(name) {
9616 // c:Src/params.c:2351-2358 — under KSH EMULATION a bare
9617 // `$assoc` is `${assoc[0]}` (KEY-"0" lookup), EMPTY unless the
9618 // hash has a key "0": `emulate -L ksh; typeset -A h=(a 1 b 2);
9619 // print $h` is empty. Every other mode (`setopt ksharrays`,
9620 // `emulate sh`) falls through to the first bucket value below.
9621 if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
9622 return Some(map.get("0").cloned().unwrap_or_default());
9623 }
9624 // Mirrors BUILTIN_GET_VAR's bare-assoc ordering
9625 // (sorted keys, first value).
9626 let mut keys: Vec<&String> = map.keys().collect();
9627 keys.sort();
9628 return Some(
9629 keys.first()
9630 .and_then(|k| map.get(*k).cloned())
9631 .unwrap_or_default(),
9632 );
9633 }
9634 None
9635 });
9636 if let Some(v) = arr_or_assoc {
9637 return vec![v];
9638 }
9639 vec![with_executor(|exec| exec.get_variable(name))]
9640}
9641
9642/// Run `body` through `crate::ported::subst::paramsubst` and convert
9643/// the resulting node list into a fusevm `Value`. Centralises the
9644/// pattern duplicated across ~10 BUILTIN_* handlers:
9645/// - build a `${...}` body string from opcode operands
9646/// - paramsubst the body
9647/// - propagate errflag to `exec.last_status`
9648/// - delegate the LinkList → Value conversion to `nodes_to_value`
9649///
9650/// **Extension** — Rust-only helper. No direct C analog because C
9651/// zsh uses LinkList everywhere; the conversion happens at the
9652/// boundary back into the VM's stack.
9653fn paramsubst_to_value(body: &str) -> Value {
9654 // c:Src/subst.c:1625 paramsubst's `qt` flag is the C signal that
9655 // the current expansion is inside `"…"`. The fast-path bridges
9656 // (BUILTIN_PARAM_*, BUILTIN_BRIDGE_BRACE_ARRAY) used to hardcode
9657 // qt=false, which silently broke DQ-only semantics inside
9658 // `${arr:^other}` / `${arr:^^other}` (Src/subst.c:3456-3520).
9659 // The executor's `in_dq_context` counter is bumped by EXPAND_TEXT
9660 // mode 1 / mode 5 before the bridge fires, so reading it here
9661 // propagates the DQ flag without changing every bridge call site.
9662 let qt = with_executor(|exec| exec.in_dq_context > 0);
9663 let mut ret_flags: i32 = 0;
9664 let (_full, _pos, nodes) = crate::ported::subst::paramsubst(body, 0, qt, 0i32, &mut ret_flags);
9665 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
9666 with_executor(|exec| exec.set_last_status(1));
9667 }
9668 // c:Src/lex.c untokenize — the final argv pass C runs on every
9669 // expanded word (glob.c:1862 / exec.c) DROPS the Nularg
9670 // empty-word sentinel (c:2089 `if (c != Nularg)`) that
9671 // remnulargs faithfully leaves in place (glob.c:3673 re-adds it
9672 // for all-empty results). These fast-path bridges are terminal —
9673 // their output lands directly in argv slots — so apply it here.
9674 // Without it, quoted splits with empty pieces ("${(s:|:)x}" on
9675 // "|a|b|") leak U+00A1 into argv.
9676 let nodes: Vec<String> = nodes
9677 .into_iter()
9678 .map(|n| crate::ported::lex::untokenize(&n))
9679 .collect();
9680 nodes_to_value(nodes)
9681}
9682
9683/// Wrap a `Vec<String>` (e.g. paramsubst nodes, multsub parts,
9684/// xpandbraces output) into a fusevm `Value`: 0 → empty Array, 1 →
9685/// Str, >1 → Array. Same unwrap idiom every handler that calls a
9686/// canonical Vec-returning fn does.
9687/// c:Src/subst.c:1663 — `int plan9 = isset(RCEXPANDPARAM);`
9688///
9689/// zsh calls the RC_EXPAND_PARAM word shape "plan9" after the rc(1)
9690/// shell it comes from. The option is read fresh on every expansion
9691/// (`setopt` mid-script changes the very next word), so the concat
9692/// builtins must consult it at RUNTIME, not bake it in at compile time.
9693fn plan9_active() -> bool {
9694 with_executor(|_exec| opt_state_get("rcexpandparam").unwrap_or(false))
9695}
9696
9697thread_local! {
9698 /// The `isarr` bit that `Value` cannot carry.
9699 ///
9700 /// c:Src/subst.c:4245 `if (isarr)` gates the whole array emit block,
9701 /// so plan9's word-removal rule (c:4362 `uremnode`) only ever applies
9702 /// to an ARRAY-valued expansion. An empty SCALAR has `isarr == 0`,
9703 /// takes the c:4437 scalar branch, and leaves the surrounding text
9704 /// intact — `setopt rcexpandparam; v=; print -rl -- x$v y` prints `x`
9705 /// and `y`, while the same line with an empty ARRAY prints only `y`.
9706 ///
9707 /// zshrs collapses BOTH shapes to `Value::Array(vec![])`: a real empty
9708 /// array, and an unquoted empty scalar (collapsed so a standalone `$v`
9709 /// contributes zero argv words, mirroring prefork's `uremnode` at
9710 /// c:Src/subst.c:184-187). The two are indistinguishable by the time
9711 /// they reach a concat builtin, so the expansion builtins record here
9712 /// whether the empty Array they just produced came from a scalar.
9713 ///
9714 /// Sticky until the next expansion overwrites it — a word folds its
9715 /// segments left-associatively (`concat(concat(x, $e), y)`), so the
9716 /// propagating second concat must still see the first one's bit.
9717 static EMPTY_EXPANSION_IS_SCALAR: std::cell::Cell<bool> =
9718 const { std::cell::Cell::new(false) };
9719}
9720
9721/// Record whether the empty expansion just produced was a scalar (`true`)
9722/// or a genuine array (`false`). See `EMPTY_EXPANSION_IS_SCALAR`.
9723fn note_empty_is_scalar(is_scalar: bool) {
9724 EMPTY_EXPANSION_IS_SCALAR.with(|c| c.set(is_scalar));
9725}
9726
9727/// True when the empty `Value::Array` about to be concatenated stands for
9728/// an empty SCALAR (c:4437), not an empty array (c:4362).
9729fn empty_is_scalar() -> bool {
9730 EMPTY_EXPANSION_IS_SCALAR.with(|c| c.get())
9731}
9732
9733/// c:Src/subst.c:4366-4437 — the NON-plan9 arm of paramsubst's array
9734/// emit block: "simply join the first and last values."
9735///
9736/// The word prefix (`ostr..aptr`) is concatenated onto element 0
9737/// (c:4386 `strcatsub(&y, ostr, aptr, x, xlen, NULL, …)`), the interior
9738/// elements are emitted bare (c:4393-4412), and the word suffix (`fstr`)
9739/// is concatenated onto the final element (c:4414-4429). Applied left-
9740/// associatively across a word's segments this reproduces zsh's
9741/// `pre${arr}post` → `prep` / `q` / `rpost`.
9742///
9743/// An EMPTY array never reaches this arm in C: c:4261
9744/// `if ((!aval[0] || !aval[1]) && !plan9)` collapses it to the scalar ""
9745/// first, so the surrounding text survives as one word (`x$e y` → `x`).
9746/// That is what the empty-Array arms below reproduce.
9747fn concat_splice(lhs: Value, rhs: Value) -> Value {
9748 match (lhs, rhs) {
9749 (Value::Array(mut la), Value::Array(ra)) => {
9750 if la.is_empty() {
9751 return Value::Array(ra);
9752 }
9753 if ra.is_empty() {
9754 return Value::Array(la);
9755 }
9756 // Last of la merges with first of ra; rest unchanged.
9757 let last_l = la.pop().unwrap();
9758 let mut ra_iter = ra.into_iter();
9759 let first_r = ra_iter.next().unwrap();
9760 let l_s = last_l.as_str_cow();
9761 let r_s = first_r.as_str_cow();
9762 let mut merged = String::with_capacity(l_s.len() + r_s.len());
9763 merged.push_str(&l_s);
9764 merged.push_str(&r_s);
9765 la.push(Value::str(merged));
9766 la.extend(ra_iter);
9767 Value::Array(la)
9768 }
9769 (Value::Array(mut la), rhs_scalar) => {
9770 // c:4261 — empty array + empty surrounding text is zero
9771 // words, not one empty word. Bug #120 in docs/BUGS.md:
9772 // `b=("${a[@]:0:-1}")` gave len=1 instead of zsh's len=0.
9773 let rhs_s = rhs_scalar.as_str_cow();
9774 if la.is_empty() {
9775 if rhs_s.is_empty() {
9776 return Value::Array(Vec::new());
9777 }
9778 return Value::str(rhs_s.to_string());
9779 }
9780 let last = la.pop().unwrap();
9781 let l_s = last.as_str_cow();
9782 let mut s = String::with_capacity(l_s.len() + rhs_s.len());
9783 s.push_str(&l_s);
9784 s.push_str(&rhs_s);
9785 la.push(Value::str(s));
9786 Value::Array(la)
9787 }
9788 (lhs_scalar, Value::Array(mut ra)) => {
9789 let lhs_s = lhs_scalar.as_str_cow();
9790 if ra.is_empty() {
9791 // Symmetric c:4261 empty-array rule; see the arm above.
9792 if lhs_s.is_empty() {
9793 return Value::Array(Vec::new());
9794 }
9795 return Value::str(lhs_s.to_string());
9796 }
9797 let first = ra.remove(0);
9798 let r_s = first.as_str_cow();
9799 let mut s = String::with_capacity(lhs_s.len() + r_s.len());
9800 s.push_str(&lhs_s);
9801 s.push_str(&r_s);
9802 let mut out = Vec::with_capacity(ra.len() + 1);
9803 out.push(Value::str(s));
9804 out.extend(ra);
9805 Value::Array(out)
9806 }
9807 (lhs_s, rhs_s) => {
9808 let l = lhs_s.as_str_cow();
9809 let r = rhs_s.as_str_cow();
9810 let mut s = String::with_capacity(l.len() + r.len());
9811 s.push_str(&l);
9812 s.push_str(&r);
9813 Value::str(s)
9814 }
9815 }
9816}
9817
9818/// c:Src/subst.c:4316-4365 — the plan9 (RC_EXPAND_PARAM) arm of
9819/// paramsubst's array emit block.
9820///
9821/// Every element gets the FULL word prefix and suffix
9822/// (c:4341 `strcatsub(&y, ostr, aptr, x, xlen, y + 1, …)` inside the
9823/// per-element loop), giving the cross product with the surrounding
9824/// text: `pre${arr}post` → `preppost` / `preqpost` / `prerpost`.
9825///
9826/// An EMPTY array removes the WHOLE word: the c:4327
9827/// `while ((x = *aval++))` loop body never runs, so `plan9` is still
9828/// non-zero at c:4362 and the node is deleted —
9829/// `if (plan9) { uremnode(l, n); return n; }` (c:4362-4365). `e=();
9830/// setopt RC_EXPAND_PARAM; print -rl -- x$e y` prints only `y`. This is
9831/// the opposite of the non-plan9 rule at c:4261, which keeps `x`.
9832fn concat_plan9(lhs: Value, rhs: Value) -> Value {
9833 // c:4245 `if (isarr)` — an empty SCALAR never enters the array emit
9834 // block, so it contributes "" and the word survives (c:4437). Only a
9835 // real empty ARRAY reaches c:4362's `uremnode`. `Value` cannot tell
9836 // the two apart; EMPTY_EXPANSION_IS_SCALAR carries the missing bit.
9837 let scalar_empty = empty_is_scalar();
9838 match (lhs, rhs) {
9839 // c:4362-4365 — an empty array on either side deletes the word.
9840 // Propagated as an empty Array so a later concat in the same word
9841 // (`x${e[@]}y` folds twice) keeps the word deleted; pop_args
9842 // splats an empty Array into zero argv words.
9843 (Value::Array(la), rhs_v) if la.is_empty() => {
9844 if scalar_empty {
9845 // Empty scalar prefix: "" + rhs (c:4437 strcatsub).
9846 return match rhs_v {
9847 Value::Array(ra) if ra.is_empty() => Value::Array(Vec::new()),
9848 other => other,
9849 };
9850 }
9851 Value::Array(Vec::new())
9852 }
9853 (lhs_v, Value::Array(ra)) if ra.is_empty() => {
9854 if scalar_empty {
9855 // Empty scalar suffix: lhs + "" (c:4437 strcatsub).
9856 return lhs_v;
9857 }
9858 Value::Array(Vec::new())
9859 }
9860 (Value::Array(la), Value::Array(ra)) => {
9861 let mut out = Vec::with_capacity(la.len() * ra.len());
9862 for a in &la {
9863 let a_s = a.as_str_cow();
9864 for b in &ra {
9865 let b_s = b.as_str_cow();
9866 let mut s = String::with_capacity(a_s.len() + b_s.len());
9867 s.push_str(&a_s);
9868 s.push_str(&b_s);
9869 out.push(Value::str(s));
9870 }
9871 }
9872 Value::Array(out)
9873 }
9874 (Value::Array(la), rhs_scalar) => {
9875 let r = rhs_scalar.as_str_cow();
9876 let out: Vec<Value> = la
9877 .into_iter()
9878 .map(|a| {
9879 let a_s = a.as_str_cow();
9880 let mut s = String::with_capacity(a_s.len() + r.len());
9881 s.push_str(&a_s);
9882 s.push_str(&r);
9883 Value::str(s)
9884 })
9885 .collect();
9886 Value::Array(out)
9887 }
9888 (lhs_scalar, Value::Array(ra)) => {
9889 let l = lhs_scalar.as_str_cow();
9890 let out: Vec<Value> = ra
9891 .into_iter()
9892 .map(|b| {
9893 let b_s = b.as_str_cow();
9894 let mut s = String::with_capacity(l.len() + b_s.len());
9895 s.push_str(&l);
9896 s.push_str(&b_s);
9897 Value::str(s)
9898 })
9899 .collect();
9900 Value::Array(out)
9901 }
9902 (lhs_s, rhs_s) => {
9903 // Both scalar: nothing to distribute (c:4444 scalar branch).
9904 let l = lhs_s.as_str_cow();
9905 let r = rhs_s.as_str_cow();
9906 let mut s = String::with_capacity(l.len() + r.len());
9907 s.push_str(&l);
9908 s.push_str(&r);
9909 Value::str(s)
9910 }
9911 }
9912}
9913
9914fn nodes_to_value(nodes: Vec<String>) -> Value {
9915 // c:Src/glob.c:3649 remnulargs — strip the Nularg (`\u{a1}`)
9916 // sentinel and other INULL bytes that paramsubst's splat block
9917 // emits for empty array elements (so prefork's empty-node-delete
9918 // pass doesn't drop them). Downstream consumers (cond `-z`/`-n`,
9919 // command args, etc.) must see the post-remnulargs strings. Bug
9920 // #185 in docs/BUGS.md: `[[ -z "${b[@]}" ]]` for b=("") returned
9921 // false because the leftover `\u{a1}` had StringLen=1.
9922 let stripped: Vec<String> = nodes
9923 .into_iter()
9924 .map(|mut s| {
9925 crate::ported::glob::remnulargs(&mut s);
9926 s
9927 })
9928 .collect();
9929 if stripped.is_empty() {
9930 // Zero nodes = an ARRAY-shaped expansion that produced no words
9931 // (empty array splat, empty slice). c:4245 `if (isarr)` holds, so
9932 // plan9 deletes the surrounding word (c:4362).
9933 note_empty_is_scalar(false);
9934 Value::Array(Vec::new())
9935 } else if stripped.len() == 1 {
9936 let only = stripped.into_iter().next().unwrap();
9937 // c:Src/subst.c:183-186 — `else if (!(flags & PREFORK_SINGLE)
9938 // && !(*ret_flags & PREFORK_KEY_VALUE) && !keep)
9939 // uremnode(list, node);`
9940 // C zsh's prefork removes empty linknodes from the result
9941 // list when in non-SINGLE (argv-context) mode. The ported
9942 // prefork at subst.rs:388-396 honors the same delete-empty
9943 // pass, but some paramsubst paths land here with a single-
9944 // empty-string Vec instead of an empty Vec (paramsubst's
9945 // slice / substring / parameter-flag branches allocate a
9946 // result before checking emptiness). Mirror the prefork
9947 // drop at this layer: single-empty under !in_dq_context
9948 // collapses to Value::Array(empty), and pop_args (line 6243)
9949 // splats the empty Array → zero argv words. DQ context
9950 // (in_dq_context > 0) keeps the empty string so
9951 // `echo "${UNSET}"` still produces an empty arg per zsh's
9952 // quoting rules (c:Src/subst.c:1650-1656 isarr comment).
9953 if only.is_empty() {
9954 let in_dq = with_executor(|exec| exec.in_dq_context > 0);
9955 if !in_dq {
9956 // One empty node = a SCALAR-shaped empty result (c:4437),
9957 // not an empty array — see EMPTY_EXPANSION_IS_SCALAR.
9958 note_empty_is_scalar(true);
9959 return Value::Array(Vec::new());
9960 }
9961 }
9962 Value::str(only)
9963 } else {
9964 Value::Array(stripped.into_iter().map(Value::str).collect())
9965 }
9966}
9967
9968fn pop_args(vm: &mut fusevm::VM, argc: u8) -> Vec<String> {
9969 let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
9970 for _ in 0..argc {
9971 popped.push(vm.pop());
9972 }
9973 popped.reverse();
9974 let mut args: Vec<String> = Vec::with_capacity(popped.len());
9975 for v in popped {
9976 match v {
9977 Value::Array(items) => {
9978 for item in items {
9979 args.push(item.to_str());
9980 }
9981 }
9982 other => args.push(other.to_str()),
9983 }
9984 }
9985 // `expand_glob` set the glob-failed cell when a no-match glob
9986 // triggered nomatch (c:Src/glob.c:1877). Signal the failure via
9987 // last_status + the per-command glob_failed cell; the dispatcher
9988 // (`host_exec_external`) consumes + clears it and returns status 1
9989 // without running the command body.
9990 if with_executor(|exec| exec.current_command_glob_failed.get()) {
9991 with_executor(|exec| exec.set_last_status(1));
9992 }
9993 // `$_` tracks the last argument of the PREVIOUSLY executed
9994 // command (zsh / bash convention). Promote the deferred value
9995 // into `$_` BEFORE this command runs (so `echo $_` reads the
9996 // prior command's last arg) then stash THIS command's last arg
9997 // for the next dispatch.
9998 let new_last = args.last().cloned();
9999 with_executor(|exec| {
10000 if let Some(prev) = exec.pending_underscore.take() {
10001 exec.set_scalar("_".to_string(), prev);
10002 }
10003 if let Some(last) = new_last {
10004 exec.pending_underscore = Some(last);
10005 }
10006 });
10007 args
10008}
10009
10010/// zsh dispatch order is alias → function → builtin → external. The
10011/// compiler emits direct CallBuiltin ops for known builtin names for
10012/// perf, which silently skips a user function that shadows the same
10013/// name (e.g. `echo() { ... }; echo hi` would run the C builtin
10014/// without this check). Returns Some(status) when the call is routed
10015/// to the user function; the builtin handler should fall through to
10016/// its native impl when None.
10017/// Fork+exec a system binary by name. Used by `reg_overridable!` as
10018/// the fall-through path when `[builtins].coreutils_shadows = off`
10019/// (the default) — runs the canonical `/bin/X` instead of zshrs's
10020/// in-process shadow so old scripts hit zero behavioral divergence.
10021///
10022/// Inherits stdin/stdout/stderr from the parent so pipelines work
10023/// transparently. Resolves the binary via PATH; mirrors what zsh's
10024/// own external-command dispatch would do. Returns the child's exit
10025/// status (or 127 if PATH lookup fails — the standard "command not
10026/// found" code).
10027/// RAII guard that queues signals for the lifetime of a synchronous
10028/// foreground `waitpid` (via `std::process::Command::status`/`wait`).
10029///
10030/// zshrs installs a process-wide SIGCHLD handler (`zhandler` →
10031/// `wait_for_processes` → `waitpid(-1, WNOHANG)`) that reaps EVERY
10032/// exited child to drive the job table. `std::process::Command` does
10033/// its own targeted `waitpid(pid)`; when the reaper fires on any
10034/// thread between the fork and that wait, it reaps the child first and
10035/// `Command::status()` fails with ECHILD ("No child processes (os
10036/// error 10)"). This surfaced as `zshrs: hostname: No child processes
10037/// (os error 10)` when a coreutils shadow (`coreutils_shadows = off`
10038/// default) fork-execs `/usr/bin/hostname` while a background prewarm
10039/// child exits at the same instant.
10040///
10041/// Holding this guard bumps `queueing_enabled` (a global SeqCst atomic
10042/// that `zhandler` honors on every thread), so a SIGCHLD arriving
10043/// during the wait is pushed onto the deferred queue instead of being
10044/// reaped — `Command::status()` reaps its own child and reads the real
10045/// status. On drop, `unqueue_signals()` drains the queue, so any
10046/// genuine background children that exited meanwhile still get reaped
10047/// and routed to the job table. This is the same queue_signals /
10048/// unqueue_signals fencing zsh uses around its own foreground waits
10049/// (Src/exec.c). Panic-safe via `Drop`.
10050pub(crate) struct ForegroundWaitGuard;
10051
10052impl ForegroundWaitGuard {
10053 #[inline]
10054 pub(crate) fn enter() -> Self {
10055 crate::ported::signals_h::queue_signals();
10056 ForegroundWaitGuard
10057 }
10058}
10059
10060impl Drop for ForegroundWaitGuard {
10061 #[inline]
10062 fn drop(&mut self) {
10063 crate::ported::signals_h::unqueue_signals();
10064 }
10065}
10066
10067fn exec_system_command(name: &str, args: &[String]) -> i32 {
10068 // c:Src/jobs.c — count the fork so `time` reports for an
10069 // overridable coreutils shadow run as an external (`time sleep 0`,
10070 // `time cat …`). This is a distinct spawn path from
10071 // execute_external_bg; without the bump BUILTIN_TIME_SUBLIST saw no
10072 // job and stayed silent. (Builtins that don't reach a spawn never
10073 // hit this fn.)
10074 crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
10075 // Queue signals across the wait so the SIGCHLD reaper can't steal
10076 // this child out from under Command::status — see ForegroundWaitGuard.
10077 let status = {
10078 let _wait_guard = ForegroundWaitGuard::enter();
10079 std::process::Command::new(name)
10080 .args(args)
10081 .stdin(std::process::Stdio::inherit())
10082 .stdout(std::process::Stdio::inherit())
10083 .stderr(std::process::Stdio::inherit())
10084 .status()
10085 };
10086 match status {
10087 Ok(s) => s.code().unwrap_or(if s.success() { 0 } else { 1 }),
10088 Err(e) => {
10089 eprintln!("zshrs: {}: {}", name, e);
10090 127
10091 }
10092 }
10093}
10094
10095fn try_user_fn_override(name: &str, args: &[String]) -> Option<i32> {
10096 let has_fn = with_executor(|exec| {
10097 exec.functions_compiled.contains_key(name) || exec.function_exists(name)
10098 });
10099 if !has_fn {
10100 return None;
10101 }
10102 Some(with_executor(|exec| {
10103 exec.dispatch_function_call(name, args).unwrap_or(127)
10104 }))
10105}
10106
10107/// Builtin ID for `${name}` reads — routes through canonical
10108/// `getsparam` (Src/params.c:3076) via paramtab + env walk so nested
10109/// VMs (function calls) see the same storage.
10110pub const BUILTIN_GET_VAR: u16 = 283;
10111
10112/// Like `BUILTIN_GET_VAR` but forces double-quoted (DQ) semantics on
10113/// the read regardless of the runtime `in_dq_context`. The compiler
10114/// emits this for a QUOTED simple-var read (`"$name"`) — those compile
10115/// to a direct GET_VAR with no EXPAND_TEXT wrapper, so `in_dq_context`
10116/// is 0 and the plain GET_VAR would wrongly word-elide an array's empty
10117/// elements (`a=(1 "" 3); "$a"` must keep the empty → `1 3`, not
10118/// `1 3`). With force_dq the array joins via sepjoin keeping empties and
10119/// a scalar is returned verbatim (no empty-drop, no SH_WORD_SPLIT).
10120pub const BUILTIN_GET_VAR_DQ: u16 = 639;
10121
10122/// Builtin ID for `name=value` assignments — pops [name, value] and
10123/// routes through canonical `setsparam` (Src/params.c:3350).
10124pub const BUILTIN_SET_VAR: u16 = 284;
10125
10126/// Builtin ID that sets the thread-local [`SET_VAR_GLOB_ELIGIBLE`] flag true.
10127/// Emitted by the compiler immediately before a `BUILTIN_SET_VAR` whose scalar
10128/// RHS carried an UNQUOTED glob token, so the runtime knows the RHS is a literal
10129/// glob pattern eligible for GLOB_ASSIGN. Takes no stack args, pushes nothing.
10130pub const BUILTIN_MARK_GLOB_ELIGIBLE: u16 = 640;
10131
10132/// Builtin ID for pipeline execution. Pops N sub-chunk indices from the stack;
10133/// each index points into `vm.chunk.sub_chunks` (compiled stage bodies). Forks
10134/// N children, wires stdin/stdout between them via pipes, runs each stage's
10135/// bytecode on a fresh VM in its child, parent waits for all and pushes the
10136/// last stage's exit status. This is bytecode-native pipeline execution —
10137/// no tree-walker delegation.
10138pub const BUILTIN_RUN_PIPELINE: u16 = 285;
10139
10140/// Builtin ID for `Array → String` joining. Pops one value: if it's an Array,
10141/// joins its string-coerced elements with a single space; otherwise passes
10142/// through. Used after `Op::Glob` to convert the pattern's matched paths into
10143/// the single argv-token form the bytecode word model expects (no per-word
10144/// splitting yet — that's a future phase).
10145pub const BUILTIN_ARRAY_JOIN: u16 = 286;
10146
10147/// Builtin ID for `cmd &` background execution. IDs 287/288/289 are reserved
10148/// for the planned array work in Phase G1 (SET_ARRAY/SET_ASSOC/ARRAY_INDEX),
10149/// so this lands at 290. Pops the sub-chunk index then the job text; forks;
10150/// child detaches (`setsid`), runs the sub-chunk on a fresh VM, exits with
10151/// last_status; parent registers the job in the canonical JOBTAB
10152/// (initjob/addproc/spawnjob per c:Src/exec.c:1700-1758) so `jobs` / `wait
10153/// %N` / `kill %N` / `disown` and the zsh/parameter assocs all see it, then
10154/// returns Status(0) immediately.
10155pub const BUILTIN_RUN_BG: u16 = 290;
10156
10157/// Indexed-array assignment: `arr=(a b c)`. Compile_simple emits N element
10158/// pushes followed by name push, then `CallBuiltin(BUILTIN_SET_ARRAY, N+1)`.
10159/// The handler pops args (last popped = name in our pushing order) and stores
10160/// `Vec<String>` into `executor.arrays`. Tree-walker callers see the same
10161/// storage. Any prior scalar binding in `executor.variables` for `name` is
10162/// removed so `${name}` (scalar context) consistently reflects the array's
10163/// first element via `get_variable`.
10164pub const BUILTIN_SET_ARRAY: u16 = 287;
10165
10166/// Single-key set on an associative array: `foo[key]=val`. Stack (top-down):
10167/// [name, key, value]. Stores `value` into `executor.assoc_arrays[name][key]`,
10168/// creating the outer entry if missing. compile_simple detects `var[...]=...`
10169/// in assignments and emits this builtin.
10170pub const BUILTIN_SET_ASSOC: u16 = 288;
10171
10172/// `${arr[idx]}` — single-element array index. Pops two args:
10173/// stack: [name, idx_str]
10174/// Returns the indexed element as Value::str. Indexing semantics: zsh is
10175/// 1-based by default; bash is 0-based. We follow zsh.
10176/// Special idx values: `@` and `*` return the whole array as Value::Array
10177/// (which fuses correctly via the Op::Exec splice for argv splice).
10178pub const BUILTIN_ARRAY_INDEX: u16 = 289;
10179
10180/// `${#arr[@]}` and `${#arr}` (when arr is an array name) — array length.
10181/// Pops one arg: name. Returns Value::str of len.
10182
10183/// `${arr[@]}` — splice all elements as a Value::Array. Pops one arg: name.
10184/// The Array gets flattened by Op::Exec/ExecBg/CallFunction into argv.
10185pub const BUILTIN_ARRAY_ALL: u16 = 292;
10186
10187/// Flatten one level of Value::Array nesting. Pops N values; for each, if it's
10188/// a Value::Array, its elements are appended directly; otherwise the value is
10189/// appended as-is. Pushes a single Value::Array of the flattened result. Used
10190/// by the for-loop word-list compile path: when a word like `${arr[@]}`
10191/// produces a nested Array, this lets `for i in ${arr[@]}` iterate over the
10192/// inner elements rather than the outer single-element array.
10193pub const BUILTIN_ARRAY_FLATTEN: u16 = 293;
10194
10195/// `coproc [name] { body }` — bidirectional pipe to async child. Pops a name
10196/// (optional, "" for default) and a sub-chunk index. Creates two pipes, forks,
10197/// child redirects its fd 0/1 to the inner ends and runs the body, parent
10198/// stores [write_fd, read_fd] into the named array (default `COPROC`). Caller
10199/// closes the fds and `wait`s when done. Job-table integration deferred to
10200/// Phase G6 alongside the bg `&` work.
10201pub const BUILTIN_RUN_COPROC: u16 = 294;
10202
10203/// `arr+=(d e f)` — append N elements to an existing indexed array. Compile
10204/// emits N element pushes + name push, then `CallBuiltin(295, N+1)`. Handler
10205/// drains args (last popped = name), extends `executor.arrays[name]` (creates
10206/// the entry if missing). Mirrors zsh's `+=` semantics for indexed arrays.
10207pub const BUILTIN_APPEND_ARRAY: u16 = 295;
10208
10209/// `name[@]=(...)` / `name[*]=(...)` whole-array SET. Identical to
10210/// BUILTIN_SET_ARRAY for an indexed array / scalar (whole replace), but
10211/// rejects an associative target with "attempt to set slice of
10212/// associative array" (c:Src/params.c:3324-3327).
10213pub const BUILTIN_SET_ARRAY_AT: u16 = 633;
10214
10215/// `name[@]+=(...)` / `name[*]+=(...)` whole-array APPEND. Indexed
10216/// append (push), assoc target → same slice-of-assoc error as 633.
10217pub const BUILTIN_APPEND_ARRAY_AT: u16 = 634;
10218
10219/// `select var in words; do body; done` — interactive numbered-menu loop.
10220/// Compile emits N word pushes + var-name push + sub-chunk index push, then
10221/// `CallBuiltin(296, N+2)`. Handler prints `1) word1\n2) word2\n...` to
10222/// stderr, prints `$PROMPT3` (default `?# `) to stderr, reads a line from
10223/// stdin. On EOF returns 0. On a valid 1-based number, sets `var` to the
10224/// chosen word, runs the sub-chunk, then redisplays the menu and loops. On
10225/// invalid input redraws the menu without running the body. `break` from
10226/// inside the body exits the loop (handled by the body's own bytecode).
10227pub const BUILTIN_RUN_SELECT: u16 = 296;
10228
10229/// `m[k]+=value` — append onto an existing assoc-array value (string concat).
10230/// If the key doesn't exist, behaves like SET_ASSOC. Stack: [name, key, value].
10231
10232/// `break` from inside a body that runs on a sub-VM (select, future
10233/// loop-via-builtin constructs). Writes the canonical
10234/// `crate::ported::builtin::BREAKS` atomic (port of `Src/loop.c:46
10235/// breaks`). Outer-loop builtins drain BREAKS/CONTFLAG after each
10236/// body run, matching the loop.c:529-534 drain pattern.
10237pub const BUILTIN_SET_BREAK: u16 = 299;
10238
10239/// `continue` from inside a sub-VM body. Sets CONTFLAG=1 + bumps
10240/// BREAKS, matching `bin_break`'s WC_CONTINUE arm at Src/builtin.c
10241/// c:5836 `contflag = 1; FALLTHROUGH; breaks++;`.
10242pub const BUILTIN_SET_CONTINUE: u16 = 300;
10243
10244/// Brace expansion: `{a,b,c}` → 3 values, `{1..5}` → 5 values, `{01..05}` →
10245/// zero-padded numerics, `{a..e}` → letter range. Pops one string, returns
10246/// Value::Array of expansions (empty array → original string preserved).
10247pub const BUILTIN_BRACE_EXPAND: u16 = 301;
10248
10249/// Glob qualifier filter: `*(qualifier)` filters glob results by predicate.
10250/// Pops [pattern, qualifier_string]. Returns Value::Array of matching paths.
10251
10252/// Re-export the regex_match host method as a builtin so `[[ s =~ pat ]]`
10253/// works even when fusevm's Op::RegexMatch isn't routed (compat fallback).
10254
10255/// Word-split a string on IFS (default: whitespace). Pops one string,
10256/// returns Value::Array of fields. Used in array-literal context where
10257/// `arr=($(cmd))` should expand cmd's stdout into multiple elements.
10258pub const BUILTIN_WORD_SPLIT: u16 = 304;
10259
10260/// `${=name}` / SH_WORD_SPLIT forced IFS split — c:Src/subst.c:3920-3928
10261/// `aval = sepsplit(val, spsep, 0, 1);`.
10262///
10263/// Unlike BUILTIN_WORD_SPLIT (which routes through `multsub`'s
10264/// PREFORK_SPLIT walker — the c:553-620 loop that COLLAPSES runs of
10265/// separators and never emits an empty field), this is the *other* zsh
10266/// splitter: `sepsplit` → `Src/utils.c:3711 spacesplit(s, allownull=0)`,
10267/// which distinguishes the two IFS classes and preserves empty fields.
10268///
10269/// Stack: \[value\]. argc selects the empty-field rule:
10270/// * argc == 0 — the expansion is a bare unquoted word (`${=v}`): the
10271/// leading/trailing `""` fields spacesplit emits for skipped
10272/// IFS-WHITESPACE are empty argv words and prefork deletes them
10273/// (c:Src/subst.c:184-187 `uremnode`).
10274/// * argc == 1 — the expansion is quoted (`"${=v}"`) or has adjacent
10275/// word segments (`x${=v}y`): those fields survive, because in C the
10276/// word's `Dnull` quote markers / literal prefix+suffix attach to the
10277/// first and last elements (c:4386 / c:4429 strcatsub) and the node is
10278/// no longer empty. `v=$' a:b '` → `""`, `a:b`, `""` quoted; `x`,
10279/// `a:b`, `y` with surrounding text.
10280///
10281/// Empty fields that come from an IFS-NON-whitespace separator are the
10282/// `nulstring` (`Nularg`, c:Src/subst.c:36) and survive in BOTH cases —
10283/// `IFS=x; v=xaxbx` splits to `""`, `a`, `b`, `""` quoted or not.
10284pub const BUILTIN_FORCE_SPLIT: u16 = 643;
10285
10286/// Register a pre-compiled fusevm chunk as a function. Stack: [name,
10287/// base64-bincode-of-Chunk]. Used by compile_zsh's compile_funcdef to
10288/// register functions parsed via parse_init+parse without going through the
10289/// ShellCommand JSON serialization path.
10290pub const BUILTIN_REGISTER_COMPILED_FN: u16 = 305;
10291/// `BUILTIN_VAR_EXISTS` constant.
10292pub const BUILTIN_VAR_EXISTS: u16 = 306;
10293/// Native param-modifier builtins. Each takes a fixed argv shape and
10294/// returns the modified value as Value::Str.
10295///
10296/// `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
10297/// — pop [name, op_byte, rhs]. op_byte: 0=`:-`, 1=`:=`, 2=`:?`, 3=`:+`.
10298pub const BUILTIN_PARAM_DEFAULT_FAMILY: u16 = 307;
10299/// `${var:offset[:length]}` — pop [name, offset, length] (length=-1 means
10300/// "rest of value"; negative offset counts from end).
10301pub const BUILTIN_PARAM_SUBSTRING: u16 = 308;
10302/// `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}` — pop
10303/// [name, pattern, op_byte]. op_byte: 0=`#`, 1=`##`, 2=`%`, 3=`%%`.
10304pub const BUILTIN_PARAM_STRIP: u16 = 309;
10305/// `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
10306/// `${var/%pat/repl}` — pop [name, pattern, replacement, op_byte].
10307/// op_byte: 0=first, 1=all, 2=anchor-prefix, 3=anchor-suffix.
10308pub const BUILTIN_PARAM_REPLACE: u16 = 310;
10309/// `${#name}` — character length of a scalar value, or element count
10310/// of an indexed/assoc array. Pops \[name\], returns count as Value::Str.
10311pub const BUILTIN_PARAM_LENGTH: u16 = 311;
10312/// `$((expr))` arithmetic substitution. Pops \[expr_string\], evaluates
10313/// via the executor's MathEval (integer-aware), returns result as
10314/// Value::Str. Bypasses ArithCompiler's float-only Op::Div path so
10315/// `$((10/3))` returns "3" not "3.333...".
10316pub const BUILTIN_ARITH_EVAL: u16 = 312;
10317/// `(( ... ))` math command post-eval status hook. Pops nothing,
10318/// pushes Value::Status. If errflag is set (math error in the
10319/// preceding BUILTIN_ARITH_EVAL call), clears it and emits status=2
10320/// matching c:Src/math.c arith-failure semantics. Otherwise emits
10321/// the current vm.last_status. Used by compile_arith's `(( ... ))`
10322/// path so the math command swallows errors without halting the
10323/// script — `$((... ))` substitutions skip this hook so their
10324/// errflag propagates up to the containing command.
10325pub const BUILTIN_ARITH_CMD_FINISH: u16 = 527;
10326/// `$(cmd)` command substitution. Pops \[cmd_string\], runs through
10327/// `run_command_substitution` which compiles via parse_init+parse + ZshCompiler
10328/// and captures stdout via an in-process pipe. Returns trimmed output
10329/// as Value::Str. Avoids the sub-chunk word-emit quoting bug in the
10330/// raw Op::CmdSubst path.
10331pub const BUILTIN_CMD_SUBST_TEXT: u16 = 313;
10332/// Text-based word expansion. Pops \[preserved_text\]: the word with
10333/// quotes preserved (Dnull→`"`, Snull→`'`, Bnull→`\`), runs
10334/// `expand_string` (variable + cmd-sub + arith) then `xpandbraces`
10335/// then `expand_glob`. Returns Value::str (single match) or
10336/// Value::Array (multi-match brace/glob).
10337pub const BUILTIN_EXPAND_TEXT: u16 = 314;
10338
10339/// `[[ a -ef b ]]` — same-inode test. Stack: [a, b]. Pushes Bool true iff
10340/// both paths resolve to the same `(dev, inode)` pair (zsh + bash semantics).
10341pub const BUILTIN_SAME_FILE: u16 = 315;
10342
10343/// `[[ a -nt b ]]` — file `a` newer than file `b` (mtime strict).
10344/// Stack: [path_a, path_b]. Pushes Bool. zsh-compatible "missing"
10345/// rules: if both exist, compare mtime; if only `a` exists → true;
10346/// otherwise false.
10347pub const BUILTIN_FILE_NEWER: u16 = 324;
10348
10349/// `[[ a -ot b ]]` — mirror of `-nt`. If both exist, compare mtime;
10350/// if only `b` exists → true; otherwise false.
10351pub const BUILTIN_FILE_OLDER: u16 = 325;
10352
10353/// `[[ -k path ]]` — sticky bit (S_ISVTX) set on path.
10354pub const BUILTIN_HAS_STICKY: u16 = 326;
10355/// `[[ -u path ]]` — setuid bit (S_ISUID).
10356pub const BUILTIN_HAS_SETUID: u16 = 327;
10357/// `[[ -g path ]]` — setgid bit (S_ISGID).
10358pub const BUILTIN_HAS_SETGID: u16 = 328;
10359/// `[[ -O path ]]` — owned by effective UID.
10360pub const BUILTIN_OWNED_BY_USER: u16 = 329;
10361/// `[[ -G path ]]` — owned by effective GID.
10362pub const BUILTIN_OWNED_BY_GROUP: u16 = 330;
10363/// `[[ -N path ]]` — file modified since last accessed (atime <= mtime).
10364pub const BUILTIN_FILE_MODIFIED_SINCE_ACCESS: u16 = 341;
10365
10366/// `name+=val` (no parens) — runtime-dispatched append.
10367/// If name is an indexed array → push val as element.
10368/// If name is an assoc array → error (zsh requires `(k v)` form).
10369/// Else → scalar concat (existing SET_VAR behavior).
10370pub const BUILTIN_APPEND_SCALAR_OR_PUSH: u16 = 331;
10371
10372/// `[[ -c path ]]` — character device.
10373pub const BUILTIN_IS_CHARDEV: u16 = 332;
10374/// `[[ -b path ]]` — block device.
10375pub const BUILTIN_IS_BLOCKDEV: u16 = 333;
10376/// `[[ -p path ]]` — FIFO / named pipe.
10377pub const BUILTIN_IS_FIFO: u16 = 334;
10378/// `[[ -S path ]]` — socket.
10379pub const BUILTIN_IS_SOCKET: u16 = 335;
10380/// `BUILTIN_ERREXIT_CHECK` constant.
10381pub const BUILTIN_ERREXIT_CHECK: u16 = 336;
10382/// Fatal-error-only abort check, for use INSIDE an `&&` / `||` chain.
10383///
10384/// A chain suppresses the errexit check (a non-zero status is "consumed"
10385/// by the connector — `false && x` must not fire ERREXIT or the ZERR
10386/// trap). But an errflag — a *fatal* error such as a `[[ ]]` bad pattern
10387/// — is not a status the connector can consume: zsh abandons the whole
10388/// list. Without this, zshrs ran the `||` right-hand side after the
10389/// error (`[[ x = [a- ]] || touch f` created `f`; zsh does not) and the
10390/// aborted builtin then overwrote the cond's status 2 with 1.
10391///
10392/// Same errflag arm as `BUILTIN_ERREXIT_CHECK`, with the errexit/ZERR
10393/// half omitted.
10394pub const BUILTIN_FATAL_ABORT_CHECK: u16 = 641;
10395/// Post-`always`-arm checks for the canonical RETFLAG / BREAKS /
10396/// CONTFLAG atomics that mark try-block escapes. Each returns
10397/// Value::Int(1) when the corresponding atomic is set (and consumes
10398/// it so the next escape doesn't re-fire) and Value::Int(0) otherwise.
10399/// Paired with JumpIfFalse + Jump to outer return_patches /
10400/// break_patches / continue_patches by compile_zsh's `Try` arm.
10401pub const BUILTIN_RETFLAG_CHECK: u16 = 600;
10402/// `BUILTIN_BREAKS_CHECK` constant.
10403pub const BUILTIN_BREAKS_CHECK: u16 = 601;
10404/// `BUILTIN_CONTFLAG_CHECK` constant.
10405pub const BUILTIN_CONTFLAG_CHECK: u16 = 602;
10406/// Fire the DEBUG trap (SIGDEBUG) before each statement.
10407/// c:Src/exec.c:1357-1500 DEBUGBEFORECMD — when a "DEBUG" entry is
10408/// installed via `trap '...' DEBUG`, run the body just before the
10409/// next command. Cheap when no DEBUG trap is set (one hashmap lookup
10410/// returns None and we early-out).
10411pub const BUILTIN_DEBUG_TRAP: u16 = 603;
10412/// `set -n` / `set -o noexec` — parse but don't execute. Returns
10413/// Value::Int(1) when the noexec option is set so the caller's
10414/// JumpIfTrue skips the statement body. c:Src/exec.c:1390 main loop
10415/// check.
10416pub const BUILTIN_NOEXEC_CHECK: u16 = 604;
10417/// Block-level redirect-failure gate. Reads exec.redirect_failed
10418/// (set by host.redirect when a redirect open fails); returns
10419/// Value::Int(1) AND clears the flag if set, else 0. Emit-side at
10420/// compile_zsh.rs::compile_command's Redirected arm pairs with a
10421/// JumpIfTrue → WithRedirectsEnd to abandon the body. Without this,
10422/// a multi-statement block after a failed redir kept running every
10423/// statement after the first (the first builtin consumed the flag,
10424/// subsequent statements ran unimpeded).
10425pub const BUILTIN_REDIRECT_FAILED_CHECK: u16 = 605;
10426/// Drop-in replacement for fusevm's Op::Exec for the dynamic-first-
10427/// word path (`$cmd`, `$(cmd)`, `~/bin/foo`). Returns
10428/// Value::Status(vm.last_status) when post-expansion argv is empty
10429/// (preserves the inner cmd-subst's exit), Value::Status(126) with
10430/// "permission denied" when `argv[0]` is empty, otherwise routes
10431/// through executor.host_exec_external like Op::Exec did.
10432pub const BUILTIN_EXEC_DYNAMIC: u16 = 606;
10433/// Reset `use_cmdoutval` to 0 at the START of a dynamic command (before
10434/// its words expand), so a command substitution from a PREVIOUS command
10435/// can't leak into this command's null-command status decision
10436/// (c:Src/exec.c:3009 `use_cmdoutval = !args`). See BUILTIN_EXEC_DYNAMIC.
10437pub const BUILTIN_USE_CMDOUTVAL_RESET: u16 = 637;
10438/// `[[ lhs == pat ]]` / `!=` glob compare — cond-specific so the
10439/// bad-pattern diagnostic follows Src/cond.c:308-316: zwarnnam
10440/// "bad pattern: %s" WITHOUT errflag (the script continues) and the
10441/// cond statement exits 2. Stack: [lhs, pat] → Bool. On compile
10442/// failure pushes Bool(false) and arms COND_BAD_PATTERN so
10443/// BUILTIN_COND_STATUS_FROM_BOOL reports 2.
10444pub const BUILTIN_COND_STRMATCH: u16 = 624;
10445/// Pops the cond result Bool → Int status per Src/cond.c: true→0,
10446/// false→1, but 2 when COND_BAD_PATTERN was armed during this cond
10447/// (covers `!=` where LogNot flips the Bool before status time).
10448pub const BUILTIN_COND_STATUS_FROM_BOOL: u16 = 625;
10449/// `[[ ]]` unknown condition. Pops \[op_name\], emits `zerr("unknown
10450/// condition: %s")` and sets ERRFLAG_ERROR so the next BUILTIN_ERREXIT_CHECK
10451/// (trigger 4) aborts the input — matching zsh's COND_MODI "unknown condition"
10452/// path (Src/cond.c:150-188) for a `-X` op with no matching loadable module.
10453/// Returns Bool(false). Replaces a compile-time `eprintln!` hack that printed
10454/// the message but never set errflag (so the line didn't abort).
10455pub const BUILTIN_COND_UNKNOWN: u16 = 632;
10456/// Bare-`exec` redirect epilogue. Consumes `exec.redirect_failed` and
10457/// applies the C `done:` tail of execcmd_exec:
10458/// - c:Src/exec.c:252-259 execerr — `redir_err = lastval = 1` (the
10459/// failed redirect makes the exec statement exit 1, NOT fatal by
10460/// itself);
10461/// - c:Src/exec.c:4367-4386 — `if (isset(POSIXBUILTINS) && (cflags
10462/// & (BINF_PSPECIAL|BINF_EXEC)) ...) { if (redir_err || errflag)
10463/// { if (!isset(INTERACTIVE)) exit(1); } }` — POSIX_BUILTINS makes
10464/// a failed exec redirect fatal in a non-interactive shell.
10465/// Returns Value::Status(0|1) for the trailing SetStatus.
10466pub const BUILTIN_EXEC_REDIR_DONE: u16 = 626;
10467/// Assignment-prefix epilogue for bare `exec` redirects
10468/// (`x=$(cmd) exec >file`). c:Src/exec.c:3969-3976 — nullexec==1
10469/// runs addvars THEN, without POSIX_BUILTINS, restores the params
10470/// (`save_params` / `restore_params`): the RHS side effects fire but
10471/// the values don't persist. With POSIX_BUILTINS the assignments
10472/// stick. Pops the BEGIN_INLINE_ENV frame either way.
10473pub const BUILTIN_EXEC_INLINE_ENV_DONE: u16 = 627;
10474
10475/// `< file` / `> file` with no command word (NULLCMD path).
10476/// Resolves NULLCMD (default "cat") / READNULLCMD (default "more")
10477/// at runtime per Src/exec.c:3340-3364 and exec's it through
10478/// host_exec_external. Argc is 1: the int (0 or 1) on the stack
10479/// indicates whether this is a single REDIR_READ redirect
10480/// (selects READNULLCMD when set + non-empty).
10481pub const BUILTIN_NULLCMD_EXEC: u16 = 607;
10482/// `.` (dot) — alias of source/bin_dot but dispatches with the
10483/// literal name "." so the diagnostic prefix matches zsh's
10484/// (`zsh:.:1: …` vs source's `zsh:source:1: …`).
10485/// c:Src/builtin.c:9308 — `BUILTIN(".", BINF_PSPECIAL, bin_dot, …)`.
10486pub const BUILTIN_DOT: u16 = 608;
10487/// `logout` — fusevm maps this to BUILTIN_EXIT alongside `exit`/`bye`,
10488/// which drops the name and dispatches with BIN_EXIT funcid. zsh's
10489/// `logout` outside a login shell must emit "not login shell" + exit 1,
10490/// which only fires when bin_break sees BIN_LOGOUT funcid. Dedicated
10491/// opcode dispatches via BUILTINS table by literal name "logout".
10492pub const BUILTIN_LOGOUT: u16 = 610;
10493/// `BUILTIN_PARAM_SUBSTRING_EXPR` constant.
10494pub const BUILTIN_PARAM_SUBSTRING_EXPR: u16 = 337;
10495/// `BUILTIN_XTRACE_LINE` constant.
10496pub const BUILTIN_XTRACE_LINE: u16 = 338;
10497/// `BUILTIN_XTRACE_ARRAY_LINE` — xtrace an `arr=(...)` assignment from the
10498/// whole assembled `Value::Array` (see compile_zsh array-literal codegen).
10499pub const BUILTIN_XTRACE_ARRAY_LINE: u16 = 649;
10500/// `BUILTIN_MAKE_ARRAY_COUNTED` — like `Op::MakeArray(u16)` but the element
10501/// count is a runtime `Int` on the stack, so it is not capped at 65535. Used
10502/// by the array-literal codegen only when the literal has > u16::MAX elements
10503/// (e.g. a .zcompdump's ~103k-element `_comps=(...)`).
10504pub const BUILTIN_MAKE_ARRAY_COUNTED: u16 = 650;
10505/// `BUILTIN_ARRAY_JOIN_STAR` constant.
10506pub const BUILTIN_ARRAY_JOIN_STAR: u16 = 339;
10507/// `BUILTIN_SET_RAW_OPT` constant.
10508pub const BUILTIN_SET_RAW_OPT: u16 = 340;
10509
10510/// `time { compound; ... }` — wall-clock-time the sub-chunk and print
10511/// elapsed seconds. Stack: [sub_chunk_idx as Int]. Runs the sub-chunk
10512/// on the current VM (so positional/local state is shared) and prints
10513/// the timing summary to stderr in zsh's format. Pushes Status.
10514pub const BUILTIN_TIME_SUBLIST: u16 = 316;
10515
10516/// `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocation.
10517/// Stack: [path, varid, op_byte]. Opens `path` per `op_byte`, gets the
10518/// new fd (≥10 in zsh; we use libc::open with O_CLOEXEC bit cleared so
10519/// the inherited fd survives Command::new spawns), stores the fd number
10520/// as a string in `$varid`. Pushes Status (0 success, 1 error).
10521pub const BUILTIN_OPEN_NAMED_FD: u16 = 317;
10522
10523/// Word-segment concat that does cartesian-product distribution over
10524/// arrays. Stack: [lhs, rhs]. Used for RC_EXPAND_PARAM `${arr}` and
10525/// explicit-distribute forms (`${^arr}`, `${(@)…}`).
10526///
10527/// - both scalar: `Value::str(a + b)` (fast path, identical to Op::Concat)
10528/// - lhs Array, rhs scalar: `Value::Array([a + rhs for a in lhs])`
10529/// - lhs scalar, rhs Array: `Value::Array([lhs + b for b in rhs])`
10530/// - both Array: cartesian product `[a + b for a in lhs for b in rhs]`
10531pub const BUILTIN_CONCAT_DISTRIBUTE: u16 = 318;
10532
10533/// Forced-distribute concat — like `BUILTIN_CONCAT_DISTRIBUTE` but
10534/// always distributes cartesian regardless of the `rcexpandparam`
10535/// option. Emitted by the segments fast-path when an
10536/// `is_distribute_expansion` segment is present (`${^arr}`,
10537/// `${(@)arr}`, `${(s.…)arr}` etc.) per zsh: the source-level
10538/// distribution flag overrides the option default.
10539/// Direct port of Src/subst.c:1875 `case Hat: nojoin = 1` and the
10540/// `rcexpandparam` test bypass for the explicit-distribute flags.
10541pub const BUILTIN_CONCAT_DISTRIBUTE_FORCED: u16 = 522;
10542
10543/// Capture current `last_status` into the `TRY_BLOCK_ERROR` variable.
10544/// Emitted between the try block and the always block of `{ … } always
10545/// { … }` so the finally arm can read $TRY_BLOCK_ERROR.
10546pub const BUILTIN_SET_TRY_BLOCK_ERROR: u16 = 320;
10547/// `BUILTIN_RESTORE_TRY_BLOCK_STATUS` constant.
10548pub const BUILTIN_RESTORE_TRY_BLOCK_STATUS: u16 = 432;
10549/// `BUILTIN_BEGIN_INLINE_ENV` constant.
10550pub const BUILTIN_BEGIN_INLINE_ENV: u16 = 433;
10551/// `BUILTIN_END_INLINE_ENV` constant.
10552pub const BUILTIN_END_INLINE_ENV: u16 = 434;
10553
10554/// `[[ -o option ]]` — shell-option-set test. Stack: \[option_name\].
10555/// Normalizes the name (strip underscores, lowercase) and reads
10556/// `exec.options`. Pushes Bool.
10557pub const BUILTIN_OPTION_SET: u16 = 321;
10558/// Tri-state `[[ -o NAME ]]` — same lookup as BUILTIN_OPTION_SET
10559/// but returns a Value::Int (0=set, 1=unset, 3=invalid-name). The
10560/// 3-state code matches zsh's `[[ -o invalid ]]` exit (Src/cond.c
10561/// :502 `optison()`). Used by compile_cond's `-o` arm to skip the
10562/// generic bool→status conversion and preserve the invalid-name
10563/// signal in `$?`.
10564pub const BUILTIN_OPTION_CHECK_TRISTATE: u16 = 609;
10565
10566/// `${var:#pattern}` — array filter: remove elements matching `pattern`.
10567/// Stack: [name, pattern]. For scalar `var`, returns empty if it matches
10568/// the pattern, else the value. For array `var`, returns Array of
10569/// non-matching elements.
10570pub const BUILTIN_PARAM_FILTER: u16 = 322;
10571
10572/// `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()` —
10573/// subscripted-array assign with array-literal RHS. Stack:
10574/// [...elements, name, key]. Empty elements + single-int key `a[i]=()`
10575/// removes that element. Comma-key `a[i,j]=(...)` splices.
10576pub const BUILTIN_SET_SUBSCRIPT_RANGE: u16 = 323;
10577
10578/// `[[ -X file ]]` for unknown unary test op `-X`. Stack: \[op_name\].
10579/// Emits zsh's `unknown condition: -X` diagnostic to stderr and
10580/// pushes Bool(false). Without this, unknown conditions silently
10581/// returned false matching neither zsh's error format nor the
10582/// expected status code (zsh returns 2 for parse error).
10583
10584/// `[[ -t fd ]]` — fd-is-a-tty check. Stack: \[fd_string\].
10585/// Routes through libc::isatty. Pushes Bool.
10586///
10587/// ID 644 (unique, next free above the previous max of 643). This was
10588/// 325, which COLLIDED with BUILTIN_FILE_OLDER (also 325). The VM's
10589/// builtin table is last-registration-wins, and FILE_OLDER registered
10590/// after IS_TTY, so every `[[ -t fd ]]` silently dispatched to the
10591/// file-`-ot` handler: it compared the mtime of a file NAMED by the fd
10592/// string ("0", "1", …) — which never exists — so `[[ -t 0 ]]` was
10593/// always false. That broke interactive detection (`[[ -t 0 && -t 1 ]]`)
10594/// and any config gated on it. c:Src/cond.c:390 `return !isatty(...)`.
10595pub const BUILTIN_IS_TTY: u16 = 644;
10596/// Runtime rejection of a process substitution used inside a `[[ … ]]`
10597/// cond operand. c:Src/exec.c:4918/5040/5069 — `getoutputfile`/`getproc`
10598/// error `"process substitution %s cannot be used here"` when `thisjob ==
10599/// -1`, which is the case during cond evaluation. zshrs's THISJOB never
10600/// distinguishes that context at runtime, so the compiler emits this
10601/// builtin (gated on `in_cond_operand`) instead of the ProcessSubIn/Out
10602/// opcode. Pops the substitution text, zerrs, sets errflag (aborting the
10603/// statement → empty stdout, exit 1, matching zsh), returns empty.
10604pub const BUILTIN_PROCSUB_COND_ERROR: u16 = 645;
10605/// `${^arr}` cross-product concat — RC_EXPAND_PARAM forced ON by the `^` flag.
10606///
10607/// Distinct from BUILTIN_CONCAT_DISTRIBUTE_FORCED, which the other distribute
10608/// shapes (`${(@)a}`, `${(f)v}`, `${a[@]}`) share: those keep the word when the
10609/// array is EMPTY (`x${(@)a}y` → `xy`), but plan9 DELETES it
10610/// (c:Src/subst.c:4362-4365 `if (plan9) { uremnode(l, n); return n; }`), so
10611/// `x${^a}y` with `a=()` produces no word at all. One builtin cannot serve both
10612/// — the plan9-ness is known only at compile time, from the `^` flag itself.
10613/// Routes to `concat_plan9`, which already ports both c:4362's removal and the
10614/// c:4316-4350 cartesian emit, and is what the OPTION path
10615/// (`setopt rcexpandparam`) has always used.
10616pub const BUILTIN_CONCAT_PLAN9: u16 = 646;
10617/// `${^^arr}` concat — RC_EXPAND_PARAM forced OFF by the doubled flag
10618/// (c:Src/subst.c:2553-2555 `plan9 = 0`).
10619///
10620/// The mirror of BUILTIN_CONCAT_PLAN9. Needed because every other concat
10621/// builtin consults `plan9_active()` (the runtime OPTION) and so cross-products
10622/// anyway under `setopt rcexpandparam`, while `^^` must override the option:
10623/// setopt rcexpandparam; a=(a b c); print -rl -- ${^^a}.x
10624/// # zsh: `a`, `b`, `c.x` — spliced, NOT `a.x b.x c.x`
10625/// The override is computed in paramsubst but the distribution call is made
10626/// here, so — like the `^` flag — the only place that knows is the compiler.
10627/// Routes straight to `concat_splice`, C's non-plan9 join-first-and-last path
10628/// (c:4366-4437).
10629pub const BUILTIN_CONCAT_SPLICE_NOPLAN9: u16 = 647;
10630/// `break N`/`continue N` runtime-count validator (see registration).
10631pub const BUILTIN_BREAK_COUNT_VALIDATE: u16 = 648;
10632/// `[[ -r/-w/-x file ]]` via access(2) (doaccess) — see handler.
10633pub const BUILTIN_COND_ACCESS: u16 = 638;
10634
10635/// Update `$LINENO` to track the source line of the next statement.
10636/// Stack: \[n\] (the line number from `ZshPipe.lineno`). Direct port
10637/// of zsh's `lineno` global tracking (Src/input.c:330) — the
10638/// compiler emits one of these per top-level pipe so `$LINENO`
10639/// reflects the source position at runtime. ID 342 picked because
10640/// the previous `326` collided with `BUILTIN_HAS_STICKY` (the 325
10641/// collision between IS_TTY and FILE_OLDER has since been fixed by
10642/// moving IS_TTY to 644).
10643pub const BUILTIN_SET_LINENO: u16 = 342;
10644
10645/// Pop a scalar from the VM stack, run expand_glob on it, push the
10646/// result as Value::Array. Used by the segment-concat compile path
10647/// when var refs concatenate with glob meta literals (`$D/*`,
10648/// `${prefix}*`, etc.) — those skip the bridge's pathname-expansion
10649/// pass and would otherwise leak the glob meta to argv as a literal.
10650pub const BUILTIN_GLOB_EXPAND: u16 = 343;
10651
10652/// MULTIOS-gated glob expansion for redirect-target words
10653/// (c:Src/glob.c:2161-2167 xpandredir: "Globbing is only done for
10654/// multios."). Same stack shape as BUILTIN_GLOB_EXPAND; additionally
10655/// passes the word through literally when `unsetopt multios`.
10656// 624 is BUILTIN_COND_STRMATCH — the VM's builtin table is
10657// last-registration-wins, so a duplicate id silently shadows the
10658// earlier handler.
10659pub const BUILTIN_REDIR_GLOB_EXPAND: u16 = 628;
10660
10661/// Reset the default-word glob-pending carrier at the START of a word
10662/// whose source contains a glob metachar (so the flag never leaks from a
10663/// prior word/statement). Paired with BUILTIN_DEFAULT_WORD_GLOB.
10664pub const BUILTIN_DEFAULT_WORD_GLOB_RESET: u16 = 635;
10665
10666/// Filename-generate the ASSEMBLED word ONLY when the default/alternate
10667/// paramsubst arm took a SOURCE word carrying glob metachars
10668/// (subst::DEFAULT_WORD_GLOB_PENDING). A parameter VALUE never sets the
10669/// flag, so `x='*file'; ${x:-d}` stays literal while `${x:-*file}` /
10670/// `${x:-a*}bar` glob. Reads+clears the flag. c:Src/subst.c → globlist.
10671pub const BUILTIN_DEFAULT_WORD_GLOB: u16 = 636;
10672/// `BUILTIN_SET_LOOP_VAR` constant — for-loop variable binding via
10673/// `setloopvar` (Src/params.c:6362): a PM_NAMEREF loop var REBINDS
10674/// to each word (SETREFNAME + setscope) instead of assigning
10675/// through the chain. Returns Bool(false) when zerr fired
10676/// (read-only reference / invalid self reference) so the loop
10677/// driver aborts, mirroring C execfor's errflag check.
10678pub const BUILTIN_SET_LOOP_VAR: u16 = 629;
10679
10680/// EXTEND step of typeset paren-init packing. Pops `argc` values:
10681/// [base, e1, …, eN] — base is either the opener (`name=(` /
10682/// `name+=(`) or a previous EXTEND result. Pushes base with
10683/// `\u{1f}` + element appended per element. Array values SPLICE
10684/// their items as separate elements (`typeset b=( x $arr )` splat);
10685/// an empty Array contributes nothing (unquoted-empty elision).
10686/// CallBuiltin's argc is u8, so the compiler emits one EXTEND per
10687/// ≤200-element chunk — p10k's 408-element `__p9k_colors=( … )`
10688/// overflowed a single-shot pack (argc wrapped mod 256 and the
10689/// stack spilled into the arg list: "not an identifier: 173…").
10690/// BUILTIN_TYPESET_PAREN_CLOSE appends the final `\u{1f})`,
10691/// yielding the exact REJOIN_SEP-delimited one-arg form
10692/// bin_typeset's single-arg splitter consumes (builtin.rs ~4891,
10693/// empties preserved, leading/trailing sentinel-empties trimmed
10694/// once). One arg in → one arg out: bin_typeset's multi-arg rejoin
10695/// (paren-depth scan, unsafe on EXPANDED paren-literal elements
10696/// like p10k's `')' ''`) never runs.
10697pub const BUILTIN_TYPESET_PAREN_PACK: u16 = 630;
10698
10699/// CLOSE step — pops the EXTEND chain's result, pushes it with
10700/// `\u{1f})` appended. See BUILTIN_TYPESET_PAREN_PACK.
10701pub const BUILTIN_TYPESET_PAREN_CLOSE: u16 = 631;
10702
10703/// Shared body of BUILTIN_GLOB_EXPAND / BUILTIN_REDIR_GLOB_EXPAND.
10704/// c:Src/glob.c:1872 — `zglob` runs per-word in the argv pipeline.
10705/// When the upstream EXPAND_TEXT returned an array (e.g. `${a:e}`
10706/// splat → ["txt","md"]), glob each element separately, not a
10707/// sepjoin'd scalar. `skip_glob` short-circuits to a literal
10708/// pass-through (noglob, or a redirect target under
10709/// `unsetopt multios`).
10710fn glob_expand_word_value(raw: Value, skip_glob: bool) -> Value {
10711 let patterns: Vec<String> = match raw {
10712 Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
10713 other => vec![other.to_str()],
10714 };
10715 if skip_glob {
10716 return if patterns.is_empty() {
10717 Value::Array(Vec::new())
10718 } else if patterns.len() == 1 {
10719 Value::str(patterns.into_iter().next().unwrap())
10720 } else {
10721 Value::Array(patterns.into_iter().map(Value::str).collect())
10722 };
10723 }
10724 let mut out: Vec<String> = Vec::with_capacity(patterns.len());
10725 for pattern in &patterns {
10726 // c:Src/subst.c — filename generation runs `filesub` (tilde/`=`
10727 // expansion) BEFORE globbing. A `~`/`=` reaching this word-glob op
10728 // comes from `${~spec}` / GLOB_SUBST marking a substituted VALUE:
10729 // literal and quoted `~` words are filesub'd (or skip glob) upstream
10730 // and never arrive here. filesubstr matches the Tilde TOKEN, so
10731 // shtokenize first (raw `~`->Tilde; already-Tilde `${~a[@]}` results
10732 // pass through), run filesub, then untokenize surviving glob metas
10733 // for expand_glob. Gated on `~`/`=` (raw or token) so ordinary
10734 // substituted words skip the roundtrip. Fixes `${~x}` x="~/foo".
10735 let filesubbed = if pattern.contains('~')
10736 || pattern.contains('=')
10737 || pattern.contains(crate::ported::zsh_h::Tilde)
10738 || pattern.contains(crate::ported::zsh_h::Equals)
10739 {
10740 let mut tok = pattern.clone();
10741 crate::ported::glob::shtokenize(&mut tok);
10742 crate::ported::lex::untokenize(&crate::ported::subst::filesub(&tok, 0))
10743 } else {
10744 pattern.clone()
10745 };
10746 let matches = with_executor(|exec| exec.expand_glob(&filesubbed));
10747 if matches.is_empty() {
10748 // c:1872 nullglob — drop this word, don't emit a hole
10749 continue;
10750 }
10751 for m in matches {
10752 out.push(m);
10753 }
10754 }
10755 if out.is_empty() {
10756 return Value::Array(Vec::new());
10757 }
10758 if patterns.len() == 1 && out.len() == 1 && out[0] == patterns[0] {
10759 // No real matches; expand_glob returned the literal. Pass
10760 // back as scalar so downstream ops don't re-flatten.
10761 return Value::str(out.into_iter().next().unwrap());
10762 }
10763 Value::Array(out.into_iter().map(Value::str).collect())
10764}
10765
10766/// Push a `CmdState` token onto the command-context stack. Direct
10767/// port of zsh's `cmdpush(int cmdtok)` (Src/prompt.c:1623). The
10768/// stack is consulted by `%_` in PS4/prompt expansion to produce
10769/// the cumulative control-flow-context labels (`if`, `then`,
10770/// `cmdand`, `cmdor`, `cmdsubst`, …) that `zsh -x` xtrace shows
10771/// in the trace prefix. Compile_zsh emits push/pop pairs around
10772/// each compound command (if/while/[[…]]/((…))/$(…) etc.).
10773/// Token is a `CmdState as u8`.
10774pub const BUILTIN_CMD_PUSH: u16 = 344;
10775
10776/// Pop the top of the command-context stack. Direct port of zsh's
10777/// `cmdpop(void)` (Src/prompt.c:1631).
10778pub const BUILTIN_CMD_POP: u16 = 345;
10779
10780/// Emit an xtrace line built from the top `argc` values on the VM
10781/// stack, peeked WITHOUT consuming. Used to trace simple commands
10782/// AFTER expansion, so `echo for $i` shows as `echo for a` / `echo
10783/// for b`. Direct port of Src/exec.c:2055-2066.
10784pub const BUILTIN_XTRACE_ARGS: u16 = 346;
10785
10786/// Trace one assignment: emits `name=<quoted-value> ` (no newline)
10787/// to xtrerr if XTRACE is on. Coalesces with subsequent
10788/// XTRACE_ASSIGN / XTRACE_ARGS calls onto the SAME line via the
10789/// `XTRACE_DONE_PS4` flag so `a=1 b=2 echo $a $b` produces:
10790/// `<PS4>a=1 b=2 echo 1 2\n`
10791/// matching C zsh's `execcmd_exec` body (Src/exec.c:2517-2582):
10792/// xtr = isset(XTRACE);
10793/// if (xtr) { printprompt4(); doneps4 = 1; }
10794/// while (assign) {
10795/// if (xtr) fprintf(xtrerr, "%s=", name);
10796/// ... eval value ...
10797/// if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
10798/// }
10799///
10800/// Stack contract on entry: [..., name, value]. Both peeked, NOT
10801/// consumed (the matching SET_VAR call pops them after). argc = 2.
10802pub const BUILTIN_XTRACE_ASSIGN: u16 = 525;
10803
10804/// Emit a trailing `\n` + flush iff XTRACE is on AND PS4 was
10805/// emitted by an earlier XTRACE_ASSIGN this line. Used at the end
10806/// of compile_simple's assignment-only path so the trace line gets
10807/// terminated. Mirrors C's exec.c:3397-3399 (the assign-only return
10808/// path through execcmd_exec which does `fputc('\n', xtrerr);
10809/// fflush(xtrerr)`).
10810///
10811/// Stack: untouched. argc = 0.
10812pub const BUILTIN_XTRACE_NEWLINE: u16 = 526;
10813
10814/// Push the live `xtrace` opt-state as `Value::Int(1)` (on) or
10815/// `Value::Int(0)` (off). Used by `compile_cond` to gate the
10816/// trace-string-building block on xtrace state at runtime — without
10817/// this the trace path's `compile_word_str` on each operand re-
10818/// evaluates side-effectful expressions (`$((i++))`) once for the
10819/// trace string and once for the actual condition, doubling the
10820/// effective increment. Bug #159 in docs/BUGS.md.
10821///
10822/// Stack: pushes Int(0|1). argc = 0.
10823pub const BUILTIN_XTRACE_IS_ON: u16 = 611;
10824
10825/// Reset the `DONETRAP` flag at the start of each top-level statement
10826/// (sublist boundary). Mirrors C `Src/exec.c:1455` — `donetrap = 0`.
10827/// Stack: untouched. argc = 0. Bug #303 in docs/BUGS.md.
10828pub const BUILTIN_DONETRAP_RESET: u16 = 612;
10829
10830/// `[[ -z X ]]` / `[[ -n X ]]` operand-empty test that honours zsh's
10831/// array-splice semantics. C zsh evaluates `[[ -z X ]]` per
10832/// `Src/cond.c:347` (case 'z'): `s` is the SCALAR operand passed
10833/// through `cond_str`'s singsub. For `"${arr[@]}"` zsh expands per
10834/// `Src/subst.c:multsub` which yields each element as its own word
10835/// list node; cond.c then sees the joined-or-single-element form.
10836///
10837/// The compile-side `-z` shortcut at `compile_zsh.rs:5371` used
10838/// `Op::StringLen` which calls `Value::len` — for `Value::Array`
10839/// that returns ARRAY LENGTH, not string length. `b=("")` produced
10840/// `Value::Array([""])` → `len = 1` → `-z` returned false.
10841///
10842/// This builtin pops one `Value` and pushes `1` (empty) or `0`
10843/// (non-empty) per the cond context:
10844/// - `Value::Str(s)` → s.is_empty()
10845/// - `Value::Array([])` → true (zero words → vacuous-empty)
10846/// - `Value::Array([s])` → s.is_empty() (single-word case)
10847/// - `Value::Array([_; n>=2])` → false (multiple non-empty
10848/// words; zsh would raise "unknown condition" but the
10849/// observable test result is non-empty/false)
10850///
10851/// Companion to BUILTIN_COND_STR_NONEMPTY (#185 in docs/BUGS.md).
10852pub const BUILTIN_COND_STR_EMPTY: u16 = 613;
10853
10854/// `[[ -n X ]]` operand-non-empty test (logical complement of
10855/// BUILTIN_COND_STR_EMPTY).
10856pub const BUILTIN_COND_STR_NONEMPTY: u16 = 614;
10857
10858/// `exec N<<<"str"` — herestring redirect to explicit fd, applied
10859/// permanently to the shell (no scope restoration). Pops `[content,
10860/// fd]` from the stack; creates a temp file, writes
10861/// `content + "\n"`, reopens read-only, dup2's to `fd`, unlinks the
10862/// temp path so it disappears on close. Mirrors C `Src/exec.c:4655
10863/// getherestr` + `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)`
10864/// at c:3766-3780 for the bare-exec-redir code path (nullexec=1).
10865/// Bug #205 in docs/BUGS.md.
10866///
10867/// Stack: pushes `Value::Status(0)` on success, `Status(1)` on
10868/// failure. argc = 2.
10869pub const BUILTIN_EXEC_HERESTR_FD: u16 = 615;
10870
10871/// MULTIOS write/append fan-out for `cmd > a > b` / `cmd > a >> b`
10872/// style redirects (Bug #36 in docs/BUGS.md). zsh's MULTIOS option
10873/// (Src/exec.c:2418 `mfds[fd1]` check + addfd splice) creates a
10874/// pipe at fd1, spawns an internal "tee" process that copies
10875/// stdin → every collected target file. Without this, only the
10876/// LAST redirect target survives because each dup2 overwrites the
10877/// previous binding.
10878///
10879/// Stack layout (pushed by compile_zsh's compile_redirs coalescing
10880/// pass): `[target_1, op_byte_1, target_2, op_byte_2, …, target_N,
10881/// op_byte_N, fd]`. Pops 2N+1 elements; `argc = 2*N + 1`. A target
10882/// may be a Value::Array of glob matches (spliced into one member
10883/// per match, c:Src/glob.c:2195-2203); an op may be DUP_WRITE for a
10884/// numeric `>&N` member (c:Src/exec.c:3895-3917).
10885///
10886/// Runtime (MULTIOS set):
10887/// 1. Seed the member list with `dup(1)` when this command's
10888/// stdout is the pipeline output (c:Src/exec.c:3722-3724).
10889/// 2. Open/dup all targets per their op_byte in redirect order
10890/// (WRITE truncate + noclobber gate / APPEND / DUP_WRITE live
10891/// dup); the first member replaces the fd (c:2448-2450).
10892/// 3. Save `dup(fd)` onto the active redirect_scope_stack so
10893/// `host_redirect_scope_end` restores the original fd.
10894/// 4. Create a pipe; spawn a thread that reads from the pipe
10895/// read-end and writes every chunk to every opened target.
10896/// 5. dup2 the pipe write-end onto `fd` so the command's writes
10897/// go through the splitter.
10898/// 6. Track `(pipe_write_fd, JoinHandle)` so scope-end can close
10899/// the pipe (draining the thread) and join before restoring.
10900///
10901/// MULTIOS unset (c:2418 `unset(MULTIOS)` replace arm): each entry
10902/// is applied as a plain sequential replace via host_apply_redirect
10903/// — every file still opened/truncated, last one wins.
10904pub const BUILTIN_MULTIOS_REDIRECT: u16 = 617;
10905
10906/// MULTIOS input-side concatenation for `cmd < a < b` shapes
10907/// (Bug #36 input arm). C zsh's `Src/exec.c:2418` mfds dispatch
10908/// also covers the read direction — when multiple `<` redirects
10909/// target the same fd, mfds[fd] grows and addfd splices a
10910/// concatenating cat into the pipe.
10911///
10912/// Stack layout (mirrors the write side): `[source_1, op_1,
10913/// source_2, op_2, …, source_N, op_N, fd]`. Pops 2N + 1 elements
10914/// (argc = 2N + 1). op is READ for file sources, DUP_READ for
10915/// numeric `<&N` members; a source may be a Value::Array of glob
10916/// matches (spliced, c:Src/glob.c:2195-2203).
10917///
10918/// Runtime (MULTIOS set):
10919/// 1. Open/dup every source in redirect order; first member
10920/// replaces the fd (c:Src/exec.c:2448-2450).
10921/// 2. Save `dup(fd)` onto the redirect_scope_stack.
10922/// 3. Create a pipe; spawn a thread that reads each source in
10923/// order and writes every chunk to the pipe write-end. Close
10924/// write-end when done so the consumer sees EOF.
10925/// 4. dup2 the pipe read-end onto `fd`.
10926/// 5. Track the JoinHandle so scope-end joins (no fd-close needed
10927/// here — the producer thread closes its own pipe write-end
10928/// on exit).
10929///
10930/// MULTIOS unset: sequential replace via host_apply_redirect — last
10931/// source wins (c:2418).
10932pub const BUILTIN_MULTIOS_READ: u16 = 618;
10933
10934/// Toggle `ShellExecutor::exec_redirs_permanent`. Emitted by
10935/// compile_zsh's bare-`exec`-with-redirects arm tightly around each
10936/// `Op::Redirect`: `LoadInt(1); CallBuiltin; …Redirect…; LoadInt(0);
10937/// CallBuiltin`. While set, `host_apply_redirect` skips pushing the
10938/// saved fd into the enclosing redirect scope, making the fd change
10939/// permanent.
10940///
10941/// c:Src/exec.c:3978-3986 — nullexec==1 (`exec` carrying only
10942/// redirections): "If nullexec is 1 we specifically *don't* restore
10943/// the original fd's before returning" — the per-execcmd `save[]`
10944/// dups are closed unrestored. An ENCLOSING group's own saves are a
10945/// different execcmd's `save[]` and still restore (verified:
10946/// `{ exec 2>/dev/null; } 2>&1; ls /nope` prints the ls error in zsh).
10947pub const BUILTIN_EXEC_PERM_REDIRS: u16 = 619;
10948
10949/// Set `ShellExecutor::pipe_output_pending`. Emitted by compile_pipe
10950/// at the head of a NON-LAST pipeline-stage sub-chunk when that
10951/// stage's top-level command carries redirects (`Simple` with redirs
10952/// or `Redirected` compound). The forked stage child runs the chunk
10953/// with stdout already dup2'd onto the pipe; the first
10954/// `host_redirect_scope_begin` (the stage command's own redirect
10955/// list) consumes the flag into `pipe_output_scope`, enabling the
10956/// MULTIOS stream-split for fd-1 write redirects in that list.
10957///
10958/// c:Src/exec.c:3722-3724 — `addfd(forked, save, mfds, 1, output, 1,
10959/// NULL)` registers the pipe in mfds[1] in the SAME execcmd that
10960/// walks the stage command's redirect list; mfds is per-execcmd, so
10961/// nested body commands (`{ echo a > f; } | cat`) never see it.
10962pub const BUILTIN_PIPE_OUTPUT_MARK: u16 = 620;
10963
10964/// Install the pipeline stage's parked fds onto 0/1.
10965///
10966/// c:Src/exec.c:3720-3724 — `addfd(forked, save, mfds, 0, input, 0,
10967/// NULL)` / `addfd(..., 1, output, 1, NULL)`. Runs after prefork
10968/// (c:3304) and globlist (c:3702) have expanded the stage's argument
10969/// words, which is why a `$(...)` in those words reads the shell's
10970/// original stdin rather than the pipe. Emitted by
10971/// `compile_zsh.rs::emit_stage_fds_install`; the fds themselves are
10972/// parked by `BUILTIN_RUN_PIPELINE`.
10973pub const BUILTIN_PIPE_FDS_INSTALL: u16 = 642;
10974
10975/// Magic-equals prefork for a single arg word of a
10976/// `BINF_MAGICEQUALS` builtin head (`alias`). Direct port of
10977/// c:Src/exec.c:3298-3304 — `esprefork = PREFORK_TYPESET;
10978/// prefork(args, esprefork, NULL)` runs on the argv BEFORE the addfd
10979/// redirect loop at c:3720, so an expansion zerr (`alias bad===` →
10980/// equalsubstr "= not found" at Src/subst.c:726) prints to the
10981/// command's UN-redirected stderr. argc=1: pops the just-pushed
10982/// word value, runs shtokenize → prefork(PREFORK_TYPESET) →
10983/// untokenize on it (each element for Array splices), pushes the
10984/// result back. Emitted by compile_simple per arg word when the
10985/// dispatch head is `alias`; BUILTIN_ALIAS itself no longer
10986/// preforks (it would double-fire the diagnostic).
10987pub const BUILTIN_MAGIC_EQUALS_PREFORK: u16 = 621;
10988
10989/// Bare (unbraced) `$name[idx]` subscript — same dispatch as
10990/// `BUILTIN_ARRAY_INDEX` while KSHARRAYS is unset, but under
10991/// KSHARRAYS the unbraced form does NOT subscript (c:Src/subst.c:
10992/// 2800-2802 + 2867): `$name` expands bare and `[idx]` stays literal
10993/// trailing text that undergoes filename generation. Operands:
10994/// [name, idx, suffix, quoted].
10995pub const BUILTIN_ARRAY_INDEX_UNBRACED: u16 = 622;
10996
10997/// Assignment-only simple-command exit status. Direct port of
10998/// `lv = (errflag ? errflag : cmdoutval)` (c:Src/exec.c:1322,
10999/// execsimple's WC_ASSIGN arm) / `if (errflag) lastval = 1; else
11000/// lastval = cmdoutval;` (c:Src/exec.c:3393-3396, execcmd_exec's
11001/// no-command-word varspc path; redir variant at c:3977). Pops
11002/// [had_cmd_subst]; cmdoutval is the live vm.last_status when a
11003/// `$()` ran in any RHS of the chain, 0 otherwise. Writes the
11004/// canonical LASTVAL (C's single `lastval` global) so the
11005/// non-interactive errflag abort exits with this value per
11006/// Src/init.c:234. Caller pairs with SetStatus.
11007pub const BUILTIN_ASSIGN_ONLY_STATUS: u16 = 623;
11008
11009/// c:Src/exec.c addvars — `if (!pm) { lastval = 1; if (!cmdoutval)
11010/// cmdoutval = 1; }`. Set by BUILTIN_SET_VAR on assignsparam
11011/// failure, consumed by BUILTIN_ASSIGN_ONLY_STATUS so the
11012/// assignment-only command reports status 1. Process-global like
11013/// C's `cmdoutval` (function bodies may run on a different thread
11014/// than the opcode that reads the status back).
11015pub static ASSIGN_FAILED_FLAG: std::sync::atomic::AtomicBool =
11016 std::sync::atomic::AtomicBool::new(false);
11017
11018/// `redirection with no command` parse-time error for bare
11019/// `builtin 2>&1` / `command < file` / `exec >&-` precmd-keyword
11020/// shapes with a redirect but no following command. Direct port
11021/// of `Src/exec.c:3342 zerr("redirection with no command")`.
11022/// argc=0; pushes Value::Status(1).
11023pub const BUILTIN_REDIR_NO_CMD: u16 = 616;
11024
11025/// GLOB_SUBST guard for `[[ x == $pat ]]` pattern RHS coming from
11026/// parameter / command substitution. C-zsh's `[[ == ]]` semantics
11027/// (Src/options.c GLOB_SUBST default OFF + Src/cond.c:552
11028/// `cond_match` + Src/pattern.c patcompile tokenization-based
11029/// meta detection) treat chars from substitution as LITERAL
11030/// unless GLOB_SUBST is on. The Rust patcompile accepts both
11031/// tokenized and raw-ASCII meta chars, losing the distinction,
11032/// so `pat="h*"; [[ hello == $pat ]]` matched in zshrs but not
11033/// in zsh. Bug #116 in docs/BUGS.md.
11034///
11035/// Compile-time signal: emitted by `compile_cond_expr` ONLY when
11036/// the RHS contains `$` or backtick. Runtime checks the live
11037/// option state. If GLOB_SUBST is OFF, the popped string has
11038/// its glob metachars escaped with `\` so the downstream StrMatch
11039/// → patcompile treats them as literals. If GLOB_SUBST is ON,
11040/// the value passes through unchanged so `setopt glob_subst`
11041/// restores zsh's pattern-on-expansion behavior.
11042///
11043/// Stack: pops one string, pushes the (possibly escaped) result.
11044/// argc = 1.
11045pub const BUILTIN_GLOB_SUBST_GUARD: u16 = 528;
11046
11047/// Coerce a string parameter value to a math number (Int or Float)
11048/// for arithmetic-context reads, mirroring C-zsh's `getmathparam`
11049/// (Src/math.c:337). When the variable holds a string like "hello"
11050/// that isn't numeric, C falls back to recursively evaluating the
11051/// raw string as an arith expression; if that fails too, returns 0.
11052///
11053/// Used by the ArithCompiler pre-load path so `(( y = x ))` with
11054/// `x="hello"` reads `x` as integer 0, then assigns y as integer 0
11055/// — matching zsh's behaviour. The previous Rust port used
11056/// BUILTIN_GET_VAR which returned the raw string "hello"; the
11057/// ArithCompiler stored it verbatim in y's slot, and the post-sync
11058/// BUILTIN_SET_VAR wrote y="hello" as scalar instead of y=0 as
11059/// integer. Bug #118 in docs/BUGS.md.
11060///
11061/// Stack: pops `name` (string), pushes coerced numeric Value.
11062/// argc = 1.
11063pub const BUILTIN_GET_MATH_VAR: u16 = 529;
11064
11065/// GLOB_SUBST runtime gate for words containing parameter / command
11066/// substitution. C-zsh's `prefork` (Src/subst.c) runs `shtokenize`
11067/// on the substituted value when `GLOB_SUBST` is set, making the
11068/// substituted chars eligible for filename generation. With the
11069/// option off, substituted chars stay literal.
11070///
11071/// The Rust port's compile_zsh emits `compile_word_str` for words
11072/// like `/tmp/X/$pat`, which returns the post-expansion string but
11073/// never runs glob expansion (no path here triggers
11074/// BUILTIN_GLOB_EXPAND). Bug #119 in docs/BUGS.md: with `setopt
11075/// glob_subst`, `for f in /tmp/X/$pat` (pat="*.txt") never matched
11076/// `*.txt` files.
11077///
11078/// This opcode wraps the substitution result and dispatches at
11079/// runtime: when GLOB_SUBST is OFF, return unchanged; when ON,
11080/// pass the value through `expand_glob` so glob metas become
11081/// active. Emitted by `compile_for_words` (and similar sites)
11082/// after WORD_SPLIT for words with unquoted expansion.
11083///
11084/// Stack: pops a Value (Str or Array of Str), pushes the glob-
11085/// expanded result (still Str or Array depending on input shape).
11086/// argc = 1.
11087pub const BUILTIN_GLOB_SUBST_EXPAND: u16 = 530;
11088/// `BUILTIN_ASSOC_HAS_KEY` constant — `${(k)assoc[name]}` key-existence
11089/// query. Returns the key text on hit, empty string on miss. Bug #145.
11090pub const BUILTIN_ASSOC_HAS_KEY: u16 = 531;
11091/// `BUILTIN_ARRAY_DROP_EMPTY` constant — filter empty elements from
11092/// an Array on the stack. Used by `for x in $@` / `for x in $*`
11093/// unquoted forms. Bug #166.
11094pub const BUILTIN_ARRAY_DROP_EMPTY: u16 = 532;
11095/// `BUILTIN_QUOTEDZPUTS` constant — run top-of-stack value through
11096/// `crate::ported::utils::quotedzputs` and push the quoted result.
11097/// Used by the cond xtrace path so non-printable bytes (e.g.
11098/// `$'\C-[OP'` expanded ESC+OP) get re-wrapped in `$'…'` form for
11099/// the trace prefix line, matching zsh's `Src/exec.c` cond trace
11100/// which calls `quotedzputs(operand, xtrerr)` on each side. Bug
11101/// surfaced when `[[ -n $'\C-[OP' ]]` traced as `[[ -n OP ]]`
11102/// (raw bytes leaked through the terminal) vs zsh's
11103/// `[[ -n $'\C-[OP' ]]` source-form preservation.
11104pub const BUILTIN_QUOTEDZPUTS: u16 = 533;
11105/// `BUILTIN_QUOTE_TOKENIZED_OUTPUT` — port of
11106/// `crate::ported::exec::quote_tokenized_output` (Src/exec.c:2114)
11107/// applied to top-of-stack scalar. Used by cond xtrace for the RHS
11108/// of pattern-context comparisons (`=` / `==` / `!=`) where C zsh
11109/// emits the SOURCE form: untokenize lexer tokens (Star → `*`,
11110/// Inpar → `(`, …) and backslash-escape special chars, but
11111/// preserve literal ASCII unchanged. Distinct from quotedzputs
11112/// which wraps the whole string in `'…'` / `$'…'` based on
11113/// non-printability — that's wrong for `[[ x = a* ]]` which must
11114/// render as `[[ x = a* ]]`, not `'a*'`.
11115pub const BUILTIN_QUOTE_TOKENIZED_OUTPUT: u16 = 534;
11116
11117/// Bridge into subst_port::substitute_brace_array for nested forms
11118/// that need to PRESERVE array shape across the expand_string
11119/// boundary. Stack: `[content_string]`. Returns Value::Array of the
11120/// per-element words. Used by the compile path for
11121/// `${(@)<nested>...##pat}` shapes — the standard substitute_brace
11122/// returns String which collapses array→scalar; this builtin
11123/// preserves the multi-word output via paramsubst's third return
11124/// (`nodes` vec, the C source's `aval` thread).
11125pub const BUILTIN_BRIDGE_BRACE_ARRAY: u16 = 347;
11126
11127/// Word-segment concat with FIRST/LAST sticking. Stack: [lhs, rhs].
11128/// Used for default unquoted splice forms (`${arr[@]}`, `$@`, `$*`)
11129/// where prefix sticks to first element only and suffix to last only.
11130///
11131/// Distribution table:
11132/// - both scalar: `Value::str(a + b)` (fast path)
11133/// - lhs scalar, rhs Array(b₀..bₙ): `Value::Array([lhs+b₀, b₁, …, bₙ])`
11134/// - lhs Array(a₀..aₙ), rhs scalar: `Value::Array([a₀, …, aₙ₋₁, aₙ+rhs])`
11135/// - both Array: `Value::Array([a₀, …, aₙ₋₁, aₙ+b₀, b₁, …, bₙ])`
11136/// (last of lhs merges with first of rhs; the rest stay separate)
11137///
11138/// This is the default zsh semantics for `print -l X${arr[@]}Y` →
11139/// "Xa", "b", "cY" — three distinct args, surrounding text only on ends.
11140pub const BUILTIN_CONCAT_SPLICE: u16 = 319;
11141
11142/// `${(flags)name}` — zsh parameter expansion flags. Stack: [name, flags].
11143/// Flags applied left-to-right. Supported subset (high-value, used by zpwr):
11144///
11145/// `L` — lowercase the value (scalar; or each element if array)
11146/// `U` — uppercase
11147/// `j:sep:` — join array with `sep` (delim is the char after `j`)
11148/// `s:sep:` — split scalar on `sep` (returns Value::Array)
11149/// `f` — split on newlines (shorthand for `s.\n.`)
11150/// `o` — sort array ascending
11151/// `O` — sort array descending
11152/// `P` — indirect: read name's value as another var name, return that's value
11153/// `@` — keep as array (returns Value::Array — useful before `j` etc.)
11154/// `k` — keys of assoc array
11155/// `v` — values of assoc array
11156/// `#` — word count (array length as scalar)
11157///
11158/// Flags can stack: `(jL)` joins then lowercases; `(s.,.U)` splits on `,`
11159/// then uppercases each element. The long-tail flags (`q`, `qq`, `qqq` for
11160/// quoting, `A` for assoc, `%` for prompt expansion, `e`/`g` for re-eval,
11161/// `n`/`p` for numeric, `t` for type, etc.) are deferred — they hit the
11162/// runtime fallback via the catch-all expansion path.
11163pub const BUILTIN_PARAM_FLAG: u16 = 297;
11164
11165/// `ShellHost` implementation that delegates to the current `ShellExecutor`
11166/// via the `with_executor` thread-local.
11167///
11168/// Construct fresh on each VM run (it carries no state itself). The VM
11169/// dispatches host method calls during `vm.run()`, and `with_executor`
11170/// resolves to the executor pointer set by `ExecutorContext::enter`.
11171/// fusevm-host implementation tying bytecode ops to the
11172/// shell executor.
11173/// zshrs-original — no C counterpart. C zsh has no bytecode VM
11174/// to host; everything runs through `execlist()`/`execpline()`
11175/// directly (Src/exec.c lines 1349/1668).
11176pub struct ZshrsHost;
11177
11178impl fusevm::ShellHost for ZshrsHost {
11179 fn glob(&mut self, pattern: &str, _recursive: bool) -> Vec<String> {
11180 with_executor(|exec| exec.expand_glob(pattern))
11181 }
11182
11183 fn tilde_expand(&mut self, s: &str) -> String {
11184 with_executor(|exec| s.to_string())
11185 }
11186
11187 fn brace_expand(&mut self, s: &str) -> Vec<String> {
11188 // Direct call to the canonical brace expander
11189 // (Src/glob.c::xpandbraces port at glob.rs:1678). Was
11190 // routing through singsub which uses PREFORK_SINGLE — that
11191 // flag explicitly suppresses brace expansion in subst.c:166,
11192 // so `print X{1,2,3}Y` returned the literal string.
11193 //
11194 // brace_ccl: respect the BRACE_CCL option which the bracket-
11195 // class form `{a-z}` requires. Pull from executor options.
11196 let brace_ccl = with_executor(|exec| opt_state_get("braceccl").unwrap_or(false));
11197 crate::ported::glob::xpandbraces(s, brace_ccl)
11198 }
11199
11200 fn str_match(&mut self, s: &str, pattern: &str) -> bool {
11201 // Shell glob match — `*`, `?`, `[...]`, alternation. After the
11202 // cond path moved to BUILTIN_COND_STRMATCH, the consumer here
11203 // is the `case` arm dispatch, whose bad-pattern semantics are
11204 // Src/loop.c:663-667: `if (!(pprog = patcompile(pat, ...)))
11205 // zerr("bad pattern: %s", pat);` — errflag set, the arm
11206 // doesn't match, and the script aborts at the next command
11207 // boundary (matching `zsh -fc 'case x in [a-) ...'` printing
11208 // the diagnostic with exit 0 = untouched lastval).
11209 let mut pat_tok = pattern.to_string();
11210 crate::ported::glob::tokenize(&mut pat_tok);
11211 if crate::ported::pattern::patcompile(
11212 &pat_tok,
11213 crate::ported::zsh_h::PAT_STATIC as i32,
11214 None,
11215 )
11216 .is_none()
11217 {
11218 crate::ported::utils::zerr(&format!("bad pattern: {}", pattern)); // c:667
11219 return false;
11220 }
11221 glob_match_static(s, pattern)
11222 }
11223
11224 fn expand_param(&mut self, name: &str, _modifier: u8, _args: &[Value]) -> Value {
11225 // Sole funnel: route through `getsparam` matching C zsh's
11226 // `getsparam(name)` → `getvalue` → `getstrvalue` →
11227 // `Param.gsu->getfn` dispatch (Src/params.c:3076 / 2335).
11228 //
11229 // The lookup chain (GSU dispatch + variables + env + array-
11230 // join) lives in `params::getsparam`; subst.rs and this
11231 // bridge both call into it so the logic is in exactly one
11232 // place — mirroring C's "every read goes through getsparam"
11233 // architecture. fuseVM bytecode triggers this bridge when
11234 // the VM hits a PARAM opcode, equivalent to C's wordcode VM
11235 // resolving a parameter read during `exec.c` execution.
11236 //
11237 // Modifier handling: the `_modifier` / `_args` parameters
11238 // are populated by the bytecode compiler but applied by
11239 // separate VM opcodes (LENGTH/STRIP/SUBST/etc.) downstream
11240 // of this fetch — matching C's split between getsparam
11241 // (value fetch) and paramsubst's modifier-walk loop. This
11242 // bridge is the value-fetch step only.
11243 let val_str = crate::ported::params::getsparam(name).unwrap_or_default();
11244 Value::str(val_str)
11245 }
11246
11247 fn process_sub_in(&mut self, sub: &fusevm::Chunk) -> String {
11248 // c:Src/exec.c:4906 getoutputfile — `=(cmd)` (marked "equalsubst" by the
11249 // compiler) is the TEMP-FILE flavor: create a real regular file, fork a
11250 // writer whose stdout is the file, WAIT for it (so the file is complete
11251 // and seekable before the consumer runs), and return the file path. It
11252 // is unlinked at job end. This differs from `<(cmd)` below, which is a
11253 // /dev/fd pipe that is never waited on.
11254 if sub.source == "equalsubst" {
11255 let nam = crate::ported::utils::gettempname(None, true)
11256 .unwrap_or_else(|| format!("/tmp/zshrs_eqsub_{}", std::process::id()));
11257 let cpath = match std::ffi::CString::new(nam.as_str()) {
11258 Ok(c) => c,
11259 Err(_) => return String::from("/dev/null"),
11260 };
11261 // c:4945 — O_WRONLY|O_CREAT|O_EXCL|O_NOCTTY, 0600.
11262 let fd = unsafe {
11263 libc::open(
11264 cpath.as_ptr(),
11265 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOCTTY,
11266 0o600,
11267 )
11268 };
11269 if fd < 0 {
11270 return String::from("/dev/null");
11271 }
11272 let sub_for_child = sub.clone();
11273 match unsafe { libc::fork() } {
11274 -1 => {
11275 unsafe { libc::close(fd) };
11276 let _ = fs::remove_file(&nam);
11277 return String::from("/dev/null");
11278 }
11279 0 => {
11280 // c:4985 — child: stdout → the temp file, run the body, exit.
11281 // Clear the inherited pending-file list so this child never
11282 // unlinks the PARENT's =() temp files when its own commands
11283 // dispatch (fork copies the list; unlink hits the shared fs).
11284 PSUB_PENDING_FILES.with(|v| v.borrow_mut().clear());
11285 unsafe {
11286 libc::dup2(fd, libc::STDOUT_FILENO);
11287 libc::close(fd);
11288 }
11289 let mut vm = fusevm::VM::new(sub_for_child);
11290 register_builtins(&mut vm);
11291 vm.set_shell_host(Box::new(ZshrsHost));
11292 let _ = vm.run();
11293 let _ = std::io::stdout().flush();
11294 unsafe { libc::_exit(0) };
11295 }
11296 child_pid => {
11297 // c:4976-4980 — parent: close the write fd and WAIT so the
11298 // file is fully written before the consumer opens it.
11299 unsafe {
11300 libc::close(fd);
11301 let mut status: libc::c_int = 0;
11302 libc::waitpid(child_pid, &mut status, 0);
11303 }
11304 let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
11305 PSUB_PENDING_FILES.with(|v| v.borrow_mut().push((depth, nam.clone())));
11306 return nam;
11307 }
11308 }
11309 }
11310 // c:Src/exec.c::getproc — `<(cmd)` uses pipe + fork + the
11311 // `/dev/fd/N` filesystem entry (where N is the read end of
11312 // the pipe held open in the parent). Consumer opens
11313 // `/dev/fd/N`, reads the cmd's stdout through the pipe.
11314 // Both macOS and Linux expose `/dev/fd` for held-open file
11315 // descriptors. Previous Rust port captured stdout into
11316 // `/tmp/zshrs_psub_*` tempfiles synchronously — works for
11317 // `diff <(a) <(b)` style readers that scan once but diverges
11318 // from zsh's observable path string and breaks any consumer
11319 // that introspects the path or expects a non-seekable pipe.
11320 let mut fds: [libc::c_int; 2] = [-1, -1];
11321 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
11322 // Pipe creation failed — fall back to tempfile so we at
11323 // least return SOMETHING.
11324 let fifo_path = format!(
11325 "/tmp/zshrs_psub_fallback_{}_{}",
11326 std::process::id(),
11327 with_executor(|e| {
11328 let n = e.process_sub_counter;
11329 e.process_sub_counter += 1;
11330 n
11331 })
11332 );
11333 let _ = fs::remove_file(&fifo_path);
11334 return fifo_path;
11335 }
11336 let (read_end, write_end) = (fds[0], fds[1]);
11337 let sub_for_child = sub.clone();
11338 match unsafe { libc::fork() } {
11339 -1 => {
11340 unsafe {
11341 libc::close(read_end);
11342 libc::close(write_end);
11343 }
11344 return String::from("/dev/null");
11345 }
11346 0 => {
11347 // Child: close read end, dup write end to stdout,
11348 // run the sub-chunk, exit. The exit closes the
11349 // write end automatically, so the parent's reader
11350 // gets EOF when the cmd finishes.
11351 PSUB_PENDING_FILES.with(|v| v.borrow_mut().clear());
11352 unsafe {
11353 libc::close(read_end);
11354 libc::dup2(write_end, libc::STDOUT_FILENO);
11355 libc::close(write_end);
11356 }
11357 crate::fusevm_disasm::maybe_print_stdout("process_subst_in", &sub_for_child);
11358 let mut vm = fusevm::VM::new(sub_for_child);
11359 register_builtins(&mut vm);
11360 vm.set_shell_host(Box::new(ZshrsHost));
11361 let _ = vm.run();
11362 let _ = std::io::stdout().flush();
11363 unsafe { libc::_exit(0) };
11364 }
11365 child_pid => {
11366 // c:Src/exec.c:5092 `procsubstpid = pid;` — record the
11367 // forked child's PID so `${sysparams[procsubstpid]}`
11368 // returns it (was reading the never-updated atomic, so it
11369 // always came back 0). p10k's gitstatus daemon reads
11370 // `sysparams[procsubstpid]` right after `sysopen <(cmd)`
11371 // to track its worker PID; with 0 the daemon's self-check
11372 // failed and gitstatus fell back to re-downloading
11373 // gitstatusd — surfacing as "no prebuilt gitstatusd".
11374 crate::ported::exec::procsubstpid
11375 .store(child_pid, std::sync::atomic::Ordering::Relaxed);
11376 // Parent: close write end, keep read end open under
11377 // the same fd value so `/dev/fd/N` resolves to the
11378 // pipe's read side. NOTE: FD_CLOEXEC must STAY clear
11379 // — consumers like `cat <(cmd)` and `diff <(a) <(b)`
11380 // discover the fd via exec inheritance, so closing
11381 // on exec defeats the whole point. C zsh's getproc
11382 // (Src/exec.c:5045+) leaves the fd open across exec.
11383 unsafe {
11384 libc::close(write_end);
11385 }
11386 // Park read_end for close-after-consuming-command,
11387 // exactly like process_sub_out does for its write_end
11388 // (c:Src/exec.c addfilelist(NULL, fd) → deletefilelist).
11389 // WITHOUT this the parent's read_end stayed open for
11390 // the whole shell lifetime: p10k's async worker /
11391 // realtime clock do `exec {fd}< <(cmd)` on every prompt,
11392 // so each keystroke/redraw leaked a pipe fd until the
11393 // ~256-fd limit was hit and the shell locked up
11394 // (107 leaked pipes + 107 unreaped children observed).
11395 let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
11396 PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, read_end)));
11397 // Reap the forked child so it doesn't linger as a
11398 // zombie. `<(cmd)` children are fire-and-forget (their
11399 // output flows through the pipe); C reaps them via the
11400 // job machinery. A non-blocking reap here is scheduled;
11401 // do a best-effort WNOHANG now and the rest drain on
11402 // subsequent proc-subs / prompt cycles.
11403 crate::fusevm_bridge::note_psub_child(child_pid);
11404 }
11405 }
11406 format!("/dev/fd/{}", read_end)
11407 }
11408
11409 fn process_sub_out(&mut self, sub: &fusevm::Chunk) -> String {
11410 // c:Src/exec.c:5025 getproc, PATH_DEV_FD branch — `>(cmd)`
11411 // (out == 0): `mpipe(pipes)`, fork; the CHILD `redup(pipes[0],
11412 // 0)` (pipe read end onto stdin) and `closem` drops the write
11413 // end; the PARENT closes pipes[0] and hands the consumer
11414 // `/dev/fd/<pipes[1]>` (the write end). The previous Rust port
11415 // used mkfifo + a child that BLOCKED in open(FIFO, O_RDONLY)
11416 // before running cmd — with no writer the child never started,
11417 // never exited, and kept its inherited stdout (e.g. a `$()`
11418 // capture pipe) open forever: `a=$(print -r -- >(true))` hung.
11419 // With the pipe shape the child runs immediately and exits,
11420 // releasing inherited fds exactly like zsh (verified: zsh
11421 // blocks ~2s on `a=$(print -r -- >(sleep 2))`, then EOFs).
11422 let mut fds: [libc::c_int; 2] = [-1, -1];
11423 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
11424 // Pipe creation failed — fall back to a plain temp file so
11425 // the consumer at least has a writable path.
11426 let fallback = format!(
11427 "/tmp/zshrs_psub_out_{}_{}",
11428 std::process::id(),
11429 with_executor(|e| {
11430 let n = e.process_sub_counter;
11431 e.process_sub_counter += 1;
11432 n
11433 })
11434 );
11435 let _ = fs::write(&fallback, "");
11436 return fallback;
11437 }
11438 let (read_end, write_end) = (fds[0], fds[1]);
11439 let sub_for_child = sub.clone();
11440 match unsafe { libc::fork() } {
11441 -1 => {
11442 unsafe {
11443 libc::close(read_end);
11444 libc::close(write_end);
11445 }
11446 String::from("/dev/null")
11447 }
11448 0 => {
11449 // Child: close the write end (c: closem after redup),
11450 // dup the read end onto stdin (c: redup(pipes[0], 0)),
11451 // run the sub-chunk, exit. Other std fds stay
11452 // inherited — zsh's child keeps the surrounding
11453 // command's stdout/stderr.
11454 unsafe {
11455 libc::close(write_end);
11456 libc::dup2(read_end, libc::STDIN_FILENO);
11457 libc::close(read_end);
11458 }
11459 crate::fusevm_disasm::maybe_print_stdout("process_subst_out:child", &sub_for_child);
11460 let mut vm = fusevm::VM::new(sub_for_child);
11461 register_builtins(&mut vm);
11462 vm.set_shell_host(Box::new(ZshrsHost));
11463 let _ = vm.run();
11464 unsafe { libc::_exit(0) };
11465 }
11466 child_pid => {
11467 // c:Src/exec.c:5143 `procsubstpid = pid;` — same fix as
11468 // the `<(cmd)` in-path above: record the forked child's
11469 // PID for `${sysparams[procsubstpid]}` (was always 0).
11470 crate::ported::exec::procsubstpid
11471 .store(child_pid, std::sync::atomic::Ordering::Relaxed);
11472 // Parent: close the read end, keep the write end open
11473 // under its fd value so `/dev/fd/N` resolves to the
11474 // pipe's write side. FD_CLOEXEC must STAY clear —
11475 // consumers (`tee >(cmd)`) discover the fd via exec
11476 // inheritance, matching process_sub_in above and C's
11477 // fdtable[fd] = FDT_PROC_SUBST bookkeeping. Park the
11478 // fd for close-after-consuming-command (c: addfilelist
11479 // (NULL, fd) → deletefilelist) so the child's reader
11480 // EOFs — without this `tee >(wc -c) </dev/null` left
11481 // wc blocked until shell exit.
11482 unsafe {
11483 libc::close(read_end);
11484 }
11485 let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
11486 PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, write_end)));
11487 format!("/dev/fd/{}", write_end)
11488 }
11489 }
11490 }
11491
11492 fn subshell_begin(&mut self) {
11493 with_executor(|exec| {
11494 // Special parameters whose value lives in a process GLOBAL behind a
11495 // GSU (`Src/params.c`'s `ifs`, `wordchars`, `home`, `histsiz`, …)
11496 // rather than in the param table. Mirrors the getfn dispatch list at
11497 // params.rs:12548. C isolates these for free by forking `(...)`;
11498 // zshrs's in-process subshell has to snapshot them by hand, or a
11499 // subshell-local `IFS=,` rewrites the parent's word-splitting.
11500 const SUBSHELL_SPECIAL_GLOBALS: &[&str] = &[
11501 "IFS",
11502 "HOME",
11503 "TERM",
11504 "USERNAME",
11505 "WORDCHARS",
11506 "TERMINFO",
11507 "TERMINFO_DIRS",
11508 "KEYBOARD_HACK",
11509 "histchars",
11510 "HISTSIZE",
11511 "SAVEHIST",
11512 ];
11513 // An UNSET special yields None and is skipped: the paramtab snapshot
11514 // restores its PM_UNSET flag, and the getfn dispatch refuses to read
11515 // the (stale) global while PM_UNSET is set (params.rs:12552).
11516 let special_globals_snap: Vec<(String, String)> = SUBSHELL_SPECIAL_GLOBALS
11517 .iter()
11518 .filter_map(|n| {
11519 crate::ported::params::getsparam(n).map(|v| ((*n).to_string(), v))
11520 })
11521 .collect();
11522 // libc::umask returns the previous mask AND sets the new
11523 // one; call with current value to read without changing.
11524 let cur_umask = unsafe {
11525 let m = libc::umask(0o022);
11526 libc::umask(m);
11527 m as u32
11528 };
11529 // Snapshot paramtab + hashed-storage too (step 1 of the
11530 // store unification mirrors writes there; restoring only
11531 // the HashMaps leaks subshell-scoped writes to the parent
11532 // via paramtab readers like `paramsubst → vars_get`).
11533 let paramtab_snap = crate::ported::params::paramtab()
11534 .read()
11535 .ok()
11536 .map(|t| t.clone())
11537 .unwrap_or_default();
11538 let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
11539 .lock()
11540 .ok()
11541 .map(|m| m.clone())
11542 .unwrap_or_default();
11543 exec.subshell_snapshots.push(SubshellSnapshot {
11544 paramtab: paramtab_snap,
11545 paramtab_hashed_storage: paramtab_hashed_snap,
11546 special_globals: special_globals_snap,
11547 positional_params: exec.pparams(),
11548 env_vars: env::vars().collect(),
11549 // Save the LOGICAL pwd ($PWD env), not `current_dir()`'s
11550 // symlink-resolved path. zsh's subshell isolation per
11551 // Src/exec.c at the `entersubsh` path treats `pwd` (the
11552 // shell-tracked logical PWD) as the carrier — see
11553 // `Src/builtin.c:1239-1242` where cd writes the logical
11554 // dest into `pwd`. Falling back to current_dir() only
11555 // when PWD is unset matches `setupvals` at
11556 // `Src/init.c:1100+`.
11557 cwd: env::var("PWD")
11558 .ok()
11559 .map(PathBuf::from)
11560 .or_else(|| env::current_dir().ok()),
11561 umask: cur_umask,
11562 // Snapshot canonical `traps_table` — bin_trap writes
11563 // there (`Src/builtin.c`).
11564 traps: crate::ported::builtin::traps_table()
11565 .lock()
11566 .map(|t| t.clone())
11567 .unwrap_or_default(),
11568 // Snapshot option store so `(set -e)` /
11569 // `(setopt extendedglob)` don't leak to parent.
11570 opts: crate::ported::options::opt_state_snapshot(),
11571 // c:Src/exec.c — fork() copies the alias table to
11572 // the subshell. `(alias x=y)` inside the subshell
11573 // dies with the child; the parent doesn't see x.
11574 // Snapshot here so subshell_end can restore.
11575 // Bug #209 in docs/BUGS.md.
11576 aliases: crate::ported::hashtable::aliastab_lock()
11577 .read()
11578 .ok()
11579 .map(|t| {
11580 t.iter()
11581 .map(|(k, v)| (k.clone(), v.text.clone(), v.node.flags))
11582 .collect()
11583 })
11584 .unwrap_or_default(),
11585 // c:Src/exec.c::entersubsh — same fork-copy
11586 // semantics for shfunctab. `(f() { ... })` defined
11587 // inside the subshell dies with the child; parent's
11588 // `type f` reports "not found". Bug #208 in
11589 // docs/BUGS.md.
11590 shfuncs: crate::ported::hashtable::shfunctab_lock()
11591 .read()
11592 .ok()
11593 .map(|t| t.snapshot())
11594 .unwrap_or_default(),
11595 functions_compiled: exec.functions_compiled.clone(),
11596 function_source: exec.function_source.clone(),
11597 // c:Src/exec.c::entersubsh — subshell forks its own
11598 // modulestab. A `(zmodload zsh/X)` inside the
11599 // subshell flips MOD_INIT_B on the CHILD's
11600 // modulestab; when the child exits the change
11601 // dies with it. zshrs's in-process subshell would
11602 // otherwise leak the load to the parent.
11603 // Bug #210 in docs/BUGS.md. Snapshot just the
11604 // (name → flags) pairs since the only mutating
11605 // field is the flags bitmask (MOD_INIT_B for
11606 // loaded, MOD_UNLOAD for unloaded).
11607 modules: crate::ported::module::MODULESTAB
11608 .lock()
11609 .ok()
11610 .map(|t| {
11611 t.modules
11612 .iter()
11613 .map(|(k, v)| (k.clone(), v.node.flags))
11614 .collect()
11615 })
11616 .unwrap_or_default(),
11617 // c:Src/exec.c::entersubsh — fork-copy semantics for
11618 // THINGYTAB (ZLE widget registry). A subshell `zle -N`
11619 // / `zle -D` mutation dies with the child in C zsh;
11620 // mirror via in-process snapshot. Bug #453.
11621 thingytab: crate::ported::zle::zle_thingy::thingytab()
11622 .lock()
11623 .ok()
11624 .map(|t| t.clone())
11625 .unwrap_or_default(),
11626 // c:Src/exec.c::entersubsh — same fork-copy for the
11627 // KEYMAPNAMTAB (named keymap registry). `bindkey -N km`
11628 // / `bindkey -D km` inside a subshell dies with the
11629 // child. Bug #454.
11630 keymapnamtab: crate::ported::zle::zle_keymap::keymapnamtab()
11631 .lock()
11632 .ok()
11633 .map(|t| t.clone())
11634 .unwrap_or_default(),
11635 // c:Src/exec.c::entersubsh fork semantics — `$!`
11636 // (clone::lastpid) set by a `&` INSIDE the subshell
11637 // dies with the child: `( : & ); echo $!` -> 0.
11638 lastpid: crate::ported::modules::clone::lastpid
11639 .load(std::sync::atomic::Ordering::Relaxed),
11640 // c:Src/exec.c::entersubsh fork semantics — the
11641 // subshell gets a COPY of the job table; its disown/
11642 // wait/`&` mutations die with it. Bug #462.
11643 jobtab: crate::ported::jobs::JOBTAB
11644 .get_or_init(|| std::sync::Mutex::new(Vec::new()))
11645 .lock()
11646 .map(|t| t.clone())
11647 .unwrap_or_default(),
11648 curjob: *crate::ported::jobs::CURJOB
11649 .get_or_init(|| std::sync::Mutex::new(-1))
11650 .lock()
11651 .unwrap(),
11652 prevjob: *crate::ported::jobs::PREVJOB
11653 .get_or_init(|| std::sync::Mutex::new(-1))
11654 .lock()
11655 .unwrap(),
11656 maxjob: *crate::ported::jobs::MAXJOB
11657 .get_or_init(|| std::sync::Mutex::new(0))
11658 .lock()
11659 .unwrap(),
11660 thisjob: *crate::ported::jobs::THISJOB
11661 .get_or_init(|| std::sync::Mutex::new(-1))
11662 .lock()
11663 .unwrap(),
11664 // c:Src/exec.c entersubsh — fork copies the fd table;
11665 // the child's `exec >file` / `exec N<&-` mutations die
11666 // with it. Dup each user-range fd to >= 10 so
11667 // subshell_end can restore the parent's exact table.
11668 saved_fds: (0..10)
11669 .map(|fd| {
11670 let dup = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
11671 (fd, dup)
11672 })
11673 .collect(),
11674 });
11675 // C forks for `(...)` — count the fork-equivalent so
11676 // `time (builtin)` reports like zsh (see FORK_EVENTS).
11677 crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
11678 // c:Src/exec.c:1088-1092 — entersubsh resets traps in the child:
11679 // if (!(flags & ESUB_KEEPTRAP))
11680 // for (sig = 0; sig <= SIGCOUNT; sig++)
11681 // if (!(sigtrapped[sig] & ZSIG_FUNC) &&
11682 // !(isset(POSIXTRAPS) && (sigtrapped[sig] & ZSIG_IGNORED)))
11683 // unsettrap(sig);
11684 //
11685 // A subshell does NOT inherit string-form traps. Two exemptions:
11686 // FUNCTION-form traps (`TRAPUSR1() { … }`, ZSIG_FUNC) survive, and
11687 // under POSIX_TRAPS an IGNORED trap (`trap '' SIG`) survives.
11688 //
11689 // The ZSIG_FUNC exemption is structural here rather than a flag
11690 // test: zshrs keeps string-form bodies in traps_table and
11691 // function-form ones in shfunctab as TRAPxxx, so filtering only
11692 // traps_table leaves the function form untouched by construction.
11693 // ZSIG_IGNORED is `trap '' SIG`, which stores an empty body.
11694 //
11695 // The LOOP BOUND is also part of the spec, not an implementation
11696 // detail: `sig <= SIGCOUNT` never reaches the PSEUDO-signals,
11697 // which zsh numbers above the real ones —
11698 // #define SIGZERR (SIGCOUNT+1)
11699 // #define SIGDEBUG (SIGCOUNT+2) (c:Src/signals.h:34-35)
11700 // so ERR/ZERR and DEBUG traps SURVIVE a subshell, while SIGEXIT
11701 // (sig 0) is inside the loop and is cleared. Verified against the
11702 // oracle: `trap 'print e' ERR; (trap)` lists the ERR trap;
11703 // `trap 'print u' USR1; (trap)` lists nothing.
11704 //
11705 // Without this a subshell kept the parent's traps: `(trap)` listed
11706 // them where zsh lists nothing, and — the part that isn't
11707 // cosmetic — an inherited trap FIRED inside the child, so
11708 // `trap 'print p' USR1; (kill -USR1 $$; print after)` printed
11709 // p before after instead of after…p (the signal is meant to reach
11710 // the parent, whose trap runs there).
11711 //
11712 // The snapshot pushed above restores the parent's table at
11713 // subshell_end, which is what makes clearing safe for zshrs's
11714 // in-process subshell.
11715 {
11716 let posixtraps = crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXTRAPS);
11717 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
11718 t.retain(|name, body| {
11719 // Above SIGCOUNT — outside c:1088's loop entirely.
11720 if name == "ERR" || name == "ZERR" || name == "DEBUG" {
11721 return true;
11722 }
11723 // c:1090-1092 — otherwise keep ONLY (POSIXTRAPS && ignored).
11724 posixtraps && body.is_empty()
11725 });
11726 }
11727 }
11728 // c:Src/exec.c:2862 — subshell fork flags carry ESUB_PGRP,
11729 // so entersubsh runs `clearjobtab(monitor)` (c:1219): the
11730 // child gets an EMPTY job table plus the procless control
11731 // job grabbed at Src/jobs.c:1828 (`thisjob = initjob()`).
11732 // That's why zsh's `(jobs)` prints nothing and `(kill %1)`
11733 // hits the empty control job instead of the parent's job 1.
11734 // The snapshot pushed above restores the parent's table at
11735 // subshell_end. Bug #462.
11736 let monitor = crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
11737 crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
11738 // clearjobtab left THISJOB on the control job (Src/jobs.c:
11739 // 1828). In C the very next pipeline's execpline reassigns
11740 // thisjob (Src/exec.c:1700 `thisjob = newjob = initjob()`),
11741 // so by the time any builtin runs, thisjob never aliases
11742 // the control job. zshrs has no per-pipeline job slot —
11743 // model the between-pipelines state (-1) so getjob's
11744 // `jobnum != thisjob` (c:jobs.c:2107) doesn't reject %1 and
11745 // setcurjob doesn't demote an inherited curjob that
11746 // collides with the control slot.
11747 *crate::ported::jobs::THISJOB
11748 .get_or_init(|| std::sync::Mutex::new(-1))
11749 .lock()
11750 .unwrap() = -1;
11751 // Subshell starts with EXIT trap cleared so the parent's
11752 // EXIT handler doesn't fire when the subshell ends. zsh:
11753 // each subshell has its own trap context. Other signals
11754 // are inherited (well, parent's are still in place — but
11755 // a trap set INSIDE the subshell shouldn't leak out).
11756 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
11757 t.remove("EXIT");
11758 }
11759 let level = exec
11760 .scalar("ZSH_SUBSHELL")
11761 .and_then(|s| s.parse::<i32>().ok())
11762 .unwrap_or(0);
11763 // c:Src/exec.c — ZSH_SUBSHELL carries PM_READONLY (declared
11764 // in params.rs special_params); setsparam would be rejected
11765 // by assignstrvalue's PM_READONLY guard. Write u_val
11766 // directly — same bypass pattern as BUILTIN_SET_LINENO at
11767 // line 2784. C zsh's PM_SPECIAL GSU vtable handles this
11768 // implicitly via the setfn callback.
11769 let new_level = (level + 1) as i64;
11770 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
11771 if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
11772 pm.u_val = new_level;
11773 pm.u_str = Some(new_level.to_string());
11774 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
11775 }
11776 }
11777 });
11778 // Bump SUBSHELL_DEPTH so zexit defers process::exit (see
11779 // SUBSHELL_DEPTH declaration in src/ported/builtin.rs for
11780 // rationale).
11781 crate::ported::builtin::SUBSHELL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
11782 // c:Src/exec.c::entersubsh — C zsh's subshell is a forked
11783 // child process: signals sent to the parent (via `kill $$`
11784 // inside the subshell, where `$$` is the parent's pid)
11785 // never reach the child's signal handlers. zshrs's
11786 // in-process subshell shares the process pid with the
11787 // parent, so without queueing the subshell's trap handler
11788 // fires for signals that zsh would deliver only to the
11789 // parent. Queue signals across the subshell body so the
11790 // parent's restored trap table sees them after
11791 // subshell_end's unqueue drain. Bug #450.
11792 crate::ported::signals_h::queue_signals();
11793 }
11794
11795 fn subshell_end(&mut self) -> Option<i32> {
11796 // Fire subshell's EXIT trap BEFORE restoring parent state so
11797 // the trap body sees the subshell's vars and exit status. zsh
11798 // forks for `(...)` so the trap runs in the child process,
11799 // before exit. We mirror by running it here, just before the
11800 // pop+restore. REMOVE the trap before firing so the inner
11801 // execute_script doesn't fire it again at its own end.
11802 let exit_trap_body = crate::ported::builtin::traps_table()
11803 .lock()
11804 .ok()
11805 .and_then(|mut t| t.remove("EXIT"));
11806 if let Some(body) = exit_trap_body {
11807 // Execute the trap body. Errors during trap execution
11808 // don't bubble — zsh ignores trap-body errors.
11809 with_executor(|exec| {
11810 let _ = exec.execute_script(&body);
11811 });
11812 }
11813 with_executor(|exec| {
11814 if let Some(snap) = exec.subshell_snapshots.pop() {
11815 // Restore paramtab + hashed storage so subshell-scoped
11816 // writes via setsparam/setaparam/sethparam don't leak
11817 // to the parent via paramtab readers.
11818 if let Some(tab) = crate::ported::params::paramtab()
11819 .write()
11820 .ok()
11821 .as_deref_mut()
11822 {
11823 *tab = snap.paramtab;
11824 }
11825 // Restore the global-backed specials (see
11826 // SubshellSnapshot::special_globals). MUST run after the
11827 // paramtab restore above: setsparam writes through the GSU setfn
11828 // to BOTH the process global and the param node, so the paramtab
11829 // overwrite would otherwise clobber the node half of it.
11830 for (name, val) in &snap.special_globals {
11831 crate::ported::params::setsparam(name, val);
11832 }
11833 // c:Src/exec.c::entersubsh fork semantics — restore
11834 // the parent's `$!`; a background job inside `(...)`
11835 // dies with the child in C zsh.
11836 crate::ported::modules::clone::lastpid
11837 .store(snap.lastpid, std::sync::atomic::Ordering::Relaxed);
11838 // c:Src/exec.c::entersubsh fork semantics — restore the
11839 // parent's job table + curjob/prevjob/maxjob/thisjob.
11840 // The subshell mutated only its own copy. Bug #462.
11841 if let Ok(mut t) = crate::ported::jobs::JOBTAB
11842 .get_or_init(|| std::sync::Mutex::new(Vec::new()))
11843 .lock()
11844 {
11845 *t = snap.jobtab;
11846 }
11847 *crate::ported::jobs::CURJOB
11848 .get_or_init(|| std::sync::Mutex::new(-1))
11849 .lock()
11850 .unwrap() = snap.curjob;
11851 *crate::ported::jobs::PREVJOB
11852 .get_or_init(|| std::sync::Mutex::new(-1))
11853 .lock()
11854 .unwrap() = snap.prevjob;
11855 *crate::ported::jobs::MAXJOB
11856 .get_or_init(|| std::sync::Mutex::new(0))
11857 .lock()
11858 .unwrap() = snap.maxjob;
11859 *crate::ported::jobs::THISJOB
11860 .get_or_init(|| std::sync::Mutex::new(-1))
11861 .lock()
11862 .unwrap() = snap.thisjob;
11863 if let Some(m) = crate::ported::params::paramtab_hashed_storage()
11864 .lock()
11865 .ok()
11866 .as_deref_mut()
11867 {
11868 *m = snap.paramtab_hashed_storage;
11869 }
11870 exec.set_pparams(snap.positional_params);
11871 // Restore the OS env to its pre-subshell state.
11872 // Removes any `export` writes the subshell made, and
11873 // restores any vars the subshell unset. Without this
11874 // `(export y=sub)` would leak `y` to the parent shell.
11875 let current: HashMap<String, String> = env::vars().collect();
11876 for k in current.keys() {
11877 if !snap.env_vars.contains_key(k) {
11878 env::remove_var(k);
11879 }
11880 }
11881 for (k, v) in &snap.env_vars {
11882 if current.get(k) != Some(v) {
11883 env::set_var(k, v);
11884 }
11885 }
11886 if let Some(cwd) = snap.cwd {
11887 let _ = env::set_current_dir(&cwd);
11888 // Resync $PWD env so a parent `pwd` doesn't read
11889 // the cwd the subshell `cd`'d into.
11890 env::set_var("PWD", &cwd);
11891 }
11892 // Restore umask. zsh's `(umask 077)` doesn't leak to
11893 // parent because the subshell forks; we run in-process
11894 // so we manually reset.
11895 unsafe {
11896 libc::umask(snap.umask as libc::mode_t);
11897 }
11898 // Restore parent's traps (the subshell's own traps die
11899 // with it). zsh: `(trap "X" USR1)` doesn't leak the
11900 // USR1 trap out of the subshell. Write back to the
11901 // canonical `traps_table` (bin_trap writes there).
11902 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
11903 *t = snap.traps;
11904 }
11905 // Restore parent's option store so `(set -e)` /
11906 // `(setopt extendedglob)` don't leak. zsh forks
11907 // subshells so child option changes die with the
11908 // child; we run in-process and must restore.
11909 crate::ported::options::opt_state_restore(snap.opts);
11910 // c:Src/exec.c — fork() means alias mutations in a
11911 // subshell die with the child. Restore parent's
11912 // alias table from snapshot. Clear current entries
11913 // then re-add parent's. Bug #209 in docs/BUGS.md.
11914 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
11915 tab.clear();
11916 for (name, text, flags) in snap.aliases {
11917 tab.add(crate::ported::zsh_h::alias {
11918 node: crate::ported::zsh_h::hashnode {
11919 next: None,
11920 nam: name,
11921 // ALIAS_GLOBAL / DISABLED must survive the
11922 // round-trip — flags:0 turned every global
11923 // alias regular on ANY subshell exit.
11924 flags,
11925 },
11926 text,
11927 inuse: 0,
11928 });
11929 }
11930 }
11931 // c:Src/exec.c::entersubsh — same fork-copy
11932 // semantics for shfunctab. Restore parent's function
11933 // table from snapshot so `(f() { ... })` definitions
11934 // inside the subshell don't leak to the parent.
11935 // Bug #208 in docs/BUGS.md.
11936 if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
11937 tab.restore(snap.shfuncs);
11938 }
11939 // Restore the runtime dispatch tables (compiled chunks
11940 // + source). Without these, a subshell-defined
11941 // override leaves its bytecode in place even after
11942 // shfunctab is restored — `g` after the subshell would
11943 // still run the override.
11944 exec.functions_compiled = snap.functions_compiled;
11945 exec.function_source = snap.function_source;
11946 // c:Src/exec.c::entersubsh — restore parent's
11947 // modulestab so a subshell `(zmodload zsh/X)` doesn't
11948 // leak to the parent. Bug #210 in docs/BUGS.md.
11949 // Restore via per-module flag write since the
11950 // snapshot is `(name → flags)` only.
11951 if let Ok(mut t) = crate::ported::module::MODULESTAB.lock() {
11952 for (name, saved_flags) in &snap.modules {
11953 if let Some(m) = t.modules.get_mut(name) {
11954 m.node.flags = *saved_flags;
11955 }
11956 }
11957 }
11958 // c:Src/exec.c::entersubsh — restore parent's THINGYTAB
11959 // so a subshell's `zle -N w f` / `zle -D w` doesn't
11960 // affect the parent's widget registry. Bug #453.
11961 if let Ok(mut t) = crate::ported::zle::zle_thingy::thingytab().lock() {
11962 *t = snap.thingytab;
11963 }
11964 // Same for KEYMAPNAMTAB. Bug #454.
11965 if let Ok(mut t) = crate::ported::zle::zle_keymap::keymapnamtab().lock() {
11966 *t = snap.keymapnamtab;
11967 }
11968 // c:Src/exec.c entersubsh fork semantics — restore the
11969 // parent's user-range fd table. A bare `exec >file` /
11970 // `exec N>&-` inside `(...)` died with the C child;
11971 // the in-process subshell must undo it here. Flush
11972 // Rust's stdout buffer FIRST so bytes the subshell
11973 // printed drain to the SUBSHELL's fd 1, not the
11974 // restored parent fd.
11975 {
11976 use std::io::Write;
11977 let _ = std::io::stdout().flush();
11978 }
11979 for (fd, saved) in snap.saved_fds {
11980 unsafe {
11981 if saved >= 0 {
11982 libc::dup2(saved, fd);
11983 libc::close(saved);
11984 } else {
11985 // fd was closed at entry; close whatever
11986 // the subshell opened on that slot.
11987 libc::close(fd);
11988 }
11989 }
11990 }
11991 }
11992 });
11993 // Decrement SUBSHELL_DEPTH. If a deferred subshell exit
11994 // landed inside (EXIT_PENDING set with depth > 0), promote
11995 // the deferred status into the subshell's exit status now
11996 // that we're at the boundary, then clear so the parent
11997 // continues. Matches C zsh's "subshell-exit-via-fork"
11998 // boundary where the child's process::exit(N) becomes
11999 // $WAITSTATUS / $? in the parent.
12000 crate::ported::builtin::SUBSHELL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
12001 // c:Src/exec.c — drain the signal queue against the now-
12002 // restored parent trap table. Pairs with the
12003 // queue_signals() call at the end of subshell_begin.
12004 // Any `kill $$` from inside the subshell is processed
12005 // here against OUTER's trap, matching C zsh's
12006 // signal-delivery-to-parent semantics. Bug #450.
12007 crate::ported::signals_h::unqueue_signals();
12008 // c:Src/exec.c — a `( … )` subshell is a FORK in C: an errflag
12009 // abort inside the child ends the child with its lastval as
12010 // the exit status, and the flag dies with the child process.
12011 // The parent's $? picks up the status and the parent's lists
12012 // keep running. zsh 5.9: `(readonly r=1; r=2); echo "after
12013 // $?"` prints `after 1`. zshrs runs the subshell in-process,
12014 // so mirror the fork isolation by clearing ERRFLAG_ERROR at
12015 // the subshell boundary — exec.last_status() already carries
12016 // the child's lastval (synced by ERREXIT_CHECK trigger 4).
12017 //
12018 // ERRFLAG_HARD must die at this boundary too: `${u:?msg}` sets
12019 // errflag |= ERRFLAG_HARD (c:Src/subst.c:3344) and then, in a
12020 // C forked subshell, `_exit(1)` (c:3353) — the parent never
12021 // sees ANY errflag bit. A leaked HARD bit here made every
12022 // subsequent zerr() take the silent arm (c:Src/utils.c:175-177
12023 // `if (errflag || noerrs) { errflag |= ERRFLAG_ERROR; return; }`),
12024 // so the next eval/source's parse silently "failed" and the
12025 // D04 harness shell wedged after chunk 10's
12026 // `(print ${unset1:?exiting1})`.
12027 crate::ported::utils::errflag.fetch_and(
12028 !(crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
12029 std::sync::atomic::Ordering::Relaxed,
12030 );
12031 let exit_pending =
12032 crate::ported::builtin::EXIT_PENDING.load(std::sync::atomic::Ordering::Relaxed);
12033 if exit_pending != 0 {
12034 // c:Src/builtin.c — `exit N` masks N to 8 bits because
12035 // POSIX _exit takes the low byte as status. `(exit 256)`
12036 // and `(exit 0)` are indistinguishable to the parent;
12037 // `(exit 257)` exits with 1. Without the mask zshrs's
12038 // in-process subshell propagated the full i32 (256) into
12039 // the parent's $?, diverging from zsh.
12040 let raw = crate::ported::builtin::EXIT_VAL.load(std::sync::atomic::Ordering::Relaxed);
12041 let val = raw & 0xFF;
12042 with_executor(|exec| exec.set_last_status(val));
12043 crate::ported::builtin::EXIT_PENDING.store(0, std::sync::atomic::Ordering::Relaxed);
12044 crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
12045 crate::ported::builtin::BREAKS.store(0, std::sync::atomic::Ordering::Relaxed);
12046 // Return the deferred-exit status so the VM updates its
12047 // own `last_status`. Otherwise run_chunk's post-script
12048 // `set_last_status(vm.last_status)` would clobber LASTVAL
12049 // back to the stale pre-subshell value.
12050 return Some(val);
12051 }
12052 None
12053 }
12054
12055 fn redirect(&mut self, fd: u8, op: u8, target: &str) {
12056 // Apply a redirection at the OS level for the next command/builtin.
12057 // The host tracks saved fds in a per-executor stack so a future
12058 // `with_redirects_end` can restore. For now, this is a thin wrapper
12059 // that performs the dup2; pairing with explicit save/restore is
12060 // delivered by `with_redirects_begin/end`.
12061 with_executor(|exec| exec.host_apply_redirect(fd, op, target));
12062 }
12063
12064 fn with_redirects_begin(&mut self, count: u8) {
12065 with_executor(|exec| exec.host_redirect_scope_begin(count));
12066 }
12067
12068 fn regex_match(&mut self, s: &str, regex: &str) -> bool {
12069 // c:Src/Modules/regex.c:54 `zcond_regex_match` — POSIX ERE
12070 // matching + populate `$MATCH` / `$MBEGIN` / `$MEND` /
12071 // `$match[]` / `$mbegin[]` / `$mend[]` (or `$BASH_REMATCH`
12072 // under BASHREMATCH). Direct delegation to the canonical
12073 // port at src/ported/modules/regex.rs:58.
12074 //
12075 // The bridge passthru path delivers TOKEN-form bytes here
12076 // (Inbrack \u{91}, Outbrack \u{92}, Star \u{87}, Quest
12077 // \u{86}, etc.) since the lexer tokenizes regex meta chars
12078 // inside `[[ ]]`. The host regex engine expects ASCII, so
12079 // untokenize the pattern (and subject, for safety) once at
12080 // this boundary. zsh C reaches its POSIX-ERE engine through
12081 // the same untokenize path inside zcond_regex_match.
12082 let s_clean = crate::lex::untokenize(s);
12083 let regex_clean = crate::lex::untokenize(regex);
12084 // c:Src/cond.c:113-119 — WHICH engine `=~` uses is an option:
12085 //
12086 // char *modname = isset(REMATCHPCRE) ? "zsh/pcre" : "zsh/regex";
12087 //
12088 // and the two speak different languages (POSIX ERE vs PCRE), so the
12089 // option decides whether `\d` is a digit class or a literal `d`, and
12090 // whether `(?<name>…)` compiles at all. This dispatch was missing:
12091 // `=~` always used the regex module, so `setopt rematchpcre` silently
12092 // did nothing.
12093 if crate::ported::zsh_h::isset(crate::ported::zsh_h::REMATCHPCRE) {
12094 // c:115 — "zsh/pcre" → the `-pcre-match` cond.
12095 crate::ported::modules::pcre::cond_pcre_match(
12096 &[s_clean, regex_clean],
12097 crate::ported::modules::pcre::CPCRE_PLAIN,
12098 ) != 0
12099 } else {
12100 // c:115 — "zsh/regex" → the `-regex-match` cond.
12101 crate::ported::modules::regex::zcond_regex_match(
12102 &[s_clean.as_str(), regex_clean.as_str()],
12103 crate::ported::modules::regex::ZREGEX_EXTENDED,
12104 ) != 0
12105 }
12106 }
12107
12108 fn with_redirects_end(&mut self) {
12109 with_executor(|exec| exec.host_redirect_scope_end());
12110 // c:Src/exec.c:5172 — if any redirect in this scope failed
12111 // (noclobber-blocked, ENOENT for read, etc.), the command's
12112 // exit status is forced to 1 regardless of what the (still-
12113 // executed) command's own exit was. C zsh prevents the
12114 // command from running at all when a redirect fails; the
12115 // Rust port still runs it (sinking output to /dev/null in
12116 // the noclobber arm at host_apply_redirect:5481) and then
12117 // overrides $? here. Same observable effect for the common
12118 // pattern `echo x > existing-file` under noclobber.
12119 let failed = with_executor(|exec| {
12120 let f = exec.redirect_failed;
12121 exec.redirect_failed = false;
12122 f
12123 });
12124 if failed {
12125 with_executor(|exec| exec.set_last_status(1));
12126 }
12127 }
12128
12129 fn heredoc(&mut self, content: &str) {
12130 // C `Src/exec.c:4641` — `parsestr(&buf)` runs parameter +
12131 // command substitution on the heredoc body. The lexer's
12132 // quoted-delimiter detection (`<<'EOF'`) routes through the
12133 // `Op::HereDoc` path in `compile_zsh.rs` which short-circuits
12134 // before reaching here; unquoted forms route through the
12135 // BUILTIN_EXPAND_TEXT mode-4 emit path that calls singsub.
12136 // This handler covers the verbatim/quoted case.
12137 with_executor(|exec| exec.host_set_pending_stdin(content.to_string()));
12138 }
12139
12140 fn herestring(&mut self, content: &str) {
12141 // Shell semantics: herestring appends a newline. `<<<` body
12142 // substitution (`Src/exec.c:4655 getherestr` calls
12143 // `quotesubst` + `untokenize`) lands here verbatim; the
12144 // upstream compiler routes through `Op::HereString` after
12145 // BUILTIN_EXPAND_TEXT for the substitution pass, so callers
12146 // of `host.herestring` see the already-expanded form.
12147 let mut s = content.to_string();
12148 s.push('\n');
12149 with_executor(|exec| exec.host_set_pending_stdin(s));
12150 }
12151
12152 fn exec(&mut self, args: Vec<String>) -> i32 {
12153 // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close
12154 // any `>(cmd)` write ends owned by this command once it
12155 // finishes (drops on every return path below).
12156 let _psub_fds = PsubFdGuard;
12157 // c:Src/subst.c paramsubst — when `${var:?msg}` or `${var?msg}`
12158 // triggered the "parameter null or not set" error, errflag
12159 // is raised and zsh aborts the simple command without
12160 // attempting exec. The expansion may have produced empty
12161 // argv[0] which falls into the c:?/permission-denied path
12162 // below, masking the real diagnostic with a spurious
12163 // "permission denied:" line and rc=126 instead of rc=1.
12164 // Honour errflag here so the script ends with the
12165 // paramsubst error as the sole diagnostic. Bug #86.
12166 //
12167 // c:Src/exec.c — C's execlist loop clears ERRFLAG_ERROR
12168 // between sublists when the error came from a NOMATCH-style
12169 // command failure (glob no-match, etc.) so subsequent
12170 // sublists run. zshrs's vm dispatch handles this at the
12171 // post-command-boundary HERE: if THIS command has its
12172 // `current_command_glob_failed` cell set (meaning the glob
12173 // NOMATCH happened during this command's argv prep), surface
12174 // status 1 and clear BOTH the cell AND ERRFLAG_ERROR so the
12175 // NEXT exec call sees a clean state. The errflag from
12176 // genuine script-fatal errors (parse, redirect, paramsubst
12177 // `${:?msg}`) does NOT come paired with glob_failed, so
12178 // those still short-circuit + propagate.
12179 consume_tilde_globsubst_carrier();
12180 let glob_failed = with_executor(|exec| {
12181 let f = exec.current_command_glob_failed.get();
12182 exec.current_command_glob_failed.set(false);
12183 f
12184 });
12185 if glob_failed {
12186 crate::ported::utils::errflag.fetch_and(
12187 !crate::ported::zsh_h::ERRFLAG_ERROR,
12188 std::sync::atomic::Ordering::Relaxed,
12189 );
12190 with_executor(|exec| exec.set_last_status(1));
12191 return 1;
12192 }
12193 // c:Src/subst.c:505-507 — CSH_NULL_GLOB external-path
12194 // boundary: command skipped with `no match` but the NEXT
12195 // sublist runs (zsh -fc 'setopt cshnullglob; ls *nope*;
12196 // print after' prints the error then `after` — verified
12197 // zsh 5.9.1), so clear ERRFLAG like the glob_failed arm.
12198 if consume_badcshglob() {
12199 crate::ported::utils::errflag.fetch_and(
12200 !crate::ported::zsh_h::ERRFLAG_ERROR,
12201 std::sync::atomic::Ordering::Relaxed,
12202 );
12203 with_executor(|exec| exec.set_last_status(1));
12204 return 1;
12205 }
12206 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
12207 & crate::ported::zsh_h::ERRFLAG_ERROR)
12208 != 0
12209 {
12210 return 1;
12211 }
12212 // c:Src/exec.c — two distinct empty-command cases:
12213 //
12214 // 1. args=[""] — an explicit empty-string command word
12215 // (`""`, `"\$unset"`, `\$'\$x'`). zsh attempts exec(2)
12216 // on the empty path → EACCES → "permission denied", \$?
12217 // = 126.
12218 //
12219 // 2. args=[] — the WORD LIST is empty (unquoted \$(\$cmd)
12220 // that produced empty, or an unquoted unset \$var that
12221 // elided). zsh: no exec is attempted; \$? becomes the
12222 // last cmd-subst's exit status (the inner sub-VM
12223 // already set last_status), and the line completes
12224 // silently. Critically NOT 126.
12225 if args.is_empty() {
12226 // c:Src/exec.c — empty word list passes through to a
12227 // no-op; preserve whatever the inner cmd-subst's exit
12228 // is. Return last_status so the caller's SetStatus
12229 // round-trips correctly.
12230 return with_executor(|exec| exec.last_status());
12231 }
12232 if args[0].is_empty() {
12233 let script_name =
12234 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
12235 let lineno: u64 = with_executor(|exec| {
12236 exec.scalar("LINENO")
12237 .and_then(|s| s.parse::<u64>().ok())
12238 .unwrap_or(1)
12239 });
12240 eprintln!("{}:{}: permission denied: ", script_name, lineno);
12241 return 126;
12242 }
12243 // c:Src/exec.c — when any redirect in the current scope
12244 // failed (e.g. noclobber blocked a `>` overwrite), zsh
12245 // refuses to execute the command and exits with status 1.
12246 // The Rust port still applied the command (writing to the
12247 // /dev/null sink installed by host_apply_redirect's
12248 // noclobber arm), but the success status overwrote the
12249 // intended `1`. Short-circuit here so the exec returns 1
12250 // without running the body.
12251 let redir_failed = with_executor(|exec| {
12252 let f = exec.redirect_failed;
12253 exec.redirect_failed = false;
12254 f
12255 });
12256 if redir_failed {
12257 return 1;
12258 }
12259 // Track `$_` as the last argument of the last command (zsh /
12260 // bash convention). Empty arglists leave it untouched.
12261 if let Some(last) = args.last() {
12262 with_executor(|exec| {
12263 exec.set_scalar("_".to_string(), last.clone());
12264 });
12265 }
12266 // Route external command spawning through `executor.execute_external`
12267 // so intercepts (AOP before/after/around), command_hash lookups,
12268 // pre/postexec hooks, and zsh-specific fork-then-exec all apply.
12269 // Without this override, fusevm's default `host.exec` calls
12270 // `Command::new` directly, bypassing zshrs's dispatch logic.
12271 let status = with_executor(|exec| exec.host_exec_external(&args));
12272 // c:Src/jobs.c:1748 waitonejob (no-procs else-branch). zshrs's
12273 // exec model routes external commands through host_exec_external
12274 // (which already waitpid'd in-line); the canonical waitonejob
12275 // expects a Job to derive lastval, but here we already know
12276 // it. Synthesize a procs-less job so waitonejob's no-procs
12277 // branch fires the `pipestats[0]=lastval; numpipestats=1;`
12278 // update via the canonical port.
12279 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
12280 let mut synth = crate::ported::zsh_h::job::default();
12281 crate::ported::jobs::waitonejob(&mut synth);
12282 status
12283 }
12284
12285 fn cmd_subst(&mut self, sub: &fusevm::Chunk) -> String {
12286 // Run the sub-chunk on a nested VM with the same host wired up,
12287 // capturing stdout. The current executor remains active via the
12288 // thread-local — the nested VM uses CallBuiltin to dispatch shell
12289 // ops back through `with_executor`.
12290 let (read_end, write_end) = match os_pipe::pipe() {
12291 Ok(p) => p,
12292 Err(_) => return String::new(),
12293 };
12294 let saved_stdout = unsafe { libc::fcntl(libc::STDOUT_FILENO, libc::F_DUPFD, 10) };
12295 if saved_stdout < 0 {
12296 return String::new();
12297 }
12298 let saved_stderr = unsafe { libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD, 10) };
12299 let write_fd = AsRawFd::as_raw_fd(&write_end);
12300 unsafe {
12301 libc::dup2(write_fd, libc::STDOUT_FILENO);
12302 }
12303 drop(write_end);
12304
12305 // c:Bug #56 — publish the saved outer fds so a trap firing
12306 // during the nested VM run can route its body output to the
12307 // PARENT's stdout instead of the cmdsub's pipe-bound fd 1.
12308 // zsh's forked cmdsub gets this for free (trap runs in the
12309 // parent process whose fd 1 is untouched). zshrs's
12310 // in-process cmdsub needs this thread-local stack so the
12311 // trap dispatcher can find the right destination fd.
12312 CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().push((saved_stdout, saved_stderr)));
12313
12314 // Nested scope for `>(cmd)` fd ownership — commands inside
12315 // the cmdsub must not drain the enclosing command's pending
12316 // psub fds (see PSUB_SCOPE_DEPTH).
12317 let _psub_scope = PsubScope::enter();
12318
12319 // c:Src/exec.c:1161 — forked cmdsub child runs entersubsh()
12320 // which does `zsh_subshell++`; in-process equivalent.
12321 let _subshell_bump = CmdSubstSubshellBump::enter();
12322
12323 crate::fusevm_disasm::maybe_print_stdout("host.cmd_subst", sub);
12324 let mut vm = fusevm::VM::new(sub.clone());
12325 register_builtins(&mut vm);
12326 vm.set_shell_host(Box::new(ZshrsHost));
12327 let _ = vm.run();
12328 let cmd_status = vm.last_status;
12329
12330 CMDSUBST_OUTER_FDS.with(|s| {
12331 s.borrow_mut().pop();
12332 });
12333
12334 unsafe {
12335 libc::dup2(saved_stdout, libc::STDOUT_FILENO);
12336 libc::close(saved_stdout);
12337 if saved_stderr >= 0 {
12338 libc::close(saved_stderr);
12339 }
12340 }
12341
12342 // Inner cmd's status not propagated for the same reason as
12343 // run_command_substitution — see GAPS.md.
12344 let _ = cmd_status;
12345
12346 let mut buf = String::new();
12347 let mut reader = read_end;
12348 let _ = reader.read_to_string(&mut buf);
12349 // Strip trailing newlines (POSIX command substitution semantics)
12350 while buf.ends_with('\n') {
12351 buf.pop();
12352 }
12353 buf
12354 }
12355
12356 fn call_function(&mut self, name: &str, args: Vec<String>) -> Option<i32> {
12357 // c:Src/exec.c — when the command word is empty (e.g. `""`
12358 // or `"$nonexistent"`), zsh attempts the exec(2) which
12359 // returns EACCES ("permission denied") and exits 126. The
12360 // Rust port silently treated empty as a no-op (status 0).
12361 // Match zsh by emitting the diagnostic and returning 126.
12362 if name.is_empty() {
12363 let script_name =
12364 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
12365 let lineno: u64 = with_executor(|exec| {
12366 exec.scalar("LINENO")
12367 .and_then(|s| s.parse::<u64>().ok())
12368 .unwrap_or(1)
12369 });
12370 eprintln!("{}:{}: permission denied: ", script_name, lineno);
12371 with_executor(|exec| exec.set_last_status(126));
12372 return Some(126);
12373 }
12374 // c:Src/exec.c — redirect failure in this scope means the
12375 // command should NOT run. The Host::exec path already has
12376 // this gate (at fn exec above); call_function takes external
12377 // commands like `cat <&3` through a different code path, so
12378 // gate here too. Without this, bad-fd redirects produced
12379 // the diagnostic but the external command still ran, so $?
12380 // came out from the command's natural exit instead of the
12381 // forced 1.
12382 let redir_failed = with_executor(|exec| {
12383 let f = exec.redirect_failed;
12384 exec.redirect_failed = false;
12385 f
12386 });
12387 if redir_failed {
12388 with_executor(|exec| exec.set_last_status(1));
12389 return Some(1);
12390 }
12391 // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
12392 // functions, NOT builtins. zshrs ships fast native impls, but they
12393 // must behave like the zsh functions — command-not-found until
12394 // `autoload -Uz <name>` creates a function entry. When autoloaded we
12395 // run the native impl here (short-circuiting the fpath source, which
12396 // can hang zshrs's parser on zsh-specific syntax); when NOT autoloaded
12397 // we fall through (return None → resolution ends in command-not-found),
12398 // matching `zsh -f; zmv` → "command not found: zmv".
12399 if matches!(name, "zmv" | "zcp" | "zln" | "zcalc")
12400 && !with_executor(|exec| exec.function_exists(name))
12401 {
12402 return None;
12403 }
12404 match name {
12405 "zmv" => {
12406 return Some(crate::extensions::ext_builtins::zmv(&args, "mv"));
12407 }
12408 "zcp" => {
12409 return Some(crate::extensions::ext_builtins::zmv(&args, "cp"));
12410 }
12411 "zln" => {
12412 return Some(crate::extensions::ext_builtins::zmv(&args, "ln"));
12413 }
12414 "zcalc" => {
12415 return Some(crate::extensions::ext_builtins::zcalc(&args));
12416 }
12417 // znative — the plugin package manager (src/extensions/pkg/). Installs
12418 // + loads zsh script and native (Rust cdylib) plugins from a global
12419 // content-addressed store. `znative add owner/repo`, `znative load`, ...
12420 "znative" => {
12421 return Some(crate::extensions::pkg::builtin::znative(&args));
12422 }
12423 // ztest framework (src/extensions/ztest.rs — port of
12424 // ../strykelang's unit-test framework). All zassert_*/
12425 // ztest_* names route through the single try_dispatch
12426 // helper so adding/removing assertions only touches
12427 // ztest.rs.
12428 n if crate::extensions::ztest::try_dispatch_known(n) => {
12429 let status = with_executor(|exec| {
12430 crate::extensions::ztest::try_dispatch(exec, n, &args).unwrap_or(1)
12431 });
12432 return Some(status);
12433 }
12434 // Daemon-managed z* builtins — thin IPC wrappers. Short-circuit BEFORE
12435 // the function-lookup path so a missing daemon doesn't fall through to
12436 // "command not found". The name list is owned by the daemon crate
12437 // (zshrs_daemon::builtins::ZSHRS_BUILTIN_NAMES); routing through
12438 // try_dispatch keeps this site zero-touch as new z* builtins land.
12439 n if crate::daemon::builtins::is_zshrs_builtin(n) => {
12440 let argv: Vec<String> = std::iter::once(name.to_string()).chain(args).collect();
12441 return Some(crate::daemon::builtins::try_dispatch(n, &argv).unwrap_or(1));
12442 }
12443 _ => {}
12444 }
12445
12446 // c:Src/exec.c:3050-3068 — module-provided builtins (registered
12447 // via each module's `bintab` and folded into the canonical
12448 // `builtintab` by `createbuiltintable`) must dispatch BEFORE
12449 // PATH lookup. fusevm's `shell_builtins::builtin_id` doesn't
12450 // know about per-module entries like `log`
12451 // (Src/Modules/watch.c:693) — they reach call_function as
12452 // CallFunction ops. Consult the merged builtintab here so
12453 // `log` runs the canonical `bin_log` instead of falling
12454 // through to `/usr/bin/log` on macOS. Bug #72 in docs/BUGS.md.
12455 //
12456 // User-defined functions still take precedence over builtins
12457 // (zsh's `alias → function → builtin → external` resolution
12458 // order, c:Src/exec.c:3038-3068). Check `functions_compiled`
12459 // first so a user `log() { ... }` shadows the module bin_log.
12460 // c:Src/exec.c — shfunctab->getnode (the DISABLED-filtering
12461 // accessor) returns NULL for entries flipped to DISABLED via
12462 // `disable -f NAME`. functions_compiled holds the body
12463 // independently of the DISABLED flag, so check shfunctab first
12464 // and mask the lookup when the entry is disabled. Bug #221
12465 // in docs/BUGS.md.
12466 let user_fn_disabled = crate::ported::hashtable::shfunctab_lock()
12467 .read()
12468 .ok()
12469 .and_then(|t| {
12470 let entry = t.get_including_disabled(name)?;
12471 Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
12472 })
12473 .unwrap_or(false);
12474 let has_user_fn =
12475 !user_fn_disabled && with_executor(|exec| exec.functions_compiled.contains_key(name));
12476 if !has_user_fn {
12477 // c:Src/exec.c:3056 — `builtintab->getnode(builtintab,
12478 // cmdarg)` returns NULL for DISABLED entries, falling
12479 // execcmd through to PATH lookup. Mirror by gating the
12480 // bn_in_tab match on the BUILTINS_DISABLED set. Bug #106
12481 // in docs/BUGS.md.
12482 let disabled = crate::ported::builtin::BUILTINS_DISABLED
12483 .lock()
12484 .map(|s| s.contains(name))
12485 .unwrap_or(false);
12486 let bn_in_tab =
12487 !disabled && crate::ported::builtin::createbuiltintable().contains_key(name);
12488 if bn_in_tab {
12489 return Some(dispatch_builtin_raw(name, args));
12490 }
12491 }
12492
12493 // c:Src/lex.c — alias expansion is a LEXER-TIME pass, not a
12494 // run-time lookup. zsh parses the whole `-c` argument (or
12495 // script) before executing, so aliases defined in the same
12496 // parse unit don't apply to commands parsed earlier. Only at
12497 // an INTERACTIVE prompt does each line parse separately with
12498 // the latest aliastab visible.
12499 //
12500 // Gate the run-time alias-rewrite path on `interactive` so
12501 // `alias hi='echo hello'; hi` in `-c` mode falls through to
12502 // "command not found" (matching zsh) while interactive REPL
12503 // input still re-parses with the live aliastab.
12504 let interactive = crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE);
12505 let already_expanding = if interactive {
12506 crate::ported::hashtable::aliastab_lock()
12507 .read()
12508 .ok()
12509 .and_then(|tab| tab.get(name).map(|a| a.inuse != 0))
12510 .unwrap_or(false)
12511 } else {
12512 true // suppress lookup entirely in non-interactive mode
12513 };
12514 let alias_body = if already_expanding {
12515 None
12516 } else {
12517 with_executor(|exec| exec.alias(name))
12518 };
12519 if let Some(body) = alias_body {
12520 let combined = if args.is_empty() {
12521 body
12522 } else {
12523 let quoted: Vec<String> = args
12524 .iter()
12525 .map(|a| {
12526 let escaped = a.replace('\'', "'\\''");
12527 format!("'{}'", escaped)
12528 })
12529 .collect();
12530 format!("{} {}", body, quoted.join(" "))
12531 };
12532 // Bump inuse → run → clear, matching C's lexer behavior.
12533 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
12534 if let Some(a) = tab.get_mut(name) {
12535 a.inuse += 1;
12536 }
12537 }
12538 let status = with_executor(|exec| exec.execute_script(&combined).unwrap_or(1));
12539 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
12540 if let Some(a) = tab.get_mut(name) {
12541 a.inuse = (a.inuse - 1).max(0);
12542 }
12543 }
12544 return Some(status);
12545 }
12546
12547 // $_ pre-body bump and pending-underscore tracking are
12548 // ZshrsHost-only concerns (prompt rendering). Apply BEFORE
12549 // delegating to dispatch_function_call so the body sees the
12550 // bumped value.
12551 //
12552 // c:Src/exec.c:3491 — `setunderscore((args && nonempty(args))
12553 // ? ((char *) getdata(lastnode(args))) : "")`. C sets $_ to
12554 // the LAST node of the WHOLE args list (which includes argv[0]
12555 // == the function name). So for a no-arg `f`, $_ becomes "f"
12556 // inside the function body. The Rust port at the CallFunction
12557 // op-handler receives `args` WITHOUT the command name
12558 // (compile_zsh.rs:1571 only pushes simple.words[1..]). The
12559 // last() fallback `|| fn_name.clone()` already covers the
12560 // no-arg case, but `exec.set_scalar("_", ...)` writes paramtab
12561 // — the canonical `$_` read goes through `underscoregetfn`
12562 // (params.rs:7836) which reads the `zunderscore` Mutex.
12563 // setsparam("_") doesn't update that mutex, so the body's
12564 // `${_}` returned empty. Bug #279 in docs/BUGS.md. Mirror the
12565 // C `setunderscore` by writing via `set_zunderscore` directly.
12566 let fn_name = name.to_string();
12567 with_executor(|exec| {
12568 let dollar_underscore = args.last().cloned().unwrap_or_else(|| fn_name.clone());
12569 exec.set_scalar("_".to_string(), dollar_underscore.clone());
12570 crate::ported::params::set_zunderscore(std::slice::from_ref(&dollar_underscore));
12571 exec.pending_underscore = Some(dollar_underscore);
12572 });
12573
12574 // Delegate the actual function dispatch to the canonical
12575 // `dispatch_function_call` (which itself wraps the canonical
12576 // `doshfunc` port from `Src/exec.c:5823`). Single doshfunc
12577 // call-site keeps scope-mgmt invariants in one place.
12578 let status = with_executor(|exec| exec.dispatch_function_call(&fn_name, &args));
12579
12580 // Anonymous functions (`() { … } args`, compiled by
12581 // parse_anon_funcdef as `_zshrs_anon_N` / `_zshrs_anon_kw_N`)
12582 // execute exactly ONCE and must not persist. zsh runs the body and
12583 // frees the function, so `${functions}` / `typeset -f` never show
12584 // it. Remove every trace right after the single invocation —
12585 // AFTER `status` is captured, so the body's exit code is preserved
12586 // ($? — calling `unfunction` here would reset it to 0 instead).
12587 // Without this, real plugins that use `() { … }` (fzf-tab, zinit,
12588 // p10k, …) leaked dozens of `_zshrs_anon_N` into `$functions`,
12589 // diverging from zsh's function table on every such config.
12590 if fn_name.starts_with("_zshrs_anon_") {
12591 // `${functions}` / `typeset -f` enumerate the canonical
12592 // `shfunctab` (via scanpmfunctions); the bytecode call path
12593 // also keeps the body in the executor's compiled-fn maps. Clear
12594 // BOTH so no trace of the one-shot anon survives.
12595 crate::ported::hashtable::removeshfuncnode(&fn_name);
12596 with_executor(|exec| {
12597 exec.functions_compiled.remove(&fn_name);
12598 exec.function_source.remove(&fn_name);
12599 exec.function_line_base.remove(&fn_name);
12600 exec.function_def_file.remove(&fn_name);
12601 });
12602 }
12603
12604 // $_ post-body — last call-arg or function name. Mirrors the
12605 // C `setunderscore` invocation after the body returns.
12606 with_executor(|exec| {
12607 let last_call_arg = args.last().cloned().unwrap_or_else(|| fn_name.clone());
12608 exec.set_scalar("_".to_string(), last_call_arg.clone());
12609 exec.pending_underscore = Some(last_call_arg);
12610 });
12611
12612 status
12613 }
12614}
12615
12616// ───────────────────────────────────────────────────────────────────────────
12617/// Render a failed-redirect open error the way C's `zerrmsg` `%e` format
12618/// code does (Src/utils.c): `strerror(errno)` with the first character
12619/// lowercased, except `EIO` (kept capitalized) and `EINTR` (→ "interrupt").
12620/// C's redirect open failures call `zwarn("%e: %s", errno, fname)`
12621/// (Src/exec.c:3741); zshrs's `zwarning` takes a pre-built string, so the
12622/// `%e` part is built here. Replaces the prior hardcoded `ErrorKind` match
12623/// that fell back to a generic "redirect failed" for `EROFS`/`EACCES`/etc.
12624fn redir_errno_msg(err: &std::io::Error) -> String {
12625 let errno = match err.raw_os_error() {
12626 Some(n) if n != 0 => n,
12627 _ => return "redirect failed".to_string(),
12628 };
12629 if errno == libc::EINTR {
12630 return "interrupt".to_string(); // c:zerrmsg %e — EINTR special-case
12631 }
12632 let cptr = unsafe { libc::strerror(errno) };
12633 if cptr.is_null() {
12634 return "redirect failed".to_string();
12635 }
12636 let msg = unsafe { std::ffi::CStr::from_ptr(cptr) }.to_string_lossy();
12637 if errno == libc::EIO {
12638 return msg.into_owned(); // c:zerrmsg %e — EIO keeps capitalization
12639 }
12640 // c:zerrmsg %e — `fputc(tulower(errmsg[0])); fputs(errmsg + 1)`.
12641 let mut chars = msg.chars();
12642 match chars.next() {
12643 Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
12644 None => "redirect failed".to_string(),
12645 }
12646}
12647
12648// Host-routed shell ops: ShellExecutor methods invoked by ZshrsHost from the
12649// fusevm VM. Not a port of Src/exec.c (see file-level docs above) — they're
12650// the bridge between fusevm opcodes and ShellExecutor state.
12651// ───────────────────────────────────────────────────────────────────────────
12652impl ShellExecutor {
12653 // ─── Host-routed shell ops (called by ZshrsHost from fusevm) ────────────
12654
12655 /// Apply a single redirection. The current scope's saved-fd vec gets a
12656 /// dup of the original fd so it can be restored by `host_redirect_scope_end`.
12657 /// `op_byte` matches `fusevm::op::redirect_op::*`.
12658 /// Apply a file-open result to a redirect fd; on error, emit
12659 /// zsh-format diagnostic, set redirect_failed, sink fd to /dev/null.
12660 /// Shared between WRITE/APPEND/READ/CLOBBER arms in
12661 /// host_apply_redirect to keep the error-handling identical.
12662 fn redir_open_or_fail(
12663 fd: i32,
12664 result: std::io::Result<fs::File>,
12665 target: &str,
12666 redirect_failed: &mut bool,
12667 ) -> bool {
12668 match result {
12669 Ok(file) => {
12670 let new_fd = file.into_raw_fd();
12671 unsafe {
12672 // When the target fd was already closed (e.g. `exec 0<&-;
12673 // cmd < file`), open() returns the lowest free fd, which is
12674 // `fd` itself. Then `dup2(fd, fd)` is a no-op: closing new_fd
12675 // would CLOSE the fd we just opened, AND — since Rust's
12676 // File::open sets O_CLOEXEC and a no-op dup2 does NOT clear
12677 // it — an exec'd child would lose the descriptor. So in the
12678 // reuse case, keep the fd and clear its close-on-exec flag;
12679 // otherwise dup2 (which clears cloexec on the copy) + close.
12680 if new_fd != fd {
12681 libc::dup2(new_fd, fd);
12682 libc::close(new_fd);
12683 } else {
12684 libc::fcntl(fd, libc::F_SETFD, 0);
12685 }
12686 }
12687 true
12688 }
12689 Err(e) => {
12690 // c:Src/exec.c:3741 — zwarn("%e: %s", errno, fname) with the
12691 // real lineno prefix; redir_errno_msg builds the `%e` errno
12692 // message for all errnos (not just the few hardcoded before).
12693 let msg = redir_errno_msg(&e);
12694 crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
12695 *redirect_failed = true;
12696 // The /dev/null sink keeps a failed scoped redirect
12697 // from leaking the aborted command's output to the
12698 // wrong fd until scope-end restores it. For a bare
12699 // `exec` redirect (permanent, no scope restore) C
12700 // leaves the fd UNTOUCHED — execerr() aborts the
12701 // statement and the original fd 1 keeps flowing
12702 // (A04redirect: `exec >./nonexistent/x` then `echo
12703 // output` still prints). c:Src/exec.c:3735-3742.
12704 let permanent = with_executor(|exec| exec.exec_redirs_permanent);
12705 if !permanent {
12706 if let Ok(devnull) = fs::OpenOptions::new()
12707 .read(true)
12708 .write(true)
12709 .open("/dev/null")
12710 {
12711 let new_fd = devnull.into_raw_fd();
12712 unsafe {
12713 if new_fd != fd {
12714 libc::dup2(new_fd, fd);
12715 libc::close(new_fd);
12716 } else {
12717 libc::fcntl(fd, libc::F_SETFD, 0);
12718 }
12719 }
12720 }
12721 }
12722 false
12723 }
12724 }
12725 }
12726 /// `host_apply_redirect` — see implementation.
12727 pub fn host_apply_redirect(&mut self, fd: u8, op_byte: u8, target: &str) {
12728 // `&>` / `&>>` always target both fd 1 and fd 2 regardless of the
12729 // fd byte the parser supplied (the lexer's tokfd clamp makes the
12730 // raw value unreliable for these forms).
12731 let fd: i32 = if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
12732 1
12733 } else {
12734 fd as i32
12735 };
12736 // c:Src/exec.c — for DUP_READ / DUP_WRITE forms (<&N / >&N),
12737 // validate the source fd is open BEFORE the save-and-dup
12738 // dance below. The save's `dup(fd)` reclaims the lowest free
12739 // fd, which on closed-fd reuse would let dup2(src=N, …)
12740 // succeed against the freshly-claimed slot — masking the
12741 // user's "bad file descriptor" error. Check src_fd first.
12742 if matches!(op_byte, r::DUP_READ | r::DUP_WRITE) {
12743 let n_check = target.trim_start_matches('&');
12744 if n_check != "-" {
12745 if let Ok(src_fd) = n_check.parse::<i32>() {
12746 if unsafe { libc::fcntl(src_fd, libc::F_GETFD) } == -1 {
12747 // c:Src/exec.c — zwarn with real lineno prefix.
12748 crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src_fd));
12749 self.set_last_status(1);
12750 self.redirect_failed = true;
12751 return;
12752 }
12753 }
12754 }
12755 }
12756 // c:Src/exec.c:3978-3986 — bare `exec` redirects (nullexec==1)
12757 // skip the save entirely: "we specifically *don't* restore the
12758 // original fd's". C's save[] is per-execcmd, so exec's redirs
12759 // never enter an enclosing group's save list either; pushing
12760 // into `redirect_scope_stack.last_mut()` here (the enclosing
12761 // group's scope) made `{ exec 1>&-; … } 2>/dev/null` restore
12762 // stdout at group end — diverging from zsh, which keeps fd 1
12763 // closed for the rest of the script.
12764 if !self.exec_redirs_permanent {
12765 let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
12766 if saved >= 0 {
12767 if let Some(top) = self.redirect_scope_stack.last_mut() {
12768 top.push((fd, saved));
12769 } else {
12770 // No scope — leave saved fd open and let the next scope
12771 // reclaim it. (Caller without a scope leaks the dup; this
12772 // matches `WithRedirects` parser construction always wrapping.)
12773 unsafe { libc::close(saved) };
12774 }
12775 }
12776 // For `&>` / `&>>` also save fd 2 so the scope restores it after
12777 // the body. Otherwise stderr stays redirected past the command.
12778 if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
12779 let saved2 = unsafe { libc::fcntl(2, libc::F_DUPFD, 10) };
12780 if saved2 >= 0 {
12781 if let Some(top) = self.redirect_scope_stack.last_mut() {
12782 top.push((2, saved2));
12783 } else {
12784 unsafe { libc::close(saved2) };
12785 }
12786 }
12787 }
12788 }
12789 // c:Src/exec.c:3722-3724 + 2447-2480 — MULTIOS split when this
12790 // command's stdout IS the pipeline output. C registers the pipe
12791 // in mfds[1] (`addfd(forked, save, mfds, 1, output, 1, NULL)`)
12792 // BEFORE walking the explicit redirect list, so a write-side
12793 // redirect of fd 1 finds mfds[1] occupied and, with MULTIOS
12794 // set, "split[s] the stream": fd 1 becomes the write end of an
12795 // internal pipe whose reader tees every chunk to BOTH the
12796 // pipeline pipe and the new target. That is why
12797 // `{ echo a; echo b >&2; } 3>&1 1>&2 2>&3 3>&- | cat` sends
12798 // `a` to the pipe (via the tee) AND to stderr — plain dup2
12799 // replacement loses the pipe stream. The scope-depth gate
12800 // mirrors mfds being per-execcmd: only the redirect list
12801 // attached to the stage's own command joins the pipe; nested
12802 // commands inside the body (`{ echo a > f; } | cat`) get a
12803 // fresh "mfds" and replace as usual.
12804 if fd == 1
12805 && self
12806 .pipe_output_scope
12807 .is_some_and(|d| d + 1 == self.redirect_scope_stack.len())
12808 && crate::ported::options::opt_state_get("multios").unwrap_or(true)
12809 {
12810 // Resolve the new write target exactly as the plain arms
12811 // below would, but as a raw fd for the tee.
12812 let new_target_fd: i32 = match op_byte {
12813 r::DUP_WRITE => {
12814 // Numeric `>&N` only; `-` (close) and `p` (coproc)
12815 // fall through to the plain arms.
12816 target
12817 .trim_start_matches('&')
12818 .parse::<i32>()
12819 .map(|src| unsafe { libc::fcntl(src, libc::F_DUPFD, 10) })
12820 .unwrap_or(-1)
12821 }
12822 r::WRITE | r::CLOBBER => fs::File::create(target)
12823 .map(|f| f.into_raw_fd())
12824 .unwrap_or(-1),
12825 r::APPEND => fs::OpenOptions::new()
12826 .create(true)
12827 .append(true)
12828 .open(target)
12829 .map(|f| f.into_raw_fd())
12830 .unwrap_or(-1),
12831 _ => -1,
12832 };
12833 if new_target_fd >= 0 {
12834 let pipe_dup = unsafe { libc::fcntl(1, libc::F_DUPFD, 10) };
12835 match (pipe_dup >= 0).then(os_pipe::pipe) {
12836 Some(Ok((read_end, write_end))) => {
12837 // Splitter: same read-loop shape as
12838 // BUILTIN_MULTIOS_REDIRECT, with one ordering
12839 // refinement. C's tee is a forked process
12840 // (closemn → teeproc) whose wakeup latency lets
12841 // the stage's DIRECT pipe writes land first —
12842 // observed zsh output for `{ echo a; echo b >&2; }
12843 // 3>&1 1>&2 2>&3 3>&- | cat` is `b` then `a`,
12844 // 15/15 runs. A Rust thread wakes faster than
12845 // the debug-build VM dispatches the next echo,
12846 // inverting the order. Emulate the C timing
12847 // observably: stream to the NEW target (file /
12848 // stderr dup) immediately, but defer the
12849 // pipe-bound copy until EOF (or a 64KB cap so a
12850 // long-running stream still flows instead of
12851 // growing memory unboundedly).
12852 let write_now = |tfd: i32, data: &[u8]| {
12853 let mut off = 0;
12854 while off < data.len() {
12855 let w = unsafe {
12856 libc::write(
12857 tfd,
12858 data[off..].as_ptr() as *const libc::c_void,
12859 data.len() - off,
12860 )
12861 };
12862 if w <= 0 {
12863 break;
12864 }
12865 off += w as usize;
12866 }
12867 };
12868 let handle = std::thread::spawn(move || {
12869 let mut rd = read_end;
12870 let mut buf = [0u8; 8192];
12871 let mut pipe_pending: Vec<u8> = Vec::new();
12872 loop {
12873 match std::io::Read::read(&mut rd, &mut buf) {
12874 Ok(0) | Err(_) => break,
12875 Ok(n) => {
12876 write_now(new_target_fd, &buf[..n]);
12877 pipe_pending.extend_from_slice(&buf[..n]);
12878 if pipe_pending.len() >= 65536 {
12879 write_now(pipe_dup, &pipe_pending);
12880 pipe_pending.clear();
12881 }
12882 }
12883 }
12884 }
12885 write_now(pipe_dup, &pipe_pending);
12886 unsafe {
12887 libc::close(pipe_dup);
12888 libc::close(new_target_fd);
12889 }
12890 });
12891 let write_raw = AsRawFd::as_raw_fd(&write_end);
12892 unsafe { libc::dup2(write_raw, 1) };
12893 drop(write_end);
12894 // Scope-end closes this dup (the last writer once
12895 // the saved fd 1 is restored) → EOF → join.
12896 let close_on_end = unsafe { libc::fcntl(1, libc::F_DUPFD, 10) };
12897 if let Some(top) = self.multios_scope_stack.last_mut() {
12898 top.push((close_on_end, handle));
12899 } else {
12900 unsafe { libc::close(close_on_end) };
12901 let _ = handle.join();
12902 }
12903 return;
12904 }
12905 _ => unsafe {
12906 // pipe()/dup failure — fall through to plain replace.
12907 if pipe_dup >= 0 {
12908 libc::close(pipe_dup);
12909 }
12910 libc::close(new_target_fd);
12911 },
12912 }
12913 }
12914 }
12915 match op_byte {
12916 r::WRITE => {
12917 // Honor `setopt noclobber`: refuse to overwrite an
12918 // existing regular file unless `>!` / `>|` (CLOBBER).
12919 // zsh internally stores the inverted-name `clobber`
12920 // (default ON); `setopt noclobber` writes
12921 // `clobber=false`. Honor both keys.
12922 //
12923 // c:Src/exec.c:2241-2245 clobber_open recover path:
12924 // after O_EXCL fails, reopen and `if (!S_ISREG(...))
12925 // return fd;` — non-regular targets (char/block-
12926 // special, FIFO, socket) bypass the noclobber check.
12927 // Bug #30 in docs/BUGS.md: this bridge-side check did
12928 // a bare `Path::exists()` and treated `/dev/null` as
12929 // a protected file, breaking `setopt no_clobber; echo
12930 // hi > /dev/null` and every `2> /dev/null` idiom.
12931 // Add a regular-file stat gate that matches the C
12932 // semantic. The canonical clobber_open at
12933 // src/ported/exec.rs:2123 already handles this; the
12934 // bridge duplicates a stripped-down version here and
12935 // must mirror the same check.
12936 let noclobber = opt_state_get("noclobber").unwrap_or(false)
12937 || !opt_state_get("clobber").unwrap_or(true);
12938 let target_meta = std::fs::metadata(target).ok();
12939 let target_is_regular_file = target_meta
12940 .as_ref()
12941 .map(|m| m.file_type().is_file())
12942 .unwrap_or(false);
12943 // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY permits
12944 // re-using an EMPTY regular file under noclobber: `setopt
12945 // noclobber clobberempty; : >f; echo hi >f` overwrites f.
12946 // The inline bridge check ignored this and errored.
12947 let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
12948 && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
12949 if noclobber && target_is_regular_file && !clobber_empty_ok {
12950 eprintln!(
12951 "{}:{}: file exists: {}",
12952 shname(),
12953 crate::ported::lex::lineno(),
12954 target
12955 );
12956 self.set_last_status(1);
12957 // c:Src/exec.c — set redirect_failed so the scope-end
12958 // hook (`with_redirects_end` in this file) forces
12959 // $? to 1 regardless of the still-running command's
12960 // own exit. Without this the next command (e.g.
12961 // `echo x` writing to /dev/null below) succeeds
12962 // and overwrites the redirect-failure status,
12963 // making noclobber unobservable from $?.
12964 self.redirect_failed = true;
12965 // Sink the upcoming command's stdout to /dev/null
12966 // so we don't leak its output to the terminal.
12967 // zsh skips the command entirely; we approximate by
12968 // discarding the output (the redirect target was
12969 // the user's chosen sink, but with noclobber the
12970 // file is protected — discarding matches the
12971 // user's intent better than printing to terminal).
12972 if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
12973 let new_fd = file.into_raw_fd();
12974 unsafe {
12975 libc::dup2(new_fd, fd);
12976 libc::close(new_fd);
12977 }
12978 }
12979 return;
12980 }
12981 if !Self::redir_open_or_fail(
12982 fd,
12983 fs::File::create(target),
12984 target,
12985 &mut self.redirect_failed,
12986 ) {
12987 self.set_last_status(1);
12988 }
12989 }
12990 r::CLOBBER => {
12991 if !Self::redir_open_or_fail(
12992 fd,
12993 fs::File::create(target),
12994 target,
12995 &mut self.redirect_failed,
12996 ) {
12997 self.set_last_status(1);
12998 }
12999 }
13000 r::APPEND => {
13001 // c:Src/exec.c:3924-3927 — `>>` honors NO_CLOBBER+!APPENDCREATE
13002 // by opening O_APPEND|O_WRONLY WITHOUT O_CREAT, so missing
13003 // files yield ENOENT. zsh source:
13004 // if (!isset(CLOBBER) && !isset(APPENDCREATE) &&
13005 // !IS_CLOBBER_REDIR(fn->type))
13006 // mode = O_WRONLY|O_APPEND|O_NOCTTY;
13007 // else mode = O_WRONLY|O_APPEND|O_CREAT|O_NOCTTY;
13008 // (IS_CLOBBER_REDIR — `>>!`/`>>|` — is currently flattened
13009 // to plain APPEND at compile time in
13010 // src/extensions/compile_zsh.rs:1654-1655, so the bang/pipe
13011 // forms can't be distinguished here yet.)
13012 let noclobber = opt_state_get("noclobber").unwrap_or(false)
13013 || !opt_state_get("clobber").unwrap_or(true);
13014 let append_create = opt_state_get("appendcreate").unwrap_or(false)
13015 || opt_state_get("append_create").unwrap_or(false);
13016 let open_result = if noclobber && !append_create {
13017 fs::OpenOptions::new().append(true).open(target) // no create
13018 } else {
13019 fs::OpenOptions::new()
13020 .create(true)
13021 .append(true)
13022 .open(target)
13023 };
13024 if !Self::redir_open_or_fail(fd, open_result, target, &mut self.redirect_failed) {
13025 self.set_last_status(1);
13026 }
13027 }
13028 r::READ => {
13029 if !Self::redir_open_or_fail(
13030 fd,
13031 fs::File::open(target),
13032 target,
13033 &mut self.redirect_failed,
13034 ) {
13035 self.set_last_status(1);
13036 }
13037 }
13038 r::READ_WRITE => {
13039 if let Ok(file) = fs::OpenOptions::new()
13040 .create(true)
13041 .truncate(false) // <> opens existing-or-new without truncating
13042 .read(true)
13043 .write(true)
13044 .open(target)
13045 {
13046 let new_fd = file.into_raw_fd();
13047 unsafe {
13048 // See redir_open_or_fail: when the opened fd IS the
13049 // destination (target fd was closed), keep it and clear
13050 // O_CLOEXEC; else dup2 + close.
13051 if new_fd != fd {
13052 libc::dup2(new_fd, fd);
13053 libc::close(new_fd);
13054 } else {
13055 libc::fcntl(fd, libc::F_SETFD, 0);
13056 }
13057 }
13058 }
13059 }
13060 r::DUP_READ | r::DUP_WRITE => {
13061 // Target is a numeric fd reference like `&3`. The parser
13062 // strips the `&` prefix before we get here in some paths,
13063 // others retain it — accept both. Also support `-` for
13064 // close-fd (`<&-` / `>&-`) per POSIX. The src_fd
13065 // validity check ran above before the save-and-dup.
13066 let n = target.trim_start_matches('&');
13067 if n == "-" {
13068 unsafe { libc::close(fd) };
13069 } else if n == "p" {
13070 // c:Src/exec.c — `<&p` / `>&p` route through the
13071 // coprocin / coprocout globals. zsh's `coproc CMD`
13072 // launch publishes those fds; the canonical
13073 // bin_print / bin_read `-p` arms already consume
13074 // them. The DUP redirect form is the third
13075 // consumer: it must dup the coproc fd onto the
13076 // target slot so the next command's stdin/stdout
13077 // is wired to the running coprocess. Bug #388.
13078 let coproc_fd = if op_byte == r::DUP_READ {
13079 crate::ported::modules::clone::coprocin
13080 .load(std::sync::atomic::Ordering::Relaxed)
13081 } else {
13082 crate::ported::modules::clone::coprocout
13083 .load(std::sync::atomic::Ordering::Relaxed)
13084 };
13085 if coproc_fd < 0 {
13086 eprintln!("{}:1: no coprocess", shname());
13087 self.set_last_status(1);
13088 self.redirect_failed = true;
13089 } else {
13090 unsafe {
13091 libc::dup2(coproc_fd, fd);
13092 }
13093 }
13094 } else if let Ok(src_fd) = n.parse::<i32>() {
13095 unsafe { libc::dup2(src_fd, fd) };
13096 } else if op_byte == r::DUP_WRITE {
13097 // c:Src/glob.c:2184-2187 xpandredir — a MERGEOUT
13098 // word that expands to a non-number becomes
13099 // REDIR_ERRWRITE: `cmd >& word` opens `word` and
13100 // routes BOTH fd 1 and fd 2 there. Reached only
13101 // for dynamic words (`>&$var`); static filenames
13102 // were converted at compile time.
13103 if let Ok(file) = fs::File::create(target) {
13104 let new_fd = file.into_raw_fd();
13105 unsafe {
13106 libc::dup2(new_fd, 1);
13107 libc::dup2(new_fd, 2);
13108 libc::close(new_fd);
13109 }
13110 }
13111 } else {
13112 // c:Src/glob.c:2185 — MERGEIN non-number:
13113 // `zerr("file number expected")`.
13114 crate::ported::utils::zerr("file number expected");
13115 self.set_last_status(1);
13116 self.redirect_failed = true;
13117 }
13118 }
13119 r::WRITE_BOTH => {
13120 if let Ok(file) = fs::File::create(target) {
13121 let new_fd = file.into_raw_fd();
13122 unsafe {
13123 libc::dup2(new_fd, 1);
13124 libc::dup2(new_fd, 2);
13125 libc::close(new_fd);
13126 }
13127 }
13128 }
13129 r::APPEND_BOTH => {
13130 if let Ok(file) = fs::OpenOptions::new()
13131 .create(true)
13132 .append(true)
13133 .open(target)
13134 {
13135 let new_fd = file.into_raw_fd();
13136 unsafe {
13137 libc::dup2(new_fd, 1);
13138 libc::dup2(new_fd, 2);
13139 libc::close(new_fd);
13140 }
13141 }
13142 }
13143 _ => {}
13144 }
13145 }
13146
13147 /// Push a fresh redirect scope. `_count` is informational — the actual
13148 /// saved fds are appended by host_apply_redirect into the top scope.
13149 pub fn host_redirect_scope_begin(&mut self, _count: u8) {
13150 // c:Src/exec.c:3722-3724 — the pipeline child set
13151 // `pipe_output_pending` right after dup2'ing its stdout onto
13152 // the pipe; the FIRST redirect scope opened in that child is
13153 // the stage command's own redirect list (same execcmd as the
13154 // pipe's addfd into mfds[1]). Capture the depth so only THAT
13155 // list's fd-1 write redirects MULTIOS-join the pipe.
13156 if self.pipe_output_pending {
13157 self.pipe_output_pending = false;
13158 self.pipe_output_scope = Some(self.redirect_scope_stack.len());
13159 }
13160 self.redirect_scope_stack.push(Vec::new());
13161 self.multios_scope_stack.push(Vec::new());
13162 }
13163
13164 /// Pop the top redirect scope, restoring saved fds.
13165 pub fn host_redirect_scope_end(&mut self) {
13166 // c:Src/exec.c — restore saved fds FIRST so the multios
13167 // pipe-write end is released from `fd`, then close our
13168 // tracked close_on_end (the last surviving writer dup), then
13169 // join the splitter thread. If we closed close_on_end before
13170 // restoring saved, `fd` would still hold a pipe writer and
13171 // the thread would block forever waiting for EOF.
13172 if let Some(saved) = self.redirect_scope_stack.pop() {
13173 for (fd, saved_fd) in saved.into_iter().rev() {
13174 unsafe {
13175 libc::dup2(saved_fd, fd);
13176 libc::close(saved_fd);
13177 }
13178 }
13179 }
13180 if let Some(scope) = self.multios_scope_stack.pop() {
13181 // Close ALL tracked writer dups BEFORE joining any
13182 // thread. When one splitter holds a dup of another's
13183 // pipe write-end (two multios in one scope where a later
13184 // one duped fd 1 while an earlier splitter owned it),
13185 // joining in push order deadlocks: splitter A's EOF
13186 // waits on splitter B's writer dup, which only closes
13187 // after B's thread exits — blocked behind A's join.
13188 let mut handles = Vec::with_capacity(scope.len());
13189 for (write_fd, handle) in scope {
13190 if write_fd >= 0 {
13191 unsafe {
13192 libc::close(write_fd);
13193 }
13194 }
13195 handles.push(handle);
13196 }
13197 for handle in handles {
13198 let _ = handle.join();
13199 }
13200 }
13201 // The scope that captured the pipeline-output marker is gone;
13202 // deeper-nested future scopes must not re-match its depth.
13203 if self.pipe_output_scope == Some(self.redirect_scope_stack.len()) {
13204 self.pipe_output_scope = None;
13205 }
13206 }
13207
13208 /// Set up `content` as stdin (fd 0) for the next command.
13209 /// Used by `Op::HereDoc(idx)` and `Op::HereString`.
13210 ///
13211 /// c:Src/exec.c:4655 getherestr — C writes the body to a TEMP
13212 /// FILE (gettempfile → write_loop → close → reopen O_RDONLY →
13213 /// unlink), NOT a pipe. The previous pipe+writer-thread shape
13214 /// SIGPIPE'd the whole shell when the consumer never read the
13215 /// body (`: <<< ${(F)x/y}` — D04parameter chunk 211, flaky
13216 /// rc=141): the redirect-scope teardown closed the read end
13217 /// while the detached thread was still in write_all, and the
13218 /// shell's SIGPIPE disposition is SIG_DFL. A temp file has no
13219 /// reader/writer coupling — matching C exactly, including
13220 /// lseek-ability of fd 0, which pipes don't give.
13221 pub fn host_set_pending_stdin(&mut self, content: String) {
13222 // c:4673 — `gettempfile(NULL, 1, &s)`.
13223 let mut tmp = std::env::temp_dir();
13224 tmp.push(format!(
13225 "zshrs-herestr-{}-{:x}",
13226 std::process::id(),
13227 std::time::SystemTime::now()
13228 .duration_since(std::time::UNIX_EPOCH)
13229 .map(|d| d.as_nanos())
13230 .unwrap_or(0)
13231 ));
13232 // c:4675 — `write_loop(fd, t, len); close(fd);`
13233 if std::fs::write(&tmp, content.as_bytes()).is_err() {
13234 return; // c:4674 — tempfile failure → no redirect
13235 }
13236 // c:Src/utils.c gettempfile → mkstemp creates the temp file mode
13237 // 0600 IGNORING the umask, so the O_RDONLY reopen below always
13238 // succeeds. `std::fs::write` honors the umask, so under `umask
13239 // 0777` the file landed mode 0000 and the reopen failed with
13240 // EACCES — `cat <<<x` then read empty stdin. Force 0600 to match
13241 // mkstemp's umask-independent permissions.
13242 let _ = std::fs::set_permissions(
13243 &tmp,
13244 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
13245 );
13246 // c:4677 — `fd = open(s, O_RDONLY | O_NOCTTY);`
13247 let file = match std::fs::File::open(&tmp) {
13248 Ok(f) => f,
13249 Err(_) => {
13250 let _ = std::fs::remove_file(&tmp);
13251 return;
13252 }
13253 };
13254 // c:4678 — `unlink(s);` — fd stays valid, name disappears.
13255 let _ = std::fs::remove_file(&tmp);
13256 let saved = unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD, 10) };
13257 if saved >= 0 {
13258 if let Some(top) = self.redirect_scope_stack.last_mut() {
13259 top.push((libc::STDIN_FILENO, saved));
13260 } else {
13261 unsafe { libc::close(saved) };
13262 }
13263 }
13264 let read_fd = AsRawFd::as_raw_fd(&file);
13265 unsafe { libc::dup2(read_fd, libc::STDIN_FILENO) };
13266 drop(file);
13267 }
13268
13269 /// Spawn an external command using zshrs's full dispatch logic
13270 /// (intercepts, command_hash, redirect handling). Used by
13271 /// `ZshrsHost::exec` so the bytecode VM's `Op::Exec` and
13272 /// `Op::CallFunction` external fallback get the same semantics as
13273 /// the tree-walker's `execute_external` rather than a plain
13274 /// `Command::new` shortcut. Returns the exit status.
13275 pub fn host_exec_external(&mut self, args: &[String]) -> i32 {
13276 // Native p10k API: the `p10k(){ zshrs-p10k-api "$@" }` stub's
13277 // body lands here (the name is neither function nor builtin).
13278 // Route into the engine instead of a PATH miss.
13279 if let Some(name) = args.first() {
13280 if let Some(status) = crate::p10k::maybe_intercept_command(name, &args[1..]) {
13281 self.set_last_status(status);
13282 return status;
13283 }
13284 }
13285 // If a glob expansion in this command's argv triggered the
13286 // nomatch error path, suppress the actual exec and return
13287 // status 1 — mirrors zsh's command-aborted-on-glob-error
13288 // behaviour. The flag is reset BEFORE returning so the next
13289 // command starts clean.
13290 //
13291 // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH sets
13292 // ERRFLAG_ERROR but C's execlist clears the bit per-sublist
13293 // so subsequent commands run. Symmetric with the builtin
13294 // dispatcher's clear at fusevm_bridge.rs:299 — clear it here
13295 // too at the external-command post-command-boundary.
13296 consume_tilde_globsubst_carrier();
13297 if self.current_command_glob_failed.get() {
13298 self.current_command_glob_failed.set(false);
13299 crate::ported::utils::errflag.fetch_and(
13300 !crate::ported::zsh_h::ERRFLAG_ERROR,
13301 std::sync::atomic::Ordering::Relaxed,
13302 );
13303 self.set_last_status(1);
13304 return 1;
13305 }
13306 // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the
13307 // NOMATCH gate above, same external-path semantics (skip
13308 // command, `no match`, clear ERRFLAG so the next sublist
13309 // runs).
13310 if consume_badcshglob() {
13311 crate::ported::utils::errflag.fetch_and(
13312 !crate::ported::zsh_h::ERRFLAG_ERROR,
13313 std::sync::atomic::Ordering::Relaxed,
13314 );
13315 self.set_last_status(1);
13316 return 1;
13317 }
13318 let Some((cmd, rest)) = args.split_first() else {
13319 return 0;
13320 };
13321 // Empty command name (e.g. result of an empty `$(false)`
13322 // command-sub being the only word) — zsh: no command runs,
13323 // exit status preserved from prior step. Was hitting the
13324 // "command not found: " path with empty name.
13325 if cmd.is_empty() && rest.is_empty() {
13326 return self.last_status();
13327 }
13328 let rest_vec: Vec<String> = rest.to_vec();
13329 // Update `$_` with the just-arriving argv so the next command
13330 // reads `_=<last_arg>`. Mirrors C zsh's writeback in
13331 // `execcmd_exec` (Src/exec.c). Per `args.last()` semantics,
13332 // when invoked as `cmd a b c`, `$_` becomes "c" — for a bare
13333 // command with no args, `$_` becomes the command name itself.
13334 crate::ported::params::set_zunderscore(args);
13335
13336 // Builtins not in fusevm's name→id table fall through to
13337 // host.exec. Catch them here before the OS-level exec attempts
13338 // to spawn a non-existent binary.
13339 match cmd.as_str() {
13340 "sched" => return dispatch_builtin("sched", rest_vec.clone()),
13341 "echotc" => return dispatch_builtin("echotc", rest_vec.clone()),
13342 "echoti" => return dispatch_builtin("echoti", rest_vec.clone()),
13343 "zpty" => return dispatch_builtin("zpty", rest_vec.clone()),
13344 "ztcp" => return dispatch_builtin("ztcp", rest_vec.clone()),
13345 "zsocket" => {
13346 // c:Src/Modules/socket.c:276 BUILTIN spec — BUILTINS["zsocket"]
13347 // optstr "ad:ltv" parsed by execbuiltin.
13348 return dispatch_builtin("zsocket", rest_vec.clone());
13349 }
13350 "private" => {
13351 // c:Src/Modules/param_private.c:217 — bin_private via
13352 // BUILTINS["private"]. The autoload require_module
13353 // (exec.c:2700-2717) fires inside
13354 // dispatch_builtin_raw, the chokepoint for all routes.
13355 return dispatch_builtin("private", rest_vec.clone());
13356 }
13357 "zformat" => return dispatch_builtin("zformat", rest_vec.clone()),
13358 "zregexparse" => return dispatch_builtin("zregexparse", rest_vec.clone()),
13359 // `unalias`/`unhash`/`unfunction` share `bin_unhash` but
13360 // each carries its own funcid (BIN_UNALIAS / BIN_UNHASH /
13361 // BIN_UNFUNCTION) — dispatch_builtin handles the BUILTINS
13362 // lookup + funcid propagation via execbuiltin.
13363 "unalias" | "unhash" | "unfunction" => {
13364 return dispatch_builtin(cmd.as_str(), rest_vec.clone());
13365 }
13366 // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
13367 // functions — implemented natively in Rust so `autoload -Uz zmv`
13368 // works without shipping the function source (and without the
13369 // fpath source hanging the parser). The `function_exists` guard
13370 // keeps them command-not-found until autoloaded, exactly like zsh;
13371 // an un-guarded arm ran them for bare `zmv`, diverging from
13372 // `zsh -f; zmv` → "command not found: zmv".
13373 "zmv" if self.function_exists("zmv") => {
13374 return crate::extensions::ext_builtins::zmv(&rest_vec, "mv")
13375 }
13376 "zcp" if self.function_exists("zcp") => {
13377 return crate::extensions::ext_builtins::zmv(&rest_vec, "cp")
13378 }
13379 "zln" if self.function_exists("zln") => {
13380 return crate::extensions::ext_builtins::zmv(&rest_vec, "ln")
13381 }
13382 "zcalc" if self.function_exists("zcalc") => {
13383 return crate::extensions::ext_builtins::zcalc(&rest_vec)
13384 }
13385 "zselect" => {
13386 // Route through canonical dispatch_builtin which goes
13387 // via execbuiltin → BUILTINS["zselect"] (zselect.c:272).
13388 return dispatch_builtin("zselect", rest_vec.clone());
13389 }
13390 "cap" => return dispatch_builtin("cap", rest_vec.clone()),
13391 "getcap" => return dispatch_builtin("getcap", rest_vec.clone()),
13392 "setcap" => return dispatch_builtin("setcap", rest_vec.clone()),
13393 "yes" => return self.builtin_yes(&rest_vec),
13394 "nl" => return self.builtin_nl(&rest_vec),
13395 "env" => return self.builtin_env(&rest_vec),
13396 "printenv" => return self.builtin_printenv(&rest_vec),
13397 "tty" => return self.builtin_tty(&rest_vec),
13398 // c:Src/Modules/files.c:806 — BUILTINS["chgrp"] with
13399 // BIN_CHGRP funcid + "hRs" optstr.
13400 "chgrp" => return dispatch_builtin("chgrp", rest_vec.clone()),
13401 "nproc" => return self.builtin_nproc(&rest_vec),
13402 "expr" => return self.builtin_expr(&rest_vec),
13403 "sha256sum" => return self.builtin_sha256sum(&rest_vec),
13404 "base64" => return self.builtin_base64(&rest_vec),
13405 "tac" => return self.builtin_tac(&rest_vec),
13406 "expand" => return self.builtin_expand(&rest_vec),
13407 "unexpand" => return self.builtin_unexpand(&rest_vec),
13408 "paste" => return self.builtin_paste(&rest_vec),
13409 "fold" => return self.builtin_fold(&rest_vec),
13410 "shuf" => return self.builtin_shuf(&rest_vec),
13411 "comm" => return self.builtin_comm(&rest_vec),
13412 "cksum" => return self.builtin_cksum(&rest_vec),
13413 "factor" => return self.builtin_factor(&rest_vec),
13414 "tsort" => return self.builtin_tsort(&rest_vec),
13415 "sum" => return self.builtin_sum(&rest_vec),
13416 "mkfifo" => return self.builtin_mkfifo(&rest_vec),
13417 "link" => return self.builtin_link(&rest_vec),
13418 "unlink" => return self.builtin_unlink(&rest_vec),
13419 "dircolors" => return self.builtin_dircolors(&rest_vec),
13420 "groups" => return self.builtin_groups(&rest_vec),
13421 "arch" => return self.builtin_arch(&rest_vec),
13422 "nice" => return self.builtin_nice(&rest_vec),
13423 "logname" => return self.builtin_logname(&rest_vec),
13424 "tput" => return self.builtin_tput(&rest_vec),
13425 "users" => return self.builtin_users(&rest_vec),
13426 // "sync" => return self.bin_sync(&rest_vec),
13427 "zbuild" => return self.builtin_zbuild(&rest_vec),
13428 // `zf_*` aliases from `zsh/files` (Src/Modules/files.c
13429 // BUILTIN table at line 816-824). The C source binds
13430 // both unprefixed (`chmod`) and prefixed (`zf_chmod`)
13431 // names to the SAME `bin_chmod` etc. handlers — the
13432 // prefixed forms exist so a script can portably reach
13433 // the builtin even when a function or alias has shadowed
13434 // the bare name. Each arm routes through the canonical
13435 // zf_* aliases route through canonical BUILTINS entries
13436 // (files.c:816-824) — execbuiltin parses each fn's optstr
13437 // automatically.
13438 "mkdir" | "zf_mkdir" | "zf_rm" | "zf_rmdir" | "zf_chmod" | "zf_chown" | "zf_chgrp"
13439 | "zf_ln" | "zf_mv" | "zf_sync"
13440 // `--zsh` parity gate: zsh -fc has zsh/files UNLOADED
13441 // — bare `mkdir` is /bin/mkdir (so `command mkdir -p`
13442 // honors the system flag set; zconvey.plugin.zsh:44
13443 // got "File exists" from the in-process bin_mkdir
13444 // that this arm intercepted) and `zf_*` names are
13445 // command-not-found 127 until `zmodload zsh/files`.
13446 // Fall through to the external/exec path in --zsh
13447 // mode; default zshrs mode keeps the anti-fork
13448 // intercept.
13449 if !crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) =>
13450 {
13451 return dispatch_builtin(cmd.as_str(), rest_vec.clone());
13452 }
13453 // `zstat` — port of zsh/stat module (Src/Modules/stat.c
13454 // BUILTIN("zstat", …)). Returns file metadata as
13455 // `field value` pairs / an assoc / a plus-separated
13456 // list depending on flags. zsh ALSO registers `stat`
13457 // bound to the same handler, but that name conflicts
13458 // with the system `stat(1)` binary (every script that
13459 // calls `stat -f '%Lp' …` would break). zsh resolves
13460 // this through opt-in `zmodload`; zshrs's modules are
13461 // statically linked so we keep `stat` routing to the
13462 // external command and only intercept the unambiguous
13463 // `zstat` name.
13464 "zstat" => {
13465 // Canonical bin_stat per stat.c:638 via BUILTINS["zstat"].
13466 return dispatch_builtin("zstat", rest_vec.clone());
13467 }
13468 _ => {}
13469 }
13470
13471 // AOP intercepts: when an `intercept :before/:around/:after foo` block
13472 // is registered, dynamic-command-name dispatch must consult it before
13473 // spawning. Without this, `cmd=ls; $cmd` bypasses every intercept that
13474 // a literal `ls` would trigger. The full_cmd string mirrors what the
13475 // tree-walker era passed (cmd + args joined by space) so existing
13476 // pattern matchers continue to work.
13477 if !self.intercepts.is_empty() {
13478 let full_cmd = if rest_vec.is_empty() {
13479 cmd.clone()
13480 } else {
13481 format!("{} {}", cmd, rest_vec.join(" "))
13482 };
13483 if let Some(intercept_result) = self.run_intercepts(cmd, &full_cmd, &rest_vec) {
13484 return intercept_result.unwrap_or(127);
13485 }
13486 }
13487
13488 // User-defined function lookup before OS-level exec. zsh's
13489 // dynamic-command-name dispatch (`cmd=hook1; $cmd`) checks
13490 // the function table FIRST — without this, `$f` for a
13491 // function-name `f` was always falling through to
13492 // `execute_external` and erroring "command not found".
13493 // Plugin code uses this pattern constantly:
13494 // for f in "${precmd_functions[@]}"; do "$f"; done
13495 if self.function_exists(cmd) {
13496 if let Some(status) = self.dispatch_function_call(cmd, &rest_vec) {
13497 return status;
13498 }
13499 }
13500
13501 self.execute_external(cmd, &rest_vec, &[]).unwrap_or(127)
13502 }
13503}