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 /// Stack of (RETFLAG, BREAKS, CONTFLAG, EXIT_PENDING) tuples saved
91 /// at try-block exit so the always-arm body can run cleanly even
92 /// when the try-block fired `return` / `break` / `continue` /
93 /// `exit`. Restored right before the post-always re-jump so the
94 /// escape resumes propagation past the construct.
95 /// c:Src/exec.c WC_TRYBLOCK — zsh's wordcode walker handles this
96 /// inline; the zshrs port lifts it into a paired SET / RESTORE
97 /// pair around the always-arm.
98 static TRY_ESCAPE_SAVE: RefCell<Vec<(i32, i32, i32, i32)>> =
99 const { RefCell::new(Vec::new()) };
100 /// Re-entry guard for BUILTIN_DEBUG_TRAP. While the DEBUG trap
101 /// body is running, the per-statement DEBUG_TRAP dispatch in the
102 /// trap body must NOT re-fire (otherwise infinite recursion +
103 /// stack overflow). zsh's in_trap counter at Src/signals.c
104 /// serves the same purpose.
105 static DEBUG_TRAP_REENTRY: Cell<bool> = const { Cell::new(false) };
106 /// Stack of (saved_stdout, saved_stderr) tuples pushed by
107 /// `cmd_subst` around its nested-VM run. RUST-ONLY: zsh forks
108 /// each cmdsub so trap output during the cmdsub naturally
109 /// lands on the PARENT's stdout. zshrs's in-process cmdsub
110 /// dups fd 1 → pipe, so a trap firing during cmdsub would
111 /// emit into the captured value. Traps consult this stack
112 /// to route their body output to the topmost saved_stdout
113 /// instead of the cmdsub's fd 1. Bug #56 in docs/BUGS.md.
114 pub static CMDSUBST_OUTER_FDS: RefCell<Vec<(i32, i32)>> =
115 const { RefCell::new(Vec::new()) };
116 /// c:Src/exec.c:5025 getproc (PATH_DEV_FD branch) — the parent
117 /// keeps the `>(cmd)` pipe WRITE end open under `/dev/fd/N`,
118 /// parks it in the job's filelist (`fdtable[fd] =
119 /// FDT_PROC_SUBST; addfilelist(NULL, fd)`), and deletefilelist
120 /// closes it when the consuming job finishes — that close is
121 /// what lets the `>(cmd)` child's reader see EOF. zshrs runs
122 /// commands in-process, so the equivalent is: record
123 /// `(scope_depth, fd)` here and drain after the consuming
124 /// command (external exec or builtin dispatch) completes.
125 static PSUB_PENDING_FDS: RefCell<Vec<(usize, i32)>> = const { RefCell::new(Vec::new()) };
126 /// Scope depth for PSUB_PENDING_FDS tagging. Incremented around
127 /// nested execution contexts (cmd-subst bodies, shell-function
128 /// bodies) so a command running INSIDE the nested context only
129 /// drains its own `>(cmd)` fds, never the enclosing command's
130 /// (e.g. `tee >(wc) $(print x)` — print must not close tee's
131 /// fd). Mirrors C's per-job filelist ownership.
132 static PSUB_SCOPE_DEPTH: Cell<usize> = const { Cell::new(0) };
133}
134
135/// RAII guard bumping the psub scope depth — see PSUB_SCOPE_DEPTH.
136pub(crate) struct PsubScope;
137
138impl PsubScope {
139 pub(crate) fn enter() -> Self {
140 PSUB_SCOPE_DEPTH.with(|d| d.set(d.get() + 1));
141 PsubScope
142 }
143}
144
145impl Drop for PsubScope {
146 fn drop(&mut self) {
147 PSUB_SCOPE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
148 }
149}
150
151/// RAII guard bumping `$ZSH_SUBSHELL` for the duration of an
152/// in-process command substitution.
153///
154/// c:Src/exec.c:1161 — entersubsh() does `zsh_subshell++;` and zsh's
155/// cmdsub FORKS, so the increment dies with the child and the parent's
156/// value is untouched. zshrs runs cmdsubs in-process on a nested VM,
157/// so the visible param must be bumped on entry and restored on exit.
158/// Writes paramtab u_val directly because ZSH_SUBSHELL is PM_READONLY
159/// (same bypass pattern as the subshell-builtin bump below); also
160/// mirrors into ported::exec::zsh_subshell so exec.c:4376-style
161/// `forked | zsh_subshell` reads agree.
162pub(crate) struct CmdSubstSubshellBump {
163 saved_val: i64,
164 saved_str: Option<String>,
165}
166
167impl CmdSubstSubshellBump {
168 pub(crate) fn enter() -> Self {
169 let mut saved_val = 0i64;
170 let mut saved_str = None;
171 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
172 if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
173 saved_val = pm.u_val;
174 saved_str = pm.u_str.clone();
175 pm.u_val = saved_val + 1;
176 pm.u_str = Some((saved_val + 1).to_string());
177 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
178 }
179 }
180 crate::ported::exec::zsh_subshell
181 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
182 CmdSubstSubshellBump { saved_val, saved_str }
183 }
184}
185
186impl Drop for CmdSubstSubshellBump {
187 fn drop(&mut self) {
188 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
189 if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
190 pm.u_val = self.saved_val;
191 pm.u_str = self.saved_str.take();
192 }
193 }
194 crate::ported::exec::zsh_subshell
195 .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
196 }
197}
198
199/// Port of deletefilelist() from Src/jobs.c (the `>(cmd)` fd arm):
200/// closes every pending proc-subst write end created at or inside
201/// the current scope depth, exactly when C deletes the consuming
202/// job's filelist (getproc parks the fd there via
203/// `addfilelist(NULL, fd)`, Src/exec.c:5025+).
204fn close_pending_psub_fds() {
205 let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
206 PSUB_PENDING_FDS.with(|v| {
207 v.borrow_mut().retain(|&(d, fd)| {
208 if d >= depth {
209 unsafe { libc::close(fd) };
210 false
211 } else {
212 true
213 }
214 });
215 });
216}
217
218/// RAII drain guard — instantiated at the top of the consuming-
219/// command paths (dispatch_builtin, ZshrsHost::exec) so the pending
220/// `>(cmd)` write ends close on every exit path once the command
221/// finished, exactly when C's job filelist would be deleted.
222struct PsubFdGuard;
223
224impl Drop for PsubFdGuard {
225 fn drop(&mut self) {
226 close_pending_psub_fds();
227 }
228}
229
230/// Peek the outermost cmdsub-saved (stdout, stderr) fds, if any.
231/// Returns None when no cmdsub is currently capturing. Used by the
232/// trap dispatcher in `src/ported/signals.rs::dotrap` to route trap
233/// body output to the parent's real stdout (matching zsh's forked
234/// cmdsub behaviour) instead of the cmdsub's pipe-bound fd 1.
235/// Bug #56 in docs/BUGS.md.
236pub fn cmdsubst_outer_stdout() -> Option<i32> {
237 CMDSUBST_OUTER_FDS.with(|s| s.borrow().last().map(|(o, _)| *o))
238}
239
240// Thread-local pointer to the current ShellExecutor.
241// Set before VM execution, cleared after. Used by builtin handlers.
242thread_local! {
243 static CURRENT_EXECUTOR: RefCell<Option<*mut ShellExecutor>> = const { RefCell::new(None) };
244 /// Set by subshell_end after a deferred subshell `exit N` lands.
245 /// Read + cleared by the next GET_VAR sync_status path so the
246 /// vm.last_status → LASTVAL sync doesn't clobber the deferred
247 /// exit status. RUST-ONLY: needed because zshrs runs subshells
248 /// in-process (no fork) so vm.last_status doesn't track the
249 /// subshell's exit; C zsh's subshell forks and the child's
250 /// process::exit(N) becomes $? in the parent automatically.
251 static SUBSHELL_EXIT_STATUS_PENDING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
252}
253
254/// RAII guard that sets/clears the thread-local executor pointer.
255///
256/// Idempotent: calling `enter` when a context is already active is a no-op
257/// for the entry side, and the guard's drop only clears the thread-local if
258/// *this* call was the one that set it. Nested `execute_command` invocations
259/// (e.g. from inside a builtin handler) reuse the outer pointer instead of
260/// stomping it.
261pub(crate) struct ExecutorContext {
262 we_set_it: bool,
263}
264
265impl ExecutorContext {
266 pub(crate) fn enter(executor: &mut ShellExecutor) -> Self {
267 let we_set_it = CURRENT_EXECUTOR.with(|cell| {
268 let mut slot = cell.borrow_mut();
269 if slot.is_some() {
270 false
271 } else {
272 *slot = Some(executor as *mut ShellExecutor);
273 true
274 }
275 });
276 ExecutorContext { we_set_it }
277 }
278}
279
280impl Drop for ExecutorContext {
281 fn drop(&mut self) {
282 if self.we_set_it {
283 CURRENT_EXECUTOR.with(|cell| {
284 *cell.borrow_mut() = None;
285 });
286 }
287 }
288}
289
290/// Access the current executor from a builtin handler.
291/// # Safety
292/// Only call this from within a VM execution context (after ExecutorContext::enter).
293#[inline]
294pub(crate) fn with_executor<F, R>(f: F) -> R
295where
296 F: FnOnce(&mut ShellExecutor) -> R,
297{
298 CURRENT_EXECUTOR.with(|cell| {
299 let ptr = cell
300 .borrow()
301 .expect("with_executor called outside VM context");
302 // SAFETY: The pointer is valid for the duration of VM execution,
303 // and we're single-threaded within the executor.
304 let executor = unsafe { &mut *ptr };
305 f(executor)
306 })
307}
308
309/// Non-panicking variant of [`with_executor`]: runs `f` against the
310/// current executor and returns `Some(result)`, or `None` when no
311/// executor is in scope (`CURRENT_EXECUTOR` unset — e.g. unit tests /
312/// compsys contexts with no fusevm bridge running).
313///
314/// This is the primitive the `crate::ported::exec` accessor wrappers
315/// (array/assoc/dispatch_function_call/execute_script/...) use to
316/// reach the live executor while preserving the exact "no executor →
317/// fall back to the direct param table / default value" semantics that
318/// the deleted `exec_hooks` OnceLock layer encoded via its
319/// "is-the-hook-installed?" check. `CURRENT_EXECUTOR` being set is the
320/// faithful equivalent of "the bridge installed the hooks".
321#[inline]
322pub(crate) fn try_with_executor<F, R>(f: F) -> Option<R>
323where
324 F: FnOnce(&mut ShellExecutor) -> R,
325{
326 CURRENT_EXECUTOR.with(|cell| {
327 let ptr = (*cell.borrow())?;
328 // SAFETY: same contract as with_executor — the pointer is valid
329 // for the duration of VM execution and access is single-threaded.
330 let executor = unsafe { &mut *ptr };
331 Some(f(executor))
332 })
333}
334
335/// Look up a canonical builtin by name in `BUILTINS` and dispatch
336/// via `execbuiltin` (Src/builtin.c:250). NO shadow check — calls the
337/// builtin even if a user function with the same name exists. Used by
338/// the `builtin foo` prefix opcode (which explicitly bypasses function
339/// lookup per zsh semantics) and by internal call sites where shadowing
340/// is unwanted. For zsh's normal name-resolution order (function shadows
341/// builtin), use `dispatch_builtin` instead.
342/// Shell-identifier prefix for diagnostic lines. Reads the canonical
343/// scriptname (`zsh` in `--zsh` parity mode, `zshrs` otherwise) so a
344/// single helper replaces hardcoded `"zshrs:"` literals across the
345/// file's eprintln paths.
346fn shname() -> String {
347 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string())
348}
349
350/// c:Src/subst.c:505-507 + Src/exec.c:3378-3380 — per-command
351/// CSH_NULL_GLOB outcome check. During this command's word expansion
352/// `expand_glob` accumulated `badcshglob |= 1` per failed glob and
353/// `|= 2` per successful one (Src/glob.c:1871-1875). Exactly 1 —
354/// failures and no successes — is the csh-style error: `no match`,
355/// command skipped, status 1. Any other value (0 = no globs, 2/3 =
356/// at least one matched) is silent. Always resets the counter for
357/// the next command (C resets at prefork entry, subst.rs:1307).
358/// Returns true when the error fired; callers mirror their
359/// glob_failed handling (builtins leave ERRFLAG_ERROR set so the
360/// script aborts, externals clear it so the next sublist runs —
361/// verified against zsh 5.9.1).
362/// Restore the user's GLOB_SUBST after a `${~spec}` carrier flip
363/// (see subst::TILDE_GLOBSUBST_CARRIER). Runs at the same
364/// command-dispatch boundaries that consume glob_failed /
365/// badcshglob — by then every glob op of the current word pipeline
366/// has read the carrier.
367pub(crate) fn consume_tilde_globsubst_carrier() {
368 crate::ported::subst::TILDE_GLOBSUBST_CARRIER.with(|c| {
369 if let Some(saved) = c.take() {
370 crate::ported::options::opt_state_set("globsubst", saved);
371 }
372 });
373}
374
375/// Pop `argc` stack slots for a whole-array assignment: the LAST popped
376/// (deepest pushed) is the param name, the rest are the values in stack
377/// order, with any `Value::Array` flattened to its elements. Mirrors the
378/// pop/flatten prologue of BUILTIN_SET_ARRAY / BUILTIN_APPEND_ARRAY.
379fn pop_array_args_with_name(vm: &mut fusevm::VM, argc: u8) -> (String, Vec<String>) {
380 let n = argc as usize;
381 let mut popped: Vec<Value> = Vec::with_capacity(n);
382 for _ in 0..n {
383 popped.push(vm.pop());
384 }
385 popped.reverse();
386 let name = popped.pop().map(|v| v.to_str()).unwrap_or_default();
387 let mut values: Vec<String> = Vec::new();
388 for v in popped {
389 match v {
390 Value::Array(items) => {
391 for it in items {
392 values.push(it.to_str());
393 }
394 }
395 other => values.push(other.to_str()),
396 }
397 }
398 (name, values)
399}
400
401fn consume_badcshglob() -> bool {
402 let v = crate::ported::glob::BADCSHGLOB.swap(0, std::sync::atomic::Ordering::Relaxed);
403 if v == 1 {
404 crate::ported::utils::zerr("no match"); // c:Src/subst.c:507
405 true
406 } else {
407 false
408 }
409}
410
411/// Map a builtin name to the zsh module that owns it, IFF zsh does
412/// not auto-load that builtin on first use. Used by
413/// `dispatch_builtin_raw` to gate `--zsh` mode dispatch behind
414/// `zmodload`, mirroring `zsh -fc <name>` returning 127 for these
415/// names without an explicit module load.
416///
417/// Returns `Some(module_name)` if `name` belongs to a non-auto-load
418/// module per the per-module `Src/Modules/<x>.c` `bintab[]` plus
419/// the auto-load flag set at module-build time. `None` for core
420/// builtins and for auto-loaded module builtins (sched, log, echotc,
421/// echoti, zformat, zparseopts, zregexparse, zstyle, strftime,
422/// private, vared, zle, bindkey, comp*) which work without zmodload.
423fn module_bound_builtin_module(name: &str) -> Option<&'static str> {
424 match name {
425 "zftp" => Some("zsh/zftp"),
426 "zsocket" => Some("zsh/net/socket"),
427 "ztcp" => Some("zsh/net/tcp"),
428 "zstat" => Some("zsh/stat"),
429 "zselect" => Some("zsh/zselect"),
430 "zpty" => Some("zsh/zpty"),
431 "zprof" => Some("zsh/zprof"),
432 "zsystem" | "syserror" => Some("zsh/system"),
433 "clone" => Some("zsh/clone"),
434 "zcurses" => Some("zsh/curses"),
435 "ztie" | "zuntie" | "zgdbmpath" => Some("zsh/db/gdbm"),
436 "pcre_compile" | "pcre_match" | "pcre_study" => Some("zsh/pcre"),
437 "example" => Some("zsh/example"),
438 "cap" | "getcap" | "setcap" => Some("zsh/cap"),
439 "zgetattr" | "zsetattr" | "zdelattr" | "zlistattr" => Some("zsh/attr"),
440 // c:Src/Modules/datetime.c — `strftime` is registered via
441 // partab[] when zsh/datetime loads. Verified by
442 // `zsh -fc 'strftime -s s %Y 0'` → 127 "command not found".
443 "strftime" => Some("zsh/datetime"),
444 _ => None,
445 }
446}
447
448pub(crate) fn dispatch_builtin_raw(name: &str, args: Vec<String>) -> i32 {
449 // c:Src/exec.c:2700-2717 — `private` is an autoloaded builtin in
450 // zsh (autofeature b:private of zsh/param/private): first use
451 // runs ensurefeature → require_module → load_module → boot_,
452 // marking the module MOD_INIT_B (what `zmodload -e` reads) and
453 // installing the wrap_private FuncWrap (param_private.c:712).
454 // doshfunc gates the wrapper dispatch on that load state, so
455 // this require_module is what activates private scoping. The
456 // raw dispatcher is the chokepoint every builtin route funnels
457 // through; require_module is idempotent after the first call
458 // (needs_load checks MOD_INIT_B).
459 if name == "private" {
460 if let Ok(mut tab) = crate::ported::module::MODULESTAB.lock() {
461 let _ = crate::ported::module::require_module(
462 &mut tab,
463 "zsh/param/private",
464 None,
465 0,
466 ); // c:2710 ensurefeature
467 }
468 }
469 // c:Src/Modules/param_private.c:682-685 setup_ — loading
470 // zsh/param/private REPLACES the `local` builtintab node's
471 // handlerfunc + optstr with bin_private's ("Even more horrible
472 // hack"), so once the module is loaded `local` IS bin_private: it
473 // accepts the -P/-Pa private-scope flags, and without -P delegates
474 // to bin_typeset, which already treats `local` and `private`
475 // identically (is_locallike, builtin.rs:3666). Replicate the swap by
476 // routing `local` through the `private` node only after the module
477 // is loaded — before then, `local -P` still errors "bad option: -P"
478 // exactly like stock zsh. The `private` node carries the augmented
479 // optstr (with P) that the `local` node lacks.
480 if name == "local"
481 && crate::ported::module::MODULESTAB
482 .lock()
483 .map(|t| t.is_bound("zsh/param/private"))
484 .unwrap_or(false)
485 {
486 return dispatch_builtin_raw("private", args);
487 }
488 // c:Bugs #475/#504/#555 — bash-only builtins (`mapfile`,
489 // `readarray`, `compopt`) should emit "command not found" in
490 // `--zsh` mode matching zsh's external-command-lookup miss.
491 // The per-opcode closures for caller/help/complete/compgen
492 // already gate via IS_ZSH_MODE at their registration sites;
493 // names without dedicated opcodes (compopt/mapfile/readarray)
494 // route through this generic builtintab lookup and need the
495 // gate here.
496 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
497 && matches!(name, "compopt" | "mapfile" | "readarray")
498 {
499 eprintln!("zsh:1: command not found: {}", name);
500 let _ = args;
501 return 127;
502 }
503 // c:Src/exec.c:2700-2724 resolvebuiltin — autoloaded-builtin stub
504 // (registered by `zmodload -ab MOD NAME`, Src/module.c:426
505 // add_autobin) fires on first use: ensurefeature loads the owning
506 // module, then dispatch proceeds against the real builtin. Must
507 // run BEFORE the module-bound 127 gate below — `zmodload -ab
508 // zsh/zselect zselect; zselect` previously died there with
509 // `command not found` because the gate only checked is_loaded,
510 // never the autoload ledger.
511 if let Some(rc) = crate::ported::module::resolvebuiltin(name) {
512 if rc != 0 {
513 // Load failed or feature undefined — diagnostics already
514 // printed (load_module zwarn / resolvebuiltin zerr).
515 // C's execbuiltin head returns 1 (Src/builtin.c:264-267).
516 return 1;
517 }
518 // Module loaded — fall through; the is_loaded gates below now
519 // pass and the normal dispatch chain runs the real builtin.
520 }
521 // c:Src/Modules/<mod>.c boot_/setup_ chain — module-bound builtins
522 // (zftp, zsocket, ztcp, zstat, etc.) are only registered into
523 // `builtintab` when their module is loaded via `zmodload`. In
524 // zsh `-fc` (the parity test harness's invocation), the modules
525 // are NOT pre-loaded, so each name reports "command not found"
526 // with exit 127. zshrs intentionally pre-loads all module bintabs
527 // in `createbuiltintable` (builtin.rs:131-152) for the default
528 // mode so users can call these without `zmodload`; that auto-load
529 // diverges from zsh's gate behavior. Match zsh's stance only when
530 // the user explicitly asked for parity via `--zsh`.
531 //
532 // The list is the union of builtins from modules that zsh does
533 // NOT auto-load (verified via `zsh -fc <name>` returning 127):
534 // zsh/zftp → zftp
535 // zsh/net/socket → zsocket
536 // zsh/net/tcp → ztcp
537 // zsh/stat → zstat (NOT `stat`; that name resolves to
538 // /bin/stat on PATH per zsh's setup)
539 // zsh/zselect → zselect
540 // zsh/zpty → zpty
541 // zsh/zprof → zprof
542 // zsh/system → zsystem, syserror
543 // zsh/clone → clone
544 // zsh/curses → zcurses
545 // zsh/db/gdbm → ztie, zuntie, zgdbmpath
546 // zsh/pcre → pcre_compile, pcre_match, pcre_study
547 // zsh/example → example
548 // zsh/cap → cap, getcap, setcap
549 // zsh/attr → zgetattr, zsetattr, zdelattr, zlistattr
550 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
551 && module_bound_builtin_module(name)
552 .map(|m| {
553 !crate::ported::module::MODULESTAB
554 .lock()
555 .map(|t| t.is_loaded(m))
556 .unwrap_or(false)
557 })
558 .unwrap_or(false)
559 {
560 eprintln!("zsh:1: command not found: {}", name);
561 let _ = args;
562 return 127;
563 }
564 // c:Src/Modules/files.c:806-824 — zsh/files registers `chmod`,
565 // `chown`, `chgrp`, `ln`, `mkdir`, `mv`, `rm`, `rmdir`, `sync`
566 // (plus their `zf_*` aliases) into builtintab on module load.
567 // Without an explicit `zmodload zsh/files`, zsh resolves the
568 // names through PATH lookup — `zsh -fc 'chmod +x f'` runs
569 // `/bin/chmod`, whose argv-parser accepts symbolic modes like
570 // `+x` that bin_chmod's octal-only parser rejects with
571 // "invalid mode `+x'". The shadow-aware wrapper at
572 // `dispatch_builtin` (line 438) already has this gate, but the
573 // direct `dispatch_builtin_raw` path used by fusevm's
574 // CallBuiltin opcode bypasses it. Mirror the gate here so the
575 // low-level dispatch matches C's PATH-fall-through behavior.
576 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
577 && module_gated_files_builtin(name)
578 && !crate::ported::module::MODULESTAB
579 .lock()
580 .map(|t| t.is_loaded("zsh/files"))
581 .unwrap_or(false)
582 {
583 // PATH lookup uses the LITERAL name: bare `mkdir` finds
584 // /bin/mkdir; a `zf_*` alias finds nothing and exits 127 —
585 // matching zsh -fc `zf_mkdir d` → "command not found:
586 // zf_mkdir" (the aliases exist ONLY in the loaded module's
587 // builtintab, Src/Modules/files.c:816-824; PATH has no
588 // /bin/zf_rm). The previous zf_-strip silently ran the
589 // system binary instead.
590 let status = with_executor(|exec| exec.execute_external(name, &args, &[]))
591 .unwrap_or(127);
592 return status;
593 }
594 // c:Src/Modules/stat.c:637-638 — zsh/stat registers BOTH `stat`
595 // and `zstat`. `zstat` is in the module_bound 127-gate above (no
596 // /usr/bin/zstat exists), but the bare `stat` name must FALL
597 // THROUGH to PATH when zsh/stat isn't loaded — zsh -fc
598 // 'stat -f %Lp f' runs /usr/bin/stat, while bin_stat's parser
599 // rejects stat(1) flags ("bad option: -c"). Same fall-through
600 // shape as the zsh/files gate above.
601 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
602 && name == "stat"
603 && !crate::ported::module::MODULESTAB
604 .lock()
605 .map(|t| t.is_loaded("zsh/stat"))
606 .unwrap_or(false)
607 {
608 let status =
609 with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
610 return status;
611 }
612 // c:Src/exec.c:3050-3068 — builtin lookup hits `builtintab` (the
613 // merged table containing module-provided builtins). The previous
614 // port walked only the core `BUILTINS` slice, so per-module
615 // entries like `log` (Src/Modules/watch.c:693 `BUILTIN("log", …,
616 // bin_log, …)`) were registered into builtintab via
617 // createbuiltintable but never reached at dispatch — `log` fell
618 // through to PATH and ran `/usr/bin/log`. Bug #72 in docs/BUGS.md.
619 let tab = crate::ported::builtin::createbuiltintable();
620 if let Some(bn_static) = tab.get(name) {
621 let bn_ptr = *bn_static as *const _ as *mut _;
622 return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
623 }
624 1
625}
626
627/// Shadow-aware dispatch matching zsh's name-resolution order:
628/// alias → reserved word → **function (shadows builtin)** → builtin →
629/// external. All `BUILTIN_X` opcode handlers route through here so a
630/// user-defined `cd () { … }` (or `r`, `fc`, `which`, … anything in
631/// fusevm's name→opcode map) takes precedence over the C builtin —
632/// matching `Src/exec.c:execcmd_exec`'s dispatch at c:3050-3068.
633/// Without this, compile-time builtin resolution silently ignored
634/// user wrappers (e.g. ZPWR's `cd () { builtin cd "$@"; … }`).
635/// True for builtins that are bound by zsh/files's boot_/setup_
636/// chain (Src/Modules/files.c:806-824). These are the bare-name
637/// `mkdir`/`rm`/`mv`/`ln`/`chmod`/`chown`/`chgrp`/`sync`/`rmdir`
638/// AND their `zf_*` aliases at c:816-824. Without explicit
639/// `zmodload zsh/files`, the names fall through to PATH lookup
640/// (zsh's `type rm` reports `/bin/rm`). Bug #28.
641fn module_gated_files_builtin(name: &str) -> bool {
642 matches!(
643 name,
644 "mkdir" | "rmdir" | "rm" | "mv" | "ln" | "chmod" | "chown" | "chgrp" | "sync"
645 | "zf_mkdir" | "zf_rmdir" | "zf_rm" | "zf_mv" | "zf_ln" | "zf_chmod"
646 | "zf_chown" | "zf_chgrp" | "zf_sync"
647 )
648}
649
650pub(crate) fn dispatch_builtin(name: &str, args: Vec<String>) -> i32 {
651 // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close any
652 // `>(cmd)` write ends owned by this command once it finishes
653 // (drops on every return path below).
654 let _psub_fds = PsubFdGuard;
655 // c:Src/exec.c — when any redirect in the current scope failed
656 // (e.g. noclobber blocked a `>` overwrite), zsh refuses to
657 // execute the command and exits with status 1. The Rust port
658 // still applied the command (writing to the /dev/null sink
659 // installed by host_apply_redirect's noclobber arm) so the
660 // success status overwrote the intended 1. Short-circuit here
661 // for builtins (the external-exec equivalent lives in
662 // ZshrsHost::exec).
663 let redir_failed = with_executor(|exec| {
664 let f = exec.redirect_failed;
665 exec.redirect_failed = false;
666 f
667 });
668 if redir_failed {
669 // c:Src/exec.c:4367-4386 — POSIX special-builtin escalation:
670 // a failed redirect on a PSPECIAL builtin (set, readonly,
671 // typeset, ...) under POSIX_BUILTINS is FATAL in a
672 // non-interactive shell (`exit(1)` at c:4383). The `command`
673 // prefix resets this (BINF_COMMAND, c:4369) — that path
674 // dispatches through bin_command, not here.
675 if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
676 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
677 && builtin_is_pspecial(name)
678 {
679 use std::sync::atomic::Ordering;
680 crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
681 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
682 }
683 return 1;
684 }
685 // c:Src/glob.c:1876-1880 NOMATCH path — when expand_glob() failed
686 // on a no-match glob, zsh aborts the simple command after zerr()
687 // printed "no matches found". In C, this works because zerr()
688 // sets ERRFLAG_ERROR (Src/utils.c) and execcmd_exec()
689 // (Src/exec.c:3050+) checks errflag before invoking the builtin
690 // table. Rust's builtin dispatch doesn't sit on the same errflag
691 // gate, so we explicitly consume the per-command glob-fail cell
692 // and short-circuit with status 1. Mirrors the external-path
693 // guard at host_exec_external (line 5167). Without this:
694 // `echo /never/*` would print empty (silently rolled back to ""
695 // by the empty glob expansion). Parity bug #13.
696 consume_tilde_globsubst_carrier();
697 let glob_failed = with_executor(|exec| {
698 let f = exec.current_command_glob_failed.get();
699 exec.current_command_glob_failed.set(false); // c:1879 cleanup
700 f
701 });
702 if glob_failed {
703 // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH zerr sets
704 // ERRFLAG_ERROR (via utils.c:184). For a BUILTIN command the
705 // expansion runs IN the shell process, so errflag stays set
706 // and the rest of the input aborts (zsh -fc 'echo /nope_*;
707 // echo after' prints nothing after the error — verified
708 // against zsh 5.9). The continue-after-nomatch behaviour
709 // belongs ONLY to externals: C forks BEFORE expansion there,
710 // so the child's zerr can't touch the parent's errflag (zsh
711 // -fc 'ls /nope_*; echo after' prints `after`) — that path's
712 // clear lives in fn exec / execute_external. Leave
713 // ERRFLAG_ERROR set here; BUILTIN_ERREXIT_CHECK trigger 4
714 // aborts the remaining script at the next command boundary.
715 return 1; // c:1880 — command aborted, status 1
716 }
717 // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the NOMATCH
718 // gate above: all of this command's globs failed silently (words
719 // dropped, badcshglob accumulated 1s and no 2s) → `no match`,
720 // skip the builtin, status 1. Like the NOMATCH path, ERRFLAG
721 // from zerr stays set for builtins so the rest of the script
722 // aborts (zsh -fc 'setopt cshnullglob; print *nope* x; print
723 // after' prints only the error — verified zsh 5.9.1).
724 if consume_badcshglob() {
725 // c:Src/exec.c:3380 — `lastval = 1;` so the shell's final
726 // exit status reflects the aborted command.
727 with_executor(|exec| exec.set_last_status(1));
728 return 1;
729 }
730 if let Some(status) = try_user_fn_override(name, &args) {
731 // c:Src/jobs.c:1748 waitonejob — canonical single-command
732 // pipestats update via the no-procs else-branch.
733 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
734 let mut synth = crate::ported::zsh_h::job::default();
735 crate::ported::jobs::waitonejob(&mut synth);
736 return status;
737 }
738 // c:Src/builtin.c:587 + Src/exec.c:3056 — a builtin disabled via
739 // `disable <name>` has its `DISABLED` flag set in `builtintab`;
740 // `builtintab->getnode` (the DISABLED-filtering accessor) returns
741 // NULL for it at lookup time, so execcmd_exec falls through to
742 // PATH lookup and runs the external. The Rust port stores the
743 // disabled set in `BUILTINS_DISABLED`; the previous dispatcher
744 // only checked the immutable `createbuiltintable` HashMap which
745 // never reflects disablement — so `disable echo; echo hi` kept
746 // running the bin_echo builtin. Bug #106 in docs/BUGS.md.
747 //
748 // dispatch_builtin (the high-level wrapper used by the BUILTIN_*
749 // opcode handlers and reg_passthru! callsites) is the correct
750 // gate: `dispatch_builtin_raw` is the low-level entry point
751 // used by `bin_builtin` itself which MUST bypass the disabled
752 // set (man zshbuiltins: `builtin name` runs the builtin
753 // regardless of disable state). Place the check here so the
754 // bypass path stays clean.
755 let disabled = crate::ported::builtin::BUILTINS_DISABLED
756 .lock()
757 .map(|s| s.contains(name))
758 .unwrap_or(false);
759 if disabled {
760 let status = with_executor(|exec| exec.execute_external(name, &args, &[]))
761 .unwrap_or(127);
762 crate::ported::builtin::LASTVAL
763 .store(status, std::sync::atomic::Ordering::Relaxed);
764 let mut synth = crate::ported::zsh_h::job::default();
765 crate::ported::jobs::waitonejob(&mut synth);
766 return status;
767 }
768 // c:Src/Modules/files.c:806-814 — `mkdir`, `rm`, `mv`, `ln`, `chmod`,
769 // `chown`, `chgrp`, `sync`, `rmdir` are bound by the `zsh/files`
770 // module's boot_/setup_ chain. Without explicit `zmodload zsh/files`,
771 // these bare names fall through to PATH (`/bin/rm`, `/usr/bin/chmod`,
772 // etc.) in zsh; `type rm` reports `rm is /bin/rm`. The `zf_*`
773 // aliases (`zf_rm`, `zf_chmod`, …) are bound by the same module
774 // and gated the same way. Bug #28 in docs/BUGS.md.
775 if module_gated_files_builtin(name) {
776 if !crate::ported::module::MODULESTAB.lock().unwrap().is_loaded("zsh/files") {
777 // PATH lookup uses the literal name. In --zsh parity mode
778 // `zf_rm` must 127 like zsh -fc (no /bin/zf_rm); default
779 // zshrs mode keeps the convenience zf_-strip so the alias
780 // still reaches the system binary.
781 let path_name = if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
782 {
783 name
784 } else {
785 name.strip_prefix("zf_").unwrap_or(name)
786 };
787 let status = with_executor(|exec| exec.execute_external(path_name, &args, &[]))
788 .unwrap_or(127);
789 crate::ported::builtin::LASTVAL
790 .store(status, std::sync::atomic::Ordering::Relaxed);
791 let mut synth = crate::ported::zsh_h::job::default();
792 crate::ported::jobs::waitonejob(&mut synth);
793 return status;
794 }
795 }
796 let status = dispatch_builtin_raw(name, args);
797 // c:Src/jobs.c:1748 waitonejob — canonical single-command pipestats update.
798 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
799 let mut synth = crate::ported::zsh_h::job::default();
800 crate::ported::jobs::waitonejob(&mut synth);
801 // c:Src/exec.c:4367-4386 — done: tail. A PSPECIAL builtin that
802 // raised errflag under POSIX_BUILTINS exits the non-interactive
803 // shell with status 1 ("hard error in POSIX" — e.g. bin_dot's
804 // zerrnam at Src/builtin.c:6133). Arm the deferred-exit pair so
805 // the next ERREXIT_CHECK unwinds; EXIT_VAL=1 matches C's
806 // hardcoded exit(1), NOT the builtin's own status (dot returns
807 // 127 but POSIX exits 1).
808 if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
809 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
810 && builtin_is_pspecial(name)
811 && (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
812 & crate::ported::zsh_h::ERRFLAG_ERROR)
813 != 0
814 {
815 use std::sync::atomic::Ordering;
816 crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
817 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
818 }
819 status
820}
821
822/// c:Src/zsh.h:1467 BINF_PSPECIAL — true when `name` is a POSIX
823/// special builtin per the canonical builtin table flags
824/// (Src/builtin.c:48-129: `.`, `:`, break, continue, declare, eval,
825/// exit, export, float, integer, local, readonly, return, set,
826/// shift, source, times, trap, typeset, unset).
827fn builtin_is_pspecial(name: &str) -> bool {
828 crate::ported::builtin::createbuiltintable()
829 .get(name)
830 .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_PSPECIAL) != 0)
831 .unwrap_or(false)
832}
833
834// The former `install_exec_hooks()` fn-pointer registry is gone. Code
835// under `src/ported/` now reaches `ShellExecutor` operations
836// (array/assoc storage, script eval, function dispatch, command
837// substitution) through the `crate::ported::exec::*` accessor wrappers,
838// which resolve the live executor via `try_with_executor`
839// (`CURRENT_EXECUTOR`). The bridge lives in exec.rs — the sanctioned
840// fusevm-access exception — per `feedback_no_exec_script_from_ported` /
841// `feedback_no_shellexecutor_in_ported`.
842
843/// Register all zsh builtins with the VM.
844pub(crate) fn register_builtins(vm: &mut fusevm::VM) {
845 // src/ported/ reaches the live executor (param store, function
846 // dispatch, nested script/cmdsubst exec) through the
847 // `crate::ported::exec::*` accessor wrappers, which read
848 // `CURRENT_EXECUTOR` via `try_with_executor`. No install step is
849 // needed: the executor is in scope for the duration of any VM run
850 // (set by `ExecutorContext::enter`), so the wrappers resolve it
851 // directly. (Replaces the former `exec_hooks` OnceLock fn-ptr
852 // registry, now deleted.)
853 // Engage fusevm's tiered JIT (block + tracing) so hot, fully-eligible
854 // numeric chunks run in native code and — with the `jit-disk-cache`
855 // feature (on by default) — persist that native code to
856 // `~/.cache/fusevm-jit`, letting repeated zsh invocations skip Cranelift
857 // codegen. fusevm gates the JIT on per-chunk eligibility and warms up by
858 // an invocation threshold, falling back to the interpreter for any chunk
859 // it cannot compile (e.g. host-builtin/`Extended` command dispatch), so
860 // enabling it here never changes observable behaviour — it only caches
861 // the numeric hot path. Idempotent: re-enabling on each VM is a no-op.
862 vm.enable_tracing_jit();
863 // Macro for builtins that user functions are allowed to shadow.
864 // zsh dispatch order is alias → function → builtin; without the
865 // try_user_fn_override probe a `cat() { ... }; cat` would silently
866 // run the C builtin and ignore the user function.
867 macro_rules! reg_overridable {
868 ($vm:expr, $id:expr, $name:literal, $method:ident) => {
869 $vm.register_builtin($id, |vm, argc| {
870 let args = pop_args(vm, argc);
871 // c:Src/exec.c getproc + Src/jobs.c deletefilelist —
872 // close `>(cmd)` write ends owned by this command
873 // once it finishes (shadows bypass dispatch_builtin
874 // and ZshrsHost::exec, so they need their own guard:
875 // `tee >(wc -c) </dev/null` left wc blocked).
876 let _psub_fds = PsubFdGuard;
877 if let Some(s) = try_user_fn_override($name, &args) {
878 return Value::Status(s);
879 }
880 // c:Src/exec.c — redirect failure in the current
881 // scope means the command must NOT run. coreutils
882 // shadows (cat / head / tail / etc.) take a separate
883 // dispatch path from dispatch_builtin, so they need
884 // their own gate. Without this `cat <&3` after a
885 // closed-fd diagnostic still ran the shadow and
886 // overwrote $? from the forced 1.
887 let redir_failed = with_executor(|exec| {
888 let f = exec.redirect_failed;
889 exec.redirect_failed = false;
890 f
891 });
892 if redir_failed {
893 return Value::Status(1);
894 }
895 // `[builtins].coreutils_shadows = off` in
896 // ~/.zshrs/zshrs.toml (or `ZSHRS_NO_COREUTILS_SHADOWS=1`
897 // env override) bypasses the in-process shadow and
898 // fork-execs the real /bin/X. Safety valve for any
899 // script that hits an edge-case divergence between
900 // the zshrs shadow and system coreutils. Cached
901 // after first call, so the hot path is one atomic
902 // load per shadowed-builtin invocation.
903 if !crate::daemon_presence::coreutils_shadows_enabled() {
904 return Value::Status(exec_system_command($name, &args));
905 }
906 let status = with_executor(|exec| exec.$method(&args));
907 Value::Status(status)
908 });
909 };
910 }
911
912 // Pure-passthru builtin: pops args, routes to canonical
913 // `dispatch_builtin(name, args)` (which goes via execbuiltin →
914 // BUILTINS[name] → bin_X). No pre/post bridge work. Used by
915 // ~25 handlers that were 4-line copy-paste boilerplate.
916 macro_rules! reg_passthru {
917 ($vm:expr, $id:expr, $name:literal) => {
918 $vm.register_builtin($id, |vm, argc| {
919 Value::Status(dispatch_builtin($name, pop_args(vm, argc)))
920 });
921 };
922 }
923
924 // Core builtins
925 vm.register_builtin(BUILTIN_CD, |vm, argc| {
926 let args = pop_args(vm, argc);
927 if let Some(s) = try_user_fn_override("cd", &args) {
928 return Value::Status(s);
929 }
930 let status = dispatch_builtin("cd", args);
931 // c:Src/builtin.c:1258 — `callhookfunc("chpwd", NULL, 1, NULL)`
932 // after cd succeeds. The canonical port at
933 // src/ported/utils.rs:1532 handles both the `chpwd` shfunc
934 // dispatch AND the `chpwd_functions` array walk.
935 if status == 0 {
936 crate::ported::utils::callhookfunc("chpwd", None, 1, std::ptr::null_mut());
937 }
938 Value::Status(status)
939 });
940
941 vm.register_builtin(BUILTIN_PWD, |vm, argc| {
942 let args = pop_args(vm, argc);
943 if let Some(s) = try_user_fn_override("pwd", &args) {
944 return Value::Status(s);
945 }
946 // Route through the canonical execbuiltin path so the `rLP`
947 // optstr at BUILTINS["pwd"] is parsed into `ops`.
948 let status = dispatch_builtin("pwd", args);
949 Value::Status(status)
950 });
951
952 vm.register_builtin(BUILTIN_ECHO, |vm, argc| {
953 let args = pop_args(vm, argc);
954 if let Some(s) = try_user_fn_override("echo", &args) {
955 return Value::Status(s);
956 }
957 // Update `$_` to the last arg before running. C zsh sets
958 // zunderscore in execcmd_exec for every simple command,
959 // including builtins.
960 crate::ported::params::set_zunderscore(&args);
961 let status = dispatch_builtin("echo", args);
962 Value::Status(status)
963 });
964
965 vm.register_builtin(BUILTIN_PRINT, |vm, argc| {
966 let args = pop_args(vm, argc);
967 if let Some(s) = try_user_fn_override("print", &args) {
968 return Value::Status(s);
969 }
970 crate::ported::params::set_zunderscore(&args);
971 let status = dispatch_builtin("print", args);
972 Value::Status(status)
973 });
974
975 reg_passthru!(vm, BUILTIN_PRINTF, "printf");
976 reg_passthru!(vm, BUILTIN_EXPORT, "export");
977 reg_passthru!(vm, BUILTIN_UNSET, "unset");
978 // `source` (Src/builtin.c c:116) wired to bin_dot via BUILTINS.
979 reg_passthru!(vm, BUILTIN_SOURCE, "source");
980 reg_passthru!(vm, BUILTIN_DOT, ".");
981 reg_passthru!(vm, BUILTIN_LOGOUT, "logout");
982
983 vm.register_builtin(BUILTIN_EXIT, |vm, argc| {
984 let args = pop_args(vm, argc);
985 let status = dispatch_builtin("exit", args);
986 Value::Status(status)
987 });
988
989 vm.register_builtin(BUILTIN_RETURN, |vm, argc| {
990 let args = pop_args(vm, argc);
991 // zsh: bare `return` (no arg) returns with the status of
992 // the most recently executed command — `false; return`
993 // returns 1, not 0. Direct port of zsh's bin_break/RETURN.
994 // The executor's `last_status` is stale here (synced at
995 // statement boundaries, not after each VM op), so read
996 // the live `vm.last_status` instead.
997 let live_status = vm.last_status;
998 let status = {
999 // Sync canonical LASTVAL to the VM's view BEFORE
1000 // bin_break("return") reads it for the no-arg fallback.
1001 with_executor(|exec| exec.set_last_status(live_status));
1002 dispatch_builtin("return", args)
1003 };
1004 Value::Status(status)
1005 });
1006
1007 vm.register_builtin(BUILTIN_TRUE, |vm, argc| {
1008 let args = pop_args(vm, argc);
1009 if let Some(s) = try_user_fn_override("true", &args) {
1010 return Value::Status(s);
1011 }
1012 // c:Src/exec.c:1257 — zsh sets `zunderscore` AT THE END of
1013 // each command (the `if (!noerrs)` block runs `zsfree(prev_argv0); …;
1014 // zunderscore = …`). For no-arg `true`, $_ becomes the
1015 // command name itself. Set DIRECTLY (not via pending_underscore)
1016 // so the NEXT command's argv-expansion of `$_` reads "true",
1017 // not the stale prior value — pending_underscore is consumed
1018 // by pop_args which runs AFTER argv expansion, too late.
1019 // c:Src/exec.c:1257 — `zunderscore = …` at end-of-command.
1020 // With args, $_ = args.last(). Without args, $_ = command name.
1021 // Write DIRECTLY to the canonical zunderscore static (the
1022 // underscoregetfn at params.rs:7003 reads from there); the
1023 // paramtab "_" slot is shadowed by lookup_special_var so
1024 // set_scalar on it has no effect on `$_` reads.
1025 if args.is_empty() {
1026 crate::ported::params::set_zunderscore(&["true".to_string()]);
1027 } else {
1028 crate::ported::params::set_zunderscore(&args);
1029 }
1030 // Route through canonical execbuiltin so PS4 xtrace fires
1031 // via the c:442 printprompt4 path.
1032 Value::Status(dispatch_builtin("true", args))
1033 });
1034 vm.register_builtin(BUILTIN_FALSE, |vm, argc| {
1035 let args = pop_args(vm, argc);
1036 if let Some(s) = try_user_fn_override("false", &args) {
1037 return Value::Status(s);
1038 }
1039 // Direct set; see BUILTIN_TRUE above for rationale.
1040 if args.is_empty() {
1041 crate::ported::params::set_zunderscore(&["false".to_string()]);
1042 } else {
1043 crate::ported::params::set_zunderscore(&args);
1044 }
1045 // Route through canonical execbuiltin — see BUILTIN_TRUE
1046 // above for the same rationale (xtrace + fast-path removal).
1047 let status = dispatch_builtin("false", args);
1048 Value::Status(status)
1049 });
1050 vm.register_builtin(BUILTIN_COLON, |vm, argc| {
1051 let args = pop_args(vm, argc);
1052 // Direct set; see BUILTIN_TRUE above for rationale.
1053 if args.is_empty() {
1054 crate::ported::params::set_zunderscore(&[":".to_string()]);
1055 } else {
1056 crate::ported::params::set_zunderscore(&args);
1057 }
1058 let status = dispatch_builtin(":", args);
1059 Value::Status(status)
1060 });
1061
1062 vm.register_builtin(BUILTIN_TEST, |vm, argc| {
1063 let args = pop_args(vm, argc);
1064 // Distinguish `[ … ]` from `test …` by sniffing the trailing
1065 // `]` — `[` requires it (c:Src/builtin.c:7241), `test` rejects
1066 // it. The compile path emits BUILTIN_TEST for both, so the
1067 // dispatch name carries the `[` vs `test` semantic for
1068 // execbuiltin's funcid (BIN_BRACKET=21 vs BIN_TEST=20). Without
1069 // this, bin_test's `if func == BIN_BRACKET` arm (which pops
1070 // the trailing `]`) never fired for `[` calls, so the `]`
1071 // leaked into evalcond as a positional and silently changed
1072 // the result. Bug surfaced via test_test_dashdash_unknown_condition.
1073 let name = if args.last().map(|s| s.as_str()) == Some("]") {
1074 "["
1075 } else {
1076 "test"
1077 };
1078 let status = dispatch_builtin(name, args);
1079 Value::Status(status)
1080 });
1081
1082 // Variable declaration. `local` (Src/builtin.c bin_local) handles
1083 // the scope chain (`pm->old = oldpm` at Src/params.c:1137 inside
1084 // createparam, `pm->level = locallevel` at Src/builtin.c:2576).
1085 // `typeset` / `declare` are aliases — fusevm maps both to
1086 // BUILTIN_TYPESET; compile_zsh special-cases `declare` to keep
1087 // the `declare:` error prefix.
1088 reg_passthru!(vm, BUILTIN_LOCAL, "local");
1089 reg_passthru!(vm, BUILTIN_TYPESET, "typeset");
1090
1091 reg_passthru!(vm, BUILTIN_DECLARE, "declare");
1092 reg_passthru!(vm, BUILTIN_READONLY, "readonly");
1093 reg_passthru!(vm, BUILTIN_INTEGER, "integer");
1094 reg_passthru!(vm, BUILTIN_FLOAT, "float");
1095 reg_passthru!(vm, BUILTIN_READ, "read");
1096 // c:Bug #504 — fusevm reserves BUILTIN_MAPFILE for the bash
1097 // mapfile/readarray builtins. Neither exists in zsh; in --zsh
1098 // parity mode the dispatch must emit "command not found" + rc=127
1099 // matching zsh's external-command-lookup miss. The previous wiring
1100 // left BUILTIN_MAPFILE unregistered, so fusevm's VM treated the op
1101 // as a no-op rc=0 — `mapfile` (and `readarray`) silently succeeded
1102 // in --zsh mode. The host gate in `dispatch_builtin_raw` never
1103 // fired because the compile path emitted `Op::CallBuiltin(31, ..)`
1104 // directly. Register the slot so the gate runs (or a future
1105 // non-zsh mode can wire in a real impl).
1106 vm.register_builtin(fusevm::shell_builtins::BUILTIN_MAPFILE, |vm, argc| {
1107 let args = pop_args(vm, argc);
1108 // The fusevm name→id map collapses both `mapfile` and
1109 // `readarray` to the same opcode; pick the right diagnostic
1110 // by sniffing the user's actual invocation. The xtrace ARGS
1111 // push earlier records the cmd-prefix as the bottom of the
1112 // popped argv, but `args` here excludes the prefix — so we
1113 // can't recover the user-typed name from the stack. Default
1114 // to `mapfile` (the more-common spelling); both produce
1115 // identical diagnostics in any case.
1116 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1117 eprintln!("zsh:1: command not found: mapfile");
1118 let _ = args;
1119 return Value::Status(127);
1120 }
1121 // Non-zsh modes (bash drop-in) get a passthru to the canonical
1122 // dispatcher in case a future port adds a real mapfile builtin
1123 // — until then the rc=1 unknown-command default applies.
1124 Value::Status(dispatch_builtin("mapfile", args))
1125 });
1126 reg_passthru!(vm, BUILTIN_BREAK, "break");
1127 reg_passthru!(vm, BUILTIN_CONTINUE, "continue");
1128 reg_passthru!(vm, BUILTIN_SHIFT, "shift");
1129
1130 vm.register_builtin(BUILTIN_EVAL, |vm, argc| {
1131 // Direct port of `bin_eval(UNUSED(char *nam), char **argv, UNUSED(Options ops), UNUSED(int func))` body from Src/builtin.c:6151:
1132 // `if (!*argv) return 0;`
1133 // `prog = parse_string(zjoin(argv, ' ', 1), 1);`
1134 // `execode(prog, 1, 0, "eval");`
1135 // The execode invocation lives here (not in the canonical
1136 // free-fn) because it must run through the bytecode VM's
1137 // current executor — the same VM that's mid-dispatch.
1138 let mut args = pop_args(vm, argc);
1139 // c:Src/builtin.c:407-411 — generic `--` end-of-options
1140 // strip applied by `execbuiltin` for builtins that have
1141 // NULL optstr AND no BINF_HANDLES_OPTS. `eval` qualifies
1142 // (Src/builtin.c:65 `BUILTIN("eval", BINF_PSPECIAL, ...,
1143 // NULL, NULL)`). The BUILTIN_EVAL fast-path bypasses
1144 // execbuiltin, so we mirror the strip inline. Bug #319.
1145 if args.first().is_some_and(|s| s == "--") {
1146 args.remove(0);
1147 }
1148 if args.is_empty() {
1149 return Value::Status(0); // c:6160
1150 }
1151 let src = args.join(" "); // c:6166
1152 // c:Src/builtin.c:6164-6165 — `if (!ineval) scriptname =
1153 // "(eval)";`. Diagnostics emitted while the eval body runs
1154 // (command-not-found, parse errors, etc.) use scriptname as
1155 // the source-context prefix. Without setting it here the
1156 // BUILTIN_EVAL fast-path leaked the outer "zsh" prefix
1157 // through, breaking the `(eval):N:` convention zsh uses
1158 // for in-eval errors. Bug #420.
1159 let oscriptname = crate::ported::utils::scriptname_get();
1160 crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
1161 let mut status = with_executor(|exec| {
1162 // c:6175 execode
1163 exec.execute_script(&src).unwrap_or(1)
1164 });
1165 // c:Src/builtin.c:6211-6212 — `if (errflag && !lastval)
1166 // lastval = errflag;`
1167 // c:Src/builtin.c:6221 — `errflag &= ~ERRFLAG_ERROR;`
1168 // eval is a CONTAINMENT boundary: an error inside the eval
1169 // body (readonly reassign, bad assoc set, ${unset?msg}, …)
1170 // breaks the eval body's lists via errflag, then eval clears
1171 // the flag and returns lastval, and the CALLER's next list
1172 // runs. zsh 5.9: `eval 'assoc=(odd)'; echo "after $?"`
1173 // prints `after 1` in -c, script, and stdin contexts.
1174 {
1175 use std::sync::atomic::Ordering;
1176 let ef = crate::ported::utils::errflag.load(Ordering::Relaxed)
1177 & crate::ported::zsh_h::ERRFLAG_ERROR;
1178 if ef != 0 && status == 0 {
1179 status = ef; // c:6212 lastval = errflag
1180 }
1181 crate::ported::utils::errflag
1182 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
1183 }
1184 crate::ported::utils::set_scriptname(oscriptname);
1185 Value::Status(status)
1186 });
1187
1188 // `builtin foo args…`: precmd-modifier that forces builtin dispatch,
1189 // bypassing alias AND function lookup. Without this, `builtin cd /`
1190 // inside a user `cd () { … }` wrapper recurses (real-world ZPWR pattern).
1191 // Handler pops argc args from the stack, treats args[0] as the builtin
1192 // name, and dispatches the rest via `dispatch_builtin` → `execbuiltin`
1193 // → `bin_*` directly. No function/alias lookup happens.
1194 vm.register_builtin(BUILTIN_BUILTIN, |vm, argc| {
1195 let args = pop_args(vm, argc);
1196 let Some((name, rest)) = args.split_first() else {
1197 // `builtin` with no args → list builtins (zsh emits nothing,
1198 // exit 0). Match that behavior; the BIN_BUILTIN bin_* in C
1199 // does the same default-list-nothing.
1200 return Value::Status(0);
1201 };
1202 // c:Src/exec.c:3435-3436 — `builtin NAME` with NAME not in
1203 // builtintab emits `zwarn("no such builtin: %s", cmdarg)`
1204 // and returns 1. zshrs's dispatch_builtin_raw bare-returned 1
1205 // silently. Probe the table here so the diagnostic fires
1206 // before dispatch.
1207 let tab = crate::ported::builtin::createbuiltintable();
1208 if !tab.contains_key(name.as_str()) {
1209 eprintln!("zshrs:1: no such builtin: {}", name);
1210 return Value::Status(1);
1211 }
1212 // `builtin foo` MUST bypass function shadow — that's the whole
1213 // point of the prefix. Use the _raw helper, not the shadow-aware
1214 // one. Without this, `cd () { builtin cd "$@"; }` recurses.
1215 Value::Status(dispatch_builtin_raw(name, rest.to_vec()))
1216 });
1217
1218 // `command foo args…` — BINF_COMMAND prefix (Src/builtin.c:44). Zsh
1219 // semantic: bypass alias+function lookup, search builtin then $PATH.
1220 // Without this, `cd () { command cd "$@" }` would re-invoke the user
1221 // wrapper (same root cause as the `builtin` bug). Flags `-p`/`-v`/`-V`
1222 // route to bin_whence with BIN_COMMAND funcid; bare `command foo`
1223 // dispatches builtin if present, else external (no fork — direct
1224 // spawn via execute_external since zshrs is non-forking).
1225 // BUILTIN_COMMAND — `command [-p] [-v|-V] cmd args…` BIN_PREFIX
1226 // (Src/builtin.c:45). PURE PASSTHRU: prepend "command" and hand
1227 // to `exec::execcmd_compile_head` (the fusevm-bytecode-time head
1228 // resolver mirroring `Src/exec.c::execcmd_exec` precommand-modifier
1229 // walk at c:3104-3187). That helper already does the -p / -v / -V
1230 // option parsing, surfaces `has_command_vv` for the whence
1231 // redirect, and reports the dispatch shape (is_builtin vs external).
1232 vm.register_builtin(BUILTIN_COMMAND, |vm, argc| {
1233 let args = pop_args(vm, argc);
1234 let mut full = Vec::with_capacity(args.len() + 1);
1235 full.push("command".to_string());
1236 full.extend(args.clone());
1237 let dispatch =
1238 crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
1239 let post = &full[dispatch.precmd_skip..];
1240 // c:Src/builtin.c:4500 — `command -p` resets PATH for the
1241 // exec to the POSIX-defined default (`getconf PATH`), so
1242 // standard utilities resolve even when the caller has
1243 // emptied $PATH. zsh restores the original PATH after the
1244 // command returns. Mirror via a scoped env::set_var.
1245 //
1246 // command's OWN options end at the first non-flag arg —
1247 // everything after the command name belongs to IT. The
1248 // previous `.any()` scan over ALL args stole `-p` from
1249 // `command mkdir -p DIR` (zconvey.plugin.zsh:44), stripping
1250 // the flag before /bin/mkdir ran → "File exists" errors on
1251 // every re-source.
1252 let mut lead = 0usize;
1253 let mut dash_p = false;
1254 let mut kept_flags: Vec<String> = Vec::new();
1255 for a in post.iter() {
1256 let s = a.as_str();
1257 if s == "--" {
1258 lead += 1;
1259 break;
1260 }
1261 if s.starts_with('-')
1262 && s.len() >= 2
1263 && s[1..].chars().all(|c| c == 'p' || c == 'v' || c == 'V')
1264 {
1265 if s.contains('p') {
1266 dash_p = true;
1267 }
1268 // -v / -V drive the whence-style lookup downstream —
1269 // keep them in post (only the PATH-reset `p` is
1270 // consumed here).
1271 let rest: String = s[1..].chars().filter(|c| *c != 'p').collect();
1272 if !rest.is_empty() {
1273 kept_flags.push(format!("-{}", rest));
1274 }
1275 lead += 1;
1276 continue;
1277 }
1278 break;
1279 }
1280 let mut post: Vec<String> = {
1281 let mut v = kept_flags;
1282 v.extend(post[lead..].iter().cloned());
1283 v
1284 };
1285 // c:Src/exec.c:3176-3177 — `BINF_COMMAND` arm strips a single
1286 // leading `--` end-of-options marker.
1287 // `execcmd_compile_head` (src/ported/exec.rs:1042) performs
1288 // this removal on its LOCAL `preargs` Vec but doesn't surface
1289 // the modified args; the caller still sees `--` in `full` and
1290 // tried to dispatch it as the command name. Bug #251. Mirror
1291 // the C strip here so `command -- echo hi` and
1292 // `command -p -- echo hi` route correctly.
1293 if let Some(first) = post.first() {
1294 if first == "--" {
1295 post.remove(0);
1296 }
1297 }
1298 let post = post.as_slice();
1299 let _path_guard = if dash_p {
1300 let saved = env::var("PATH").ok();
1301 let default_path = std::process::Command::new("getconf")
1302 .arg("PATH")
1303 .output()
1304 .ok()
1305 .and_then(|o| String::from_utf8(o.stdout).ok())
1306 .map(|s| s.trim().to_string())
1307 .filter(|s| !s.is_empty())
1308 .unwrap_or_else(|| "/usr/bin:/bin:/usr/sbin:/sbin".to_string());
1309 env::set_var("PATH", &default_path);
1310 crate::ported::params::setsparam("PATH", &default_path);
1311 Some(saved)
1312 } else {
1313 None
1314 };
1315 struct PathGuard {
1316 saved: Option<String>,
1317 active: bool,
1318 }
1319 impl Drop for PathGuard {
1320 fn drop(&mut self) {
1321 if !self.active {
1322 return;
1323 }
1324 match self.saved.take() {
1325 Some(p) => {
1326 env::set_var("PATH", &p);
1327 crate::ported::params::setsparam("PATH", &p);
1328 }
1329 None => {
1330 env::remove_var("PATH");
1331 crate::ported::params::setsparam("PATH", "");
1332 }
1333 }
1334 }
1335 }
1336 let _restore = PathGuard {
1337 saved: _path_guard.unwrap_or(None),
1338 active: dash_p,
1339 };
1340 if dispatch.has_command_vv {
1341 // `-v` / `-V` → bin_whence with BIN_COMMAND funcid.
1342 let mut ops = options {
1343 ind: [0u8; MAX_OPS],
1344 args: Vec::new(),
1345 argscount: 0,
1346 argsalloc: 0,
1347 };
1348 let mut name_pos = 0usize;
1349 let mut flag_byte = b'v';
1350 for (i, a) in post.iter().enumerate() {
1351 if a.starts_with('-') && a.len() >= 2 {
1352 let body = &a.as_bytes()[1..];
1353 if body.contains(&b'V') {
1354 flag_byte = b'V';
1355 }
1356 name_pos = i + 1;
1357 } else {
1358 name_pos = i;
1359 break;
1360 }
1361 }
1362 ops.ind[flag_byte as usize] = 1;
1363 let whence_args: Vec<String> = post[name_pos..].to_vec();
1364 return Value::Status(crate::ported::builtin::bin_whence(
1365 "command",
1366 &whence_args,
1367 &ops,
1368 crate::ported::hashtable_h::BIN_COMMAND,
1369 ));
1370 }
1371 if dispatch.is_empty_command {
1372 return Value::Status(0);
1373 }
1374 let Some((name, rest)) = post.split_first() else {
1375 return Value::Status(0);
1376 };
1377 // c:Src/exec.c:3275-3278 — `execcmd_compile_head` cleared
1378 // hn for the BINF_COMMAND + !POSIXBUILTINS case, surfacing
1379 // is_builtin=false. Run as external. Under POSIXBUILTINS
1380 // dispatch.is_builtin would be true; honour it.
1381 let n = name.clone();
1382 let r = rest.to_vec();
1383 if dispatch.is_builtin
1384 && crate::ported::builtin::BUILTINS
1385 .iter()
1386 .any(|b| b.node.nam == n.as_str())
1387 {
1388 return Value::Status(dispatch_builtin_raw(&n, r));
1389 }
1390 Value::Status(with_executor(|exec| exec.execute_external(&n, &r, &[])).unwrap_or(127))
1391 });
1392
1393 // `exec cmd args…` — BINF_EXEC prefix (Src/builtin.c:45). Zsh
1394 // semantic: replace the current shell process with `cmd`. On Unix
1395 // this is `execvp(2)`; the call only returns on error. zshrs is
1396 // non-forking, so the shell process IS the calling process —
1397 // execvp here directly replaces it. Options `-a name` (override
1398 // argv[0]), `-c` (clean env), `-l` (login shell — prepend `-`)
1399 // ported minimally; advanced redirect-only `exec >file` is handled
1400 // upstream by compile_zsh and never reaches this handler.
1401 vm.register_builtin(BUILTIN_EXEC, |vm, argc| {
1402 let mut args = pop_args(vm, argc);
1403 let mut argv0_override: Option<String> = None;
1404 let mut clean_env = false;
1405 let mut login = false;
1406 let mut i = 0;
1407 // c:Src/builtin.c:1075-1080 — track if any flag was consumed.
1408 // `exec -c`, `exec -l`, `exec -a NAME` without a following
1409 // command emit "exec requires a command to execute" rc=1.
1410 // Bare `exec` (no args at all) is the silent-redirect-apply
1411 // form per POSIX.
1412 let mut saw_flag = false;
1413 while i < args.len() {
1414 let a = &args[i];
1415 if a == "--" {
1416 args.remove(i);
1417 break;
1418 }
1419 // c:Src/builtin.c:42 `BIN_PREFIX("-", BINF_DASH)`. A bare
1420 // `-` is its own BINF_PREFIX builtin (BINF_DASH flag —
1421 // "login shell, prepend `-` to argv[0]"). In the canonical
1422 // precmd-walk at Src/exec.c:3056-3091 a bare `-` after
1423 // `exec` is recognized AS a builtin and stripped from
1424 // preargs (precmd_skip++), accumulating BINF_DASH into
1425 // cflags. The fast-path here bypasses execcmd_compile_head,
1426 // so we mirror the strip locally: bare `-` → set login,
1427 // remove, continue. Without this `exec -` (with no command
1428 // following) tried to exec `-` as a literal command and
1429 // exited the shell. Bug #252.
1430 if a == "-" {
1431 saw_flag = true;
1432 login = true;
1433 args.remove(i);
1434 continue;
1435 }
1436 if !a.starts_with('-') || a.len() < 2 {
1437 break;
1438 }
1439 match a.as_str() {
1440 "-a" => {
1441 saw_flag = true;
1442 args.remove(i);
1443 if i < args.len() {
1444 argv0_override = Some(args.remove(i));
1445 }
1446 }
1447 "-c" => {
1448 saw_flag = true;
1449 clean_env = true;
1450 args.remove(i);
1451 }
1452 "-l" => {
1453 saw_flag = true;
1454 login = true;
1455 args.remove(i);
1456 }
1457 _ => {
1458 // c:Src/exec.c:3196-3208 — when an unrecognized
1459 // `-X`-style arg has NO following arg, the lexer's
1460 // IS_DASH walk hits the "no next node" branch at
1461 // c:3199 before the unknown-flag-letter switch at
1462 // c:3249, so the canonical message is "exec
1463 // requires a command to execute" rc=1 (verified vs
1464 // `/opt/homebrew/bin/zsh -fc 'exec --bad'`).
1465 // Consume the lone flag so the post-loop check
1466 // fires. When a following arg exists, leave the
1467 // unknown-flag arg in place — that arg becomes
1468 // the command name and execution proceeds.
1469 if args.len() == 1 {
1470 saw_flag = true;
1471 args.remove(i);
1472 continue;
1473 }
1474 break;
1475 }
1476 }
1477 }
1478 let Some(cmd) = args.first().cloned() else {
1479 if saw_flag {
1480 // c:Src/builtin.c:1078-1080 — flags consumed but no
1481 // command follows → "exec requires a command to
1482 // execute" rc=1.
1483 eprintln!("zshrs:1: exec requires a command to execute");
1484 return Value::Status(1);
1485 }
1486 // `exec` with no command + no redirects = no-op success.
1487 return Value::Status(0);
1488 };
1489 let rest: Vec<String> = args[1..].to_vec();
1490 let display_argv0 = match argv0_override {
1491 Some(a) => a,
1492 None => {
1493 if login {
1494 format!("-{}", cmd)
1495 } else {
1496 cmd.clone()
1497 }
1498 }
1499 };
1500 // c:Src/exec.c::execcmd — `exec funcname` runs the function
1501 // in-process as the shell's last act, then exits with the
1502 // function's status. zsh's dispatcher falls through from the
1503 // BINF_EXEC prefix into the normal Builtin/External/Function
1504 // resolution and only execvp's if the target ISN'T a
1505 // function. Bug #101 in docs/BUGS.md: zshrs's exec went
1506 // straight to execvp and errored `not found` for shell
1507 // functions.
1508 //
1509 // For both subshell and top-level contexts: dispatch through
1510 // the function/builtin lookup first; only fall through to
1511 // execvp/spawn if the name isn't shell-resolvable.
1512 let has_user_fn = with_executor(|exec| exec.functions_compiled.contains_key(&cmd));
1513 if has_user_fn {
1514 let status = with_executor(|exec| {
1515 exec.dispatch_function_call(&cmd, &rest).unwrap_or(127)
1516 });
1517 // Top-level `exec funcname` — exit the shell with the
1518 // function's status (mirrors C's "exec replaces shell as
1519 // last act"). Subshell `(exec funcname)` — return through
1520 // the EXIT_PENDING path so the subshell body aborts and
1521 // the parent resumes via subshell_end.
1522 let in_subshell_now =
1523 with_executor(|exec| !exec.subshell_snapshots.is_empty());
1524 if in_subshell_now {
1525 crate::ported::builtin::EXIT_VAL
1526 .store(status, std::sync::atomic::Ordering::Relaxed);
1527 crate::ported::builtin::EXIT_PENDING
1528 .store(1, std::sync::atomic::Ordering::Relaxed);
1529 return Value::Status(status);
1530 }
1531 std::process::exit(status);
1532 }
1533 // c:Src/exec.c — builtin path: `exec builtin` runs the
1534 // builtin in-process and exits.
1535 let bn_in_tab = crate::ported::builtin::createbuiltintable().contains_key(&cmd);
1536 if bn_in_tab {
1537 let status = dispatch_builtin_raw(&cmd, rest.clone());
1538 let in_subshell_now =
1539 with_executor(|exec| !exec.subshell_snapshots.is_empty());
1540 if in_subshell_now {
1541 crate::ported::builtin::EXIT_VAL
1542 .store(status, std::sync::atomic::Ordering::Relaxed);
1543 crate::ported::builtin::EXIT_PENDING
1544 .store(1, std::sync::atomic::Ordering::Relaxed);
1545 return Value::Status(status);
1546 }
1547 std::process::exit(status);
1548 }
1549 // c:Src/exec.c — `exec` inside a subshell (`(exec cmd)`)
1550 // replaces ONLY the subshell child process; the parent shell
1551 // continues. C zsh always forks for `(...)`, so the actual
1552 // execvp lands in the forked child. zshrs runs subshells via
1553 // a snapshot/restore pattern in the SAME process — calling
1554 // execvp here would replace the parent too. Bug #94 in
1555 // docs/BUGS.md.
1556 //
1557 // Detect subshell context via the non-empty
1558 // `subshell_snapshots` stack. When in a subshell: spawn the
1559 // command as a child, wait for it, then signal the subshell
1560 // body to abort (return Status(N) and the caller's
1561 // subshell_end will pop the snapshot and resume the parent).
1562 let in_subshell = with_executor(|exec| !exec.subshell_snapshots.is_empty());
1563 if in_subshell {
1564 let mut command = std::process::Command::new(&cmd);
1565 command.arg0(&display_argv0);
1566 command.args(&rest);
1567 if clean_env {
1568 command.env_clear();
1569 }
1570 let status = match command.spawn() {
1571 Ok(mut child) => match child.wait() {
1572 Ok(s) => s.code().unwrap_or(127),
1573 Err(_) => 127,
1574 },
1575 Err(e) => {
1576 // c:Src/exec.c:797 — `zerr("%e: %s", lerrno, arg0)`
1577 // when arg0 contains `/`.
1578 // c:872-876 — when arg0 has no `/` (PATH search
1579 // path), C tracks the "good" errno
1580 // via `isgooderr`; if all PATH entries
1581 // were ENOENT-not-good, eno stays 0
1582 // and C emits `command not found: %s`
1583 // instead of strerror.
1584 // %e expands to strerror(errno) with the first
1585 // letter lowercased (unless errno == EIO; see
1586 // Src/utils.c:362-368). `zerr` prepends the
1587 // scriptname:lineno: prefix — matching zsh's
1588 // canonical `zsh:N: <errmsg>: <cmd>` pattern.
1589 // Previously emitted `zshrs: exec: {}: not found`
1590 // (wrong prefix, hardcoded message, missing
1591 // lineno). Bug #140 in docs/BUGS.md.
1592 let errno = e.raw_os_error().unwrap_or(libc::ENOENT);
1593 let has_slash = cmd.contains('/');
1594 if !has_slash && errno == libc::ENOENT {
1595 // c:876 — PATH search exhausted with no good
1596 // errno → `command not found: arg0`.
1597 crate::ported::utils::zerr(&format!(
1598 "command not found: {}",
1599 cmd
1600 ));
1601 } else {
1602 let mut errmsg = crate::ported::compat::strerror(errno);
1603 if errno != libc::EIO {
1604 if let Some(c) = errmsg.chars().next() {
1605 errmsg = format!(
1606 "{}{}",
1607 c.to_ascii_lowercase(),
1608 &errmsg[c.len_utf8()..]
1609 );
1610 }
1611 }
1612 crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
1613 }
1614 // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
1615 if errno == libc::EACCES || errno == libc::ENOEXEC {
1616 126
1617 } else {
1618 127
1619 }
1620 }
1621 };
1622 // Mark the subshell as exec-replaced so subsequent body
1623 // commands skip — mirrors the post-execvp "child process
1624 // is gone" reality in C. EXIT_PENDING + EXIT_VAL drive
1625 // the next ERREXIT_CHECK to unwind to the subshell-end
1626 // patch.
1627 crate::ported::builtin::EXIT_VAL
1628 .store(status, std::sync::atomic::Ordering::Relaxed);
1629 crate::ported::builtin::EXIT_PENDING
1630 .store(1, std::sync::atomic::Ordering::Relaxed);
1631 return Value::Status(status);
1632 }
1633 let mut command = std::process::Command::new(&cmd);
1634 command.arg0(&display_argv0);
1635 command.args(&rest);
1636 if clean_env {
1637 command.env_clear();
1638 }
1639 use std::os::unix::process::CommandExt;
1640 // `exec` returns the OS error iff exec(2) failed; on success
1641 // it never returns. Match zsh: print the error to stderr with
1642 // the `exec` prefix and exit 127 (cmd not found) or 126 (not
1643 // executable).
1644 let err = command.exec();
1645 // c:Src/exec.c:797 / c:872-876 — same format as in-subshell
1646 // branch. arg0-has-/ → `<strerror>: <cmd>`; arg0-no-/ +
1647 // ENOENT → `command not found: <cmd>`. Lowercase strerror
1648 // first letter unless EIO. Bug #140 in docs/BUGS.md.
1649 let errno = err.raw_os_error().unwrap_or(libc::ENOENT);
1650 let has_slash = cmd.contains('/');
1651 if !has_slash && errno == libc::ENOENT {
1652 crate::ported::utils::zerr(&format!("command not found: {}", cmd));
1653 } else {
1654 let mut errmsg = crate::ported::compat::strerror(errno);
1655 if errno != libc::EIO {
1656 if let Some(c) = errmsg.chars().next() {
1657 errmsg = format!(
1658 "{}{}",
1659 c.to_ascii_lowercase(),
1660 &errmsg[c.len_utf8()..]
1661 );
1662 }
1663 }
1664 crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
1665 }
1666 // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
1667 let code = if errno == libc::EACCES || errno == libc::ENOEXEC {
1668 126
1669 } else {
1670 127
1671 };
1672 std::process::exit(code);
1673 });
1674
1675 reg_passthru!(vm, BUILTIN_LET, "let");
1676
1677 // Job control
1678 reg_passthru!(vm, BUILTIN_JOBS, "jobs");
1679 reg_passthru!(vm, BUILTIN_FG, "fg");
1680 reg_passthru!(vm, BUILTIN_BG, "bg");
1681 reg_passthru!(vm, BUILTIN_KILL, "kill");
1682 reg_passthru!(vm, BUILTIN_DISOWN, "disown");
1683 reg_passthru!(vm, BUILTIN_WAIT, "wait");
1684 reg_passthru!(vm, BUILTIN_SUSPEND, "suspend");
1685
1686 // History — `fc` / `history` / `r` all route to `bin_fc` (zsh
1687 // registers them as aliases of the same builtin per Src/builtin.c).
1688 reg_passthru!(vm, BUILTIN_FC, "fc");
1689 reg_passthru!(vm, BUILTIN_HISTORY, "history");
1690 reg_passthru!(vm, BUILTIN_R, "r");
1691
1692 // Aliases — alias is `BINF_MAGICEQUALS` per Src/builtin.c:50.
1693 // c:Src/exec.c:3298-3304 — when a builtin has BINF_MAGICEQUALS,
1694 // execcmd_exec sets esprefork = PREFORK_TYPESET and calls
1695 // `prefork(args, esprefork, NULL)` on the argv. prefork (subst.c:
1696 // 100) drives `filesub` on each word (c:133), which (c:677-686)
1697 // looks for the assignment Equals and runs `filesubstr` on the
1698 // VALUE side. That's how `alias bad===` triggers equalsubstr's
1699 // "= not found" via the inner Equals after the first `=`.
1700 //
1701 // The fusevm dispatch path doesn't go through execcmd_exec, so
1702 // BUILTIN_ALIAS previously passed args straight to bin_alias with
1703 // no expansion — `alias x=~/foo` stored literal `~/foo` (no tilde
1704 // expand), `alias bad===` stored a broken entry without firing
1705 // the "= not found" diagnostic. The prefork(PREFORK_TYPESET) runs
1706 // per arg word via BUILTIN_MAGIC_EQUALS_PREFORK ops that
1707 // compile_simple emits BEFORE the redirect scope opens (matching
1708 // c:3304 prefork-before-addfd order), so the dispatch here is a
1709 // plain passthrough — re-running prefork would double-fire the
1710 // "= not found" diagnostic.
1711 reg_passthru!(vm, BUILTIN_ALIAS, "alias");
1712 // c:Src/exec.c:3298-3304 — per-word magic-equals prefork; see the
1713 // const doc at BUILTIN_MAGIC_EQUALS_PREFORK. prefork's filesub
1714 // trigger (subst.c:678 `strchr(*namptr+1, Equals)`) looks for the
1715 // EQUALS TOKEN, not literal `=`. The fusevm path delivers args
1716 // already-untokenized, so re-tokenize each element via
1717 // `shtokenize` (the same call C's lexer makes implicitly when
1718 // assembling the word) so prefork sees Equals tokens at `=`
1719 // boundaries and Tilde tokens at `~` starts. After prefork
1720 // expands, untokenize for storage.
1721 vm.register_builtin(BUILTIN_MAGIC_EQUALS_PREFORK, |vm, _argc| {
1722 let raw = vm.pop();
1723 let inputs: Vec<String> = match raw {
1724 Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
1725 other => vec![other.to_str()],
1726 };
1727 let mut as_linklist: crate::ported::linklist::LinkList<String> =
1728 Default::default();
1729 for s in &inputs {
1730 let mut tokd = s.clone();
1731 crate::ported::glob::shtokenize(&mut tokd);
1732 as_linklist.push_back(tokd);
1733 }
1734 let mut rf = 0i32;
1735 crate::ported::subst::prefork(
1736 &mut as_linklist,
1737 crate::ported::zsh_h::PREFORK_TYPESET,
1738 &mut rf,
1739 );
1740 let mut expanded: Vec<String> = Vec::with_capacity(inputs.len());
1741 while let Some(s) = as_linklist.pop_front() {
1742 expanded.push(crate::ported::lex::untokenize(&s).to_string());
1743 }
1744 if expanded.len() == 1 {
1745 Value::str(expanded.into_iter().next().unwrap())
1746 } else {
1747 Value::Array(expanded.into_iter().map(Value::str).collect())
1748 }
1749 });
1750
1751 // Options. `setopt` (BIN_SETOPT=0) / `unsetopt` (BIN_UNSETOPT=1)
1752 // share bin_setopt (options.c:580) — funcid bit discriminates
1753 // the polarity via BUILTINS table entries.
1754 reg_passthru!(vm, BUILTIN_SET, "set");
1755 reg_passthru!(vm, BUILTIN_SETOPT, "setopt");
1756 reg_passthru!(vm, BUILTIN_UNSETOPT, "unsetopt");
1757
1758 vm.register_builtin(BUILTIN_SHOPT, |vm, argc| {
1759 let args = pop_args(vm, argc);
1760 let status = crate::extensions::ext_builtins::shopt(&args);
1761 Value::Status(status)
1762 });
1763
1764 reg_passthru!(vm, BUILTIN_EMULATE, "emulate");
1765 reg_passthru!(vm, BUILTIN_GETOPTS, "getopts");
1766 reg_passthru!(vm, BUILTIN_AUTOLOAD, "autoload");
1767 reg_passthru!(vm, BUILTIN_FUNCTIONS, "functions");
1768 reg_passthru!(vm, BUILTIN_TRAP, "trap");
1769 reg_passthru!(vm, BUILTIN_DIRS, "dirs");
1770 // pushd / popd dispatch through canonical bin_cd via execbuiltin
1771 // — the BUILTINS table at src/ported/builtin.rs:9298 wires
1772 // `pushd` to bin_cd with funcid=BIN_PUSHD, and `popd` similarly
1773 // with BIN_POPD. Without these reg_passthru lines the fusevm
1774 // BUILTIN_PUSHD/POPD opcodes had no handler installed, so the
1775 // emitted CallBuiltin(110, …) silently returned a no-op and the
1776 // dirstack/$dirstack/pwd all stayed unchanged.
1777 reg_passthru!(vm, BUILTIN_PUSHD, "pushd");
1778 reg_passthru!(vm, BUILTIN_POPD, "popd");
1779 // type / whence / where / which all route through `bin_whence`
1780 // (canonical port at `src/ported/builtin.rs:3734` of
1781 // `Src/builtin.c:3975`). Each gets its own opcode so funcid +
1782 // defopts come from the BUILTINS table entry — execbuiltin
1783 // applies them correctly via the module-level dispatch_builtin.
1784 reg_passthru!(vm, BUILTIN_WHENCE, "whence");
1785 reg_passthru!(vm, BUILTIN_TYPE, "type");
1786 reg_passthru!(vm, BUILTIN_WHICH, "which");
1787 reg_passthru!(vm, BUILTIN_WHERE, "where");
1788 reg_passthru!(vm, BUILTIN_HASH, "hash");
1789 reg_passthru!(vm, BUILTIN_REHASH, "rehash");
1790
1791 // `unhash`/`unalias`/`unfunction` share `bin_unhash` (Src/builtin.c
1792 // c:4350) but each carries its own funcid (BIN_UNHASH /
1793 // BIN_UNALIAS / BIN_UNFUNCTION) in the BUILTINS table.
1794 reg_passthru!(vm, BUILTIN_UNHASH, "unhash");
1795 vm.register_builtin(BUILTIN_UNALIAS, |vm, argc| {
1796 let args = pop_args(vm, argc);
1797 Value::Status(dispatch_builtin("unalias", args))
1798 });
1799 vm.register_builtin(BUILTIN_UNFUNCTION, |vm, argc| {
1800 let args = pop_args(vm, argc);
1801 Value::Status(dispatch_builtin("unfunction", args))
1802 });
1803
1804 // Completion
1805 vm.register_builtin(BUILTIN_COMPGEN, |vm, argc| {
1806 let args = pop_args(vm, argc);
1807 // c:Bug #475/#555 — `compgen` is a bash-only builtin. In
1808 // `--zsh` mode emit "command not found" matching zsh's
1809 // external-command lookup miss.
1810 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1811 eprintln!("zsh:1: command not found: compgen");
1812 let _ = args;
1813 return Value::Status(127);
1814 }
1815 let status = with_executor(|exec| exec.builtin_compgen(&args));
1816 Value::Status(status)
1817 });
1818
1819 vm.register_builtin(BUILTIN_COMPLETE, |vm, argc| {
1820 let args = pop_args(vm, argc);
1821 // c:Bug #475 — `complete` is a bash-only builtin. Same gate
1822 // as BUILTIN_COMPGEN above.
1823 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1824 eprintln!("zsh:1: command not found: complete");
1825 let _ = args;
1826 return Value::Status(127);
1827 }
1828 let status = with_executor(|exec| exec.builtin_complete(&args));
1829 Value::Status(status)
1830 });
1831
1832 reg_passthru!(vm, BUILTIN_COMPADD, "compadd");
1833 reg_passthru!(vm, BUILTIN_COMPSET, "compset");
1834
1835 // See the const's doc comment for the contract. Stack (bottom→top):
1836 // base, e1, …, eN — argc = N + 1.
1837 vm.register_builtin(BUILTIN_TYPESET_PAREN_PACK, |vm, argc| {
1838 let mut vals: Vec<Value> = Vec::with_capacity(argc as usize);
1839 for _ in 0..argc {
1840 vals.push(vm.pop());
1841 }
1842 vals.reverse();
1843 let mut it = vals.into_iter();
1844 let mut out = it.next().map(|v| v.to_str()).unwrap_or_default();
1845 for v in it {
1846 match v {
1847 // Array → splice items as separate elements (splat);
1848 // empty array contributes nothing (empty elision).
1849 Value::Array(items) => {
1850 for item in items {
1851 out.push('\u{1f}');
1852 out.push_str(&item.to_str());
1853 }
1854 }
1855 other => {
1856 out.push('\u{1f}');
1857 out.push_str(&other.to_str());
1858 }
1859 }
1860 }
1861 Value::str(out)
1862 });
1863
1864 vm.register_builtin(BUILTIN_TYPESET_PAREN_CLOSE, |vm, _argc| {
1865 let base = vm.pop().to_str();
1866 Value::str(format!("{}\u{1f})", base))
1867 });
1868
1869 vm.register_builtin(BUILTIN_COMPDEF, |vm, argc| {
1870 let args = pop_args(vm, argc);
1871 // A compsys-defined `compdef` FUNCTION (autoload compinit →
1872 // compinit defines compdef) wins over the extension builtin
1873 // in every mode.
1874 if let Some(s) = try_user_fn_override("compdef", &args) {
1875 return Value::Status(s);
1876 }
1877 // c:zsh -f — compdef is a FUNCTION defined by compinit, not
1878 // a builtin; without compinit it's command-not-found (127).
1879 // openshift-aliases sources `<(oc completion zsh)` whose
1880 // compdef calls must error exactly like the oracle. Same
1881 // gate shape as BUILTIN_COMPGEN/BUILTIN_COMPLETE above.
1882 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1883 eprintln!("zsh:1: command not found: compdef");
1884 return Value::Status(127);
1885 }
1886 Value::Status(with_executor(|exec| exec.builtin_compdef(&args)))
1887 });
1888
1889 vm.register_builtin(BUILTIN_COMPINIT, |vm, argc| {
1890 let args = pop_args(vm, argc);
1891 Value::Status(with_executor(|exec| exec.builtin_compinit(&args)))
1892 });
1893
1894 vm.register_builtin(BUILTIN_CDREPLAY, |vm, argc| {
1895 let args = pop_args(vm, argc);
1896 Value::Status(with_executor(|exec| exec.builtin_cdreplay(&args)))
1897 });
1898
1899 // Zsh-specific
1900 reg_passthru!(vm, BUILTIN_ZSTYLE, "zstyle");
1901 reg_passthru!(vm, BUILTIN_ZMODLOAD, "zmodload");
1902 reg_passthru!(vm, BUILTIN_BINDKEY, "bindkey");
1903 reg_passthru!(vm, BUILTIN_ZLE, "zle");
1904 reg_passthru!(vm, BUILTIN_VARED, "vared");
1905 reg_passthru!(vm, BUILTIN_ZCOMPILE, "zcompile");
1906 reg_passthru!(vm, BUILTIN_ZFORMAT, "zformat");
1907 reg_passthru!(vm, BUILTIN_ZPARSEOPTS, "zparseopts");
1908 reg_passthru!(vm, BUILTIN_ZREGEXPARSE, "zregexparse");
1909
1910 // Resource limits
1911 reg_passthru!(vm, BUILTIN_ULIMIT, "ulimit");
1912 reg_passthru!(vm, BUILTIN_LIMIT, "limit");
1913 reg_passthru!(vm, BUILTIN_UNLIMIT, "unlimit");
1914 reg_passthru!(vm, BUILTIN_UMASK, "umask");
1915
1916 // Misc
1917 reg_passthru!(vm, BUILTIN_TIMES, "times");
1918
1919 vm.register_builtin(BUILTIN_CALLER, |vm, argc| {
1920 let args = pop_args(vm, argc);
1921 // c:Bug #475 — `caller` is a bash-only builtin. In `--zsh`
1922 // mode emit the canonical "command not found" diagnostic
1923 // and rc=127 matching zsh's external-command-lookup miss.
1924 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1925 eprintln!("zsh:1: command not found: caller");
1926 let _ = args;
1927 return Value::Status(127);
1928 }
1929 Value::Status(with_executor(|exec| exec.builtin_caller(&args)))
1930 });
1931
1932 vm.register_builtin(BUILTIN_HELP, |vm, argc| {
1933 let args = pop_args(vm, argc);
1934 // c:Bug #475 — `help` is a bash-only builtin. Same gate as
1935 // BUILTIN_CALLER above.
1936 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1937 eprintln!("zsh:1: command not found: help");
1938 let _ = args;
1939 return Value::Status(127);
1940 }
1941 Value::Status(with_executor(|exec| exec.builtin_help(&args)))
1942 });
1943
1944 reg_passthru!(vm, BUILTIN_ENABLE, "enable");
1945 reg_passthru!(vm, BUILTIN_DISABLE, "disable");
1946 reg_passthru!(vm, BUILTIN_TTYCTL, "ttyctl");
1947 reg_passthru!(vm, BUILTIN_SYNC, "sync");
1948 reg_passthru!(vm, BUILTIN_MKDIR, "mkdir");
1949 reg_passthru!(vm, BUILTIN_STRFTIME, "strftime");
1950
1951 vm.register_builtin(BUILTIN_ZSLEEP, |vm, argc| {
1952 Value::Status(crate::extensions::ext_builtins::zsleep(&pop_args(vm, argc)))
1953 });
1954
1955 reg_passthru!(vm, BUILTIN_ZSYSTEM, "zsystem");
1956
1957 // PCRE
1958 reg_passthru!(vm, BUILTIN_PCRE_COMPILE, "pcre_compile");
1959 reg_passthru!(vm, BUILTIN_PCRE_MATCH, "pcre_match");
1960 reg_passthru!(vm, BUILTIN_PCRE_STUDY, "pcre_study");
1961
1962 // Database (GDBM)
1963 reg_passthru!(vm, BUILTIN_ZTIE, "ztie");
1964 reg_passthru!(vm, BUILTIN_ZUNTIE, "zuntie");
1965 reg_passthru!(vm, BUILTIN_ZGDBMPATH, "zgdbmpath");
1966
1967 // Prompt
1968 vm.register_builtin(BUILTIN_PROMPTINIT, |vm, argc| {
1969 let args = pop_args(vm, argc);
1970 Value::Status(crate::extensions::ext_builtins::promptinit(&args))
1971 });
1972
1973 vm.register_builtin(BUILTIN_PROMPT, |vm, argc| {
1974 let args = pop_args(vm, argc);
1975 Value::Status(crate::extensions::ext_builtins::prompt(&args))
1976 });
1977
1978 // Async / Parallel (zshrs extensions)
1979 vm.register_builtin(BUILTIN_ASYNC, |vm, argc| {
1980 let args = pop_args(vm, argc);
1981 let status = with_executor(|exec| exec.builtin_async(&args));
1982 Value::Status(status)
1983 });
1984
1985 vm.register_builtin(BUILTIN_AWAIT, |vm, argc| {
1986 let args = pop_args(vm, argc);
1987 let status = with_executor(|exec| exec.builtin_await(&args));
1988 Value::Status(status)
1989 });
1990
1991 vm.register_builtin(BUILTIN_PMAP, |vm, argc| {
1992 let args = pop_args(vm, argc);
1993 let status = with_executor(|exec| exec.builtin_pmap(&args));
1994 Value::Status(status)
1995 });
1996
1997 vm.register_builtin(BUILTIN_PGREP, |vm, argc| {
1998 let args = pop_args(vm, argc);
1999 let status = with_executor(|exec| exec.builtin_pgrep(&args));
2000 Value::Status(status)
2001 });
2002
2003 vm.register_builtin(BUILTIN_PEACH, |vm, argc| {
2004 let args = pop_args(vm, argc);
2005 let status = with_executor(|exec| exec.builtin_peach(&args));
2006 Value::Status(status)
2007 });
2008
2009 vm.register_builtin(BUILTIN_BARRIER, |vm, argc| {
2010 let args = pop_args(vm, argc);
2011 let status = with_executor(|exec| exec.builtin_barrier(&args));
2012 Value::Status(status)
2013 });
2014
2015 // Intercept (AOP)
2016 vm.register_builtin(BUILTIN_INTERCEPT, |vm, argc| {
2017 let args = pop_args(vm, argc);
2018 let status = with_executor(|exec| exec.builtin_intercept(&args));
2019 Value::Status(status)
2020 });
2021
2022 vm.register_builtin(BUILTIN_INTERCEPT_PROCEED, |vm, argc| {
2023 let args = pop_args(vm, argc);
2024 let status = with_executor(|exec| exec.builtin_intercept_proceed(&args));
2025 Value::Status(status)
2026 });
2027
2028 // Debug / Profile
2029 vm.register_builtin(BUILTIN_DOCTOR, |vm, argc| {
2030 let args = pop_args(vm, argc);
2031 let status = with_executor(|exec| exec.builtin_doctor(&args));
2032 Value::Status(status)
2033 });
2034
2035 vm.register_builtin(BUILTIN_DBVIEW, |vm, argc| {
2036 let args = pop_args(vm, argc);
2037 let status = with_executor(|exec| exec.builtin_dbview(&args));
2038 Value::Status(status)
2039 });
2040
2041 vm.register_builtin(BUILTIN_PROFILE, |vm, argc| {
2042 let args = pop_args(vm, argc);
2043 let status = with_executor(|exec| exec.builtin_profile(&args));
2044 Value::Status(status)
2045 });
2046
2047 reg_passthru!(vm, BUILTIN_ZPROF, "zprof");
2048
2049 // ═══════════════════════════════════════════════════════════════════════
2050 // Coreutils builtins (anti-fork, gated by !posix_mode)
2051 //
2052 // All of these are routinely wrapped by user functions in real
2053 // dotfiles (zpwr, oh-my-zsh, etc.) — `cat() { ... }`, `ls() { ... }`,
2054 // `find() { ... }`. Each handler MUST consult try_user_fn_override
2055 // first (via reg_overridable!) so the user definition wins, matching
2056 // zsh's alias → function → builtin dispatch order.
2057 // ═══════════════════════════════════════════════════════════════════════
2058
2059 reg_overridable!(vm, BUILTIN_CAT, "cat", builtin_cat);
2060 reg_overridable!(vm, BUILTIN_HEAD, "head", builtin_head);
2061 reg_overridable!(vm, BUILTIN_TAIL, "tail", builtin_tail);
2062 reg_overridable!(vm, BUILTIN_WC, "wc", builtin_wc);
2063 reg_overridable!(vm, BUILTIN_BASENAME, "basename", builtin_basename);
2064 reg_overridable!(vm, BUILTIN_DIRNAME, "dirname", builtin_dirname);
2065 reg_overridable!(vm, BUILTIN_TOUCH, "touch", builtin_touch);
2066 reg_overridable!(vm, BUILTIN_REALPATH, "realpath", builtin_realpath);
2067 reg_overridable!(vm, BUILTIN_SORT, "sort", builtin_sort);
2068 reg_overridable!(vm, BUILTIN_FIND, "find", builtin_find);
2069 reg_overridable!(vm, BUILTIN_UNIQ, "uniq", builtin_uniq);
2070 reg_overridable!(vm, BUILTIN_CUT, "cut", builtin_cut);
2071 reg_overridable!(vm, BUILTIN_TR, "tr", builtin_tr);
2072 reg_overridable!(vm, BUILTIN_SEQ, "seq", builtin_seq);
2073 reg_overridable!(vm, BUILTIN_REV, "rev", builtin_rev);
2074 reg_overridable!(vm, BUILTIN_TEE, "tee", builtin_tee);
2075 reg_overridable!(vm, BUILTIN_SLEEP, "sleep", builtin_sleep);
2076 reg_overridable!(vm, BUILTIN_WHOAMI, "whoami", builtin_whoami);
2077 reg_overridable!(vm, BUILTIN_ID, "id", builtin_id);
2078
2079 reg_overridable!(vm, BUILTIN_HOSTNAME, "hostname", builtin_hostname);
2080 reg_overridable!(vm, BUILTIN_UNAME, "uname", builtin_uname);
2081 reg_overridable!(vm, BUILTIN_DATE, "date", builtin_date);
2082 reg_overridable!(vm, BUILTIN_MKTEMP, "mktemp", builtin_mktemp);
2083 // `cp` — zshrs extension (NOT in upstream zsh; upstream's
2084 // zsh/files module ships `ln`/`mv`/`rm`/`chmod`/`chown` but no
2085 // `cp`). In-process implementation in
2086 // `ext_builtins::cp_impl` — recursive copy with -r/-R, -f, -i,
2087 // -n, -p (chown + utimensat), -v. ID 263 is the first slot
2088 // past fusevm's built-in range (260-262) and before BUILTIN_MAX
2089 // (280).
2090 /// `BUILTIN_CP` constant.
2091 pub const BUILTIN_CP: u16 = 263;
2092 reg_overridable!(vm, BUILTIN_CP, "cp", builtin_cp);
2093
2094 // Pipeline execution — bytecode-native fork-per-stage. Pops N sub-chunk
2095 // indices, forks N children with stdin/stdout wired through N-1 pipes,
2096 // each child runs its stage's compiled bytecode and exits. Parent waits
2097 // and returns the last stage's status.
2098 //
2099 // Caveats: post-fork in a multi-threaded program, only async-signal-safe
2100 // ops are POSIX-safe. We violate this (running the bytecode VM after fork
2101 // touches mutexes like REGEX_CACHE). In practice, most pipeline stages
2102 // don't touch shared mutex state — externals fork/exec away, builtins do
2103 // pure I/O. Risks are bounded; if a stage does touch a held mutex, the
2104 // child deadlocks.
2105 vm.register_builtin(BUILTIN_RUN_PIPELINE, |vm, argc| {
2106 let n = argc as usize;
2107 if n == 0 {
2108 return Value::Status(0);
2109 }
2110
2111 // c:Src/exec.c — every pipeline stage forks from the current
2112 // shell state, so each stage observes the pre-pipeline $? until
2113 // it runs its own command. Stage sub-VMs start fresh with
2114 // last_status=0, so seed them with the parent's lastval; without
2115 // this `false; echo $? | cat` prints 0 instead of zsh's 1.
2116 let parent_status = vm.last_status;
2117
2118 // Pop N sub-chunk indices (LIFO → reverse to stage order)
2119 let mut indices: Vec<u16> = Vec::with_capacity(n);
2120 for _ in 0..n {
2121 indices.push(vm.pop().to_int() as u16);
2122 }
2123 indices.reverse();
2124
2125 // Clone each stage's sub-chunk
2126 let stages: Vec<fusevm::Chunk> = indices
2127 .iter()
2128 .filter_map(|&i| vm.chunk.sub_chunks.get(i as usize).cloned())
2129 .collect();
2130 if stages.len() != n {
2131 return Value::Status(1);
2132 }
2133
2134 // Single stage — no pipe, just run inline
2135 if n == 1 {
2136 let stage = stages.into_iter().next().unwrap();
2137 crate::fusevm_disasm::maybe_print_stdout("pipeline:single", &stage);
2138 let mut stage_vm = fusevm::VM::new(stage);
2139 stage_vm.last_status = parent_status;
2140 register_builtins(&mut stage_vm);
2141 let _ = stage_vm.run();
2142 return Value::Status(stage_vm.last_status);
2143 }
2144
2145 // Build N-1 pipes
2146 let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(n - 1);
2147 for _ in 0..n - 1 {
2148 let mut fds = [0i32; 2];
2149 if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
2150 // Cleanup any pipes we already created
2151 for (r, w) in &pipes {
2152 unsafe {
2153 libc::close(*r);
2154 libc::close(*w);
2155 }
2156 }
2157 return Value::Status(1);
2158 }
2159 pipes.push((fds[0], fds[1]));
2160 }
2161
2162 // zsh runs the LAST stage of a pipeline in the CURRENT shell
2163 // (not a forked child) so a trailing `read x` keeps its
2164 // assignment in the parent. Other shells (bash) fork every
2165 // stage. Honor zsh by leaving stage N-1 inline. Forks the
2166 // first N-1 stages with fork(); runs the last in this process
2167 // with stdin dup2'd to the last pipe's read end and stdout
2168 // restored after.
2169 let last_idx = n - 1;
2170 let stages_vec: Vec<fusevm::Chunk> = stages.into_iter().collect();
2171
2172 let mut child_pids: Vec<libc::pid_t> = Vec::with_capacity(n - 1);
2173 for (i, chunk) in stages_vec.iter().take(last_idx).enumerate() {
2174 match unsafe { libc::fork() } {
2175 -1 => {
2176 // fork failed — kill any children we already started
2177 for pid in &child_pids {
2178 unsafe { libc::kill(*pid, libc::SIGTERM) };
2179 }
2180 for (r, w) in &pipes {
2181 unsafe {
2182 libc::close(*r);
2183 libc::close(*w);
2184 }
2185 }
2186 return Value::Status(1);
2187 }
2188 0 => {
2189 // Reset SIGPIPE to default so a broken-pipe write
2190 // kills the child cleanly instead of triggering a
2191 // Rust println! panic. The parent shell ignores
2192 // SIGPIPE so it can handle EPIPE itself, but child
2193 // pipeline stages should die quietly when their
2194 // downstream stage closes early (e.g. `seq | head -3`).
2195 unsafe {
2196 libc::signal(libc::SIGPIPE, libc::SIG_DFL);
2197 }
2198 // c:Src/exec.c — pipeline children are forked
2199 // subshells; their EXIT trap context is reset so
2200 // the parent's `trap '...' EXIT` doesn't fire when
2201 // the child exits. Mirror by dropping EXIT from
2202 // the inherited traps_table inside the child.
2203 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
2204 t.remove("EXIT");
2205 }
2206 // c:Src/exec.c:2862 → 1219 — pipeline children run
2207 // entersubsh with ESUB_PGRP, which clears the job
2208 // table (clearjobtab, Src/jobs.c:1780). Without
2209 // this, `sleep 5 & jobs -p | wc -l` reports 1 in
2210 // the forked stage where zsh reports 0. The fork
2211 // already copy-isolates the statics, so mutating
2212 // them here can't leak to the parent.
2213 with_executor(|exec| {
2214 let monitor = crate::ported::zsh_h::isset(
2215 crate::ported::zsh_h::MONITOR,
2216 ) as i32;
2217 crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
2218 });
2219 *crate::ported::jobs::THISJOB
2220 .get_or_init(|| std::sync::Mutex::new(-1))
2221 .lock()
2222 .unwrap() = -1;
2223 // Child: wire stdin from previous pipe's read end
2224 if i > 0 {
2225 unsafe {
2226 libc::dup2(pipes[i - 1].0, libc::STDIN_FILENO);
2227 }
2228 }
2229 // Wire stdout to next pipe's write end
2230 unsafe {
2231 libc::dup2(pipes[i].1, libc::STDOUT_FILENO);
2232 }
2233 // (Pipe-output MULTIOS marking — c:Src/exec.c:3724 —
2234 // is emitted INTO the stage chunk by compile_pipe
2235 // via BUILTIN_PIPE_OUTPUT_MARK, gated on the stage's
2236 // top-level command actually carrying redirects, so
2237 // a nested `{ echo a > f; } | cat` body redirect
2238 // does not wrongly join the pipe.)
2239 // Close all original pipe fds (keeping stdin/stdout dups)
2240 for (r, w) in &pipes {
2241 unsafe {
2242 libc::close(*r);
2243 libc::close(*w);
2244 }
2245 }
2246
2247 // Run this stage's bytecode on a fresh VM
2248 crate::fusevm_disasm::maybe_print_stdout(
2249 &format!("pipeline:child:stage:{i}"),
2250 chunk,
2251 );
2252 let mut stage_vm = fusevm::VM::new(chunk.clone());
2253 stage_vm.last_status = parent_status;
2254 register_builtins(&mut stage_vm);
2255 let _ = stage_vm.run();
2256 // Flush any buffered output before exiting
2257 let _ = std::io::stdout().flush();
2258 let _ = std::io::stderr().flush();
2259 std::process::exit(stage_vm.last_status);
2260 }
2261 pid => {
2262 child_pids.push(pid);
2263 }
2264 }
2265 }
2266
2267 // Parent runs the LAST stage inline. Save stdin, dup the last
2268 // pipe's read end onto fd 0, run the chunk, restore stdin.
2269 // Close every other pipe fd so the producer side gets EOF
2270 // when the last upstream stage exits.
2271 let saved_stdin = unsafe { libc::dup(libc::STDIN_FILENO) };
2272 if last_idx > 0 {
2273 let read_fd = pipes[last_idx - 1].0;
2274 unsafe {
2275 libc::dup2(read_fd, libc::STDIN_FILENO);
2276 }
2277 }
2278 // Close all pipe fds in the parent now that stdin is wired.
2279 // (Children already have their own copies. The dup2 above
2280 // already gave us a fresh fd 0 if needed.)
2281 for (r, w) in &pipes {
2282 unsafe {
2283 libc::close(*r);
2284 libc::close(*w);
2285 }
2286 }
2287
2288 // Run the last stage's bytecode on a sub-VM with the host
2289 // wired up. The host points back at the executor so reads
2290 // (`read x`) update the parent's variables directly.
2291 let last_stage_status = {
2292 let last_chunk = stages_vec.into_iter().last().unwrap();
2293 crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
2294 let mut stage_vm = fusevm::VM::new(last_chunk);
2295 stage_vm.last_status = parent_status;
2296 register_builtins(&mut stage_vm);
2297 stage_vm.set_shell_host(Box::new(ZshrsHost));
2298 let _ = stage_vm.run();
2299 let _ = std::io::stdout().flush();
2300 let _ = std::io::stderr().flush();
2301 stage_vm.last_status
2302 };
2303
2304 // Restore stdin
2305 if saved_stdin >= 0 {
2306 unsafe {
2307 libc::dup2(saved_stdin, libc::STDIN_FILENO);
2308 libc::close(saved_stdin);
2309 }
2310 }
2311
2312 // Wait for all forked stages, capture per-stage statuses for PIPESTATUS.
2313 let mut pipestatus: Vec<i32> = Vec::with_capacity(n);
2314 for pid in child_pids {
2315 let mut status: i32 = 0;
2316 unsafe {
2317 libc::waitpid(pid, &mut status, 0);
2318 }
2319 let s = if libc::WIFEXITED(status) {
2320 libc::WEXITSTATUS(status)
2321 } else if libc::WIFSIGNALED(status) {
2322 128 + libc::WTERMSIG(status)
2323 } else {
2324 1
2325 };
2326 pipestatus.push(s);
2327 }
2328 // Append the in-parent last-stage status so `pipestatus` ends
2329 // with N entries (one per stage).
2330 pipestatus.push(last_stage_status);
2331 // Pipeline exit status: by default, the LAST stage's status.
2332 // With `setopt pipefail` (or `set -o pipefail`), use the
2333 // first non-zero stage status (so failures earlier in the
2334 // pipeline propagate even if the last stage succeeded).
2335 let pipefail_on = with_executor(|exec| opt_state_get("pipefail").unwrap_or(false));
2336 let last_status = if pipefail_on {
2337 pipestatus
2338 .iter()
2339 .copied()
2340 .rfind(|&s| s != 0)
2341 .or_else(|| pipestatus.last().copied())
2342 .unwrap_or(0)
2343 } else {
2344 *pipestatus.last().unwrap_or(&0)
2345 };
2346
2347 // c:Src/params.c:265,438 — only `pipestatus` (lowercase) is the
2348 // zsh special parameter; bash's `PIPESTATUS` doesn't exist in
2349 // zsh's special-params table. Prior port also populated
2350 // `PIPESTATUS` "for portability" — but that's a real divergence
2351 // from zsh: a script doing `[[ -z $PIPESTATUS ]]` to detect
2352 // zsh-vs-bash would mis-classify. Bug #64 in docs/BUGS.md.
2353 with_executor(|exec| {
2354 let strs: Vec<String> = pipestatus.iter().map(|s| s.to_string()).collect();
2355 exec.set_array("pipestatus".to_string(), strs);
2356 });
2357
2358 Value::Status(last_status)
2359 });
2360
2361 // Array→String join. Pops one value; if it's an Array (e.g. from Op::Glob),
2362 // joins string-coerced elements with a single space. Pass-through for
2363 // non-arrays so the op is safe to chain after any String-or-Array producer.
2364 vm.register_builtin(BUILTIN_ARRAY_JOIN, |vm, _argc| {
2365 let val = vm.pop();
2366 match val {
2367 Value::Array(items) => {
2368 let parts: Vec<String> = items.iter().map(|v| v.to_str()).collect();
2369 Value::str(parts.join(" "))
2370 }
2371 other => other,
2372 }
2373 });
2374
2375 // `cmd &` background execution. Compile_list emits this for any item
2376 // followed by ListOp::Amp: the job text + the cmd's sub-chunk index are
2377 // pushed, then this builtin pops both, looks up the chunk, forks. The
2378 // child detaches via setsid (so SIGINT to the foreground job doesn't kill
2379 // it), runs the bytecode on a fresh VM with builtins re-registered, exits
2380 // with the last status. The parent registers the job in the canonical
2381 // JOBTAB (c:Src/exec.c::execpline Z_ASYNC arm) and returns Status(0).
2382 vm.register_builtin(BUILTIN_RUN_BG, |vm, _argc| {
2383 let sub_idx = vm.pop().to_int() as usize;
2384 let job_text = vm.pop().to_str();
2385 let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
2386 Some(c) => c,
2387 None => return Value::Status(1),
2388 };
2389
2390 match unsafe { libc::fork() } {
2391 -1 => Value::Status(1),
2392 0 => {
2393 // Child: detach and run.
2394 unsafe { libc::setsid() };
2395 crate::fusevm_disasm::maybe_print_stdout("background_job", &chunk);
2396 let mut bg_vm = fusevm::VM::new(chunk);
2397 register_builtins(&mut bg_vm);
2398 let _ = bg_vm.run();
2399 let _ = std::io::stdout().flush();
2400 let _ = std::io::stderr().flush();
2401 std::process::exit(bg_vm.last_status);
2402 }
2403 pid => {
2404 // Parent: record the PID into `$!` (most recent
2405 // backgrounded job's pid). zsh exposes this for any
2406 // script that needs `wait $!`. Also register the
2407 // bare-pid job so a no-args `wait` can synchronize.
2408 // c:Src/jobs.c:73 — `lastpid = pid;` after a
2409 // background fork. zshrs's `$!` getter
2410 // (params.rs::lookup_special_var "!") reads from
2411 // the same atomic, so a single store here is the
2412 // canonical writer.
2413 crate::ported::modules::clone::lastpid
2414 .store(pid, std::sync::atomic::Ordering::Relaxed);
2415 // c:Src/exec.c:1700 — `thisjob = newjob = initjob()`:
2416 // allocate the canonical jobtab slot. c:Src/exec.c:2950
2417 // zfork path → addproc(pid, text, 0, &bgtime, ...) hangs
2418 // the proc entry (with its display text) off the job.
2419 // c:Src/exec.c:1744-1746 — `clearoldjobtab();
2420 // jobtab[thisjob].stat |= STAT_NOSTTY;` then c:1758
2421 // `spawnjob()` promotes it to curjob (top-level shell
2422 // only), marks STAT_LOCKED and resets thisjob.
2423 {
2424 use crate::ported::jobs;
2425 use std::sync::Mutex;
2426 let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
2427 let idx = {
2428 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
2429 let idx = jobs::initjob(&mut tab); // c:exec.c:1700
2430 jobs::addproc(
2431 &mut tab[idx],
2432 pid,
2433 &job_text,
2434 false,
2435 Some(std::time::Instant::now()),
2436 -1,
2437 -1,
2438 ); // c:exec.c:2950 addproc
2439 tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
2440 idx
2441 };
2442 jobs::clearoldjobtab(); // c:exec.c:1744
2443 if let Ok(mut tj) = jobs::THISJOB
2444 .get_or_init(|| Mutex::new(-1))
2445 .lock()
2446 {
2447 *tj = idx as i32;
2448 }
2449 jobs::spawnjob(); // c:exec.c:1758
2450 }
2451 with_executor(|exec| {
2452 exec.jobs
2453 .add_pid_job(pid, job_text.clone(), JobState::Running);
2454 });
2455 Value::Status(0)
2456 }
2457 }
2458 });
2459
2460 // ── Indexed-array storage ─────────────────────────────────────────────
2461 //
2462 // Stack: pushed values then name (LAST). `arr=(a b c)` → 4 args
2463 // (a, b, c, arr). `arr=($(cmd))` → 2 args (FlatArray, arr).
2464 //
2465 // PURE PASSTHRU: pop name + values, dispatch to canonical
2466 // `setaparam` / `sethparam` (C port of `Src/params.c:3595/3602`).
2467 // assignaparam already handles PM_UNIQUE dedupe, type-flag flip,
2468 // PM_NAMEREF rejection, ASSPM_AUGMENT prepend, and createparam
2469 // for fresh names.
2470 vm.register_builtin(BUILTIN_SET_ARRAY, |vm, argc| {
2471 // `${~spec}` carrier: an assignment statement is a word-
2472 // pipeline boundary too — restore the user's GLOB_SUBST
2473 // before the NEXT word expands (`Z[d]=${~Z[d]}; print
2474 // ${options[globsubst]}` must read the user value).
2475 consume_tilde_globsubst_carrier();
2476 let n = argc as usize;
2477 let mut popped: Vec<Value> = Vec::with_capacity(n);
2478 for _ in 0..n {
2479 popped.push(vm.pop());
2480 }
2481 popped.reverse();
2482 if popped.is_empty() {
2483 return Value::Status(1);
2484 }
2485 let name = popped.pop().unwrap().to_str();
2486 let mut values: Vec<String> = Vec::new();
2487 for v in popped {
2488 match v {
2489 Value::Array(items) => {
2490 for it in items {
2491 values.push(it.to_str());
2492 }
2493 }
2494 other => values.push(other.to_str()),
2495 }
2496 }
2497 let blocked = with_executor(|exec| {
2498 // Assoc init `typeset -A m; m=(k v k v ...)` — route to
2499 // canonical sethparam (Src/params.c:3602) which parses the
2500 // flat (k,v) pair list internally.
2501 if exec.assoc(&name).is_some() {
2502 // `[k]=v` / `[k]+=v` elements arrive from the compiler
2503 // as Marker / key / value triples (compile_zsh's port
2504 // of keyvalpairelement, c:Src/subst.c:49-79).
2505 let marker = crate::ported::zsh_h::Marker;
2506 let values = if values.iter().any(|e| e.starts_with(marker)) {
2507 // c:Src/params.c:3544-3560 — under ASSPM_KEY_VALUE
2508 // assocs strictly enforce `[key]=value`: every
2509 // stride-of-3 element must be a Marker. Mixing
2510 // plain pairs with kv triads is an error.
2511 let mut i = 0usize;
2512 while i < values.len() {
2513 if !values[i].starts_with(marker) {
2514 crate::ported::utils::zerr(
2515 "bad [key]=value syntax for associative array",
2516 );
2517 crate::ported::utils::errflag.fetch_or(
2518 crate::ported::zsh_h::ERRFLAG_ERROR,
2519 std::sync::atomic::Ordering::Relaxed,
2520 );
2521 exec.set_last_status(1);
2522 return true;
2523 }
2524 i += 3;
2525 }
2526 if values.len() % 3 != 0 {
2527 // c:Src/params.c:4124-4131 arrhashsetfn — a
2528 // truncated triad leaves an odd non-Marker
2529 // count → "bad set of key/value pairs".
2530 crate::ported::utils::zerr(
2531 "bad set of key/value pairs for associative array",
2532 );
2533 crate::ported::utils::errflag.fetch_or(
2534 crate::ported::zsh_h::ERRFLAG_ERROR,
2535 std::sync::atomic::Ordering::Relaxed,
2536 );
2537 exec.set_last_status(1);
2538 return true;
2539 }
2540 // c:Src/params.c:4136-4168 arrhashsetfn — whole
2541 // assignment builds a FRESH table; a `Marker +`
2542 // triad (`[k]+=v`) appends to the value inserted
2543 // EARLIER IN THIS SAME LITERAL (assignstrvalue
2544 // with eltflags=ASSPM_AUGMENT against the new ht),
2545 // so `h=([k]=a [k]+=b)` yields "ab". Resolve the
2546 // appends here, then hand flat pairs to sethparam.
2547 let mut order: Vec<String> = Vec::new();
2548 let mut map: std::collections::HashMap<String, String> =
2549 std::collections::HashMap::new();
2550 for ch in values.chunks(3) {
2551 let elt_append = ch[0].chars().nth(1) == Some('+');
2552 let k = ch[1].clone();
2553 let v = ch[2].clone();
2554 let nv = if elt_append {
2555 format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
2556 } else {
2557 v
2558 };
2559 if !map.contains_key(&k) {
2560 order.push(k.clone());
2561 }
2562 map.insert(k, nv);
2563 }
2564 order
2565 .into_iter()
2566 .flat_map(|k| {
2567 let v = map.get(&k).cloned().unwrap_or_default();
2568 [k, v]
2569 })
2570 .collect()
2571 } else {
2572 values
2573 };
2574 // Odd-count rejection lives in the canonical chain:
2575 // sethparam → setarrvalue (c:3651/c:2920) →
2576 // arrhashsetfn's zerr "bad set of key/value pairs"
2577 // (Src/params.c:4128-4131). zerr sets ERRFLAG_ERROR,
2578 // which aborts the remaining list at the next command
2579 // boundary (BUILTIN_ERREXIT_CHECK trigger 4) —
2580 // matching `zsh -fc 'typeset -A m; m=(odd); print x'`
2581 // printing nothing after the error. Like C's sethparam
2582 // (c:3652-3653 returns v->pm regardless), the Rust
2583 // port returns Some on the odd-count path — the
2584 // failure travels via errflag, so check BOTH.
2585 let pre_err = crate::ported::utils::errflag
2586 .load(std::sync::atomic::Ordering::Relaxed)
2587 & crate::ported::zsh_h::ERRFLAG_ERROR;
2588 let res = crate::ported::params::sethparam(&name, values.clone());
2589 let now_err = crate::ported::utils::errflag
2590 .load(std::sync::atomic::Ordering::Relaxed)
2591 & crate::ported::zsh_h::ERRFLAG_ERROR;
2592 if res.is_none() || (pre_err == 0 && now_err != 0) {
2593 // c:Src/exec.c:2632-2633 addvars — `if
2594 // (!assignaparam(name, arr, myflags)) lastval = 1;`
2595 // — failed assignment sets lastval so the errflag
2596 // abort exits 1 (init.c loop() breaks, zsh_main
2597 // returns lastval).
2598 exec.set_last_status(1);
2599 return true;
2600 }
2601 #[cfg(feature = "recorder")]
2602 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
2603 let ctx = exec.recorder_ctx();
2604 let attrs = exec.recorder_attrs_for(&name);
2605 let mut pairs: Vec<(String, String)> = Vec::with_capacity(values.len() / 2);
2606 let mut iter = values.iter().cloned();
2607 while let Some(k) = iter.next() {
2608 if let Some(v) = iter.next() {
2609 pairs.push((k, v));
2610 }
2611 }
2612 crate::recorder::emit_assoc_assign(&name, pairs, attrs, false, ctx);
2613 }
2614 return false;
2615 }
2616 // Indexed-array: setaparam (Src/params.c:3766) wraps
2617 // assignaparam with ASSPM_WARN — handles PM_UNIQUE dedupe,
2618 // type-flag flip, PM_READONLY rejection.
2619 //
2620 // `[k]=v` elements arrive as Marker / key / value triples
2621 // (compile_zsh's keyvalpairelement port). Mirror
2622 // c:Src/exec.c:2552-2553 — `if (prefork_ret &
2623 // PREFORK_KEY_VALUE) myflags |= ASSPM_KEY_VALUE;` — so
2624 // assignaparam runs its kv-resolution block (sparse fill
2625 // for PM_ARRAY, c:3447-3541; strict-triad enforcement for
2626 // special PM_HASHED targets like `options`, c:3544-3560).
2627 let values = values;
2628 let has_kv = values
2629 .iter()
2630 .any(|e| e.starts_with(crate::ported::zsh_h::Marker));
2631 // The tied-array mirror to a PM_TIED scalar
2632 // (`typeset -T PATH path`) lives canonically in
2633 // setarrvalue's dispatch in C zsh; until that wires
2634 // through assignaparam, mirror here so PATH stays in sync
2635 // after `path=(/x)`.
2636 if let Some((scalar_name, sep)) = exec.tied_array_to_scalar.get(&name).cloned() {
2637 let joined = values.join(&sep);
2638 exec.set_scalar(scalar_name, joined);
2639 }
2640 // c:Src/exec.c:2632-2633 addvars — `if (!assignaparam(...))
2641 // lastval = 1;` — a failed assignment (bad subscript, bad
2642 // [key]=value syntax, readonly) exits 1 and the errflag
2643 // abort stops the remaining list. Track errflag pre/post
2644 // like the assoc branch above.
2645 let kv_flag = if has_kv {
2646 crate::ported::zsh_h::ASSPM_KEY_VALUE
2647 } else {
2648 0
2649 };
2650 let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2651 & crate::ported::zsh_h::ERRFLAG_ERROR;
2652 let res = crate::ported::params::assignaparam(
2653 &name,
2654 values.clone(),
2655 crate::ported::zsh_h::ASSPM_WARN | kv_flag,
2656 );
2657 let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2658 & crate::ported::zsh_h::ERRFLAG_ERROR;
2659 if res.is_none() && pre_err == 0 && now_err != 0 {
2660 exec.set_last_status(1);
2661 return true;
2662 }
2663 #[cfg(feature = "recorder")]
2664 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
2665 let ctx = exec.recorder_ctx();
2666 let attrs = exec.recorder_attrs_for(&name);
2667 emit_path_or_assign(&name, &values, attrs, false, &ctx);
2668 }
2669 false
2670 });
2671 let status = if blocked { 1 } else { 0 };
2672 // c:Src/jobs.c:1748-1757 waitonejob — in C an array-assignment
2673 // simple command goes through execpline → waitjobs; with no
2674 // procs the else-branch stores `pipestats[0] = lastval;
2675 // numpipestats = 1`. Bare SCALAR assignments never create a
2676 // job (no waitjobs), so this clobber is array/assoc-assignment
2677 // specific: `false|true; x=(1 2); echo $pipestatus` → `0` in
2678 // zsh while `x=1` preserves `1 0`. Bug #373.
2679 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
2680 let mut synth = crate::ported::zsh_h::job::default();
2681 crate::ported::jobs::waitonejob(&mut synth);
2682 Value::Status(status)
2683 });
2684 // `arr+=(d e f)` — array append. Same calling conventions as SET_ARRAY.
2685 //
2686 // PURE PASSTHRU shape: pop name + values, dispatch through the
2687 // canonical assoc / array setter. assignaparam's ASSPM_AUGMENT
2688 // flag handles the C-source-equivalent "preserve prior value"
2689 // semantics; for now we read the current array, extend with
2690 // new values, write through set_array (which routes to
2691 // setaparam → assignaparam where PM_UNIQUE dedupe lands).
2692 vm.register_builtin(BUILTIN_APPEND_ARRAY, |vm, argc| {
2693 let n = argc as usize;
2694 let mut popped: Vec<Value> = Vec::with_capacity(n);
2695 for _ in 0..n {
2696 popped.push(vm.pop());
2697 }
2698 popped.reverse();
2699 if popped.is_empty() {
2700 return Value::Status(1);
2701 }
2702 let name = popped.pop().unwrap().to_str();
2703 let mut values: Vec<String> = Vec::new();
2704 for v in popped {
2705 match v {
2706 Value::Array(items) => {
2707 for it in items {
2708 values.push(it.to_str());
2709 }
2710 }
2711 other => values.push(other.to_str()),
2712 }
2713 }
2714 let blocked = with_executor(|exec| -> bool {
2715 // Assoc append `m+=(k1 v1 ...)`: merge the (k,v) pairs into
2716 // the existing map and write back via canonical sethparam
2717 // (Src/params.c:3602). The canonical C path would go
2718 // assignaparam(ASSPM_AUGMENT) → arrhashsetfn(ASSPM_AUGMENT)
2719 // at Src/params.c:3850, but the zshrs port of
2720 // arrhashsetfn doesn't yet implement value-storage
2721 // (pending Param.u_hash backend wireup) — until that
2722 // lands, do the augment + write here so the storage
2723 // actually mutates.
2724 if exec.assoc(&name).is_some() {
2725 // `[k]=v` / `[k]+=v` elements arrive as Marker / key /
2726 // value triples (compile_zsh's port of
2727 // keyvalpairelement, c:Src/subst.c:49-79).
2728 let marker = crate::ported::zsh_h::Marker;
2729 let mut map = exec.assoc(&name).unwrap_or_default();
2730 if values.iter().any(|e| e.starts_with(marker)) {
2731 // c:Src/params.c:3544-3560 — strict triad rule.
2732 let mut i = 0usize;
2733 while i < values.len() {
2734 if !values[i].starts_with(marker) {
2735 crate::ported::utils::zerr(
2736 "bad [key]=value syntax for associative array",
2737 );
2738 crate::ported::utils::errflag.fetch_or(
2739 crate::ported::zsh_h::ERRFLAG_ERROR,
2740 std::sync::atomic::Ordering::Relaxed,
2741 );
2742 exec.set_last_status(1);
2743 return true;
2744 }
2745 i += 3;
2746 }
2747 if values.len() % 3 != 0 {
2748 // c:Src/params.c:4124-4131 — odd pair count.
2749 crate::ported::utils::zerr(
2750 "bad set of key/value pairs for associative array",
2751 );
2752 crate::ported::utils::errflag.fetch_or(
2753 crate::ported::zsh_h::ERRFLAG_ERROR,
2754 std::sync::atomic::Ordering::Relaxed,
2755 );
2756 exec.set_last_status(1);
2757 return true;
2758 }
2759 // c:Src/params.c:4133-4168 arrhashsetfn with
2760 // ASSPM_AUGMENT — ht = the EXISTING table, so
2761 // `[k]+=v` appends to the current value
2762 // (assignstrvalue eltflags=ASSPM_AUGMENT,
2763 // c:4144-4150) and `[k]=v` overwrites.
2764 for ch in values.chunks(3) {
2765 let elt_append = ch[0].chars().nth(1) == Some('+');
2766 let k = ch[1].clone();
2767 let v = ch[2].clone();
2768 let nv = if elt_append {
2769 format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
2770 } else {
2771 v
2772 };
2773 map.insert(k, nv);
2774 }
2775 } else {
2776 let mut it = values.iter().cloned();
2777 while let Some(k) = it.next() {
2778 if let Some(v) = it.next() {
2779 map.insert(k, v);
2780 }
2781 }
2782 }
2783 exec.set_assoc(name, map);
2784 return false;
2785 }
2786 // Indexed-array append `arr+=(d e f)` — route directly
2787 // through canonical assignaparam with ASSPM_AUGMENT
2788 // (`Src/params.c:3570-3585` append-on-array branch).
2789 // assignaparam reads the prior array internally and
2790 // appends the new values, so the bridge no longer needs
2791 // to pre-concat manually. Marker triples from `[k]=v`
2792 // elements add ASSPM_KEY_VALUE (c:Src/exec.c:2552-2553)
2793 // so the kv sparse-fill block (c:Src/params.c:3447-3541)
2794 // resolves them against the existing elements.
2795 let kv_flag = if values
2796 .iter()
2797 .any(|e| e.starts_with(crate::ported::zsh_h::Marker))
2798 {
2799 crate::ported::zsh_h::ASSPM_KEY_VALUE
2800 } else {
2801 0
2802 };
2803 let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2804 & crate::ported::zsh_h::ERRFLAG_ERROR;
2805 let res = crate::ported::params::assignaparam(
2806 &name,
2807 values.clone(),
2808 crate::ported::zsh_h::ASSPM_AUGMENT | kv_flag,
2809 );
2810 let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2811 & crate::ported::zsh_h::ERRFLAG_ERROR;
2812 if res.is_none() && pre_err == 0 && now_err != 0 {
2813 // c:Src/exec.c:2632-2633 — failed assignment → lastval 1.
2814 exec.set_last_status(1);
2815 return true;
2816 }
2817 #[cfg(feature = "recorder")]
2818 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
2819 let ctx = exec.recorder_ctx();
2820 let attrs = exec.recorder_attrs_for(&name);
2821 emit_path_or_assign(&name, &values, attrs, true, &ctx);
2822 }
2823 // Tied-scalar mirror — TODO faithful: should live in
2824 // setarrvalue's gsu dispatch once boot_ paramtab wiring
2825 // lands (Task #16). Re-read the canonical post-augment
2826 // array so the joined scalar matches.
2827 let tied_scalar = exec.tied_array_to_scalar.get(&name).cloned();
2828 if let Some((scalar_name, sep)) = tied_scalar {
2829 let merged = exec.array(&name).unwrap_or_default();
2830 let joined = merged.join(&sep);
2831 exec.set_scalar(scalar_name.clone(), joined.clone());
2832 let _ = crate::ported::params::zputenv(&format!("{}={}", &scalar_name, &joined)); // c:Src/params.c:5354
2833 }
2834 false
2835 });
2836 // c:Src/jobs.c:1748-1757 waitonejob — `arr+=(...)` is an
2837 // array-assignment simple command and clobbers pipestats to
2838 // `[lastval]` exactly like `arr=(...)` above. Bug #373.
2839 let status = if blocked { 1 } else { 0 };
2840 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
2841 let mut synth = crate::ported::zsh_h::job::default();
2842 crate::ported::jobs::waitonejob(&mut synth);
2843 Value::Status(status)
2844 });
2845 // `name[@]=(...)` / `name[*]=(...)` — whole-array SET with the assoc
2846 // guard (c:Src/params.c:3324-3327). Stack: [v0..vn, name].
2847 vm.register_builtin(BUILTIN_SET_ARRAY_AT, |vm, argc| {
2848 let (name, values) = pop_array_args_with_name(vm, argc);
2849 let status = with_executor(|exec| {
2850 if exec.assoc(&name).is_some() {
2851 // c:Src/params.c:3324-3327 — `[@]` (any slice) on a
2852 // PM_HASHED target is an error.
2853 crate::ported::utils::zerr(&format!(
2854 "{}: attempt to set slice of associative array",
2855 name
2856 ));
2857 crate::ported::utils::errflag.fetch_or(
2858 crate::ported::zsh_h::ERRFLAG_ERROR,
2859 std::sync::atomic::Ordering::Relaxed,
2860 );
2861 exec.set_last_status(1);
2862 return 1;
2863 }
2864 exec.set_array(name, values); // whole replace (c:3528 setarrvalue)
2865 0
2866 });
2867 Value::Status(status)
2868 });
2869 // `name[@]+=(...)` / `name[*]+=(...)` — whole-array APPEND (push) with
2870 // the same assoc guard.
2871 vm.register_builtin(BUILTIN_APPEND_ARRAY_AT, |vm, argc| {
2872 let (name, values) = pop_array_args_with_name(vm, argc);
2873 let status = with_executor(|exec| {
2874 if exec.assoc(&name).is_some() {
2875 crate::ported::utils::zerr(&format!(
2876 "{}: attempt to set slice of associative array",
2877 name
2878 ));
2879 crate::ported::utils::errflag.fetch_or(
2880 crate::ported::zsh_h::ERRFLAG_ERROR,
2881 std::sync::atomic::Ordering::Relaxed,
2882 );
2883 exec.set_last_status(1);
2884 return 1;
2885 }
2886 let mut cur = exec.array(&name).unwrap_or_default();
2887 cur.extend(values);
2888 exec.set_array(name, cur); // c:3511-3528 AUGMENT on array → push
2889 0
2890 });
2891 Value::Status(status)
2892 });
2893 vm.register_builtin(BUILTIN_RUN_SELECT, |vm, argc| {
2894 if argc < 2 {
2895 return Value::Status(1);
2896 }
2897 let n = argc as usize;
2898 let mut popped: Vec<Value> = Vec::with_capacity(n);
2899 for _ in 0..n {
2900 popped.push(vm.pop());
2901 }
2902 // popped: [sub_idx, name, word_N, ..., word_1] (popping from top)
2903 let sub_idx_val = popped.remove(0);
2904 let name_val = popped.remove(0);
2905 // c:Src/loop.c — `select` flattens Array values (from `$@`,
2906 // `${arr[@]}`, etc.) into the menu. Without per-element
2907 // splice, `select x do ... done` (bare, iterating $@)
2908 // collapsed all positionals into one joined entry.
2909 let mut words: Vec<String> = Vec::new();
2910 for v in popped.into_iter().rev() {
2911 match v {
2912 Value::Array(items) => {
2913 for item in items {
2914 words.push(item.to_str());
2915 }
2916 }
2917 other => words.push(other.to_str()),
2918 }
2919 }
2920
2921 let sub_idx = sub_idx_val.to_int() as usize;
2922 let name = name_val.to_str();
2923
2924 // c:Src/loop.c:248-252 — `if (!args || empty(args)) {
2925 // state->pc = end; ... return 0; }`. An empty option list
2926 // skips the body entirely; without this gate the prompt loop
2927 // runs indefinitely (or twice on the EOF stdin case before
2928 // exiting). Bug #401.
2929 if words.is_empty() {
2930 return Value::Status(0);
2931 }
2932
2933 let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
2934 Some(c) => c,
2935 None => return Value::Status(1),
2936 };
2937
2938 let prompt =
2939 with_executor(|exec| exec.scalar("PROMPT3").unwrap_or_else(|| "?# ".to_string()));
2940
2941 let stdin = std::io::stdin();
2942 let mut reader = stdin.lock();
2943 let mut last_status: i32 = 0;
2944
2945 loop {
2946 // Direct port of zsh's selectlist from
2947 // src/zsh/Src/loop.c:347-409. Layout is column-major
2948 // ("down columns, then across") — NOT row-major. With
2949 // 6 items in 3 cols zsh produces:
2950 // 1 3 5
2951 // 2 4 6
2952 // The previous Rust impl walked row-major which
2953 // produced 1 2 3 / 4 5 6 (visually similar but wrong
2954 // for prompts that mention ordering and breaks scripts
2955 // that rely on column count == ceil(N/rows)).
2956 //
2957 // C variable mapping:
2958 // ct -> word count (n)
2959 // longest -> max item width + 1, then plus digits-of-ct
2960 // fct -> column count
2961 // fw -> per-column width
2962 // colsz -> row count = ceil(ct / fct)
2963 // t1 -> row index, walks 0..colsz
2964 // ap -> item pointer; advances by colsz to step
2965 // DOWN a column.
2966 let term_width: usize = env::var("COLUMNS")
2967 .ok()
2968 .and_then(|v| v.parse().ok())
2969 .unwrap_or(80);
2970 let ct = words.len();
2971 // loop.c:354-363 — find longest item width.
2972 let mut longest = 1usize;
2973 for w in &words {
2974 let aplen = w.chars().count();
2975 if aplen > longest {
2976 longest = aplen;
2977 }
2978 }
2979 // loop.c:365-367 — `longest++` then add digits of `ct`.
2980 longest += 1;
2981 let mut t0 = ct;
2982 while t0 > 0 {
2983 t0 /= 10;
2984 longest += 1;
2985 }
2986 // loop.c:369-373 — fct = (cols - 1) / (longest + 3); if
2987 // 0, fct = 1; else fw = (cols - 1) / fct.
2988 let raw_fct = (term_width.saturating_sub(1)) / (longest + 3);
2989 let (fct, fw) = if raw_fct == 0 {
2990 (1, longest + 3)
2991 } else {
2992 (raw_fct, (term_width.saturating_sub(1)) / raw_fct)
2993 };
2994 // loop.c:374 — colsz = (ct + fct - 1) / fct.
2995 let colsz = ct.div_ceil(fct);
2996 // loop.c:375-395 — for each row t1, walk down columns.
2997 for t1 in 0..colsz {
2998 let mut ap_idx = t1;
2999 while ap_idx < ct {
3000 let w = &words[ap_idx];
3001 let n = ap_idx + 1;
3002 let _ = write!(std::io::stderr(), "{}) {}", n, w);
3003 let mut t2 = w.chars().count() + 2;
3004 let mut t3 = n;
3005 while t3 > 0 {
3006 t2 += 1;
3007 t3 /= 10;
3008 }
3009 // Pad to fw (loop.c:389-390).
3010 while t2 < fw {
3011 let _ = write!(std::io::stderr(), " ");
3012 t2 += 1;
3013 }
3014 ap_idx += colsz;
3015 }
3016 let _ = writeln!(std::io::stderr());
3017 }
3018 let _ = write!(std::io::stderr(), "{}", prompt);
3019 let _ = std::io::stderr().flush();
3020
3021 let mut line = String::new();
3022 match reader.read_line(&mut line) {
3023 Ok(0) => {
3024 // EOF — emit the final newline that zsh prints
3025 // after the prompt-then-EOF sequence (c:Src/loop.c
3026 // selectlist falls through to fputc('\n', stderr)
3027 // at the end of the read failure path). Without
3028 // this the next process's output runs directly
3029 // after `-->>>> ` on the same line.
3030 let _ = writeln!(std::io::stderr());
3031 let _ = std::io::stderr().flush();
3032 break;
3033 }
3034 Ok(_) => {}
3035 Err(_) => break,
3036 }
3037 let trimmed = line.trim_end_matches(['\n', '\r'][..].as_ref()).to_string();
3038
3039 with_executor(|exec| {
3040 exec.set_scalar("REPLY".to_string(), trimmed.clone());
3041 });
3042
3043 if trimmed.is_empty() {
3044 // Empty input → redraw menu without running body.
3045 continue;
3046 }
3047
3048 let chosen = match trimmed.parse::<usize>() {
3049 Ok(n) if n >= 1 && n <= words.len() => words[n - 1].clone(),
3050 _ => String::new(),
3051 };
3052
3053 with_executor(|exec| {
3054 exec.set_scalar(name.clone(), chosen);
3055 });
3056
3057 // Reset canonical BREAKS/CONTFLAG before running the body
3058 // so a stale value from a sibling construct doesn't leak in.
3059 crate::ported::builtin::BREAKS.store(0, SeqCst);
3060 crate::ported::builtin::CONTFLAG.store(0, SeqCst);
3061
3062 // c:Src/loop.c — `select` increments LOOPS for the body so
3063 // `break` / `continue` inside the body see loops > 0 and
3064 // don't emit `not in while, until, select, or repeat loop`.
3065 // Mirrors execwhile/execrepeat's `LOOPS.fetch_add` pattern.
3066 // The decrement happens after the body call so a body that
3067 // explicitly returns / errors still leaves the counter
3068 // balanced for the next iteration.
3069 crate::ported::builtin::LOOPS.fetch_add(1, SeqCst);
3070
3071 crate::fusevm_disasm::maybe_print_stdout("select:body", &chunk);
3072 let mut body_vm = fusevm::VM::new(chunk.clone());
3073 register_builtins(&mut body_vm);
3074 let _ = body_vm.run();
3075 last_status = body_vm.last_status;
3076
3077 crate::ported::builtin::LOOPS.fetch_sub(1, SeqCst);
3078
3079 // Drain the canonical BREAKS/CONTFLAG counters. Mirrors
3080 // loop.c:529-534's `if (breaks) { breaks--; if (breaks ||
3081 // !contflag) break; contflag = 0; }` drain pattern.
3082 // The legacy `BREAK_SELECT=1` env-var sentinel is still
3083 // honored for backward compat.
3084 let break_legacy = with_executor(|exec| {
3085 let v = exec.scalar("BREAK_SELECT");
3086 exec.unset_scalar("BREAK_SELECT");
3087 v.map(|s| s != "0" && !s.is_empty()).unwrap_or(false)
3088 });
3089 use std::sync::atomic::Ordering::SeqCst;
3090 let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
3091 if breaks > 0 {
3092 let cont = crate::ported::builtin::CONTFLAG.load(SeqCst);
3093 crate::ported::builtin::BREAKS.fetch_sub(1, SeqCst);
3094 if breaks - 1 > 0 || cont == 0 {
3095 break;
3096 }
3097 crate::ported::builtin::CONTFLAG.store(0, SeqCst);
3098 continue;
3099 }
3100 if break_legacy {
3101 break;
3102 }
3103 }
3104
3105 Value::Status(last_status)
3106 });
3107
3108 // Magic special-parameter assoc lookup. Synthesizes values from
3109 // shell state for zsh's shell-introspection assocs:
3110 // commands, aliases, galiases, saliases, dis_aliases, dis_galiases,
3111 // dis_saliases, functions, dis_functions, builtins, dis_builtins,
3112 // reswords, options, parameters, jobtexts, jobdirs, jobstates,
3113 // nameddirs, userdirs, modules.
3114 // Returns None if `name` isn't a recognized magic name.
3115
3116 // `${arr[idx]}` — pop name, then idx_str. zsh is 1-based for positive
3117 // indices; we honor that. `@`/`*` return the whole array as Value::Array
3118 // so Op::Exec splice produces N argv slots. For `${foo[key]}` where foo
3119 // is an assoc, the idx is a string key — we check assoc_arrays first
3120 // when the idx isn't `@`/`*` and the name has an assoc binding.
3121 // BUILTIN_ARRAY_INDEX — `${name[idx]}` paramsubst dispatch.
3122 // PURE PASSTHRU: pops the idx + name, hands the canonical
3123 // `${name[idx]}` form to `subst::paramsubst` (C port of
3124 // `Src/subst.c::paramsubst`). All subscript-flag dispatch
3125 // ((I)pat / (R)pat / (i)/(r)/(K)/(k), range slices `[N,M]`,
3126 // negative indices, magic-assoc shape lookup, DQ-join collapse)
3127 // lives inside paramsubst → fetchvalue → getarg in params.rs.
3128 //
3129 // Outer-flag dispatch (`(@)` / `(@k)` / `(v)NAME[(I)pat]` / etc.)
3130 // routes through BUILTIN_BRIDGE_BRACE_ARRAY at the compile path
3131 // (canonical paramsubst flag parser owns dispatch at Src/subst.c:2147+),
3132 // so BUILTIN_ARRAY_INDEX receives clean name+key with no sentinel
3133 // prefixes.
3134 vm.register_builtin(BUILTIN_ARRAY_INDEX, |vm, _argc| {
3135 let idx = vm.pop().to_str();
3136 let name = vm.pop().to_str();
3137 array_index_lookup(&name, &idx)
3138 });
3139 // BUILTIN_ARRAY_INDEX_UNBRACED — bare `$name[idx]` (no braces).
3140 // Same subscript dispatch as BUILTIN_ARRAY_INDEX when KSHARRAYS
3141 // is unset, but under KSHARRAYS the UNBRACED form does NOT
3142 // subscript at all:
3143 // c:Src/subst.c:2800-2802 — fetchvalue's bracket-parse arg is
3144 // `(unset(KSHARRAYS) || inbrace) ? 1 : -1`; -1 inhibits
3145 // subscript parsing for the bare form under KSHARRAYS.
3146 // c:Src/subst.c:2867 — the bracket-consuming loop only runs
3147 // `while (v || ((inbrace || (unset(KSHARRAYS) && vunset)) &&
3148 // isbrack(*s)))` — bare + KSHARRAYS leaves `[...]` as literal
3149 // trailing text.
3150 // The bare `$name` expands (first element for identifier-named
3151 // arrays per c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`),
3152 // the literal `[idx]` (+ any literal suffix) joins the last word,
3153 // and the word undergoes filename generation: `[...]` is a glob
3154 // char class, so unquoted it hits the c:Src/glob.c:1873-1886
3155 // nomatch/nullglob dispatch (reused via exec.expand_glob).
3156 // Operands: [name, idx, suffix, quoted] — `quoted` set when the
3157 // word carries DQ markers (no filename generation in DQ; zsh 5.9:
3158 // `setopt ksharrays; a=(x y z); print "$a[0]"` → `x[0]`).
3159 // Verbatim zsh 5.9 ground truth for the unquoted form:
3160 // `setopt ksharrays; a=(x y z); print -- $a[0]` →
3161 // stderr `zsh:1: no matches found: x[0]`, rc=1, empty stdout.
3162 vm.register_builtin(BUILTIN_ARRAY_INDEX_UNBRACED, |vm, _argc| {
3163 let quoted = vm.pop().to_str() == "1";
3164 let suffix = vm.pop().to_str();
3165 let idx = vm.pop().to_str();
3166 let name = vm.pop().to_str();
3167 if !crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
3168 let v = array_index_lookup(&name, &idx);
3169 if suffix.is_empty() {
3170 return v;
3171 }
3172 // Mirrors the previous compile-shape `ARRAY_INDEX +
3173 // Op::Concat` exactly: Concat stringifies via as_str_cow
3174 // (fusevm value.rs:132-146, arrays join with " ").
3175 return Value::str(format!("{}{}", v.to_str(), suffix));
3176 }
3177 // KSHARRAYS bare form: no subscript. Bare-`$name` words +
3178 // literal `[idx]suffix` glued onto the last word.
3179 let mut words = ksharrays_bare_words(&name);
3180 let last = format!(
3181 "{}[{}]{}",
3182 words.pop().unwrap_or_default(),
3183 idx,
3184 suffix
3185 );
3186 if quoted {
3187 // DQ context — no filename generation, bracket text stays
3188 // literal.
3189 words.push(last);
3190 return Value::str(words.join(" "));
3191 }
3192 // c:Src/glob.c:1873-1886 — expand_glob handles nullglob /
3193 // NOMATCH (zerr "no matches found" + errflag + the
3194 // current_command_glob_failed cell consumed at the command
3195 // dispatch boundary) / literal passthrough for glob-free text.
3196 let matches = with_executor(|exec| exec.expand_glob(&last));
3197 let mut out: Vec<Value> = words.into_iter().map(Value::str).collect();
3198 out.extend(matches.into_iter().map(Value::str));
3199 if out.len() == 1 {
3200 return out.pop().unwrap();
3201 }
3202 Value::Array(out)
3203 });
3204 // BUILTIN_ASSOC_HAS_KEY — `${(k)assoc[name]}` key-existence query.
3205 // Pops [assoc_name, key]; returns key (Str) if present in the
3206 // assoc, empty Str otherwise. Mirrors zsh's `${(k)h[name]}`
3207 // documented semantics in zshparam(1) "Parameter Expansion Flags".
3208 // Distinct from BUILTIN_ARRAY_INDEX (which returns the VALUE) and
3209 // from `${+h[name]}` (which returns "0"/"1"). Bug #145.
3210 vm.register_builtin(BUILTIN_ASSOC_HAS_KEY, |vm, _argc| {
3211 let key = vm.pop().to_str();
3212 let name = vm.pop().to_str();
3213 // c:Src/params.c getindex — the subscript text is substituted
3214 // (singsub) before the lookup; `${(k)H[$k]}` must resolve $k.
3215 // The compiler hands this opcode the RAW subscript text, so a
3216 // dynamic key arrived literally ("$k") and matched nothing.
3217 // singsub is identity for plain keys.
3218 let key = if key.contains('$') || key.contains('`') || key.contains('\u{8c}') {
3219 crate::ported::subst::singsub(&key)
3220 } else {
3221 key
3222 };
3223 // c:Src/params.c:3131 gethkparam covers ordinary PM_HASHED
3224 // paramtab entries only. Special/magic hashes (`parameters`,
3225 // `options`, … — the zsh/parameter module's partab-backed
3226 // params, Src/Modules/parameter.c) aren't in that storage, so
3227 // a None here doesn't mean "no such assoc". Route those
3228 // through paramsubst, whose assoc materialization handles the
3229 // magic hashes and whose getarg port (c:Src/params.c:1591 +
3230 // Src/subst.c:2922) returns the KEY for `(k)` on a plain
3231 // subscript. zsh 5.9: `${(k)parameters[PATH]}` → "PATH".
3232 match crate::ported::params::gethkparam(&name) {
3233 Some(keys) => {
3234 if keys.iter().any(|k| k == &key) {
3235 Value::str(key)
3236 } else {
3237 Value::str("")
3238 }
3239 }
3240 None => paramsubst_to_value(&format!("${{(k){}[{}]}}", name, key)),
3241 }
3242 });
3243 vm.register_builtin(BUILTIN_BRIDGE_BRACE_ARRAY, |vm, _argc| {
3244 // Inner body of `${(...)...}` (already stripped of `${`/`}` by
3245 // the caller). The compiler optionally prefixes Qstring
3246 // (\u{8c}) to signal "expanded in DQ context" — strip it
3247 // here and bump in_dq_context for the paramsubst call so the
3248 // SUB_ZIP and other qt-aware paths fire.
3249 let body = vm.pop().to_str();
3250 let (dq, inner) = if let Some(rest) = body.strip_prefix('\u{8c}') {
3251 (true, rest.to_string())
3252 } else {
3253 (false, body)
3254 };
3255 if dq {
3256 with_executor(|exec| exec.in_dq_context += 1);
3257 }
3258 let v = paramsubst_to_value(&format!("${{{}}}", inner));
3259 if dq {
3260 with_executor(|exec| exec.in_dq_context -= 1);
3261 }
3262 v
3263 });
3264
3265 // BUILTIN_PARAM_FLAG — `${(flags)name}` paramsubst dispatch.
3266 // PURE PASSTHRU: pops sentinel-tagged flags + name, hands the
3267 // canonical `${(flags)name}` form to `subst::paramsubst` (C port
3268 // of `Src/subst.c::paramsubst`). The bridge does no flag
3269 // walking, no DQ-context branching, no array/scalar shape
3270 // selection — all of that lives inside paramsubst. Compile-time
3271 // context (DQ / scalar-assign-RHS) flows through executor cells
3272 // (in_dq_context, in_scalar_assign) bumped by BUILTIN_EXPAND_TEXT.
3273 vm.register_builtin(BUILTIN_PARAM_FLAG, |vm, _argc| {
3274 let flags = vm.pop().to_str();
3275 let name = vm.pop().to_str();
3276 let body = format!("${{({}){}}}", flags, name);
3277 paramsubst_to_value(&body)
3278 });
3279
3280 // `foo[key]=val` — single-key set on an assoc array. Stack: [name, key, value].
3281 // PURE PASSTHRU: assignsparam with `name[key]` form (C port of
3282 // `Src/params.c::assignsparam` subscript path at c:3210-3231)
3283 // already does the indexed-array vs assoc decision, PM_HASHED
3284 // auto-vivification, numeric-subscript bounds handling, and
3285 // PM_READONLY rejection.
3286 vm.register_builtin(BUILTIN_SET_ASSOC, |vm, _argc| {
3287 // `${~spec}` carrier: an assignment statement is a word-
3288 // pipeline boundary too — restore the user's GLOB_SUBST
3289 // before the NEXT word expands (`Z[d]=${~Z[d]}; print
3290 // ${options[globsubst]}` must read the user value).
3291 consume_tilde_globsubst_carrier();
3292 // argc 4 = compile flagged the subscript as DYNAMIC (`H[$k]`):
3293 // an EXPANDED-empty key is then a legal assoc key (C's
3294 // assignsparam isident gate sees the raw `$k` text and the
3295 // empty key stores at getindex time — zinit's
3296 // ZINIT_SICE[$1…$2] relies on it). argc 3 = source-literal
3297 // key; `H[]` stays the "not an identifier" error.
3298 let key_is_dynamic = if _argc == 4 {
3299 vm.pop().to_int() != 0
3300 } else {
3301 false
3302 };
3303 let value = vm.pop().to_str();
3304 let key = vm.pop().to_str();
3305 let name = vm.pop().to_str();
3306 if key_is_dynamic && key.is_empty() {
3307 with_executor(|exec| {
3308 let _ = exec;
3309 });
3310 // Mirror assignsparam's PM_HASHED tail directly (the
3311 // textual `name[]` reconstruction can't pass isident).
3312 if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
3313 let entry = store.entry(name.clone()).or_default();
3314 let newval = if let Some(old) = entry.get("") {
3315 // `+=` arrives pre-concatenated by the compile
3316 // read-modify-write; plain `=` overwrites.
3317 let _ = old;
3318 value.clone()
3319 } else {
3320 value.clone()
3321 };
3322 entry.insert(String::new(), newval);
3323 }
3324 return Value::Status(0);
3325 }
3326 with_executor(|exec| {
3327 #[cfg(feature = "recorder")]
3328 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3329 let ctx = exec.recorder_ctx();
3330 let attrs = exec.recorder_attrs_for(&name);
3331 crate::recorder::emit_assoc_assign(
3332 &name,
3333 vec![(key.clone(), value.clone())],
3334 attrs,
3335 true,
3336 ctx,
3337 );
3338 }
3339 let _ = exec;
3340 });
3341 // Build `name[key]=value` shape for assignsparam's subscript
3342 // dispatch. Arith-evaluate numeric subscripts on an existing
3343 // indexed array (`a[i+1]=v` form) before handing off — the
3344 // canonical port currently only handles literal int / string
3345 // keys, so pre-resolve here.
3346 let resolved_key = with_executor(|exec| {
3347 let is_indexed = exec.array(&name).is_some();
3348 let is_assoc = exec.assoc(&name).is_some();
3349 let is_scalar = !is_indexed && !is_assoc && exec.scalar(&name).is_some();
3350 // c:Src/params.c::getindex — `(i)pat` / `(I)pat` / `(R)pat`
3351 // / `(r)pat` subscript flags on an indexed array LHS resolve
3352 // to a numeric index (first / last match of pat). On a
3353 // SCALAR LHS the same flags resolve to a CHAR position
3354 // (1-based first/last match of pat in the scalar string)
3355 // for the c:2748+ char-splice assignment. zshrs's
3356 // read-form `${a[(i)pat]}` already implements both shapes;
3357 // the LHS assignment path silently stored the literal
3358 // "(i)pat" as an assoc key (for scalar: auto-vivified to
3359 // PM_HASHED via the assignsparam unknown-subscript
3360 // fallback). Bug #293 (array) / scalar sibling.
3361 //
3362 // Detect the `(flags)pat` shape and resolve to a numeric
3363 // index before assignsparam.
3364 if is_indexed || is_scalar {
3365 if let Some(rest) = key.strip_prefix('(') {
3366 if let Some(close) = rest.find(')') {
3367 let flags = &rest[..close];
3368 let pat = &rest[close + 1..];
3369 if !flags.is_empty()
3370 && flags
3371 .chars()
3372 .all(|c| matches!(c, 'I' | 'R' | 'i' | 'r' | 'n' | 'e'))
3373 {
3374 // Resolve via the array's contents.
3375 if let Some(arr) = exec.array(&name) {
3376 let return_index = true; // LHS write — index needed
3377 let down = flags.contains('I') || flags.contains('R');
3378 let exact = flags.contains('e');
3379 let iter: Box<dyn Iterator<Item = (usize, &String)>> = if down {
3380 Box::new(arr.iter().enumerate().rev())
3381 } else {
3382 Box::new(arr.iter().enumerate())
3383 };
3384 let mut found: Option<usize> = None;
3385 for (idx, elem) in iter {
3386 let matched = if exact {
3387 elem == pat
3388 } else {
3389 crate::ported::pattern::patcompile(&{ let mut __pat_tok = (pat).to_string(); crate::ported::glob::tokenize(&mut __pat_tok); __pat_tok },
3390 crate::ported::zsh_h::PAT_HEAPDUP as i32,
3391 None,
3392 )
3393 .map_or(false, |p| {
3394 crate::ported::pattern::pattry(&p, elem)
3395 })
3396 };
3397 if matched {
3398 found = Some(idx);
3399 break;
3400 }
3401 }
3402 let _ = return_index;
3403 // (i)/(r) return 1-based index of match,
3404 // arr.len()+1 (or 1 for I/R) on miss
3405 // per zsh docs. We mirror the read-form
3406 // semantics from subst.rs.
3407 let idx_1based = match found {
3408 Some(i) => (i + 1) as i64,
3409 None => (arr.len() + 1) as i64,
3410 };
3411 return idx_1based.to_string();
3412 }
3413 // Scalar LHS — resolve to a CHAR position
3414 // (1-based first/last match of pat in the
3415 // string). c:Src/params.c:1411-1418 — the
3416 // scalar path returns the char index from
3417 // sliding-window pattern match against
3418 // pm.u_str. Same algorithm as the read-form
3419 // at subst.rs:5283-5306. Bug (scalar
3420 // sibling of #293): `a=hello; a[(I)l]=X`
3421 // previously auto-vivified `a` into
3422 // PM_HASHED with key "(I)l" instead of
3423 // splicing X at the last 'l' position
3424 // (yielding "helXo").
3425 if is_scalar {
3426 let s = exec.scalar(&name).unwrap_or_default();
3427 let s_chars: Vec<char> = s.chars().collect();
3428 let n = s_chars.len();
3429 let want_last = flags.contains('I') || flags.contains('R');
3430 let exact = flags.contains('e');
3431 let mut found: Option<usize> = None;
3432 'outer: for start in 0..=n {
3433 let lengths: Box<dyn Iterator<Item = usize>> = if want_last {
3434 Box::new((1..=(n - start)).rev())
3435 } else {
3436 Box::new(1..=(n - start))
3437 };
3438 for len in lengths {
3439 let cand: String =
3440 s_chars[start..start + len].iter().collect();
3441 let matched = if exact {
3442 cand == pat
3443 } else {
3444 crate::ported::pattern::patcompile(&{ let mut __pat_tok = (pat).to_string(); crate::ported::glob::tokenize(&mut __pat_tok); __pat_tok },
3445 crate::ported::zsh_h::PAT_HEAPDUP as i32,
3446 None,
3447 )
3448 .map_or(false, |p| {
3449 crate::ported::pattern::pattry(&p, &cand)
3450 })
3451 };
3452 if matched {
3453 found = Some(start);
3454 if !want_last {
3455 break 'outer;
3456 }
3457 break;
3458 }
3459 }
3460 }
3461 // (I)/(R): scan again to find LAST.
3462 if want_last {
3463 let mut last_found: Option<usize> = found;
3464 for start in (0..=n).rev() {
3465 for len in 1..=(n - start) {
3466 let cand: String =
3467 s_chars[start..start + len].iter().collect();
3468 let matched = if exact {
3469 cand == pat
3470 } else {
3471 crate::ported::pattern::patcompile(&{ let mut __pat_tok = (pat).to_string(); crate::ported::glob::tokenize(&mut __pat_tok); __pat_tok },
3472 crate::ported::zsh_h::PAT_HEAPDUP as i32,
3473 None,
3474 )
3475 .map_or(false, |p| {
3476 crate::ported::pattern::pattry(&p, &cand)
3477 })
3478 };
3479 if matched {
3480 last_found = Some(start);
3481 break;
3482 }
3483 }
3484 if last_found.is_some()
3485 && last_found.unwrap() >= start
3486 {
3487 break;
3488 }
3489 }
3490 found = last_found;
3491 }
3492 let idx_1based = match found {
3493 Some(i) => (i + 1) as i64,
3494 // (i) miss → len+1 (one past end).
3495 None => (n + 1) as i64,
3496 };
3497 return idx_1based.to_string();
3498 }
3499 }
3500 }
3501 }
3502 }
3503 if is_indexed && key.trim().parse::<i64>().is_err() {
3504 crate::ported::math::mathevali(&crate::ported::subst::singsub(&key))
3505 .map(|n| n.to_string())
3506 .unwrap_or(key.clone())
3507 } else {
3508 key.clone()
3509 }
3510 });
3511 let subscripted = format!("{}[{}]", name, resolved_key);
3512 crate::ported::params::assignsparam(&subscripted, &value, crate::ported::zsh_h::ASSPM_WARN);
3513 Value::Status(0)
3514 });
3515
3516 // Brace expansion. Routes through executor.xpandbraces (already
3517 // implemented for the pre-fusevm executor). Returns Value::Array.
3518 // BUILTIN_ARRAY_DROP_EMPTY — filter out empty Value::Str entries
3519 // from a Value::Array on the stack. Used by `for x in $@` /
3520 // `for x in $*` unquoted forms which drop empty positionals
3521 // (POSIX-like) but do NOT IFS-split each element internally
3522 // (zsh-specific — scalar word splitting is off by default).
3523 // Distinct from BUILTIN_WORD_SPLIT which routes through
3524 // multsub PREFORK_SPLIT (full IFS-split). Bug #166.
3525 vm.register_builtin(BUILTIN_ARRAY_DROP_EMPTY, |vm, _argc| {
3526 let v = vm.pop();
3527 match v {
3528 Value::Array(items) => {
3529 let filtered: Vec<Value> = items
3530 .into_iter()
3531 .filter(|x| !x.to_str().is_empty())
3532 .collect();
3533 Value::Array(filtered)
3534 }
3535 Value::Str(s) if s.is_empty() => Value::Array(Vec::new()),
3536 other => other,
3537 }
3538 });
3539
3540 // BUILTIN_QUOTEDZPUTS — re-wrap top-of-stack scalar via the
3541 // canonical quotedzputs (Src/utils.c:6464). Non-printable bytes
3542 // come back as `$'…'` C-string form so the cond xtrace prefix
3543 // line preserves the source-quoting form for `[[ -n $'\C-[OP' ]]`
3544 // instead of leaking raw ESC + "OP" bytes through the terminal.
3545 vm.register_builtin(BUILTIN_QUOTEDZPUTS, |vm, _argc| {
3546 let s = vm.pop().to_str();
3547 Value::str(crate::ported::utils::quotedzputs(&s))
3548 });
3549
3550 // BUILTIN_QUOTE_TOKENIZED_OUTPUT — char-aware mirror of
3551 // c:Src/exec.c:2114 `quote_tokenized_output`. The canonical
3552 // port at exec::quote_tokenized_output operates on bytes
3553 // (zsh's metafied encoding); zshrs strings are UTF-8 so
3554 // `\u{87}` Star is `[0xC2, 0x87]`, and a byte walk writes
3555 // 0xC2 raw (invalid UTF-8 lead → U+FFFD on lossy decode).
3556 // Walk by char and dispatch the same switch the byte port
3557 // uses, but with the token chars matching the UTF-8 form.
3558 vm.register_builtin(BUILTIN_QUOTE_TOKENIZED_OUTPUT, |vm, _argc| {
3559 let s = vm.pop().to_str();
3560 let mut out = String::with_capacity(s.len());
3561 let chars: Vec<char> = s.chars().collect();
3562 let mut i = 0;
3563 while i < chars.len() {
3564 let c = chars[i];
3565 // c:2120 — Meta-quoted byte: emit `*++s ^ 32`.
3566 // In UTF-8 strings Meta is `\u{83}`; the next char is
3567 // the metafied payload.
3568 if c == '\u{83}' {
3569 if let Some(&n) = chars.get(i + 1) {
3570 if (n as u32) < 0x80 {
3571 out.push(((n as u8) ^ 32) as char);
3572 } else {
3573 out.push(n);
3574 }
3575 i += 2;
3576 continue;
3577 }
3578 i += 1;
3579 continue;
3580 }
3581 // c:2124 — Nularg: skip.
3582 if c == '\u{a1}' {
3583 i += 1;
3584 continue;
3585 }
3586 // c:2128-2143 — ASCII specials get backslash-prefixed
3587 // then fall through to emit the literal char.
3588 match c {
3589 '\\' | '<' | '>' | '(' | '|' | ')' | '^' | '#' | '~'
3590 | '[' | ']' | '*' | '?' | '$' | ' ' => {
3591 out.push('\\');
3592 out.push(c);
3593 i += 1;
3594 continue;
3595 }
3596 '\t' => {
3597 out.push_str("$'\\t'");
3598 i += 1;
3599 continue;
3600 }
3601 '\n' => {
3602 out.push_str("$'\\n'");
3603 i += 1;
3604 continue;
3605 }
3606 '\r' => {
3607 out.push_str("$'\\r'");
3608 i += 1;
3609 continue;
3610 }
3611 '=' => {
3612 if i == 0 {
3613 out.push('\\');
3614 }
3615 out.push(c);
3616 i += 1;
3617 continue;
3618 }
3619 _ => {}
3620 }
3621 // c:2163 — `if (itok(*s)) putc(ztokens[*s - Pound]);`
3622 // Map zsh token chars (`\u{84}`..`\u{a1}` range, the
3623 // ones the lexer emits for `#$^*()…`) back to their
3624 // source ASCII via the `ztokens` table.
3625 let cp = c as u32;
3626 if (0x84..=0xa1).contains(&cp) {
3627 let idx = (cp - 0x84) as usize;
3628 let ztokens = crate::ported::lex::ztokens.as_bytes();
3629 if idx < ztokens.len() {
3630 out.push(ztokens[idx] as char);
3631 i += 1;
3632 continue;
3633 }
3634 }
3635 out.push(c);
3636 i += 1;
3637 }
3638 Value::str(out)
3639 });
3640
3641 // BUILTIN_WORD_SPLIT — `${=var}` IFS-split runtime.
3642 // PURE PASSTHRU: route through canonical `subst::multsub` with
3643 // PREFORK_SPLIT flag (C port of `Src/subst.c::multsub` at c:544
3644 // — the IFS-split walker with whitespace-vs-non-whitespace
3645 // gating, quote-aware parsing, and empty-field handling).
3646 vm.register_builtin(BUILTIN_WORD_SPLIT, |vm, _argc| {
3647 let s = vm.pop().to_str();
3648 let (_joined, parts, _isarr, _flags) =
3649 crate::ported::subst::multsub(&s, crate::ported::zsh_h::PREFORK_SPLIT);
3650 // Empty single-string special case → empty Array (drop empty arg).
3651 if parts.len() == 1 && parts[0].is_empty() {
3652 return Value::Array(Vec::new());
3653 }
3654 nodes_to_value(parts)
3655 });
3656
3657 vm.register_builtin(BUILTIN_BRACE_EXPAND, |vm, _argc| {
3658 // c:Src/glob.c::xpandbraces — brace expansion runs per word.
3659 // When the upstream produced an array (e.g. `${a:e}` splat),
3660 // expand braces on each element separately so the splat
3661 // survives. `pop().to_str()` would join with space and lose
3662 // the array shape. Parity bug #28 cousin: the BRACE_EXPAND
3663 // emit always fires for any word containing `{` (including
3664 // `${...}` param-expansion braces), so its collapse hit even
3665 // pure-paramsubst args.
3666 let raw = vm.pop();
3667 // c:Src/options.c — `no_brace_expand` (negated braceexpand)
3668 // disables brace expansion entirely. When set, `{a,b}` stays
3669 // literal. Mirror by short-circuiting xpandbraces; pass the
3670 // input through unchanged.
3671 let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
3672 let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
3673 let inputs: Vec<String> = match raw {
3674 Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
3675 other => vec![other.to_str()],
3676 };
3677 if !brace_expand {
3678 return nodes_to_value(inputs);
3679 }
3680 let mut out: Vec<String> = Vec::with_capacity(inputs.len());
3681 for s in inputs {
3682 for w in crate::ported::glob::xpandbraces(&s, brace_ccl) {
3683 out.push(w);
3684 }
3685 }
3686 nodes_to_value(out)
3687 });
3688
3689 // `*(qual)` glob qualifier filter. Stack: [pattern, qualifier].
3690 // Pattern is glob-expanded normally, then each result is filtered by the
3691 // qualifier predicate. Common qualifiers:
3692 // . — regular files only
3693 // / — directories only
3694 // @ — symlinks
3695 // x — executable
3696 // r/w/x — readable/writable/executable
3697 // N — nullglob (no error if no match)
3698 // L+N / L-N — size > N / size < N (in bytes)
3699 // mh-N / mh+N — modified within N hours / older than N hours
3700 // md-N / md+N — modified within N days / older than N days
3701 // on/On — sort by name asc/desc (default)
3702 // oL/OL — sort by length
3703 // om/Om — sort by mtime
3704 // Pop a scalar pattern, run expand_glob, push Value::Array. Used
3705 // by the segment-concat compile path for `$D/*`-style words.
3706 vm.register_builtin(BUILTIN_GLOB_EXPAND, |vm, _argc| {
3707 // c:Src/glob.c:1872 — honour `setopt noglob` / `noglob CMD`
3708 // precommand. When the option is on, the word stays literal
3709 // (zsh skips the glob expansion entirely). Without this, the
3710 // segment-fast-path BUILTIN_GLOB_EXPAND fired even after
3711 // `noglob` set the option, so `noglob echo *.xyz` saw the
3712 // NOMATCH error instead of the literal pass-through.
3713 let raw = vm.pop();
3714 let noglob =
3715 opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
3716 glob_expand_word_value(raw, noglob)
3717 });
3718 // Redirect-target variant of BUILTIN_GLOB_EXPAND. c:Src/glob.c:
3719 // 2161-2167 xpandredir — `prefork(&fake, isset(MULTIOS) ? 0 :
3720 // PREFORK_SINGLE, NULL)` then "Globbing is only done for
3721 // multios.": a redirect target word is only globbed when the
3722 // MULTIOS option is set. With it unset, `echo hi > *.txt`
3723 // creates the literal file `*.txt`, and `wc -c < *.txt` errors
3724 // "no such file or directory: *.txt". Bug #36 follow-up in
3725 // docs/BUGS.md.
3726 vm.register_builtin(BUILTIN_REDIR_GLOB_EXPAND, |vm, _argc| {
3727 let raw = vm.pop();
3728 let noglob =
3729 opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
3730 let multios = opt_state_get("multios").unwrap_or(true);
3731 glob_expand_word_value(raw, noglob || !multios)
3732 });
3733 // Clear the default-word glob-pending carrier before the word's
3734 // expansion runs, so a flag set by a prior word never leaks in.
3735 vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB_RESET, |_vm, _argc| {
3736 crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| c.set(false));
3737 Value::Status(0)
3738 });
3739 // After the word is assembled, run filename generation ONLY if the
3740 // default/alternate paramsubst arm flagged a source-glob default
3741 // (DEFAULT_WORD_GLOB_PENDING). Otherwise pass the word through
3742 // literally — a parameter VALUE must not glob. c:Src/subst.c globlist.
3743 vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB, |vm, _argc| {
3744 let raw = vm.pop();
3745 let pending = crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| {
3746 let v = c.get();
3747 c.set(false); // read + clear
3748 v
3749 });
3750 if !pending {
3751 return raw;
3752 }
3753 let noglob =
3754 opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
3755 glob_expand_word_value(raw, noglob)
3756 });
3757
3758 // `break`/`continue` from a sub-VM body. The compile path emits
3759 // these when the keyword appears at chunk top-level (no enclosing
3760 // for/while in the current chunk's patch lists). Outer-loop
3761 // builtins (BUILTIN_RUN_SELECT and any future loop-via-builtin
3762 // construct) drain canonical BREAKS/CONTFLAG after each iteration.
3763 //
3764 // Writes match `bin_break`'s c:5836+ pattern:
3765 // continue: contflag = 1; breaks++ (Src/builtin.c::bin_break)
3766 // break: breaks++
3767 vm.register_builtin(BUILTIN_SET_BREAK, |_vm, _argc| {
3768 use std::sync::atomic::Ordering::SeqCst;
3769 crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
3770 Value::Status(0)
3771 });
3772 vm.register_builtin(BUILTIN_SET_CONTINUE, |_vm, _argc| {
3773 use std::sync::atomic::Ordering::SeqCst;
3774 crate::ported::builtin::CONTFLAG.store(1, SeqCst);
3775 crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
3776 Value::Status(0)
3777 });
3778
3779 // `${arr[*]}` — join array elements with the first IFS char into
3780 // a single string. Matches zsh: in DQ context this preserves the
3781 // join; in array context too the result is one Value::Str.
3782 // Set or clear a shell option directly. Used by `noglob CMD ...`
3783 // precommand wrapping — the compiler emits SET_RAW_OPT to flip the
3784 // option ON before compiling the inner words and OFF after, so glob
3785 // expansion of the inner args sees the temporary state.
3786 vm.register_builtin(BUILTIN_SET_RAW_OPT, |vm, _argc| {
3787 let on = vm.pop().to_int() != 0;
3788 let opt = vm.pop().to_str();
3789 // Pure passthru: canonical port lives in
3790 // src/ported/options.rs::opt_state_set_via_alias and
3791 // handles negation-alias resolution per c:Src/options.c.
3792 crate::ported::options::opt_state_set_via_alias(&opt, on);
3793 Value::Status(0)
3794 });
3795
3796 // c:Src/options.c GLOB_SUBST — runtime glob expansion of
3797 // substituted words. Pop a Value (Str or Array); when
3798 // GLOB_SUBST is ON, run expand_glob on each string element;
3799 // when OFF, pass through unchanged. Bug #119 in docs/BUGS.md.
3800 vm.register_builtin(BUILTIN_GLOB_SUBST_EXPAND, |vm, _argc| {
3801 let raw = vm.pop();
3802 let glob_subst =
3803 crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
3804 if !glob_subst {
3805 return raw;
3806 }
3807 // Collect input strings (Str → vec![s]; Array → multiple).
3808 let inputs: Vec<String> = match raw {
3809 Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
3810 other => vec![other.to_str()],
3811 };
3812 // Run expand_glob on each. Empty matches collapse to a
3813 // single literal pass-through to mirror nullglob-off default.
3814 let mut out: Vec<String> = Vec::with_capacity(inputs.len());
3815 for pattern in inputs {
3816 let matches = with_executor(|exec| exec.expand_glob(&pattern));
3817 if matches.is_empty() {
3818 // No match: keep the literal (like nullglob off).
3819 out.push(pattern);
3820 } else {
3821 for m in matches {
3822 out.push(m);
3823 }
3824 }
3825 }
3826 if out.len() == 1 {
3827 Value::str(out.into_iter().next().unwrap())
3828 } else {
3829 Value::Array(out.into_iter().map(Value::str).collect())
3830 }
3831 });
3832
3833 // c:Src/math.c:337 — `getmathparam` for ArithCompiler pre-load.
3834 // Pop a variable name, return its math-coerced value. Mirrors
3835 // the routing in math::getmathparam: try i64, then f64, then
3836 // recursive arith-eval, else 0. Bug #118 in docs/BUGS.md.
3837 vm.register_builtin(BUILTIN_GET_MATH_VAR, |vm, _argc| {
3838 let name = vm.pop().to_str();
3839 let raw = crate::ported::params::getsparam(&name).unwrap_or_default();
3840 // Empty / unset → 0.
3841 if raw.is_empty() {
3842 return Value::Int(0);
3843 }
3844 // Direct int / float parse.
3845 if let Ok(n) = raw.parse::<i64>() {
3846 return Value::Int(n);
3847 }
3848 if let Ok(f) = raw.parse::<f64>() {
3849 return Value::Float(f);
3850 }
3851 // Recursive arith eval (matches getmathparam fallback at
3852 // Src/math.c:337). If that fails too, return 0 — C's
3853 // mathevall returns 0 with errflag set on parse failure.
3854 match crate::ported::math::mathevali(&raw) {
3855 Ok(n) => Value::Int(n),
3856 Err(_) => Value::Int(0),
3857 }
3858 });
3859
3860 // c:Src/options.c GLOB_SUBST + Src/cond.c:552 cond_match.
3861 // Pop pattern string; when GLOB_SUBST is OFF, escape every glob
3862 // metachar with `\` so the downstream StrMatch + patcompile
3863 // treat them as literals (matching C's tokenization-based
3864 // gate). When GLOB_SUBST is ON, pass through unchanged.
3865 // See BUILTIN_GLOB_SUBST_GUARD docs above for full rationale.
3866 vm.register_builtin(BUILTIN_GLOB_SUBST_GUARD, |vm, _argc| {
3867 let p = vm.pop().to_str();
3868 let glob_subst =
3869 crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
3870 if glob_subst {
3871 return Value::str(p);
3872 }
3873 let mut out = String::with_capacity(p.len() * 2);
3874 for c in p.chars() {
3875 match c {
3876 '*' | '?' | '[' | ']' | '(' | ')' | '|' | '<' | '>' | '#' | '^'
3877 | '~' | '\\' => {
3878 out.push('\\');
3879 out.push(c);
3880 }
3881 _ => out.push(c),
3882 }
3883 }
3884 Value::str(out)
3885 });
3886
3887 vm.register_builtin(BUILTIN_ARRAY_JOIN_STAR, |vm, _argc| {
3888 let name = vm.pop().to_str();
3889 let (joined, ifs_full, in_dq) = with_executor(|exec| {
3890 // c:Src/params.c — `"$*"` joins by IFS[0]. zsh
3891 // distinguishes IFS=unset (→ default `" "`) from
3892 // IFS="" (→ EMPTY separator → fields concatenate).
3893 // chars().next() collapsed both into the default, so
3894 // IFS="" was treated as IFS=" ".
3895 let ifs_full = exec
3896 .scalar("IFS")
3897 .unwrap_or_else(|| " \t\n".to_string());
3898 let sep = ifs_full
3899 .chars()
3900 .next()
3901 .map(|c| c.to_string())
3902 .unwrap_or_default();
3903 let in_dq = exec.in_dq_context > 0;
3904 let joined = if name == "@" || name == "*" || name == "argv" {
3905 exec.pparams().join(&sep)
3906 } else if let Some(assoc_map) = exec.assoc(&name) {
3907 // c:Src/params.c — assoc-splat values for
3908 // `"${h[@]}"` / `"${h[*]}"`. Bug #109 in
3909 // docs/BUGS.md.
3910 assoc_map.values().cloned().collect::<Vec<_>>().join(&sep)
3911 } else if let Some(arr) = exec.array(&name) {
3912 arr.join(&sep)
3913 } else {
3914 exec.get_variable(&name)
3915 };
3916 (joined, ifs_full, in_dq)
3917 });
3918 // c:Src/subst.c — UNQUOTED `${name[*]}` (or `$*`) goes
3919 // through the canonical "join via IFS[0], then word-split
3920 // via IFS" pipeline. The fast-path bypassed paramsubst
3921 // entirely so it never word-split, producing one joined
3922 // string instead of N argv entries. Bug #428.
3923 //
3924 // In QUOTED (`"${name[*]}"`) context, the result IS a
3925 // single scalar — return it as Str without splitting.
3926 if in_dq {
3927 return Value::str(joined);
3928 }
3929 if joined.is_empty() {
3930 return Value::Array(Vec::new());
3931 }
3932 // IFS word-split — every IFS char is a separator. Empty
3933 // resulting fields are dropped (the canonical
3934 // "remove empty unquoted words" pass from
3935 // Src/subst.c::prefork c:184-187).
3936 let parts: Vec<String> = joined
3937 .split(|c: char| ifs_full.contains(c))
3938 .filter(|s| !s.is_empty())
3939 .map(String::from)
3940 .collect();
3941 if parts.is_empty() {
3942 Value::Array(Vec::new())
3943 } else if parts.len() == 1 {
3944 Value::str(parts.into_iter().next().unwrap())
3945 } else {
3946 Value::Array(parts.into_iter().map(Value::str).collect())
3947 }
3948 });
3949
3950 vm.register_builtin(BUILTIN_ARRAY_ALL, |vm, _argc| {
3951 let name = vm.pop().to_str();
3952 with_executor(|exec| {
3953 // Special positional names — splice the positional list.
3954 if name == "@" || name == "*" || name == "argv" {
3955 return Value::Array(exec.pparams().iter().map(Value::str).collect());
3956 }
3957 // c:Src/Modules/parameter.c — funcstack/funcfiletrace/
3958 // funcsourcetrace/functrace are PM_ARRAY|PM_READONLY
3959 // specials backed by the canonical FUNCSTACK Vec.
3960 // `${funcstack[@]}` inside a function call should splat
3961 // the innermost-first names; without this branch the
3962 // runtime fell to the scalar fallback (get_variable
3963 // returns empty for these specials) and `[@]` came out
3964 // empty. Bug #276 in docs/BUGS.md. Mirrors the parallel
3965 // arrays_get handler at src/ported/subst.rs ~10685.
3966 // c:Src/Modules/datetime.c:256 — `epochtime` PM_ARRAY|
3967 // PM_READONLY backed by getcurrenttime(). Same parallel
3968 // arrangement as the FUNCSTACK-backed specials below.
3969 if name == "epochtime" {
3970 let arr = crate::ported::modules::datetime::getcurrenttime();
3971 return Value::Array(arr.into_iter().map(Value::str).collect());
3972 }
3973 if matches!(
3974 name.as_str(),
3975 "funcstack" | "funcfiletrace" | "funcsourcetrace" | "functrace"
3976 ) {
3977 // Route the three trace arrays through the canonical
3978 // ported getfns (Src/Modules/parameter.c:648/:679/:711)
3979 // — the previous inline copy emitted wrong shapes
3980 // (bare filename for funcfiletrace, `name:lineno` for
3981 // functrace instead of `caller:lineno`); same dedup as
3982 // the parallel arrays_get handler in subst.rs.
3983 let vals: Vec<String> = match name.as_str() {
3984 "funcstack" => crate::ported::modules::parameter::FUNCSTACK
3985 .lock()
3986 .map(|f| f.iter().rev().map(|fs| fs.name.clone()).collect())
3987 .unwrap_or_default(),
3988 "funcfiletrace" => crate::ported::modules::parameter::funcfiletracegetfn(
3989 std::ptr::null_mut(),
3990 ),
3991 "funcsourcetrace" => crate::ported::modules::parameter::funcsourcetracegetfn(
3992 std::ptr::null_mut(),
3993 ),
3994 _ => crate::ported::modules::parameter::functracegetfn(std::ptr::null_mut()),
3995 };
3996 return Value::Array(vals.into_iter().map(Value::str).collect());
3997 }
3998 // c:Src/params.c — `${assoc[@]}` enumerates VALUES (per
3999 // params.c:1696-1750 hashparam splat). Check assoc
4000 // storage BEFORE the scalar fallback so an associative
4001 // array named X resolves `${X[@]}` to the values, not
4002 // empty. Bug #109 in docs/BUGS.md: `${h[@]}` on an
4003 // assoc routed through BUILTIN_ARRAY_ALL, which only
4004 // consulted `exec.array(name)` (the indexed-array map)
4005 // — that lookup missed for assocs, fell through to
4006 // `get_variable("h")` (also empty for an assoc-only
4007 // name), and returned `Array(vec![])`. zsh's expected
4008 // behavior is to enumerate values.
4009 if let Some(assoc_map) = exec.assoc(&name) {
4010 return Value::Array(
4011 assoc_map.values().cloned().map(Value::str).collect(),
4012 );
4013 }
4014 match exec.array(&name) {
4015 Some(v) => Value::Array(v.iter().map(Value::str).collect()),
4016 None => {
4017 // Fall back to scalar lookup. zsh (unlike bash)
4018 // does NOT IFS-split a scalar variable in a for
4019 // list — `for w in $scalar` iterates ONCE with the
4020 // scalar value. Word-splitting requires either
4021 // sh_word_split option or explicit `${(s.,.)scalar}`.
4022 let val = exec.get_variable(&name);
4023 if val.is_empty() && !exec.has_scalar(&name) && env::var(&name).is_err() {
4024 Value::Array(vec![])
4025 } else if opt_state_get("shwordsplit").unwrap_or(false) {
4026 // bash-compat: under setopt sh_word_split, do
4027 // split scalars on IFS chars.
4028 let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
4029 let parts: Vec<Value> = val
4030 .split(|c: char| ifs.contains(c))
4031 .filter(|s| !s.is_empty())
4032 .map(Value::str)
4033 .collect();
4034 Value::Array(parts)
4035 } else {
4036 Value::Array(vec![Value::str(val)])
4037 }
4038 }
4039 }
4040 })
4041 });
4042
4043 // BUILTIN_ARRAY_FLATTEN(N): pops N values, flattens one level of Array
4044 // nesting, pushes the resulting Array AND its length as a separate Int.
4045 // The two-value return shape lets the caller (for-loop compile path)
4046 // SetSlot the length before SetSlot'ing the array, without re-deriving
4047 // the length from the array via a second builtin call.
4048 // `coproc [name] { body }` — bidirectional pipe to backgrounded body.
4049 // Stack discipline (top first): [name (str, "" for default), sub_idx (int)].
4050 // On success: parent's `executor.arrays[name]` becomes [write_fd, read_fd]
4051 // and Status(0) is returned. The caller writes to the child's stdin via
4052 // write_fd, reads its stdout via read_fd, and closes both when done.
4053 //
4054 // Bash's coproc convention is `${NAME[0]}` = read_fd, `${NAME[1]}` =
4055 // write_fd. We follow that: arrays[name] = [read_fd_str, write_fd_str].
4056 vm.register_builtin(BUILTIN_RUN_COPROC, |vm, _argc| {
4057 let sub_idx = vm.pop().to_int() as usize;
4058 let job_text = vm.pop().to_str();
4059 let raw_name = vm.pop().to_str();
4060 let name = if raw_name.is_empty() {
4061 "COPROC".to_string()
4062 } else {
4063 raw_name
4064 };
4065 let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
4066 Some(c) => c,
4067 None => return Value::Status(1),
4068 };
4069
4070 // c:Src/exec.c:1710-1712 — starting a new coproc closes the
4071 // previous one's fds FIRST:
4072 // if (coprocin >= 0) { zclose(coprocin); zclose(coprocout); }
4073 // The old coproc child then sees EOF on its stdin and exits on
4074 // its own schedule (its job-table entry stays until it's
4075 // reaped) — zsh does NOT deletejob it here. This is also what
4076 // makes the `exec 4<&p; coproc exit; read -u4` EOF idiom work:
4077 // the replacement coproc closes the shell's write end to the
4078 // old one.
4079 {
4080 use std::sync::atomic::Ordering;
4081 let old_in = crate::ported::modules::clone::coprocin.load(Ordering::Relaxed);
4082 if old_in >= 0 {
4083 let old_out =
4084 crate::ported::modules::clone::coprocout.load(Ordering::Relaxed);
4085 unsafe {
4086 libc::close(old_in);
4087 if old_out >= 0 {
4088 libc::close(old_out);
4089 }
4090 }
4091 crate::ported::modules::clone::coprocin.store(-1, Ordering::Relaxed);
4092 crate::ported::modules::clone::coprocout.store(-1, Ordering::Relaxed);
4093 }
4094 }
4095
4096 // (parent_read ← child_stdout)
4097 let mut p2c = [0i32; 2]; // parent writes, child reads
4098 let mut c2p = [0i32; 2]; // child writes, parent reads
4099 if unsafe { libc::pipe(p2c.as_mut_ptr()) } < 0 {
4100 return Value::Status(1);
4101 }
4102 if unsafe { libc::pipe(c2p.as_mut_ptr()) } < 0 {
4103 unsafe {
4104 libc::close(p2c[0]);
4105 libc::close(p2c[1]);
4106 }
4107 return Value::Status(1);
4108 }
4109 // c:Src/exec.c:5160 mpipe — both pipes' fds are moved above
4110 // the user-visible range (movefd → F_DUPFD ≥ 10) so the
4111 // coproc fds never collide with explicit user fds like
4112 // `exec 3>&p`.
4113 for fd in p2c.iter_mut().chain(c2p.iter_mut()) {
4114 *fd = crate::ported::utils::movefd(*fd);
4115 }
4116
4117 match unsafe { libc::fork() } {
4118 -1 => {
4119 unsafe {
4120 libc::close(p2c[0]);
4121 libc::close(p2c[1]);
4122 libc::close(c2p[0]);
4123 libc::close(c2p[1]);
4124 }
4125 Value::Status(1)
4126 }
4127 0 => {
4128 // Child: stdin from p2c[0], stdout to c2p[1]. Close all
4129 // unused fds. setsid so SIGINT to fg doesn't hit us.
4130 unsafe {
4131 libc::dup2(p2c[0], libc::STDIN_FILENO);
4132 libc::dup2(c2p[1], libc::STDOUT_FILENO);
4133 libc::close(p2c[0]);
4134 libc::close(p2c[1]);
4135 libc::close(c2p[0]);
4136 libc::close(c2p[1]);
4137 libc::setsid();
4138 }
4139 crate::fusevm_disasm::maybe_print_stdout("coproc:child", &chunk);
4140 let mut co_vm = fusevm::VM::new(chunk);
4141 register_builtins(&mut co_vm);
4142 let _ = co_vm.run();
4143 let _ = std::io::stdout().flush();
4144 let _ = std::io::stderr().flush();
4145 std::process::exit(co_vm.last_status);
4146 }
4147 pid => {
4148 // Parent: close child ends, store [read_fd, write_fd] in NAME.
4149 unsafe {
4150 libc::close(p2c[0]);
4151 libc::close(c2p[1]);
4152 }
4153 let read_fd = c2p[0];
4154 let write_fd = p2c[1];
4155 with_executor(|exec| {
4156 exec.unset_scalar(&name);
4157 exec.set_array(name, vec![read_fd.to_string(), write_fd.to_string()]);
4158 });
4159 // c:Src/exec.c — `coprocin`/`coprocout` are the
4160 // canonical globals that bin_read's `-p` arm
4161 // (Src/builtin.c:6510) and bin_print's `-p` arm
4162 // (Src/builtin.c:4827) read to find the
4163 // coprocess fds. The Rust port has the atomic
4164 // declarations at src/ported/modules/clone.rs:262
4165 // but the coproc-launch path never updated them,
4166 // so `read -p` / `print -p` always errored with
4167 // "-p: no coprocess" even when a coproc was
4168 // running. Bug #388 in docs/BUGS.md. Update them
4169 // here so the canonical builtins find the live
4170 // pipe.
4171 crate::ported::modules::clone::coprocin
4172 .store(read_fd, std::sync::atomic::Ordering::Relaxed);
4173 crate::ported::modules::clone::coprocout
4174 .store(write_fd, std::sync::atomic::Ordering::Relaxed);
4175 // c:Src/exec.c:1725 — `fdtable[coprocin] =
4176 // fdtable[coprocout] = FDT_UNUSED;`: the two kept ends
4177 // are user-reachable (via `>&p` / `<&p`), so they drop
4178 // the FDT_INTERNAL mark movefd gave them.
4179 crate::ported::utils::fdtable_set(
4180 read_fd,
4181 crate::ported::zsh_h::FDT_UNUSED,
4182 );
4183 crate::ported::utils::fdtable_set(
4184 write_fd,
4185 crate::ported::zsh_h::FDT_UNUSED,
4186 );
4187 // c:Src/exec.c:2837 — `lastpid = (zlong) pid;`. zsh
4188 // sets the `$!` global to the coproc child's PID so
4189 // subsequent `$!` reads return it. The Rust port at
4190 // exec.rs:6773 mirrors this for regular background
4191 // jobs but the coproc launch path was missing the
4192 // assignment, leaving `$!` at 0 after `coproc cmd`.
4193 crate::ported::modules::clone::lastpid
4194 .store(pid, std::sync::atomic::Ordering::Relaxed);
4195 // c:Src/exec.c:1700-1758 — the coproc rides the SAME
4196 // Z_ASYNC job-table path as `cmd &`: `thisjob = newjob
4197 // = initjob()` (c:1700), addproc hangs the pid+text
4198 // proc entry off the job, `jobtab[thisjob].stat |=
4199 // STAT_NOSTTY` (c:1746), `clearoldjobtab()` (c:1744)
4200 // and `spawnjob()` (c:1758) promote it to curjob. This
4201 // is what makes `jobs` list the coproc as
4202 // `[1] + running cat` and `kill %1` resolve it.
4203 // Mirrors the BUILTIN_RUN_BG parent arm exactly.
4204 {
4205 use crate::ported::jobs;
4206 use std::sync::Mutex;
4207 let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
4208 let idx = {
4209 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
4210 let idx = jobs::initjob(&mut tab); // c:exec.c:1700
4211 jobs::addproc(
4212 &mut tab[idx],
4213 pid,
4214 &job_text,
4215 false,
4216 Some(std::time::Instant::now()),
4217 -1,
4218 -1,
4219 );
4220 tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
4221 idx
4222 };
4223 jobs::clearoldjobtab(); // c:exec.c:1744
4224 if let Ok(mut tj) = jobs::THISJOB
4225 .get_or_init(|| Mutex::new(-1))
4226 .lock()
4227 {
4228 *tj = idx as i32;
4229 }
4230 jobs::spawnjob(); // c:exec.c:1758
4231 }
4232 with_executor(|exec| {
4233 exec.jobs
4234 .add_pid_job(pid, job_text.clone(), JobState::Running);
4235 });
4236 Value::Status(0)
4237 }
4238 }
4239 });
4240
4241 vm.register_builtin(BUILTIN_ARRAY_FLATTEN, |vm, argc| {
4242 let n = argc as usize;
4243 let start = vm.stack.len().saturating_sub(n);
4244 let raw: Vec<Value> = vm.stack.drain(start..).collect();
4245 let mut flat: Vec<Value> = Vec::with_capacity(raw.len());
4246 for v in raw {
4247 match v {
4248 Value::Array(items) => flat.extend(items),
4249 other => flat.push(other),
4250 }
4251 }
4252 let len = flat.len() as i64;
4253 // Push the array first; the Int(len) becomes the builtin's return
4254 // value (which CallBuiltin already pushes). Caller consumes in
4255 // reverse: SetSlot(len_slot) pops Int, SetSlot(arr_slot) pops Array.
4256 vm.push(Value::Array(flat));
4257 Value::Int(len)
4258 });
4259
4260 // Shell variable get/set — routes through executor.variables so nested
4261 // VMs (function calls) and tree-walker callers see the same storage.
4262 vm.register_builtin(BUILTIN_GET_VAR, |vm, argc| {
4263 let args = pop_args(vm, argc);
4264 let name = args.into_iter().next().unwrap_or_default();
4265 let live_status = vm.last_status;
4266 // Suppress sync when a deferred subshell exit just landed:
4267 // LASTVAL holds the correct deferred status, vm.last_status
4268 // is stale (post-subshell vm doesn't propagate status). See
4269 // SUBSHELL_EXIT_STATUS_PENDING TLS declaration for rationale.
4270 let suppress_sync = SUBSHELL_EXIT_STATUS_PENDING.with(|c| {
4271 let prev = c.get();
4272 c.set(false);
4273 prev
4274 });
4275 // `$@` and `$*` need splice semantics — return Value::Array of
4276 // positional params so for-loop's BUILTIN_ARRAY_FLATTEN spreads them
4277 // and pop_args splits them into argv slots. zsh's `"$@"` bslashquote-each-
4278 // word semantics matches: each pos-param becomes its own arg.
4279 // Same for arrays accessed by name (e.g. `$arr` in some contexts).
4280 let sync_status = |exec: &mut ShellExecutor| {
4281 if !suppress_sync {
4282 exec.set_last_status(live_status);
4283 }
4284 };
4285 if name == "@" || name == "*" {
4286 // Quoting decides empty-word retention (c:Src/subst.c:
4287 // 184-187): the COMPILE site knows it and emits
4288 // BUILTIN_ARRAY_DROP_EMPTY after this read for the
4289 // unquoted form only — in_dq_context is NOT a valid
4290 // discriminator here (the quoted "$@" fast path emits
4291 // GET_VAR directly without an EXPAND_TEXT wrapper).
4292 return with_executor(|exec| {
4293 sync_status(exec);
4294 Value::Array(exec.pparams().iter().map(Value::str).collect())
4295 });
4296 }
4297 // RC_EXPAND_PARAM: when the option is set and `name` refers to
4298 // an array, return Value::Array so the enclosing word's
4299 // BUILTIN_CONCAT_DISTRIBUTE distributes element-wise. Without
4300 // the option, arrays still join to a space-separated scalar
4301 // (zsh's default unquoted-array-as-scalar semantics).
4302 let rc_expand = with_executor(|exec| opt_state_get("rcexpandparam").unwrap_or(false));
4303 if rc_expand {
4304 let arr_val = with_executor(|exec| {
4305 sync_status(exec);
4306 exec.array(&name)
4307 });
4308 if let Some(arr) = arr_val {
4309 return Value::Array(arr.into_iter().map(Value::str).collect());
4310 }
4311 }
4312 // Magic-assoc fallback FIRST — `${aliases}` / `${functions}`
4313 // / `${commands}` / etc. should return the value list per
4314 // zsh's bare-assoc semantics. Without this, those names fell
4315 // through to `get_variable` which is empty (they live in
4316 // separate executor tables, not `assoc_arrays`). Return as
4317 // a Value::Array so `arr=(${aliases})` distributes into
4318 // multiple elements, matching zsh's array-context word
4319 // splitting for assoc-bare references.
4320 let magic_vals = with_executor(|exec| {
4321 sync_status(exec);
4322 // Canonical PARTAB dispatch (Src/Modules/parameter.c:2235-
4323 // 2298 + SPECIALPMDEFs in mapfile/terminfo/termcap/system/
4324 // zleparameter): PARTAB_ARRAY entries → whole-array getfn;
4325 // PARTAB entries → scan keys + per-key getpm/scanpm fn
4326 // pointers.
4327 let _ = exec;
4328 if let Some(values) = partab_array_get(&name) {
4329 Some(values)
4330 } else if let Some(keys) = partab_scan_keys(&name) {
4331 Some(
4332 keys.iter()
4333 .map(|k| partab_get(&name, k).unwrap_or_default())
4334 .collect::<Vec<_>>(),
4335 )
4336 } else {
4337 None
4338 }
4339 });
4340 if let Some(vals) = magic_vals {
4341 // Distinguish "name IS a magic-assoc with no entries"
4342 // (return Array(empty)) from "name is unknown — fall
4343 // through to get_variable".
4344 // c:Src/params.c:2293-2296 — KSHARRAYS bare reference
4345 // collapses to the FIRST element in scan order
4346 // (`v->end = 1, v->isarr = 0`). For `options` the scan
4347 // order is optiontab bucket order (OPTIONTAB), so zsh 5.9
4348 // prints `off` (posixargzero) for
4349 // `setopt ksharrays; print $options`.
4350 if opt_state_get("ksharrays").unwrap_or(false) {
4351 return Value::str(vals.into_iter().next().unwrap_or_default());
4352 }
4353 return Value::Array(vals.into_iter().map(Value::str).collect());
4354 }
4355 // Indexed-array path: return Value::Array so pop_args splats
4356 // each element into its own argv slot. Direct port of zsh's
4357 // unquoted `$arr` semantics — each element becomes a separate
4358 // word in command-arg position.
4359 //
4360 // DQ context exception: inside `"...$arr..."`, zsh joins with
4361 // the first char of $IFS (default space) so the DQ word stays
4362 // a single argv slot. Detect via in_dq_context (bumped by
4363 // BUILTIN_EXPAND_TEXT mode 1) and return the joined scalar.
4364 // Direct port of Src/subst.c:1759-1813 nojoin/sepjoin: in DQ
4365 // (qt=1) without explicit `(@)`, sepjoin runs and the result
4366 // is one word.
4367 let arr_assoc_data = with_executor(|exec| {
4368 sync_status(exec);
4369 let in_dq = exec.in_dq_context > 0;
4370 // KSH_ARRAYS: bare `$arr` returns ONLY arr[0] (zero-
4371 // based first-element-only semantics). Direct port of
4372 // Src/params.c getstrvalue's KSH_ARRAYS gate which
4373 // returns aval[0] instead of the whole array.
4374 let ksh_arrays = opt_state_get("ksharrays").unwrap_or(false);
4375 if let Some(arr) = exec.array(&name) {
4376 if ksh_arrays {
4377 return Some((vec![arr.first().cloned().unwrap_or_default()], in_dq));
4378 }
4379 return Some((arr.clone(), in_dq));
4380 }
4381 if let Some(map) = exec.assoc(&name) {
4382 let mut keys: Vec<&String> = map.keys().collect();
4383 keys.sort();
4384 let values: Vec<String> =
4385 keys.iter().filter_map(|k| map.get(*k).cloned()).collect();
4386 if ksh_arrays {
4387 return Some((vec![values.into_iter().next().unwrap_or_default()], in_dq));
4388 }
4389 return Some((values, in_dq));
4390 }
4391 None
4392 });
4393 if let Some((items, in_dq)) = arr_assoc_data {
4394 // c:Src/subst.c:184-187 — prefork's `else if (!keep)
4395 // uremnode(list, node)`: UNQUOTED expansion drops empty
4396 // list nodes before they reach argv, so `a=(y '' x);
4397 // print -- $a` passes TWO args in zsh (`y x`), while the
4398 // quoted "${a[@]}" splat keeps the empty slot. The
4399 // paramsubst splat path already does this (Bug #578
4400 // retain); this GET_VAR fast path bypassed it and leaked
4401 // empty argv slots (visible double-space, wrong arg
4402 // counts in `for`/`print -l`).
4403 let items: Vec<String> = if in_dq {
4404 items
4405 } else {
4406 items.into_iter().filter(|s| !s.is_empty()).collect()
4407 };
4408 if in_dq {
4409 // c:Src/utils.c:3936-3945 sepjoin default-sep rule:
4410 // set-but-empty IFS joins with "" (`IFS=""; echo
4411 // "$arr"` concatenates); only unset / space-leading
4412 // IFS yields " ". The previous get_variable read
4413 // couldn't distinguish unset from set-empty.
4414 return Value::str(crate::ported::utils::sepjoin(&items, None));
4415 }
4416 return Value::Array(items.into_iter().map(Value::str).collect());
4417 }
4418 let (val, in_dq, is_known) = with_executor(|exec| {
4419 sync_status(exec);
4420 let v = exec.get_variable(&name);
4421 // For nounset detection: a name is "known" when it has a
4422 // paramtab/array/assoc/env entry. Special chars ($?, $#,
4423 // $@, $*, $-, $$, $!, $_, $0) always count as known
4424 // regardless of value. Pure-digit positional params
4425 // count as known iff index <= $# (set -- has populated
4426 // that slot). c:Src/subst.c:1689 — NOUNSET fires on
4427 // unset positional param too: `set --; echo "$1"` with
4428 // nounset must diagnose.
4429 let is_special_single = name.len() == 1
4430 && matches!(
4431 name.chars().next().unwrap(),
4432 '?' | '#' | '@' | '*' | '-' | '$' | '!' | '_' | '0'
4433 );
4434 let is_pure_digit = !name.is_empty() && name.chars().all(|c| c.is_ascii_digit());
4435 let positional_known = if is_pure_digit {
4436 let idx: usize = name.parse().unwrap_or(0);
4437 if idx == 0 {
4438 true // $0 always set
4439 } else {
4440 idx <= exec.pparams().len()
4441 }
4442 } else {
4443 false
4444 };
4445 let known = !v.is_empty()
4446 || name.is_empty()
4447 || is_special_single
4448 || positional_known
4449 || crate::ported::params::paramtab()
4450 .read()
4451 .ok()
4452 .map(|t| t.contains_key(&name))
4453 .unwrap_or(false)
4454 || env::var(&name).is_ok();
4455 (v, exec.in_dq_context > 0, known)
4456 });
4457 // c:Src/subst.c:1689 — NO_UNSET / nounset: reading an unset
4458 // parameter fires "parameter not set" diagnostic and aborts
4459 // the substitution. Direct port of the noerrs gate at c:1689
4460 // (zerr + errflag). Matches `set -u` POSIX semantics.
4461 if !is_known && opt_state_get("nounset").unwrap_or(false) {
4462 crate::ported::utils::zerr(&format!("{}: parameter not set", name));
4463 crate::ported::utils::errflag.fetch_or(
4464 crate::ported::zsh_h::ERRFLAG_ERROR,
4465 std::sync::atomic::Ordering::Relaxed,
4466 );
4467 with_executor(|exec| exec.set_last_status(1));
4468 return Value::str("");
4469 }
4470 // Empty unquoted scalar → drop the arg (zsh "remove empty
4471 // unquoted words" rule). Returning empty Value::Array makes
4472 // pop_args contribute zero items. DQ context keeps the empty
4473 // string so "$a" stays a single empty arg. Direct port of
4474 // subst.c's elide-empty pass.
4475 if val.is_empty() && !in_dq {
4476 return Value::Array(Vec::new());
4477 }
4478 // c:Src/subst.c:1759 SH_WORD_SPLIT — when shwordsplit is set and
4479 // we're in unquoted command-arg position (not DQ), split scalar
4480 // value on IFS into multiple words. Matches BUILTIN_ARRAY_ALL's
4481 // shwordsplit arm (fusevm_bridge.rs:2200). Without this, bare
4482 // `$s` in `print $s` stayed a single arg even with the option
4483 // set, breaking POSIX-style scalar word-splitting.
4484 if !in_dq && opt_state_get("shwordsplit").unwrap_or(false) {
4485 let ifs =
4486 with_executor(|exec| exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string()));
4487 let parts: Vec<Value> = val
4488 .split(|c: char| ifs.contains(c))
4489 .filter(|s| !s.is_empty())
4490 .map(|s| Value::str(s.to_string()))
4491 .collect();
4492 if parts.is_empty() {
4493 return Value::Array(Vec::new());
4494 } else if parts.len() == 1 {
4495 return parts.into_iter().next().unwrap();
4496 } else {
4497 return Value::Array(parts);
4498 }
4499 }
4500 Value::str(val)
4501 });
4502
4503 // `name+=val` (no parens) — runtime dispatch:
4504 // - if `name` is in `arrays` → push `val` as new element
4505 // - if `name` is in `assoc_arrays` → refuse (zsh errors here)
4506 // - else → scalar concat (existing behavior)
4507 // Stack: [name, value].
4508 vm.register_builtin(BUILTIN_APPEND_SCALAR_OR_PUSH, |vm, argc| {
4509 let args = pop_args(vm, argc);
4510 let mut iter = args.into_iter();
4511 let name = iter.next().unwrap_or_default();
4512 let value = iter.next().unwrap_or_default();
4513 with_executor(|exec| {
4514 // Array form: `arr+=elem` pushes a single element.
4515 // Routes through canonical assignaparam(name, [value],
4516 // ASSPM_AUGMENT) — Src/params.c:3357 c:3402-3412 augment
4517 // path prepends prior scalar / appends to existing array.
4518 if exec.array(&name).is_some() {
4519 // c:Src/params.c — under KSHARRAYS a bare array name
4520 // addresses element 0 (ksh), so `a+=X` (scalar augment)
4521 // CONCATENATES onto the first element ("firstlast second"),
4522 // it does NOT push a new element. C routes scalar `+=`
4523 // through assignsparam (which targets the elem-0 value);
4524 // zshrs's APPEND_SCALAR_OR_PUSH would otherwise push.
4525 if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
4526 let mut arr = exec.array(&name).unwrap_or_default();
4527 if arr.is_empty() {
4528 arr.push(value.clone());
4529 } else {
4530 arr[0] = format!("{}{}", arr[0], value);
4531 }
4532 exec.set_array(name.clone(), arr);
4533 #[cfg(feature = "recorder")]
4534 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
4535 let ctx = exec.recorder_ctx();
4536 let attrs = exec.recorder_attrs_for(&name);
4537 emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
4538 }
4539 return;
4540 }
4541 let _ = crate::ported::params::assignaparam(
4542 &name,
4543 vec![value.clone()],
4544 crate::ported::zsh_h::ASSPM_AUGMENT,
4545 );
4546 #[cfg(feature = "recorder")]
4547 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
4548 let ctx = exec.recorder_ctx();
4549 let attrs = exec.recorder_attrs_for(&name);
4550 emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
4551 }
4552 return;
4553 }
4554 if exec.assoc(&name).is_some() {
4555 eprintln!("zshrs: {}: cannot use += on assoc without (key val)", name);
4556 return;
4557 }
4558 // Scalar / integer / float form: route through canonical
4559 // assignsparam(name, value, ASSPM_AUGMENT) which
4560 // dispatches PM_TYPE — PM_SCALAR concats, PM_INTEGER
4561 // arith-adds (c:2775-2778), PM_FLOAT float-adds.
4562 let _ = crate::ported::params::assignsparam(
4563 &name,
4564 &value,
4565 crate::ported::zsh_h::ASSPM_AUGMENT,
4566 );
4567 #[cfg(feature = "recorder")]
4568 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
4569 let ctx = exec.recorder_ctx();
4570 let attrs = exec.recorder_attrs_for(&name);
4571 // Re-read the canonical value via get_variable for the
4572 // recorder bundle (assignsparam may have transformed it
4573 // through integer/float arithmetic).
4574 let final_val = exec.get_variable(&name);
4575 let lower = name.to_ascii_lowercase();
4576 if matches!(
4577 lower.as_str(),
4578 "path" | "fpath" | "manpath" | "module_path" | "cdpath"
4579 ) {
4580 emit_path_or_assign(&name, std::slice::from_ref(&final_val), attrs, true, &ctx);
4581 } else {
4582 crate::recorder::emit_assign_typed(&name, &final_val, attrs, ctx);
4583 }
4584 }
4585 });
4586 Value::Status(0)
4587 });
4588
4589 // BUILTIN_SET_VAR — `name=value` runtime scalar assignment.
4590 // PURE PASSTHRU: hand to canonical `setsparam` (C port of
4591 // `Src/params.c::setsparam`). That walks assignsparam →
4592 // assignstrvalue which already does:
4593 // - readonly rejection (zerr + errflag at c:2701)
4594 // - PM_INTEGER math evaluation (mathevali at c:3590)
4595 // - PM_EFLOAT / PM_FFLOAT float coercion (c:3608)
4596 // - PM_LOWER / PM_UPPER case fold (via setstrvalue)
4597 // - GSU special-param dispatch (homesetfn / ifssetfn / etc.)
4598 // - allexport env mirror via the PM_EXPORTED setfn
4599 //
4600 // Bridge-only concerns kept here:
4601 // - inline_env_stack (zsh `X=foo cmd` scoped env)
4602 // - recorder emission (PFA-SMR)
4603 // - vm.last_status propagation for `a=$(cmd)` exit-code chaining
4604 vm.register_builtin(BUILTIN_SET_VAR, |vm, argc| {
4605 // `${~spec}` carrier: an assignment statement is a word-
4606 // pipeline boundary too — restore the user's GLOB_SUBST
4607 // before the NEXT word expands (`Z[d]=${~Z[d]}; print
4608 // ${options[globsubst]}` must read the user value).
4609 consume_tilde_globsubst_carrier();
4610 // Snapshot the raw Values BEFORE pop_args's to_str
4611 // flattening — needed to distinguish Int (arith assignment,
4612 // integer-typed param) from Str (scalar assignment).
4613 let mut raw_values: Vec<fusevm::Value> = Vec::with_capacity(argc as usize);
4614 for _ in 0..argc {
4615 raw_values.push(vm.pop());
4616 }
4617 raw_values.reverse();
4618 let name = raw_values.first().map(|v| v.to_str()).unwrap_or_default();
4619 let value_raw = raw_values.get(1).cloned();
4620 let value = value_raw.as_ref().map(|v| v.to_str()).unwrap_or_default();
4621 // c:Src/params.c — when the bytecode hands us an Int value
4622 // (only the arith assignment paths emit this — `(( X = N ))`
4623 // is the canonical site), route through setiparam so the
4624 // param ends up PM_INTEGER + inherits the math layer's
4625 // `lastbase` for display formatting (`(( X = 16#ff ));
4626 // echo \$X` → `16#FF`). Scalar `X=val` and `$((expr))`
4627 // assignments still take the setsparam path below.
4628 let int_assign = matches!(value_raw, Some(fusevm::Value::Int(_)));
4629 let float_assign = matches!(value_raw, Some(fusevm::Value::Float(_)));
4630 let mut assign_failed = false;
4631 with_executor(|exec| {
4632 // c:Src/params.c assignsparam — PM_READONLY rejection
4633 // BEFORE any env mutation. The inline-env-prefix path
4634 // (`X=2 env`) called env::set_var unconditionally before
4635 // the readonly check fired in setsparam, so the OS env
4636 // got X=2 even though the assignment errored. env then
4637 // inherited the polluted env from fork, leaking the
4638 // attempted override past the readonly guard. Mirror
4639 // C's order: readonly check → zerr → bail; only mutate
4640 // env when the assignment is admissible. Bug #551
4641 // (security-relevant).
4642 if exec.is_readonly_param(&name) {
4643 crate::ported::utils::zerr(&format!("read-only variable: {}", name));
4644 return;
4645 }
4646 // Inline-assignment frame tracking (`X=foo cmd` reverts on
4647 // command return).
4648 if !exec.inline_env_stack.is_empty() {
4649 let prev_var = crate::ported::params::getsparam(&name);
4650 let prev_env = env::var(&name).ok();
4651 exec.inline_env_stack
4652 .last_mut()
4653 .unwrap()
4654 .push((name.clone(), prev_var, prev_env));
4655 let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value)); // c:Src/params.c:5354
4656 }
4657 // Canonical setsparam handles readonly, integer math, case
4658 // fold, GSU dispatch. For Int values (arith assigns) route
4659 // through setiparam so the param is PM_INTEGER + inherits
4660 // the math layer's lastbase for display formatting. For
4661 // Float (arith assigns producing MN_FLOAT) route through
4662 // setnparam so the param is PM_FFLOAT — `(( b = a * 2 ))`
4663 // with scalar `a="3.14"` should create b as typeset -F,
4664 // not a scalar holding "6.28".
4665 if int_assign {
4666 if let Some(fusevm::Value::Int(i)) = value_raw {
4667 crate::ported::params::setiparam(&name, i);
4668 } else {
4669 assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
4670 }
4671 } else if float_assign {
4672 if let Some(fusevm::Value::Float(f)) = value_raw {
4673 // ArithCompiler returns Value::Float whenever any
4674 // operand came through Str (BUILTIN_GET_VAR yields
4675 // Value::Str even for integer-shaped scalars). To
4676 // avoid forcing every `(( b = a + 3 ))` to PM_FFLOAT
4677 // when `a="5"` (integer-shaped), detect integer-
4678 // valued floats and route through setiparam instead.
4679 // True floats (non-integral) reach setnparam →
4680 // PM_FFLOAT so `typeset -p b` shows `typeset -F …`.
4681 if f.fract() == 0.0 && f.is_finite() && f.abs() <= i64::MAX as f64 {
4682 crate::ported::params::setiparam(&name, f as i64);
4683 } else {
4684 let mnval = crate::ported::math::mnumber {
4685 l: 0,
4686 d: f,
4687 type_: crate::ported::math::MN_FLOAT,
4688 };
4689 crate::ported::params::setnparam(&name, mnval);
4690 }
4691 } else {
4692 assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
4693 }
4694 } else {
4695 // c:Src/exec.c addvars — a NULL return from
4696 // assignsparam (e.g. nameref resolving out of scope,
4697 // createparam refusal at c:1108-1118) fails the
4698 // assignment with status 1.
4699 assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
4700 }
4701 // PM_EXPORTED / allexport env mirror — read AFTER setsparam
4702 // so the flag bit reflects any GSU setfn side-effects.
4703 let allexport = opt_state_get("allexport").unwrap_or(false);
4704 let already_exported =
4705 (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
4706 if allexport || already_exported {
4707 let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value)); // c:Src/params.c:5354
4708 }
4709 #[cfg(feature = "recorder")]
4710 if crate::recorder::is_enabled()
4711 && exec.local_scope_depth == 0
4712 && !matches!(
4713 name.as_str(),
4714 "PPID" | "LINENO" | "ZSH_ARGZERO" | "argv0" | "ARGC" | "?" | "_" | "RANDOM"
4715 )
4716 {
4717 let ctx = exec.recorder_ctx();
4718 let attrs = exec.recorder_attrs_for(&name);
4719 crate::recorder::emit_assign_typed(&name, &value, attrs, ctx);
4720 }
4721 });
4722 Value::Status(vm.last_status)
4723 });
4724
4725 // c:Src/exec.c execfor → Src/params.c:6362 setloopvar — the
4726 // for-loop variable bind. Distinct from BUILTIN_SET_VAR because a
4727 // PM_NAMEREF loop variable REBINDS (new refname) instead of
4728 // assigning through the resolved chain.
4729 vm.register_builtin(BUILTIN_SET_LOOP_VAR, |vm, argc| {
4730 let args = pop_args(vm, argc);
4731 let name = args.first().cloned().unwrap_or_default();
4732 let value = args.get(1).cloned().unwrap_or_default();
4733 if crate::vm_helper::is_nameref(&name) {
4734 let ef_before = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
4735 crate::ported::params::setloopvar(&name, &value); // c:6362
4736 let ef_after = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
4737 if (ef_after & crate::ported::utils::ERRFLAG_ERROR) != 0 && ef_after != ef_before {
4738 // zerr fired (read-only reference / invalid self
4739 // reference) — abort the loop, status 1 (C errflag).
4740 vm.last_status = 1;
4741 return Value::Bool(false);
4742 }
4743 return Value::Bool(true);
4744 }
4745 // Plain loop var — canonical scalar path (same shape as
4746 // BUILTIN_SET_VAR's setsparam arm).
4747 with_executor(|exec| {
4748 if exec.is_readonly_param(&name) {
4749 crate::ported::utils::zerr(&format!("read-only variable: {}", name));
4750 return;
4751 }
4752 crate::ported::params::setsparam(&name, &value);
4753 let allexport = opt_state_get("allexport").unwrap_or(false);
4754 let already_exported =
4755 (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
4756 if allexport || already_exported {
4757 let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value)); // c:Src/params.c:5354
4758 }
4759 });
4760 Value::Bool(true)
4761 });
4762
4763 // Pre-compiled function registration — used by compile_zsh.rs's
4764 // FuncDef path. Stack: [name, base64-bincode-of-Chunk]. We decode
4765 // the base64, deserialize the Chunk, and store directly in
4766 // executor.functions_compiled. Bypasses the ShellCommand JSON layer.
4767 // BUILTIN_VAR_EXISTS — `[[ -v name ]]` set-test.
4768 // PURE PASSTHRU: build `${+name}` and route through canonical
4769 // `subst::paramsubst` which returns "1" for set / "0" for unset
4770 // (C port of `Src/subst.c::paramsubst` plus-prefix arm).
4771 // paramsubst handles all the shapes the 48-line hand-roll did:
4772 // - bare scalar / array / assoc
4773 // - subscripted `a[N]` / `h[key]`
4774 // - positional params (any digit-only name)
4775 // - env-var fallback (`HOME` set via getsparam → lookup_special_var)
4776 vm.register_builtin(BUILTIN_VAR_EXISTS, |vm, _argc| {
4777 let name = vm.pop().to_str();
4778 let body = format!("${{+{}}}", name);
4779 let mut ret_flags: i32 = 0;
4780 let (_full, _pos, nodes) =
4781 crate::ported::subst::paramsubst(&body, 0, false, 0i32, &mut ret_flags);
4782 let result = nodes.into_iter().next().unwrap_or_default();
4783 Value::Bool(result == "1")
4784 });
4785
4786 // `time { compound; ... }` — runs the sub-chunk and prints elapsed
4787 // wall-clock time. zsh's full `time` also tracks user/system CPU via
4788 // getrusage on the *child*; we approximate via wall-time only since
4789 // the sub-chunk runs in-process (no fork). Output format matches
4790 // `time simple-cmd` (already implemented elsewhere via exectime).
4791 vm.register_builtin(BUILTIN_TIME_SUBLIST, |vm, argc| {
4792 let sub_idx = vm.pop().to_int() as usize;
4793 // c:Src/jobs.c:1028-1029 — `pn->text` arg to printtime. argc==2
4794 // means the compiler also pushed a desc string (bug #66 fix);
4795 // older callers with argc==1 push only sub_idx and we synthesize
4796 // an empty desc for backward compat with cached bytecode that
4797 // predates the desc-threading patch.
4798 let desc = if argc >= 2 {
4799 vm.pop().to_str().to_string()
4800 } else {
4801 String::new()
4802 };
4803 let chunk_opt = vm.chunk.sub_chunks.get(sub_idx).cloned();
4804 let Some(chunk) = chunk_opt else {
4805 return Value::Status(0);
4806 };
4807 // c:Src/jobs.c:1968 — `getrusage(RUSAGE_CHILDREN, &ti)` before
4808 // and after the timed sublist gives accurate per-stage user/sys
4809 // CPU. Wall-time-only approximation (0.7×/0.1× fudge factors)
4810 // produced bogus user/sys columns and ignored TIMEFMT. Bug #66
4811 // in docs/BUGS.md.
4812 let ru_before: libc::rusage = unsafe {
4813 let mut r: libc::rusage = std::mem::zeroed();
4814 libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
4815 r
4816 };
4817 // c:Src/jobs.c — zsh's `time` reports only for JOBS (forked
4818 // work). Builtins/brace-groups/functions run in the shell
4819 // process with no job, so `zsh -fc 'time true'` emits NOTHING.
4820 // Snapshot the fork-event counter; report only if the timed
4821 // body forked (external command or subshell).
4822 let forks_before =
4823 crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed);
4824 let start = Instant::now();
4825 crate::fusevm_disasm::maybe_print_stdout("time_sublist", &chunk);
4826 let mut sub_vm = fusevm::VM::new(chunk);
4827 register_builtins(&mut sub_vm);
4828 let _ = sub_vm.run();
4829 let status = sub_vm.last_status;
4830 let elapsed = start.elapsed();
4831 let ru_after: libc::rusage = unsafe {
4832 let mut r: libc::rusage = std::mem::zeroed();
4833 libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
4834 r
4835 };
4836 // Delta children rusage = timed work's CPU.
4837 let mut delta = ru_after;
4838 let sub = |a: libc::timeval, b: libc::timeval| -> libc::timeval {
4839 let mut sec = a.tv_sec - b.tv_sec;
4840 let mut usec = a.tv_usec as i64 - b.tv_usec as i64;
4841 if usec < 0 {
4842 sec -= 1;
4843 usec += 1_000_000;
4844 }
4845 libc::timeval {
4846 tv_sec: sec,
4847 tv_usec: usec as libc::suseconds_t,
4848 }
4849 };
4850 delta.ru_utime = sub(ru_after.ru_utime, ru_before.ru_utime);
4851 delta.ru_stime = sub(ru_after.ru_stime, ru_before.ru_stime);
4852 let ti = crate::ported::zsh_h::timeinfo::from_rusage(&delta);
4853 // c:Src/jobs.c:808-809 — `s = getsparam("TIMEFMT"); s ||
4854 // DEFAULT_TIMEFMT`. Honor user-set TIMEFMT, fall back to the
4855 // canonical default.
4856 let fmt = crate::ported::params::getsparam("TIMEFMT")
4857 .unwrap_or_else(|| crate::ported::zsh_system_h::DEFAULT_TIMEFMT.to_string());
4858 // c:Src/jobs.c:768 `desc` arg — for the `time { sublist }` /
4859 // `time simple-cmd` keyword path, zsh passes the sublist's
4860 // source text (used by %J via printtime). The compiler now
4861 // threads the rendered source text through as the desc operand
4862 // (compile_zsh.rs Time arm, argc==2 form). Bug #66.
4863 // c:Src/jobs.c — no job, no report (see forks_before above).
4864 let forked = crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed)
4865 != forks_before;
4866 if forked {
4867 let line = crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
4868 eprintln!("{}", line);
4869 }
4870 Value::Status(status)
4871 });
4872
4873 // `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocator.
4874 // Stack: [path, varid, op_byte]. Opens path with the appropriate mode
4875 // and stores the resulting fd number in $varid as a string. We use
4876 // a high starting fd (10+) by allocating then dup'ing — matches zsh's
4877 // "fresh fd >= 10" promise so subsequent commands don't collide on
4878 // stdin/out/err.
4879 vm.register_builtin(BUILTIN_OPEN_NAMED_FD, |vm, _argc| {
4880 use std::sync::atomic::Ordering;
4881 let op_byte = vm.pop().to_int() as u8;
4882 let varid = vm.pop().to_str();
4883 let path = vm.pop().to_str();
4884 // Param introspection used by both the open and close forms.
4885 let param_flags = crate::ported::params::paramtab()
4886 .read()
4887 .ok()
4888 .and_then(|t| t.get(&varid).map(|p| p.node.flags));
4889 let param_readonly = param_flags
4890 .map(|f| (f & crate::ported::zsh_h::PM_READONLY as i32) != 0)
4891 .unwrap_or(false);
4892 // `{varid}>&-` / `{varid}<&-` — REDIR_CLOSE with varid.
4893 // Direct port of Src/exec.c:3805-3850.
4894 if matches!(
4895 op_byte,
4896 b if b == fusevm::op::redirect_op::DUP_WRITE
4897 || b == fusevm::op::redirect_op::DUP_READ
4898 ) {
4899 let n = path.trim_start_matches('&');
4900 if n == "-" {
4901 let val = with_executor(|exec| exec.scalar(&varid)).unwrap_or_default();
4902 let fd1 = val.parse::<i32>();
4903 // c:3811-3816 — bad=1: parameter doesn't contain an fd.
4904 let Ok(fd1) = fd1 else {
4905 crate::ported::utils::zwarn(&format!(
4906 "parameter {} does not contain a file descriptor",
4907 varid
4908 ));
4909 with_executor(|exec| exec.redirect_failed = true);
4910 return Value::Status(1);
4911 };
4912 // c:3813-3814 — bad=2: readonly parameter.
4913 if param_readonly {
4914 crate::ported::utils::zwarn(&format!(
4915 "can't close file descriptor from readonly parameter {}",
4916 varid
4917 ));
4918 with_executor(|exec| exec.redirect_failed = true);
4919 return Value::Status(1);
4920 }
4921 // c:3830-3835 — bad=3: fd >= 10 marked FDT_INTERNAL.
4922 if fd1 >= 10
4923 && fd1 <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
4924 && crate::ported::utils::fdtable_get(fd1)
4925 == crate::ported::zsh_h::FDT_INTERNAL
4926 {
4927 crate::ported::utils::zwarn(&format!(
4928 "file descriptor {} used by shell, not closed",
4929 fd1
4930 ));
4931 with_executor(|exec| exec.redirect_failed = true);
4932 return Value::Status(1);
4933 }
4934 // c:3870-3873 — close; report failure (varid form
4935 // always reports, unlike bare `N>&-`).
4936 if crate::ported::utils::zclose(fd1) < 0 {
4937 crate::ported::utils::zwarn(&format!(
4938 "failed to close file descriptor {}: {}",
4939 fd1,
4940 std::io::Error::last_os_error()
4941 ));
4942 return Value::Status(1);
4943 }
4944 return Value::Status(0);
4945 }
4946 // `{varid}>&N` — dup N to a fresh fd >= 10, store in varid.
4947 if let Ok(src) = n.parse::<i32>() {
4948 if param_readonly {
4949 crate::ported::utils::zwarn(&format!(
4950 "can't allocate file descriptor to readonly parameter {}",
4951 varid
4952 ));
4953 with_executor(|exec| exec.redirect_failed = true);
4954 return Value::Status(1);
4955 }
4956 let dup = unsafe { libc::dup(src) };
4957 if dup < 0 {
4958 crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src));
4959 with_executor(|exec| exec.redirect_failed = true);
4960 return Value::Status(1);
4961 }
4962 // c:2404-2412 addfd varid arm — movefd + FDT_EXTERNAL.
4963 let final_fd = crate::ported::utils::movefd(dup);
4964 crate::ported::utils::fdtable_set(
4965 final_fd,
4966 crate::ported::zsh_h::FDT_EXTERNAL,
4967 );
4968 with_executor(|exec| {
4969 exec.set_scalar(varid, final_fd.to_string());
4970 });
4971 return Value::Status(0);
4972 }
4973 return Value::Status(1);
4974 }
4975 // `{varid}<<HERE` / `{varid}<<<str` — op byte 255 (zshrs-side
4976 // contract with compile_redir; fusevm's redirect_op stops at
4977 // 8). C path: gethere/getherestr write the body to a temp
4978 // file (Src/exec.c:4660-4682), then addfd's varid arm moves
4979 // the read fd >= 10, marks FDT_EXTERNAL and sets the param
4980 // (c:2402-2412). `path` carries the BODY text here.
4981 if op_byte == 255 {
4982 if param_readonly {
4983 crate::ported::utils::zwarn(&format!(
4984 "can't allocate file descriptor to readonly parameter {}",
4985 varid
4986 ));
4987 with_executor(|exec| exec.redirect_failed = true);
4988 return Value::Status(1);
4989 }
4990 let body = format!("{}\n", path.trim_end_matches('\n'));
4991 let mut tmpl: Vec<u8> = b"/tmp/zshrs_hd_XXXXXX\0".to_vec();
4992 let write_fd =
4993 unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
4994 if write_fd < 0 {
4995 crate::ported::utils::zwarn(&format!(
4996 "can't create temp file for here document: {}",
4997 std::io::Error::last_os_error()
4998 ));
4999 return Value::Status(1);
5000 }
5001 let bytes = body.as_bytes();
5002 let mut off = 0;
5003 while off < bytes.len() {
5004 let n = unsafe {
5005 libc::write(
5006 write_fd,
5007 bytes[off..].as_ptr() as *const libc::c_void,
5008 bytes.len() - off,
5009 )
5010 };
5011 if n <= 0 {
5012 unsafe { libc::close(write_fd) };
5013 return Value::Status(1);
5014 }
5015 off += n as usize;
5016 }
5017 unsafe { libc::close(write_fd) };
5018 let read_fd = unsafe {
5019 libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY)
5020 };
5021 unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
5022 if read_fd < 0 {
5023 return Value::Status(1);
5024 }
5025 let final_fd = crate::ported::utils::movefd(read_fd);
5026 if final_fd < 0 {
5027 return Value::Status(1);
5028 }
5029 crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
5030 with_executor(|exec| {
5031 exec.set_scalar(varid, final_fd.to_string());
5032 });
5033 return Value::Status(0);
5034 }
5035 // Open form: `{varid}>file` etc.
5036 // c:Src/exec.c:2177-2215 checkclobberparam — gate BEFORE open.
5037 if param_readonly {
5038 // c:2191-2197
5039 crate::ported::utils::zwarn(&format!(
5040 "can't allocate file descriptor to readonly parameter {}",
5041 varid
5042 ));
5043 with_executor(|exec| exec.redirect_failed = true);
5044 return Value::Status(1);
5045 }
5046 // c:2199-2213 — NO_CLOBBER refuses to overwrite a parameter
5047 // already holding an OPEN fd (decimal value, fdtable says
5048 // FDT_EXTERNAL).
5049 if !isset(crate::ported::zsh_h::CLOBBER)
5050 && op_byte != fusevm::op::redirect_op::CLOBBER
5051 {
5052 if let Some(val) = with_executor(|exec| exec.scalar(&varid)) {
5053 if let Ok(fd) = val.parse::<i32>() {
5054 if fd >= 0
5055 && fd <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
5056 && crate::ported::utils::fdtable_get(fd)
5057 == crate::ported::zsh_h::FDT_EXTERNAL
5058 {
5059 crate::ported::utils::zwarn(&format!(
5060 "can't clobber parameter {} containing file descriptor {}",
5061 varid, fd
5062 ));
5063 with_executor(|exec| exec.redirect_failed = true);
5064 return Value::Status(1);
5065 }
5066 }
5067 }
5068 }
5069 let path_c = match CString::new(path.clone()) {
5070 Ok(c) => c,
5071 Err(_) => return Value::Status(1),
5072 };
5073 let flags = match op_byte {
5074 b if b == fusevm::op::redirect_op::READ => libc::O_RDONLY,
5075 b if b == fusevm::op::redirect_op::WRITE || b == fusevm::op::redirect_op::CLOBBER => {
5076 libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC
5077 }
5078 b if b == fusevm::op::redirect_op::APPEND => {
5079 libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND
5080 }
5081 b if b == fusevm::op::redirect_op::READ_WRITE => libc::O_RDWR | libc::O_CREAT,
5082 _ => return Value::Status(1),
5083 };
5084 let fd = unsafe { libc::open(path_c.as_ptr(), flags, 0o666) };
5085 if fd < 0 {
5086 return Value::Status(1);
5087 }
5088 // c:2404-2412 addfd varid arm — `fd1 = movefd(fd2);
5089 // fdtable[fd1] = FDT_EXTERNAL; setiparam(varid, fd1);`.
5090 // FDT_EXTERNAL (not INTERNAL): the user owns this fd — the
5091 // NO_CLOBBER gate above and `{fd}>&-` close both key off it.
5092 let final_fd = crate::ported::utils::movefd(fd);
5093 if final_fd < 0 {
5094 crate::ported::utils::zerr(&format!(
5095 "cannot move fd {}: {}",
5096 fd,
5097 std::io::Error::last_os_error()
5098 ));
5099 return Value::Status(1);
5100 }
5101 crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
5102 let _ = Ordering::Relaxed;
5103 with_executor(|exec| {
5104 exec.set_scalar(varid, final_fd.to_string());
5105 });
5106 Value::Status(0)
5107 });
5108
5109 // BUILTIN_SET_TRY_BLOCK_ERROR — capture the try-block's exit
5110 // status into `__zshrs_try_block_saved_status` (a scratch
5111 // scalar) so the always-arm can later restore it. Also set
5112 // `TRY_BLOCK_ERROR` per zsh semantics: it stays at -1 unless
5113 // the try-block fired an explicit error (errflag), per
5114 // c:Src/exec.c execlist's WC_TRYBLOCK arm.
5115 vm.register_builtin(BUILTIN_SET_TRY_BLOCK_ERROR, |vm, _argc| {
5116 use std::sync::atomic::Ordering;
5117 let vm_status = vm.last_status;
5118 let errored = (crate::ported::utils::errflag.load(Ordering::Relaxed)
5119 & crate::ported::zsh_h::ERRFLAG_ERROR)
5120 != 0;
5121 // c:Src/exec.c WC_TRYBLOCK — the always-arm runs with a
5122 // clean escape state. Snapshot RETFLAG / BREAKS / CONTFLAG /
5123 // EXIT_PENDING here and clear them; RESTORE_TRY_BLOCK_STATUS
5124 // re-applies them at always-arm exit so the propagation jump
5125 // emitted by compile_zsh fires correctly.
5126 let ret_save = crate::ported::builtin::RETFLAG.swap(0, Ordering::Relaxed);
5127 let brk_save = crate::ported::builtin::BREAKS.swap(0, Ordering::Relaxed);
5128 let cont_save = crate::ported::builtin::CONTFLAG.swap(0, Ordering::Relaxed);
5129 let exit_save = crate::ported::builtin::EXIT_PENDING.swap(0, Ordering::Relaxed);
5130 TRY_ESCAPE_SAVE.with(|s| {
5131 s.borrow_mut()
5132 .push((ret_save, brk_save, cont_save, exit_save));
5133 });
5134 with_executor(|exec| {
5135 exec.set_scalar(
5136 "__zshrs_try_block_saved_status".to_string(),
5137 vm_status.to_string(),
5138 );
5139 // c:Src/loop.c:765-766 — `try_errflag = errflag &
5140 // ERRFLAG_ERROR; try_interrupt = (errflag & ERRFLAG_INT) ?
5141 // 1 : 0`. Updates the canonical loop.c globals that
5142 // `$TRY_BLOCK_ERROR` / `$TRY_BLOCK_INTERRUPT` read from
5143 // (port at src/ported/loop.rs::try_errflag etc.).
5144 // Mirror into paramtab too via set_scalar so
5145 // `${parameters[TRY_BLOCK_ERROR]}` and direct assignment
5146 // shapes stay in sync.
5147 let try_err = if errored { vm_status as i64 } else { 0 };
5148 crate::ported::r#loop::try_errflag.store(try_err, Ordering::Relaxed);
5149 crate::ported::r#loop::try_interrupt.store(0, Ordering::Relaxed);
5150 if errored {
5151 exec.set_scalar(
5152 "TRY_BLOCK_ERROR".to_string(),
5153 vm_status.to_string(),
5154 );
5155 // Clear errflag so always-arm runs cleanly. c:768.
5156 crate::ported::utils::errflag.fetch_and(
5157 !crate::ported::zsh_h::ERRFLAG_ERROR,
5158 Ordering::Relaxed,
5159 );
5160 } else {
5161 exec.set_scalar("TRY_BLOCK_ERROR".to_string(), "0".to_string());
5162 }
5163 });
5164 Value::Status(0)
5165 });
5166
5167 // BUILTIN_BEGIN_INLINE_ENV / END_INLINE_ENV — wrap an
5168 // inline-assignment-prefixed command (`X=foo Y=bar cmd`):
5169 // BEGIN pushes a save frame; SET_VAR fires for each assign and
5170 // ALSO env::set_var's the value (visible to cmd's child); the
5171 // command runs; END pops the frame and restores both shell-var
5172 // and process-env state. Direct port of zsh's addvars() →
5173 // execute_simple → restore-after-exec contract.
5174 vm.register_builtin(BUILTIN_BEGIN_INLINE_ENV, |_vm, _argc| {
5175 with_executor(|exec| {
5176 exec.inline_env_stack.push(Vec::new());
5177 });
5178 Value::Status(0)
5179 });
5180 vm.register_builtin(BUILTIN_END_INLINE_ENV, |_vm, _argc| {
5181 with_executor(|exec| {
5182 if let Some(frame) = exec.inline_env_stack.pop() {
5183 for (name, prev_var, prev_env) in frame.into_iter().rev() {
5184 match prev_var {
5185 Some(v) => {
5186 exec.set_scalar(name.clone(), v);
5187 }
5188 None => {
5189 exec.unset_scalar(&name);
5190 }
5191 }
5192 match prev_env {
5193 Some(v) => env::set_var(&name, &v),
5194 None => env::remove_var(&name),
5195 }
5196 }
5197 }
5198 });
5199 Value::Status(0)
5200 });
5201 // c:Src/exec.c:3969-3976 — bare-exec assignment epilogue: see the
5202 // const's doc block. POSIX_BUILTINS → assignments persist (pop the
5203 // frame, discard the saved state); otherwise → restore_params
5204 // (same walk as END_INLINE_ENV).
5205 vm.register_builtin(BUILTIN_EXEC_INLINE_ENV_DONE, |_vm, _argc| {
5206 let persist = isset(crate::ported::zsh_h::POSIXBUILTINS);
5207 with_executor(|exec| {
5208 if let Some(frame) = exec.inline_env_stack.pop() {
5209 if persist {
5210 return; // c:3971 — no save/restore under POSIX_BUILTINS
5211 }
5212 for (name, prev_var, prev_env) in frame.into_iter().rev() {
5213 match prev_var {
5214 Some(v) => {
5215 exec.set_scalar(name.clone(), v);
5216 }
5217 None => {
5218 exec.unset_scalar(&name);
5219 }
5220 }
5221 match prev_env {
5222 Some(v) => env::set_var(&name, &v),
5223 None => env::remove_var(&name),
5224 }
5225 }
5226 }
5227 });
5228 Value::Status(0)
5229 });
5230
5231 // BUILTIN_RESTORE_TRY_BLOCK_STATUS — emitted at the end of an
5232 // `always` arm. Per zshmisc, the exit status of the entire
5233 // `{ try } always { finally }` construct is the try-list's
5234 // status, regardless of what happens in the always-list (the
5235 // exception is `return`/`exit` inside always, which short-
5236 // circuits and the cleanup is the only thing that runs). So
5237 // restore TRY_BLOCK_ERROR unconditionally — the always-list's
5238 // exit status is discarded for the construct.
5239 vm.register_builtin(BUILTIN_RESTORE_TRY_BLOCK_STATUS, |_vm, _argc| {
5240 use std::sync::atomic::Ordering;
5241 // c:Src/exec.c — the entire `{try} always {…}` construct's
5242 // exit status is the try-block's last status. Per zsh
5243 // semantics this carries through regardless of what the
5244 // always-arm did (including reads/writes of TRY_BLOCK_ERROR
5245 // — those affect later commands' visible value but don't
5246 // override the construct's exit). The "swallow" idiom in
5247 // C is gated on errflag state at always-arm exit, not on
5248 // TBE's literal value; full fidelity needs more state and
5249 // is deferred.
5250 let saved = with_executor(|exec| {
5251 exec.scalar("__zshrs_try_block_saved_status")
5252 .and_then(|s| s.parse::<i32>().ok())
5253 .unwrap_or(0)
5254 });
5255 // Re-apply the escape flags captured by SET_TRY_BLOCK_ERROR.
5256 // If the always-arm itself fired return/break/continue/exit,
5257 // its handler already overwrote the canonical atomics; let
5258 // those win — the always-arm's own escape always takes
5259 // priority over the try-block's deferred one.
5260 if let Some((ret, brk, cont, exit_p)) = TRY_ESCAPE_SAVE.with(|s| s.borrow_mut().pop()) {
5261 if crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) == 0 {
5262 crate::ported::builtin::RETFLAG.store(ret, Ordering::Relaxed);
5263 }
5264 if crate::ported::builtin::BREAKS.load(Ordering::Relaxed) == 0 {
5265 crate::ported::builtin::BREAKS.store(brk, Ordering::Relaxed);
5266 }
5267 if crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed) == 0 {
5268 crate::ported::builtin::CONTFLAG.store(cont, Ordering::Relaxed);
5269 }
5270 if crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) == 0 {
5271 crate::ported::builtin::EXIT_PENDING.store(exit_p, Ordering::Relaxed);
5272 }
5273 }
5274 Value::Status(saved)
5275 });
5276
5277 vm.register_builtin(BUILTIN_IS_TTY, |vm, _argc| {
5278 let fd_str = vm.pop().to_str();
5279 let fd: i32 = fd_str.trim().parse().unwrap_or(-1);
5280 let is_tty = if fd < 0 {
5281 false
5282 } else {
5283 unsafe { libc::isatty(fd) != 0 }
5284 };
5285 Value::Bool(is_tty)
5286 });
5287
5288 // Set $LINENO before executing the next statement. Direct
5289 // port of zsh's `lineno` global tracking from Src/input.c
5290 // (`if ((inbufflags & INP_LINENO) || !strin) && c == '\n')
5291 // lineno++;`). The compiler emits one of these before each
5292 // top-level pipe in `compile_sublist`, carrying the line
5293 // number captured by the parser at `ZshPipe.lineno`. Pops
5294 // [n], updates `$LINENO` in the variable table.
5295 vm.register_builtin(BUILTIN_SET_LINENO, |vm, _argc| {
5296 let n = vm.pop().to_int();
5297 // c:Src/exec.c:lineno = N — direct write to the param's
5298 // u_val. Cannot go through setsparam because LINENO carries
5299 // PM_READONLY (so `(t)LINENO` reads `integer-readonly-special`
5300 // per zsh); setsparam → assignstrvalue's PM_READONLY guard
5301 // would reject the internal write. C zsh handles this via the
5302 // PM_SPECIAL GSU vtable's setfn callback which bypasses the
5303 // generic readonly check; the Rust port writes the canonical
5304 // field directly instead.
5305 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
5306 if let Some(pm) = tab.get_mut("LINENO") {
5307 pm.u_val = n;
5308 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
5309 }
5310 }
5311 // Mirror to the file-static `lineno` (utils.c:121) that
5312 // zerrmsg reads at utils.c:301 for the `:N: msg` prefix.
5313 crate::ported::utils::set_lineno(n as i32);
5314 // Also drive lex::LEX_LINENO — zerrmsg (utils.rs:376) reads
5315 // THAT counter for the `name:N:` prefix. C zsh interleaves
5316 // parse and execute per top-level list, so its single
5317 // `lineno` global serves both; zshrs compiles the whole
5318 // script before running, leaving LEX_LINENO parked at EOF.
5319 // Without this write, every runtime zwarn/zerr reported the
5320 // script's LAST line instead of the failing statement's.
5321 crate::ported::lex::set_lineno(n as u64);
5322 // DAP hook — checks breakpoints / step mode / pause-request
5323 // for the line we just landed on. O(1) no-op when DAP is off
5324 // (single atomic load on a OnceLock). Inside `--dap` mode
5325 // this is the call that blocks the executor on a Condvar
5326 // until the IDE sends `continue`. Mirrors strykelang's
5327 // `debugger.should_stop(line) → debugger.prompt(...)` flow.
5328 crate::extensions::dap::check_line(n as u32);
5329 Value::Status(0)
5330 });
5331
5332 // Direct port of Src/prompt.c:1623 cmdpush. Token is a `CS_*`
5333 // value (zsh.h:2775-2806) emitted by compile_zsh around each
5334 // compound command (if/while/[[…]]/((…))/$(…)) and consumed by
5335 // `%_` in PS4 / prompt expansion.
5336 vm.register_builtin(BUILTIN_CMD_PUSH, |vm, _argc| {
5337 let token = vm.pop().to_int() as u8;
5338 // Route through canonical cmdpush (Src/prompt.c:1623). The
5339 // prompt expander reads from the file-static `CMDSTACK` at
5340 // `prompt.rs:2006`, not `exec.cmd_stack` — without this,
5341 // `%_` in PS4 saw an empty stack during xtrace.
5342 if (token as i32) < crate::ported::zsh_h::CS_COUNT {
5343 crate::ported::prompt::cmdpush(token);
5344 }
5345 Value::Status(0)
5346 });
5347
5348 // Direct port of Src/prompt.c:1631 cmdpop.
5349 vm.register_builtin(BUILTIN_CMD_POP, |_vm, _argc| {
5350 crate::ported::prompt::cmdpop();
5351 Value::Status(0)
5352 });
5353
5354 vm.register_builtin(BUILTIN_OPTION_SET, |vm, _argc| {
5355 let name = vm.pop().to_str();
5356 // Direct port of `optison(char *name, char *s)` at Src/cond.c:502 — `[[ -o NAME ]]`
5357 // reads through the same `opts[]` array that `setopt NAME`
5358 // writes via `dosetopt`. Earlier code read a duplicate Executor
5359 // HashMap which never saw `bin_setopt`'s writes (those land in
5360 // `OPTS_LIVE` via `opt_state_set`). Routing through the canonical
5361 // C port restores the single-store invariant: one `opts[]`,
5362 // shared between setopt/unsetopt and `[[ -o ]]`.
5363 let r = crate::ported::cond::optison("test", &name); // c:cond.c:502
5364 match r {
5365 0 => Value::Bool(true), // c:cond.c:520 set
5366 1 => Value::Bool(false), // c:cond.c:518/520 unset
5367 _ => {
5368 // c:cond.c:514 — unknown option: zwarnnam emitted by
5369 // optison itself when POSIXBUILTINS is unset; mirror to
5370 // stderr here for parity with the earlier diagnostic.
5371 eprintln!("{}:1: no such option: {}", shname(), name);
5372 Value::Bool(false)
5373 }
5374 }
5375 });
5376 // Tri-state `-o` for compile_cond's direct status path. Returns
5377 // 0 / 1 / 3 as a Value::Int that compile_cond consumes via
5378 // Op::SetStatus. Mirrors zsh's `[[ -o invalid ]]` returning $?=3.
5379 vm.register_builtin(BUILTIN_OPTION_CHECK_TRISTATE, |vm, _argc| {
5380 let name = vm.pop().to_str();
5381 let r = crate::ported::cond::optison("test", &name); // c:cond.c:502
5382 // optison itself prints the diagnostic via zwarnnam when r=3
5383 // and POSIXBUILTINS is unset (the canonical path). Don't
5384 // double-emit here. r is already 0/1/3.
5385 Value::Int(r as i64)
5386 });
5387
5388 // BUILTIN_PARAM_FILTER — `${var:#pat}` / `${var:|name}` etc.
5389 // PURE PASSTHRU: rebuild `${name:#pat}` and route to paramsubst.
5390 vm.register_builtin(BUILTIN_PARAM_FILTER, |vm, _argc| {
5391 let pattern = vm.pop().to_str();
5392 let name = vm.pop().to_str();
5393 let body = format!("${{{}:#{}}}", name, pattern);
5394 paramsubst_to_value(&body)
5395 });
5396
5397 // `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()`
5398 // — subscripted-array assign with array RHS. Stack pushed by
5399 // compile_assign as: [elem0, elem1, …, elemN-1, name, key].
5400 vm.register_builtin(BUILTIN_SET_SUBSCRIPT_RANGE, |vm, argc| {
5401 let n = argc as usize;
5402 let mut popped: Vec<Value> = Vec::with_capacity(n);
5403 for _ in 0..n {
5404 popped.push(vm.pop());
5405 }
5406 popped.reverse();
5407 if popped.len() < 3 {
5408 return Value::Status(1);
5409 }
5410 // c:Src/params.c:3511-3526 — trailing append flag. The ARRAY
5411 // subscript path (`a[N]+=(v)` / `a[lo,hi]+=(v)`) sets it so the
5412 // AUGMENT transform below collapses the range to an empty range
5413 // after the slice end and inserts ONLY the new value. The scalar
5414 // path pre-concats the old slice (ARRAY_INDEX+Concat) and passes
5415 // 0, so it keeps plain-replace semantics.
5416 let append = popped.pop().map_or(false, |v| v.to_str() == "1");
5417 let key = popped.pop().unwrap().to_str();
5418 let name = popped.pop().unwrap().to_str();
5419 let mut values: Vec<String> = Vec::new();
5420 for v in popped {
5421 match v {
5422 Value::Array(items) => {
5423 for it in items {
5424 values.push(it.to_str());
5425 }
5426 }
5427 other => values.push(other.to_str()),
5428 }
5429 }
5430 with_executor(|exec| {
5431 // Parse subscript: slice `lo,hi` or single index `i`.
5432 // setarrvalue (Src/params.c:2895) expects 1-based start/
5433 // end inclusive where start==end means replace one
5434 // element. Negative bounds translate to len+n+1 (1-based).
5435 //
5436 // c:Src/params.c — the END side accepts 0 as a valid value
5437 // that signals "insert BEFORE start position" (the canonical
5438 // `a[N,N-1]=val` prepend / mid-insert idiom). Bug #275 in
5439 // docs/BUGS.md: the previous Rust port clamped end up to 1,
5440 // collapsing `a[1,0]=(X Y)` into `a[1,1]=(X Y)` which
5441 // OVERWRITES position 1 instead of prepending. Provide two
5442 // translators — start_translate clamps to 1 (1-based);
5443 // end_translate keeps 0 intact so the splice in
5444 // setarrvalue (start_idx=0..end_idx=0) inserts at the front.
5445 // Bug #589: for scalars (no array), use the scalar's char
5446 // count as `len` so negative-index translation (`a[2,-1]`)
5447 // computes against the actual string length, not 0.
5448 let len = exec
5449 .array(&name)
5450 .map(|a| a.len() as i64)
5451 .or_else(|| {
5452 crate::ported::params::paramtab()
5453 .read()
5454 .ok()
5455 .and_then(|t| {
5456 t.get(&name).and_then(|pm| {
5457 if crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
5458 == crate::ported::zsh_h::PM_SCALAR
5459 {
5460 pm.u_str
5461 .as_ref()
5462 .map(|s| s.chars().count() as i64)
5463 } else {
5464 None
5465 }
5466 })
5467 })
5468 })
5469 .unwrap_or(0);
5470 let start_translate = |raw: i64| -> i32 {
5471 if raw < 0 {
5472 (len + raw + 1).max(1) as i32
5473 } else {
5474 raw.max(1) as i32
5475 }
5476 };
5477 let end_translate = |raw: i64| -> i32 {
5478 if raw < 0 {
5479 (len + raw + 1).max(0) as i32
5480 } else {
5481 raw.max(0) as i32
5482 }
5483 };
5484 // c:Src/params.c — KSH_ARRAYS option flips array subscripts
5485 // from 1-based to 0-based. setarrvalue expects 1-based
5486 // inclusive bounds, so under KSH_ARRAYS we shift positive
5487 // inputs by +1 before translation. Negative bounds left
5488 // alone (count from end). Sibling of #610/#611/#612.
5489 // Bug #613.
5490 let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
5491 let ksh_shift = |raw: i64| -> i64 {
5492 if ksh_arrays && raw >= 0 { raw + 1 } else { raw }
5493 };
5494 // c:Src/params.c getindex — subscript bounds are MATH
5495 // expressions, not bare integers: `a[(( ${#a}+1 ))]=(x)`,
5496 // `a[n+1]=(x)`. Plain `parse::<i64>()` returned 0 on any
5497 // arithmetic subscript, which the `i == 0` guard turned into
5498 // a silent no-op (computed-index append never landed). Parse
5499 // the literal fast-path first, then fall back to mathevali
5500 // (which handles `(( ))` grouping, var refs, and operators).
5501 let eval_bound = |s: &str| -> i64 {
5502 let t = s.trim();
5503 t.parse::<i64>()
5504 .ok()
5505 .or_else(|| crate::ported::math::mathevali(t).ok())
5506 .unwrap_or(0)
5507 };
5508 let (start, end) = if let Some((s_str, e_str)) = key.split_once(',') {
5509 let s = ksh_shift(eval_bound(s_str));
5510 let e = ksh_shift(eval_bound(e_str));
5511 (start_translate(s), end_translate(e))
5512 } else {
5513 let i = ksh_shift(eval_bound(&key));
5514 if i == 0 {
5515 return;
5516 }
5517 let n = start_translate(i);
5518 (n, n)
5519 };
5520 // c:Src/params.c:3518-3520 (assignaparam ASSPM_AUGMENT) — a
5521 // subscripted `+=` to an array does NOT prepend the old slice;
5522 // it collapses the range to an EMPTY range positioned right
5523 // AFTER the slice end (`v->start = v->end--`) and splices in
5524 // ONLY the new value: `a[2]+=(d)` on (a b c) → (a b d c);
5525 // `a[2,3]+=(x)` on (1 2 3 4) → (1 2 3 x 4). In setarrvalue's
5526 // 1-based convention here that means start = end+1 (so
5527 // start_idx == end_idx == end → splice arr[end..end]).
5528 let (start, end) = if append && end > 0 {
5529 (end + 1, end)
5530 } else {
5531 (start, end)
5532 };
5533 // Route through canonical setarrvalue (Src/params.c:2895).
5534 // It handles PM_READONLY rejection, PM_HASHED slice-error,
5535 // PM_ARRAY splice + bounds clamp + padding (c:2980+).
5536 let taken = match crate::ported::params::paramtab().write() {
5537 Ok(mut tab) => tab.remove(&name),
5538 Err(_) => None,
5539 };
5540 // c:Src/exec.c:2640 / getvalue(…, 1) — a subscript assignment to
5541 // a NONEXISTENT parameter auto-creates it. getindex/fetchvalue
5542 // with the create flag calls createparam(name, PM_ARRAY) so the
5543 // splice has an array to write into; `unset u; u[1,2]=(a z)`
5544 // then yields the array (a z). Without this, setarrvalue saw
5545 // v.pm == None and silently stored nothing. The single-index
5546 // scalar-value path (SET_ASSOC/SET_ARRAY_AT) already vivifies;
5547 // this brings the range/array-value path to parity.
5548 let taken = taken.or_else(|| {
5549 crate::ported::params::createparam(
5550 &name,
5551 crate::ported::zsh_h::PM_ARRAY as i32,
5552 );
5553 crate::ported::params::paramtab()
5554 .write()
5555 .ok()
5556 .and_then(|mut t| t.remove(&name))
5557 });
5558 // c:Src/params.c:2748+ — PM_SCALAR with subscript range
5559 // SPLICES the value into the scalar's char string. Bug
5560 // #589: zshrs's slice handler always called setarrvalue,
5561 // erroring "attempt to assign array value to non-array"
5562 // for `a=hello; a[2,3]=XYZ`. Detect PM_SCALAR and route
5563 // through assignstrvalue (which does scalar splice via
5564 // the PM_SCALAR arm at params.rs:3709-3789).
5565 let is_scalar = taken.as_ref().map_or(false, |pm| {
5566 crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
5567 == crate::ported::zsh_h::PM_SCALAR
5568 });
5569 let mut v = crate::ported::zsh_h::value {
5570 pm: taken,
5571 arr: Vec::new(),
5572 scanflags: 0,
5573 valflags: 0,
5574 start,
5575 end,
5576 };
5577 if is_scalar {
5578 // Scalar splice — concat values, route through
5579 // assignstrvalue which dispatches by PM_TYPE.
5580 // start_translate returns 1-based positions; assignstrvalue's
5581 // PM_SCALAR arm at params.rs:3735+ expects 0-based start
5582 // (chars before start are kept) and 0-based end-exclusive
5583 // (chars from end are kept). Convert: start-=1.
5584 if v.start > 0 {
5585 v.start -= 1;
5586 }
5587 let val: String = values.join("");
5588 crate::ported::params::assignstrvalue(Some(&mut v), Some(val), 0);
5589 } else {
5590 crate::ported::params::setarrvalue(&mut v, values);
5591 }
5592 // Write the mutated Param back to paramtab — setarrvalue
5593 // mutated v.pm in-place; the prior `tab.remove(&name)` at
5594 // the top of this handler took ownership, so we re-insert
5595 // here. setarrvalue + this re-insert IS the canonical
5596 // store (Src/params.c:2895). No further mirror needed.
5597 if let Some(pm) = v.pm {
5598 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
5599 tab.insert(name, pm);
5600 }
5601 }
5602 });
5603 Value::Status(0)
5604 });
5605
5606 // BUILTIN_CONCAT_SPLICE — word-segment concat with first/last
5607 // sticking (default zsh splice semantics for `${arr[@]}`, `$@`).
5608 vm.register_builtin(BUILTIN_CONCAT_SPLICE, |vm, _argc| {
5609 let rhs = vm.pop();
5610 let lhs = vm.pop();
5611 match (lhs, rhs) {
5612 (Value::Array(mut la), Value::Array(ra)) => {
5613 if la.is_empty() {
5614 return Value::Array(ra);
5615 }
5616 if ra.is_empty() {
5617 return Value::Array(la);
5618 }
5619 // Last of la merges with first of ra; rest unchanged.
5620 let last_l = la.pop().unwrap();
5621 let mut ra_iter = ra.into_iter();
5622 let first_r = ra_iter.next().unwrap();
5623 let l_s = last_l.as_str_cow();
5624 let r_s = first_r.as_str_cow();
5625 let mut merged = String::with_capacity(l_s.len() + r_s.len());
5626 merged.push_str(&l_s);
5627 merged.push_str(&r_s);
5628 la.push(Value::str(merged));
5629 la.extend(ra_iter);
5630 Value::Array(la)
5631 }
5632 (Value::Array(mut la), rhs_scalar) => {
5633 // c:Src/subst.c paramsubst splice — empty array on
5634 // either side preserves the empty (zero words),
5635 // doesn't collapse into a single-empty-string scalar.
5636 // Bug #120 in docs/BUGS.md: empty array slice
5637 // concatenated with empty literal returned
5638 // Value::str("") which surfaced as one empty arg
5639 // instead of zero args.
5640 let rhs_s = rhs_scalar.as_str_cow();
5641 if la.is_empty() {
5642 if rhs_s.is_empty() {
5643 return Value::Array(Vec::new());
5644 }
5645 return Value::str(rhs_s.to_string());
5646 }
5647 let last = la.pop().unwrap();
5648 let l_s = last.as_str_cow();
5649 let mut s = String::with_capacity(l_s.len() + rhs_s.len());
5650 s.push_str(&l_s);
5651 s.push_str(&rhs_s);
5652 la.push(Value::str(s));
5653 Value::Array(la)
5654 }
5655 (lhs_scalar, Value::Array(mut ra)) => {
5656 let lhs_s = lhs_scalar.as_str_cow();
5657 if ra.is_empty() {
5658 // Empty-array RHS — preserve emptiness when the
5659 // LHS is also empty (no prefix to attach). Bug
5660 // #120 in docs/BUGS.md.
5661 if lhs_s.is_empty() {
5662 return Value::Array(Vec::new());
5663 }
5664 return Value::str(lhs_s.to_string());
5665 }
5666 let first = ra.remove(0);
5667 let r_s = first.as_str_cow();
5668 let mut s = String::with_capacity(lhs_s.len() + r_s.len());
5669 s.push_str(&lhs_s);
5670 s.push_str(&r_s);
5671 let mut out = Vec::with_capacity(ra.len() + 1);
5672 out.push(Value::str(s));
5673 out.extend(ra);
5674 Value::Array(out)
5675 }
5676 (lhs_s, rhs_s) => {
5677 let l = lhs_s.as_str_cow();
5678 let r = rhs_s.as_str_cow();
5679 let mut s = String::with_capacity(l.len() + r.len());
5680 s.push_str(&l);
5681 s.push_str(&r);
5682 Value::str(s)
5683 }
5684 }
5685 });
5686
5687 // BUILTIN_CONCAT_DISTRIBUTE — word-segment concat. With
5688 // rcexpandparam (zsh option), distributes element-wise (cartesian
5689 // product). Default mode: joins arrays with IFS first char to a
5690 // single scalar before concat, matching zsh's default unquoted
5691 // and DQ semantics. Direct port of Src/subst.c sepjoin path
5692 // (line ~1813) which gates element-vs-join on the rc_expand_param
5693 // option, defaulting to join.
5694 // BUILTIN_CONCAT_DISTRIBUTE_FORCED — same shape as
5695 // CONCAT_DISTRIBUTE, but always cartesian-distributes when one
5696 // side is Array. Used for compile-time-detected explicit
5697 // distribution forms (`${^arr}` etc.) where the source flag
5698 // overrides the rcexpandparam option default.
5699 vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE_FORCED, |vm, _argc| {
5700 let rhs = vm.pop();
5701 let lhs = vm.pop();
5702 match (lhs, rhs) {
5703 (Value::Array(la), Value::Array(ra)) => {
5704 if ra.is_empty() {
5705 return Value::Array(la);
5706 }
5707 if la.is_empty() {
5708 return Value::Array(ra);
5709 }
5710 let mut out = Vec::with_capacity(la.len() * ra.len());
5711 for a in &la {
5712 let a_s = a.as_str_cow();
5713 for b in &ra {
5714 let b_s = b.as_str_cow();
5715 let mut s = String::with_capacity(a_s.len() + b_s.len());
5716 s.push_str(&a_s);
5717 s.push_str(&b_s);
5718 out.push(Value::str(s));
5719 }
5720 }
5721 Value::Array(out)
5722 }
5723 (Value::Array(la), rhs_scalar) => {
5724 let r = rhs_scalar.as_str_cow();
5725 let out: Vec<Value> = la
5726 .into_iter()
5727 .map(|a| {
5728 let a_s = a.as_str_cow();
5729 let mut s = String::with_capacity(a_s.len() + r.len());
5730 s.push_str(&a_s);
5731 s.push_str(&r);
5732 Value::str(s)
5733 })
5734 .collect();
5735 Value::Array(out)
5736 }
5737 (lhs_scalar, Value::Array(ra)) => {
5738 let l = lhs_scalar.as_str_cow();
5739 let out: Vec<Value> = ra
5740 .into_iter()
5741 .map(|b| {
5742 let b_s = b.as_str_cow();
5743 let mut s = String::with_capacity(l.len() + b_s.len());
5744 s.push_str(&l);
5745 s.push_str(&b_s);
5746 Value::str(s)
5747 })
5748 .collect();
5749 Value::Array(out)
5750 }
5751 (lhs_s, rhs_s) => {
5752 let l = lhs_s.as_str_cow();
5753 let r = rhs_s.as_str_cow();
5754 let mut s = String::with_capacity(l.len() + r.len());
5755 s.push_str(&l);
5756 s.push_str(&r);
5757 Value::str(s)
5758 }
5759 }
5760 });
5761
5762 vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE, |vm, argc| {
5763 let rhs = vm.pop();
5764 let lhs = vm.pop();
5765 // c:Src/options.c — RC_EXPAND_PARAM applies to UNQUOTED
5766 // expansions only; inside DQ `"$foo${arr}bar"` joins via
5767 // $IFS[0] regardless of the option. The compiler emits
5768 // CallBuiltin(BUILTIN_CONCAT_DISTRIBUTE, 1) when the parent
5769 // word is DQ-wrapped (compile_zsh.rs parent_is_dq); the
5770 // default UNQUOTED path emits argc=2 (lhs + rhs). Treat
5771 // argc==1 as "force rc_expand off." Bug #246 in docs/BUGS.md.
5772 let dq_suppress = argc == 1;
5773 let rc_expand = !dq_suppress
5774 && with_executor(|exec| opt_state_get("rcexpandparam").unwrap_or(false));
5775 // Helper: join an Array to scalar via sepjoin's IFS default.
5776 // c:Src/utils.c:3936-3945 — set-but-empty IFS joins with ""
5777 // (`IFS=""; echo "x$*y"` → `xabcy`); only unset /
5778 // space-leading IFS yields " ".
5779 let join_arr = |arr: Vec<Value>| -> String {
5780 let strs: Vec<String> = arr
5781 .iter()
5782 .map(|v| v.as_str_cow().into_owned())
5783 .collect();
5784 crate::ported::utils::sepjoin(&strs, None)
5785 };
5786 if !rc_expand {
5787 // Default: join any Array side to scalar, then concat.
5788 let l = match lhs {
5789 Value::Array(a) => join_arr(a),
5790 other => other.as_str_cow().into_owned(),
5791 };
5792 let r = match rhs {
5793 Value::Array(a) => join_arr(a),
5794 other => other.as_str_cow().into_owned(),
5795 };
5796 let mut s = String::with_capacity(l.len() + r.len());
5797 s.push_str(&l);
5798 s.push_str(&r);
5799 return Value::str(s);
5800 }
5801 match (lhs, rhs) {
5802 (Value::Array(la), Value::Array(ra)) => {
5803 // Cartesian product: [a + b for a in la for b in ra].
5804 let mut out = Vec::with_capacity(la.len() * ra.len().max(1));
5805 if ra.is_empty() {
5806 return Value::Array(la);
5807 }
5808 if la.is_empty() {
5809 return Value::Array(ra);
5810 }
5811 for a in &la {
5812 let a_s = a.as_str_cow();
5813 for b in &ra {
5814 let b_s = b.as_str_cow();
5815 let mut s = String::with_capacity(a_s.len() + b_s.len());
5816 s.push_str(&a_s);
5817 s.push_str(&b_s);
5818 out.push(Value::str(s));
5819 }
5820 }
5821 Value::Array(out)
5822 }
5823 (Value::Array(la), rhs_scalar) => {
5824 let r = rhs_scalar.as_str_cow();
5825 let out: Vec<Value> = la
5826 .into_iter()
5827 .map(|a| {
5828 let a_s = a.as_str_cow();
5829 let mut s = String::with_capacity(a_s.len() + r.len());
5830 s.push_str(&a_s);
5831 s.push_str(&r);
5832 Value::str(s)
5833 })
5834 .collect();
5835 Value::Array(out)
5836 }
5837 (lhs_scalar, Value::Array(ra)) => {
5838 let l = lhs_scalar.as_str_cow();
5839 let out: Vec<Value> = ra
5840 .into_iter()
5841 .map(|b| {
5842 let b_s = b.as_str_cow();
5843 let mut s = String::with_capacity(l.len() + b_s.len());
5844 s.push_str(&l);
5845 s.push_str(&b_s);
5846 Value::str(s)
5847 })
5848 .collect();
5849 Value::Array(out)
5850 }
5851 (lhs_s, rhs_s) => {
5852 // Fast path: both scalar → identical to Op::Concat.
5853 let l = lhs_s.as_str_cow();
5854 let r = rhs_s.as_str_cow();
5855 let mut s = String::with_capacity(l.len() + r.len());
5856 s.push_str(&l);
5857 s.push_str(&r);
5858 Value::str(s)
5859 }
5860 }
5861 });
5862
5863 // `[[ a -ef b ]]` — same-inode test. Resolves both paths via fs::metadata
5864 // (follows symlinks the way zsh's -ef does) and compares (dev, inode).
5865 // Returns false on any I/O error (path missing, permission denied, etc.).
5866 vm.register_builtin(BUILTIN_SAME_FILE, |vm, _argc| {
5867 let b = vm.pop().to_str();
5868 let a = vm.pop().to_str();
5869 let same = match (fs::metadata(&a), fs::metadata(&b)) {
5870 (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
5871 _ => false,
5872 };
5873 Value::Bool(same)
5874 });
5875
5876 // `[[ -c path ]]` — character device.
5877 vm.register_builtin(BUILTIN_IS_CHARDEV, |vm, _argc| {
5878 let path = vm.pop().to_str();
5879 let result = fs::metadata(&path)
5880 .map(|m| m.file_type().is_char_device())
5881 .unwrap_or(false);
5882 Value::Bool(result)
5883 });
5884 // `[[ -b path ]]` — block device.
5885 vm.register_builtin(BUILTIN_IS_BLOCKDEV, |vm, _argc| {
5886 let path = vm.pop().to_str();
5887 let result = fs::metadata(&path)
5888 .map(|m| m.file_type().is_block_device())
5889 .unwrap_or(false);
5890 Value::Bool(result)
5891 });
5892 // `[[ -p path ]]` — FIFO (named pipe).
5893 vm.register_builtin(BUILTIN_IS_FIFO, |vm, _argc| {
5894 let path = vm.pop().to_str();
5895 let result = fs::metadata(&path)
5896 .map(|m| m.file_type().is_fifo())
5897 .unwrap_or(false);
5898 Value::Bool(result)
5899 });
5900 // `[[ -S path ]]` — socket.
5901 vm.register_builtin(BUILTIN_IS_SOCKET, |vm, _argc| {
5902 let path = vm.pop().to_str();
5903 let result = fs::symlink_metadata(&path)
5904 .map(|m| m.file_type().is_socket())
5905 .unwrap_or(false);
5906 Value::Bool(result)
5907 });
5908
5909 // `[[ -k path ]]` / `-u` / `-g` — sticky / setuid / setgid bit.
5910 vm.register_builtin(BUILTIN_HAS_STICKY, |vm, _argc| {
5911 let path = vm.pop().to_str();
5912 let result = fs::metadata(&path)
5913 .map(|m| m.permissions().mode() & libc::S_ISVTX as u32 != 0)
5914 .unwrap_or(false);
5915 Value::Bool(result)
5916 });
5917 vm.register_builtin(BUILTIN_HAS_SETUID, |vm, _argc| {
5918 let path = vm.pop().to_str();
5919 let result = fs::metadata(&path)
5920 .map(|m| m.permissions().mode() & libc::S_ISUID as u32 != 0)
5921 .unwrap_or(false);
5922 Value::Bool(result)
5923 });
5924 vm.register_builtin(BUILTIN_HAS_SETGID, |vm, _argc| {
5925 let path = vm.pop().to_str();
5926 let result = fs::metadata(&path)
5927 .map(|m| m.permissions().mode() & libc::S_ISGID as u32 != 0)
5928 .unwrap_or(false);
5929 Value::Bool(result)
5930 });
5931 vm.register_builtin(BUILTIN_OWNED_BY_USER, |vm, _argc| {
5932 let path = vm.pop().to_str();
5933 let euid = unsafe { libc::geteuid() };
5934 let result = fs::metadata(&path)
5935 .map(|m| m.uid() == euid)
5936 .unwrap_or(false);
5937 Value::Bool(result)
5938 });
5939 vm.register_builtin(BUILTIN_OWNED_BY_GROUP, |vm, _argc| {
5940 let path = vm.pop().to_str();
5941 let egid = unsafe { libc::getegid() };
5942 let result = fs::metadata(&path)
5943 .map(|m| m.gid() == egid)
5944 .unwrap_or(false);
5945 Value::Bool(result)
5946 });
5947
5948 // `[[ -N path ]]` — file's access time is NOT newer than its
5949 // modification time (zsh man: "true if file exists and its
5950 // access time is not newer than its modification time"). Used
5951 // by zsh's mailbox-watching code. The semantic is `atime <=
5952 // mtime` (equivalent to `mtime >= atime`) — equal counts as
5953 // true, which a strict `mtime > atime` check missed for newly
5954 // created files where both stamps are identical.
5955 vm.register_builtin(BUILTIN_FILE_MODIFIED_SINCE_ACCESS, |vm, _argc| {
5956 let path = vm.pop().to_str();
5957 let result = fs::metadata(&path)
5958 .map(|m| m.atime() <= m.mtime())
5959 .unwrap_or(false);
5960 Value::Bool(result)
5961 });
5962
5963 // `[[ a -nt b ]]` — true if `a`'s mtime is strictly later than `b`'s.
5964 // BOTH files must exist; if either is missing the result is false.
5965 // (Earlier behavior was bash's "missing == infinitely-old"; zsh
5966 // strictly requires both files to exist.)
5967 vm.register_builtin(BUILTIN_FILE_NEWER, |vm, _argc| {
5968 let b = vm.pop().to_str();
5969 let a = vm.pop().to_str();
5970 // Use SystemTime modified() for nanosecond precision —
5971 // MetadataExt::mtime() returns seconds only, so two files
5972 // touched within the same second compared equal even when
5973 // 500ms apart. zsh tracks ns and uses `>=` for ties (touching
5974 // a then b in quick succession should still report b newer).
5975 let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
5976 let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
5977 let result = match (ta, tb) {
5978 (Some(ta), Some(tb)) => ta > tb,
5979 _ => false,
5980 };
5981 Value::Bool(result)
5982 });
5983
5984 // `[[ a -ot b ]]` — mirror of -nt. Same both-must-exist contract.
5985 vm.register_builtin(BUILTIN_FILE_OLDER, |vm, _argc| {
5986 let b = vm.pop().to_str();
5987 let a = vm.pop().to_str();
5988 let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
5989 let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
5990 let result = match (ta, tb) {
5991 (Some(ta), Some(tb)) => ta < tb,
5992 _ => false,
5993 };
5994 Value::Bool(result)
5995 });
5996
5997 // `set -e` / `setopt errexit` post-command check. Compiler emits
5998 // this after each top-level command's SetStatus (skipped inside
5999 // conditionals/pipelines/&&||/`!`). If errexit is on AND the last
6000 // command exited non-zero AND it's not a `return` from a function,
6001 // exit the shell with that status.
6002 // `set -x` / `setopt xtrace` — print each command before it runs.
6003 // The compiler emits this BEFORE the actual builtin/external call
6004 // with the command's literal text as a single string arg. We
6005 // print to stderr if xtrace is on. Honors `$PS4` (default `+ `).
6006 //
6007 // ── XTRACE flow control ────────────────────────────────────────
6008 // Mirror of C zsh's `doneps4` flag in execcmd_exec (Src/exec.c).
6009 // When an assignment trace fires (XTRACE_ASSIGN), it emits PS4
6010 // and sets this flag so the subsequent XTRACE_ARGS skips its own
6011 // PS4 emission — the assignment + command end up on the SAME
6012 // line: `<PS4>a=1 echo hello\n`. XTRACE_ARGS / XTRACE_NEWLINE
6013 // reset the flag after emitting the trailing `\n`.
6014 vm.register_builtin(BUILTIN_XTRACE_IS_ON, |_vm, _argc| {
6015 // Push live xtrace state. Caller pairs this with JumpIfFalse
6016 // to skip the trace-string-building block when xtrace is off,
6017 // avoiding side-effectful operand re-evaluation. Bug #159 in
6018 // docs/BUGS.md.
6019 let on = with_executor(|_| opt_state_get("xtrace").unwrap_or(false));
6020 Value::Int(if on { 1 } else { 0 })
6021 });
6022
6023 vm.register_builtin(BUILTIN_XTRACE_LINE, |vm, _argc| {
6024 let cmd_text = vm.pop().to_str();
6025 // Sync exec.last_status with the live vm.last_status BEFORE
6026 // the next command runs. Direct port of the zsh exec.c
6027 // contract — `$?` reads the exit status of the *most recent*
6028 // command. XTRACE_LINE is emitted by the compiler BEFORE
6029 // every simple command, so it's the natural sync point.
6030 let live = vm.last_status;
6031 with_executor(|exec| {
6032 exec.set_last_status(live);
6033 });
6034 // C zsh emits xtrace for `(( … ))` / `[[ … ]]` / `case` /
6035 // `if/while/until/for/repeat` head expressions via
6036 // `printprompt4(); fprintf(xtrerr, "%s\n", expr)` at
6037 // Src/exec.c:5240 (math), c:5286 (cond), c:4117 (for), etc.
6038 // The compiler emits BUILTIN_XTRACE_LINE only at those
6039 // construct boundaries (compile_arith / compile_cond /
6040 // compile_if / compile_while / compile_for / compile_case);
6041 // simple commands route to BUILTIN_XTRACE_ARGS instead. So
6042 // this handler always emits when xtrace is on — no prefix-
6043 // string heuristic.
6044 let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
6045 if on {
6046 let already = XTRACE_DONE_PS4.with(|f| f.get());
6047 if !already {
6048 printprompt4();
6049 }
6050 eprintln!("{}", cmd_text);
6051 XTRACE_DONE_PS4.with(|f| f.set(false));
6052 }
6053 Value::Status(0)
6054 });
6055
6056 // Like XTRACE_LINE but reads the top `argc - 1` values from the
6057 // VM stack WITHOUT consuming them (peek), then pops a prefix
6058 // string at the top. Joins prefix + peeked args with spaces using
6059 // zsh's quotedzputs-equivalent quoting. Direct port of
6060 // Src/exec.c:2055-2066 — emit AFTER expansion, with each arg
6061 // shell-quoted, so `for i in a b; echo for $i` traces as
6062 // `echo for a` / `echo for b`, not `echo for $i`.
6063 //
6064 // Stack contract on entry: [arg1, arg2, ..., argN, prefix].
6065 // Pops prefix; peeks argN..arg1 below. argc = N + 1.
6066 vm.register_builtin(BUILTIN_XTRACE_ARGS, |vm, argc| {
6067 let prefix = vm.pop().to_str();
6068 let live = vm.last_status;
6069 with_executor(|exec| {
6070 exec.set_last_status(live);
6071 });
6072 let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
6073 if on {
6074 let n_args = argc.saturating_sub(1) as usize;
6075 let len = vm.stack.len();
6076 // c:Src/exec.c:2055 — argv is the POST-expansion word
6077 // list, so an arg that expanded to multiple words splats
6078 // into multiple trace tokens AND an arg that expanded to
6079 // zero words (empty unquoted `${UNSET}`) emits nothing.
6080 // pop_args (line 6243) already does this splat for the
6081 // real handler; mirror the same Array → splat / empty →
6082 // drop logic here so xtrace renders `echo ${UNSET}` as
6083 // `echo` (zsh) instead of `echo ''` (the previous
6084 // single-arg stringify path returned "" and then
6085 // quotedzputs wrapped it in `''`).
6086 let arg_strs: Vec<String> = if n_args > 0 && len >= n_args {
6087 let mut out = Vec::new();
6088 for v in &vm.stack[len - n_args..] {
6089 match v {
6090 Value::Array(items) => {
6091 for item in items {
6092 out.push(quotedzputs(&item.to_str()));
6093 }
6094 }
6095 other => out.push(quotedzputs(&other.to_str())),
6096 }
6097 }
6098 out
6099 } else {
6100 Vec::new()
6101 };
6102 // Builtins dispatch through `execbuiltin` (Src/builtin.c:442)
6103 // which emits its own PS4 + name + args xtrace. To avoid
6104 // double-emission, skip our emission here when the first
6105 // arg is a known builtin with a registered HandlerFunc —
6106 // those go through execbuiltin and will trace themselves.
6107 // Externals + builtins-not-yet-routed-through-execbuiltin
6108 // keep our emission as a stand-in.
6109 let goes_through_execbuiltin = crate::ported::builtin::BUILTINS
6110 .iter()
6111 .any(|b| b.node.nam == prefix && b.handlerfunc.is_some());
6112 if !goes_through_execbuiltin {
6113 let line = if arg_strs.is_empty() {
6114 prefix
6115 } else {
6116 format!("{} {}", prefix, arg_strs.join(" "))
6117 };
6118 // Mirrors Src/exec.c:2055 xtrace emission. C does:
6119 // if (!doneps4) printprompt4();
6120 // ... emit args + spaces ...
6121 // fputc('\n', xtrerr); fflush(xtrerr);
6122 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
6123 if !already_ps4 {
6124 printprompt4();
6125 }
6126 eprintln!("{}", line);
6127 }
6128 XTRACE_DONE_PS4.with(|f| f.set(false));
6129 }
6130 Value::Status(0)
6131 });
6132
6133 // BUILTIN_XTRACE_ASSIGN — direct port of the per-assignment
6134 // trace block at Src/exec.c:2517-2582. C body excerpt:
6135 // xtr = isset(XTRACE);
6136 // if (xtr) { printprompt4(); doneps4 = 1; }
6137 // while (assign) {
6138 // if (xtr) fprintf(xtrerr, "%s+=" or "%s=", name);
6139 // ... eval value into `val` ...
6140 // if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
6141 // ...
6142 // }
6143 //
6144 // Stack on entry: [..., name, value]. PEEKS both (they're left
6145 // on stack for SET_VAR to pop). Emits `name=<quoted-val> ` with
6146 // no newline; trailing `\n` comes from XTRACE_ARGS (cmd path)
6147 // or XTRACE_NEWLINE (assignment-only path).
6148 vm.register_builtin(BUILTIN_XTRACE_ASSIGN, |vm, _argc| {
6149 let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
6150 if on {
6151 // PEEK [..., name, value] — argc==2 by contract.
6152 let len = vm.stack.len();
6153 if len >= 2 {
6154 let name = vm.stack[len - 2].to_str();
6155 let value = vm.stack[len - 1].to_str();
6156 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
6157 if !already_ps4 {
6158 printprompt4();
6159 XTRACE_DONE_PS4.with(|f| f.set(true));
6160 }
6161 // C: `fprintf(xtrerr, "%s=", name)` then `quotedzputs
6162 // (val); fputc(' ', xtrerr);`. Emit no newline.
6163 eprint!("{}={} ", name, quotedzputs(&value));
6164 }
6165 }
6166 Value::Status(0)
6167 });
6168
6169 // BUILTIN_XTRACE_NEWLINE — emit trailing `\n` + flush iff a
6170 // prior XTRACE_ASSIGN this line already emitted PS4. Mirrors
6171 // C's `fputc('\n', xtrerr); fflush(xtrerr);` at exec.c:3398
6172 // (the assignment-only path through execcmd_exec).
6173 vm.register_builtin(BUILTIN_XTRACE_NEWLINE, |_vm, _argc| {
6174 let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
6175 if on {
6176 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
6177 if already_ps4 {
6178 eprintln!();
6179 XTRACE_DONE_PS4.with(|f| f.set(false));
6180 }
6181 }
6182 Value::Status(0)
6183 });
6184
6185 // c:Src/exec.c WC_TRYBLOCK — post-always re-jump probes. Each
6186 // returns 1 + consumes the atomic when the corresponding
6187 // escape flag is set; the try-block compile pairs each with
6188 // a JumpIfFalse + Jump → outer scope's return / break /
6189 // continue patches.
6190 vm.register_builtin(BUILTIN_RETFLAG_CHECK, |_vm, _argc| {
6191 use std::sync::atomic::Ordering;
6192 let r = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
6193 if r != 0 {
6194 // Don't clear here — doshfunc owns the clear at c:6047
6195 // when the function unwinds. Leaving it set propagates
6196 // through nested `eval`/`source` callers correctly.
6197 Value::Int(1)
6198 } else {
6199 Value::Int(0)
6200 }
6201 });
6202 vm.register_builtin(BUILTIN_BREAKS_CHECK, |_vm, _argc| {
6203 use std::sync::atomic::Ordering;
6204 let b = crate::ported::builtin::BREAKS.load(Ordering::Relaxed);
6205 let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
6206 // `break` sets BREAKS but NOT CONTFLAG; `continue` sets both.
6207 // Filter out the continue path here so the two checks are
6208 // mutually exclusive.
6209 if b != 0 && c == 0 {
6210 // Consume BREAKS so the outer loop's break_patches
6211 // landing doesn't double-decrement.
6212 crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
6213 Value::Int(1)
6214 } else {
6215 Value::Int(0)
6216 }
6217 });
6218 vm.register_builtin(BUILTIN_CONTFLAG_CHECK, |_vm, _argc| {
6219 use std::sync::atomic::Ordering;
6220 let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
6221 if c != 0 {
6222 crate::ported::builtin::CONTFLAG.store(0, Ordering::Relaxed);
6223 crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
6224 Value::Int(1)
6225 } else {
6226 Value::Int(0)
6227 }
6228 });
6229 vm.register_builtin(BUILTIN_NOEXEC_CHECK, |_vm, _argc| {
6230 // c:Src/exec.c:1390 — `set -n` / `noexec` option: parse but
6231 // don't execute. Returns Int(1) when noexec is set so the
6232 // emit-side JumpIfTrue skips the statement body.
6233 if opt_state_get("noexec").unwrap_or(false) {
6234 return Value::Int(1);
6235 }
6236 // c:Src/exec.c:1390 — execlist's list-loop gate:
6237 // `while (wc_code(code) == WC_LIST && !breaks && !retflag
6238 // && !errflag)`
6239 // — once errflag is set, the NEXT sublist never starts, so
6240 // lastval survives untouched to the shell exit. Without this
6241 // prologue gate the follow-up statement RAN, its dispatch
6242 // saw errflag, returned 1, and SetStatus clobbered lastval —
6243 // `[[ x == [a- ]]; print rc=$?` exited 1 instead of zsh's 2
6244 // (the cond syntax error set lastval=2 per exec.c:5216-5221).
6245 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
6246 & crate::ported::zsh_h::ERRFLAG_ERROR)
6247 != 0
6248 {
6249 return Value::Int(1);
6250 }
6251 Value::Int(0)
6252 });
6253 vm.register_builtin(BUILTIN_DONETRAP_RESET, |_vm, _argc| {
6254 // c:Src/exec.c:1455 — `donetrap = 0;` at sublist start.
6255 // Reset before each top-level statement so the next
6256 // sublist's ERREXIT_CHECK fires the ZERR trap on its FIRST
6257 // non-zero command. Carries the "already fired" state
6258 // across function-call returns within the SAME outer
6259 // sublist (per C semantics — donetrap is process-global).
6260 // Bug #303 in docs/BUGS.md.
6261 crate::ported::exec::DONETRAP.store(0, std::sync::atomic::Ordering::Relaxed);
6262 Value::Status(0)
6263 });
6264
6265 // `[[ -z X ]]` / `[[ -n X ]]` — pop one Value, route through
6266 // canonical `src/ported/cond.rs::evalcond` so the actual
6267 // empty/non-empty test reuses the C-port at `cond.rs:270-271`
6268 // (`'n' => !arg.is_empty()`, `'z' => arg.is_empty()`).
6269 //
6270 // The Array→args conversion lives at the bridge because cond.rs
6271 // expects `&[&str]` (C `cond_str` signature equivalent). For
6272 // `"${arr[@]}"` in DQ context the splice yields `Value::Array`
6273 // — an empty array still expands to one implicit empty word
6274 // (per zsh's "${arr[@]}" splat preserving at least one slot
6275 // in cond context), so:
6276 // - Array(0) → ["-z", ""] → evalcond → 0 (true)
6277 // - Array(1) → ["-z", word] → evalcond → 0/1
6278 // - Array(2+) → ["-z", w1, w2, ...] → evalcond → 2 (parse
6279 // error: too many ops)
6280 // → coerced to false
6281 // - Str(s) → ["-z", s] → evalcond → 0/1
6282 //
6283 // Bug #185 in docs/BUGS.md.
6284 fn run_cond_str_empty(v: Value, op: &str) -> Value {
6285 let words: Vec<String> = match v {
6286 Value::Array(arr) => arr.into_iter().map(|x| x.to_str()).collect(),
6287 Value::Str(s) => vec![s.to_string()],
6288 other => vec![other.to_str()],
6289 };
6290 let mut args: Vec<&str> = vec![op];
6291 if words.is_empty() {
6292 args.push("");
6293 } else {
6294 args.extend(words.iter().map(|s| s.as_str()));
6295 }
6296 let opts: std::collections::HashMap<String, bool> =
6297 std::collections::HashMap::new();
6298 let vars: std::collections::HashMap<String, String> =
6299 std::collections::HashMap::new();
6300 // c:Src/cond.c:62-66 — `evalcond` returns 0=true, 1=false,
6301 // 2=syntax-error. Coerce error to false (observable behavior
6302 // in zsh: `[[ -z a b ]]` errors and the test as a whole
6303 // returns non-zero).
6304 // `[[ ]]` dispatch — C's `evalcond(state, NULL)` calling convention.
6305 // `None` for from_test → mathevali integer-compare coercion path.
6306 let ret = crate::ported::cond::evalcond(&args, &opts, &vars, false, None);
6307 Value::Int(if ret == 0 { 1 } else { 0 })
6308 }
6309 vm.register_builtin(BUILTIN_COND_STR_EMPTY, |vm, _argc| {
6310 let v = vm.pop();
6311 run_cond_str_empty(v, "-z")
6312 });
6313 vm.register_builtin(BUILTIN_COND_STR_NONEMPTY, |vm, _argc| {
6314 let v = vm.pop();
6315 run_cond_str_empty(v, "-n")
6316 });
6317
6318 // `exec N<<<"str"` — herestring redirect to explicit fd, applied
6319 // permanently. Direct port of `Src/exec.c:4655 getherestr` +
6320 // `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)` at c:3766-
6321 // 3780 for the nullexec=1 bare-exec-redir path. Bug #205 in
6322 // docs/BUGS.md.
6323 vm.register_builtin(BUILTIN_EXEC_HERESTR_FD, |vm, _argc| {
6324 let fd = vm.pop().to_int() as i32;
6325 let content = vm.pop().to_str();
6326 // c:4671-4672 — append `\n` for "real" herestrings (not
6327 // heredoc-derived). zshrs's bare-exec path only fires for
6328 // the `<<<` syntax (REDIR_HERESTR), so always append.
6329 let body = format!("{}\n", content);
6330 // c:4673-4679 — gettempfile → write_loop → close → reopen
6331 // read-only → unlink. Rust equivalent via tempfile crate or
6332 // explicit O_TMPFILE; use mkstemp + unlink-immediately to
6333 // mirror C exactly.
6334 use std::ffi::CString;
6335 let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
6336 let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
6337 if write_fd < 0 {
6338 crate::ported::utils::zwarn(&format!(
6339 "can't create temp file for here document: {}",
6340 std::io::Error::last_os_error()
6341 ));
6342 return Value::Status(1);
6343 }
6344 // c:4675 — write_loop(fd, t, len)
6345 let bytes = body.as_bytes();
6346 let mut off = 0;
6347 while off < bytes.len() {
6348 let n = unsafe {
6349 libc::write(
6350 write_fd,
6351 bytes[off..].as_ptr() as *const libc::c_void,
6352 bytes.len() - off,
6353 )
6354 };
6355 if n <= 0 {
6356 unsafe { libc::close(write_fd) };
6357 return Value::Status(1);
6358 }
6359 off += n as usize;
6360 }
6361 unsafe { libc::close(write_fd) }; // c:4676
6362 // Path null-terminated by mkstemp; reopen for reading.
6363 let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
6364 // c:4678 — unlink immediately so the file disappears on
6365 // close, leaving only the fd reference.
6366 unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
6367 if read_fd < 0 {
6368 return Value::Status(1);
6369 }
6370 // c:3779 addfd → dup2 to target fd, close intermediate.
6371 let r = unsafe { libc::dup2(read_fd, fd) };
6372 unsafe { libc::close(read_fd) };
6373 if r < 0 {
6374 return Value::Status(1);
6375 }
6376 Value::Status(0)
6377 });
6378 // c:Src/exec.c:2418 + addfd splice — MULTIOS fan-out. Stack
6379 // layout pushed by compile_zsh's coalescing pass:
6380 // [target_1, op_byte_1, target_2, op_byte_2, …, target_N,
6381 // op_byte_N, fd]
6382 // argc = 2N + 1. Pops, opens every target, sets up a pipe +
6383 // splitter thread that reads pipe → writes every chunk to
6384 // every opened target, dup2's pipe-write-end onto fd. The
6385 // splitter is closed + joined by host_redirect_scope_end.
6386 // Bug #36 in docs/BUGS.md.
6387 vm.register_builtin(BUILTIN_MULTIOS_REDIRECT, |vm, argc| {
6388 if argc < 3 || argc % 2 == 0 {
6389 // Bad shape — bail.
6390 return Value::Status(1);
6391 }
6392 // Pop fd first (top of stack).
6393 let fd = vm.pop().to_int() as i32;
6394 // Then pop (op, target) pairs in reverse compile order. Keep
6395 // targets as Values — a glob-bearing target arrives as a
6396 // Value::Array of matches.
6397 let n_targets = ((argc - 1) / 2) as usize;
6398 let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_targets);
6399 for _ in 0..n_targets {
6400 let op_byte = vm.pop().to_int() as u8;
6401 let target = vm.pop();
6402 pairs.push((op_byte, target));
6403 }
6404 // Restore compile order (target_1 first).
6405 pairs.reverse();
6406
6407 // c:Src/glob.c:2195-2203 xpandredir — "Loop over matches,
6408 // duplicating the redirection for each file found": a glob
6409 // target with N matches becomes N members of the same multio
6410 // (`echo hi > *.txt` with two matches writes both files).
6411 let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
6412 for (op_byte, target) in pairs {
6413 match target {
6414 Value::Array(items) => {
6415 for item in items {
6416 entries.push((op_byte, item.to_str()));
6417 }
6418 }
6419 other => entries.push((op_byte, other.to_str())),
6420 }
6421 }
6422 if entries.is_empty() {
6423 return Value::Status(1);
6424 }
6425
6426 // c:Src/exec.c:2418 — `else if (!mfds[fd1] || unset(MULTIOS))`:
6427 // with MULTIOS unset every redirect takes the REPLACE path in
6428 // script order — each target is still opened (created /
6429 // truncated) and dup2'd over the fd, so the LAST one wins and
6430 // earlier files end up empty (`unsetopt multios; print x > a
6431 // > b` leaves `a` empty, `x` in `b`). host_apply_redirect is
6432 // exactly one replace step, noclobber gate included.
6433 let multios_on = opt_state_get("multios").unwrap_or(true);
6434 if !multios_on {
6435 with_executor(|exec| {
6436 for (op_byte, target) in &entries {
6437 exec.host_apply_redirect(fd as u8, *op_byte, target);
6438 if exec.redirect_failed {
6439 // c:Src/exec.c execerr — abort the remaining
6440 // redirect list on failure.
6441 break;
6442 }
6443 }
6444 });
6445 return Value::Status(0);
6446 }
6447
6448 if entries.len() == 1 {
6449 // Single member after splicing — a plain replace
6450 // (c:2418 new-multio arm). Route through
6451 // host_apply_redirect so the noclobber gate, the
6452 // pipeline-output split partial, and error handling all
6453 // apply exactly as for an un-bagged redirect.
6454 let (op_byte, target) = &entries[0];
6455 with_executor(|exec| {
6456 exec.host_apply_redirect(fd as u8, *op_byte, target);
6457 });
6458 return Value::Status(0);
6459 }
6460
6461 // c:Src/exec.c:3722-3724 — when this command's stdout IS the
6462 // pipeline output, C seeds mfds[1] with the pipe BEFORE
6463 // walking the redirect list, so the pipe is the multio's
6464 // first member (`print x >&1 > f | cat` sends `x` down the
6465 // pipe TWICE: once for the seed, once for the `>&1` dup).
6466 let pipe_seed = fd == 1
6467 && with_executor(|exec| {
6468 exec.pipe_output_scope
6469 .is_some_and(|d| d + 1 == exec.redirect_scope_stack.len())
6470 });
6471
6472 // Save current fd state for scope-end restoration — BEFORE
6473 // the first member's replace dup2 below.
6474 let saved = unsafe { libc::dup(fd) };
6475 if saved >= 0 {
6476 with_executor(|exec| {
6477 if let Some(top) = exec.redirect_scope_stack.last_mut() {
6478 top.push((fd, saved));
6479 } else {
6480 unsafe { libc::close(saved) };
6481 }
6482 });
6483 }
6484
6485 // Accumulate member fds in redirect order. c:Src/exec.c:
6486 // 2447-2480 addfd — the FIRST member REPLACES the fd
6487 // (c:2448-2450 `mfds[fd1]->ct=1; mfds[fd1]->fds[0]=fd1;`), so
6488 // a later numeric `>&N` self-dup resolves against the fd's
6489 // value at that point in the sequence: `print x > f >&1`
6490 // writes f TWICE; `print x >&1 > f` writes the ORIGINAL
6491 // stdout + f.
6492 let mut target_fds: Vec<i32> = Vec::with_capacity(entries.len() + 1);
6493 if pipe_seed {
6494 let p = unsafe { libc::dup(fd) };
6495 if p >= 0 {
6496 target_fds.push(p);
6497 }
6498 }
6499 let noclobber = opt_state_get("noclobber").unwrap_or(false)
6500 || !opt_state_get("clobber").unwrap_or(true);
6501 for (i, (op_byte, target)) in entries.iter().enumerate() {
6502 let open_result: std::io::Result<i32> = match *op_byte {
6503 r::DUP_WRITE | r::DUP_READ => {
6504 // Numeric `>&N` — dup the LIVE fd N (after any
6505 // earlier member's replace).
6506 match target.trim_start_matches('&').parse::<i32>() {
6507 Ok(src) => {
6508 let d = unsafe { libc::dup(src) };
6509 if d >= 0 {
6510 Ok(d)
6511 } else {
6512 Err(std::io::Error::last_os_error())
6513 }
6514 }
6515 Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
6516 }
6517 }
6518 r::WRITE => {
6519 // c:Src/exec.c clobber_open — noclobber applies
6520 // to multio file targets too; failure aborts the
6521 // remaining redirect list (execerr), so `setopt
6522 // noclobber; touch a; print x > a > b` errors on
6523 // `a` and never creates `b`.
6524 let target_meta = std::fs::metadata(target).ok();
6525 let target_is_regular_file = target_meta
6526 .as_ref()
6527 .map(|m| m.file_type().is_file())
6528 .unwrap_or(false);
6529 // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY re-uses
6530 // an empty regular file under noclobber (same allowance
6531 // as the single-redirect path).
6532 let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
6533 && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
6534 if noclobber && target_is_regular_file && !clobber_empty_ok {
6535 eprintln!("{}:1: file exists: {}", shname(), target);
6536 for prev in &target_fds {
6537 unsafe {
6538 libc::close(*prev);
6539 }
6540 }
6541 with_executor(|exec| {
6542 exec.redirect_failed = true;
6543 });
6544 // Sink the upcoming command's output (mirrors
6545 // the single-redirect noclobber arm in
6546 // host_apply_redirect).
6547 if let Ok(file) =
6548 fs::OpenOptions::new().write(true).open("/dev/null")
6549 {
6550 let new_fd = file.into_raw_fd();
6551 unsafe {
6552 libc::dup2(new_fd, fd);
6553 libc::close(new_fd);
6554 }
6555 }
6556 return Value::Status(1);
6557 }
6558 fs::OpenOptions::new()
6559 .write(true)
6560 .create(true)
6561 .truncate(true)
6562 .open(target)
6563 .map(|f| f.into_raw_fd())
6564 }
6565 r::APPEND => fs::OpenOptions::new()
6566 .write(true)
6567 .create(true)
6568 .append(true)
6569 .open(target)
6570 .map(|f| f.into_raw_fd()),
6571 _ => fs::OpenOptions::new()
6572 .write(true)
6573 .create(true)
6574 .truncate(true)
6575 .open(target)
6576 .map(|f| f.into_raw_fd()),
6577 };
6578 match open_result {
6579 Ok(tfd) => {
6580 if i == 0 && !pipe_seed {
6581 // c:2448-2450 — first member replaces the fd.
6582 unsafe {
6583 libc::dup2(tfd, fd);
6584 }
6585 }
6586 target_fds.push(tfd);
6587 }
6588 Err(e) => {
6589 let msg = match e.kind() {
6590 std::io::ErrorKind::PermissionDenied => "permission denied",
6591 std::io::ErrorKind::NotFound => "no such file or directory",
6592 std::io::ErrorKind::IsADirectory => "is a directory",
6593 _ => "redirect failed",
6594 };
6595 // c:Src/exec.c:3741 — `zwarn("%e: %s", errno, fname)`:
6596 // zwarning supplies the `name:LINE:` prefix with the
6597 // REAL current lineno (the old eprintln hardcoded `:1:`).
6598 crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
6599 // Close already-opened fds to avoid leaks.
6600 for prev in &target_fds {
6601 unsafe {
6602 libc::close(*prev);
6603 }
6604 }
6605 with_executor(|exec| {
6606 exec.redirect_failed = true;
6607 });
6608 return Value::Status(1);
6609 }
6610 }
6611 }
6612
6613 // Create the splitter pipe.
6614 let (read_end, write_end) = match os_pipe::pipe() {
6615 Ok(p) => p,
6616 Err(_) => {
6617 for f in &target_fds {
6618 unsafe {
6619 libc::close(*f);
6620 }
6621 }
6622 return Value::Status(1);
6623 }
6624 };
6625 let pipe_write_raw = AsRawFd::as_raw_fd(&write_end);
6626 // Spawn the splitter thread: read pipe → write every chunk
6627 // to every target fd. Each write inside the thread uses
6628 // libc::write directly on the raw fd (no Rust File ownership
6629 // so the splitter can close after EOF without racing main).
6630 let target_fds_for_thread = target_fds.clone();
6631 let handle = std::thread::spawn(move || {
6632 let mut r = read_end;
6633 let mut buf = [0u8; 8192];
6634 loop {
6635 match std::io::Read::read(&mut r, &mut buf) {
6636 Ok(0) => break,
6637 Ok(n) => {
6638 for &tfd in &target_fds_for_thread {
6639 let mut off = 0;
6640 while off < n {
6641 let w = unsafe {
6642 libc::write(
6643 tfd,
6644 buf[off..n].as_ptr() as *const libc::c_void,
6645 n - off,
6646 )
6647 };
6648 if w <= 0 {
6649 break;
6650 }
6651 off += w as usize;
6652 }
6653 }
6654 }
6655 Err(_) => break,
6656 }
6657 }
6658 // Close every target so file contents flush.
6659 for tfd in target_fds_for_thread {
6660 unsafe {
6661 libc::close(tfd);
6662 }
6663 }
6664 });
6665
6666 // Dup the pipe write-end onto the target fd; close the
6667 // original write_end so EOF arrives when host_redirect_scope_end
6668 // closes our tracked pipe_write_fd.
6669 let write_dup = unsafe { libc::dup(pipe_write_raw) };
6670 drop(write_end);
6671 if write_dup < 0 {
6672 return Value::Status(1);
6673 }
6674 unsafe {
6675 libc::dup2(write_dup, fd);
6676 libc::close(write_dup);
6677 }
6678 // Track the running splitter so scope-end can drain + join.
6679 // The "write_fd" we store is the user-visible fd (e.g. 1).
6680 // Closing that fd at scope-end isn't quite right; we need a
6681 // way to send EOF. Solution: track the write_dup we just
6682 // closed; instead keep a second dup for the close-on-end.
6683 let close_on_end = unsafe { libc::dup(fd) };
6684 with_executor(|exec| {
6685 if let Some(top) = exec.multios_scope_stack.last_mut() {
6686 top.push((close_on_end, handle));
6687 } else {
6688 // No scope — leak the dup; thread will keep running
6689 // until process exit. Should not happen because
6690 // host_redirect_scope_begin pushed a frame.
6691 unsafe { libc::close(close_on_end) };
6692 }
6693 });
6694 Value::Status(0)
6695 });
6696 // c:Src/exec.c:2418 input-arm — MULTIOS read fan-in. Stack
6697 // layout pushed by compile_zsh (mirrors the write side):
6698 // [source_1, op_1, source_2, op_2, …, source_N, op_N, fd]
6699 // argc = 2N + 1; op distinguishes file opens (READ) from numeric
6700 // `<&N` dups (DUP_READ); a glob source arrives as Value::Array
6701 // and splices into one member per match (c:Src/glob.c:2195-2203).
6702 // Opens every source, sets up a pipe + producer thread that
6703 // reads each source in order and writes to the pipe write-end,
6704 // then closes its write-end so the consumer gets EOF. dup2 the
6705 // pipe read-end onto fd. Bug #36 input side in docs/BUGS.md.
6706 vm.register_builtin(BUILTIN_MULTIOS_READ, |vm, argc| {
6707 if argc < 3 || argc % 2 == 0 {
6708 return Value::Status(1);
6709 }
6710 let fd = vm.pop().to_int() as i32;
6711 let n_sources = ((argc - 1) / 2) as usize;
6712 let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_sources);
6713 for _ in 0..n_sources {
6714 let op_byte = vm.pop().to_int() as u8;
6715 let source = vm.pop();
6716 pairs.push((op_byte, source));
6717 }
6718 pairs.reverse();
6719
6720 // Splice glob match arrays (c:Src/glob.c:2195-2203).
6721 let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
6722 for (op_byte, source) in pairs {
6723 match source {
6724 Value::Array(items) => {
6725 for item in items {
6726 entries.push((op_byte, item.to_str()));
6727 }
6728 }
6729 other => entries.push((op_byte, other.to_str())),
6730 }
6731 }
6732 if entries.is_empty() {
6733 return Value::Status(1);
6734 }
6735
6736 // c:Src/exec.c:2418 — `unset(MULTIOS)`: sequential replace,
6737 // last source wins (`unsetopt multios; cat < a < b` reads
6738 // only b; a is still opened — and errors still surface).
6739 let multios_on = opt_state_get("multios").unwrap_or(true);
6740 if !multios_on {
6741 with_executor(|exec| {
6742 for (op_byte, source) in &entries {
6743 exec.host_apply_redirect(fd as u8, *op_byte, source);
6744 if exec.redirect_failed {
6745 break;
6746 }
6747 }
6748 });
6749 return Value::Status(0);
6750 }
6751
6752 if entries.len() == 1 {
6753 // Single member after splicing — plain replace.
6754 let (op_byte, source) = &entries[0];
6755 with_executor(|exec| {
6756 exec.host_apply_redirect(fd as u8, *op_byte, source);
6757 });
6758 return Value::Status(0);
6759 }
6760
6761 // Save current fd state for scope-end restoration — BEFORE
6762 // the first member's replace dup2 below.
6763 let saved = unsafe { libc::dup(fd) };
6764 if saved >= 0 {
6765 with_executor(|exec| {
6766 if let Some(top) = exec.redirect_scope_stack.last_mut() {
6767 top.push((fd, saved));
6768 } else {
6769 unsafe { libc::close(saved) };
6770 }
6771 });
6772 }
6773
6774 // Open every source in redirect order; numeric `<&N` dups
6775 // resolve against the LIVE fd table. First member replaces
6776 // the fd (c:2448-2450) so later self-dups see it.
6777 let mut source_fds: Vec<i32> = Vec::with_capacity(entries.len());
6778 for (i, (op_byte, source)) in entries.iter().enumerate() {
6779 let open_result: std::io::Result<i32> = match *op_byte {
6780 r::DUP_READ | r::DUP_WRITE => {
6781 match source.trim_start_matches('&').parse::<i32>() {
6782 Ok(src) => {
6783 let d = unsafe { libc::dup(src) };
6784 if d >= 0 {
6785 Ok(d)
6786 } else {
6787 Err(std::io::Error::last_os_error())
6788 }
6789 }
6790 Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
6791 }
6792 }
6793 _ => fs::File::open(source).map(|f| f.into_raw_fd()),
6794 };
6795 match open_result {
6796 Ok(tfd) => {
6797 if i == 0 {
6798 unsafe {
6799 libc::dup2(tfd, fd);
6800 }
6801 }
6802 source_fds.push(tfd);
6803 }
6804 Err(e) => {
6805 let msg = match e.kind() {
6806 std::io::ErrorKind::PermissionDenied => "permission denied",
6807 std::io::ErrorKind::NotFound => "no such file or directory",
6808 _ => "open failed",
6809 };
6810 // c:Src/exec.c:3741 — zwarn with real lineno prefix.
6811 crate::ported::utils::zwarn(&format!("{}: {}", msg, source));
6812 for prev in &source_fds {
6813 unsafe {
6814 libc::close(*prev);
6815 }
6816 }
6817 with_executor(|exec| {
6818 exec.redirect_failed = true;
6819 });
6820 return Value::Status(1);
6821 }
6822 }
6823 }
6824
6825 // Create the concatenator pipe.
6826 let (read_end, write_end) = match os_pipe::pipe() {
6827 Ok(p) => p,
6828 Err(_) => {
6829 for f in &source_fds {
6830 unsafe {
6831 libc::close(*f);
6832 }
6833 }
6834 return Value::Status(1);
6835 }
6836 };
6837 // dup the pipe read-end onto fd before spawning the
6838 // producer; close the original read_end so the consumer
6839 // (reading via fd) is the sole reference until scope-end.
6840 let read_dup = unsafe { libc::dup(AsRawFd::as_raw_fd(&read_end)) };
6841 drop(read_end);
6842 if read_dup < 0 {
6843 for f in &source_fds {
6844 unsafe {
6845 libc::close(*f);
6846 }
6847 }
6848 return Value::Status(1);
6849 }
6850 unsafe {
6851 libc::dup2(read_dup, fd);
6852 libc::close(read_dup);
6853 }
6854 // Spawn the producer.
6855 let source_fds_for_thread = source_fds.clone();
6856 let handle = std::thread::spawn(move || {
6857 let mut w = write_end;
6858 let mut buf = [0u8; 8192];
6859 for sfd in source_fds_for_thread {
6860 loop {
6861 let n = unsafe {
6862 libc::read(
6863 sfd,
6864 buf.as_mut_ptr() as *mut libc::c_void,
6865 buf.len(),
6866 )
6867 };
6868 if n <= 0 {
6869 break;
6870 }
6871 let n = n as usize;
6872 if std::io::Write::write_all(&mut w, &buf[..n]).is_err() {
6873 break;
6874 }
6875 }
6876 unsafe {
6877 libc::close(sfd);
6878 }
6879 }
6880 // Closing w (the write_end) at scope drop signals EOF
6881 // to the consumer.
6882 });
6883 with_executor(|exec| {
6884 // Track using a closed-write sentinel — the producer
6885 // owns write_end so we just need to join. Use -1 fd
6886 // marker meaning "no fd to close".
6887 if let Some(top) = exec.multios_scope_stack.last_mut() {
6888 top.push((-1, handle));
6889 } else {
6890 let _ = handle.join();
6891 }
6892 });
6893 Value::Status(0)
6894 });
6895 // c:Src/exec.c:3978-3986 — nullexec==1 marker. See the const's
6896 // doc block. Arg: 1 = entering a bare-exec redirect, 0 = leaving.
6897 vm.register_builtin(BUILTIN_EXEC_PERM_REDIRS, |vm, _argc| {
6898 let on = vm.pop().to_int() != 0;
6899 with_executor(|exec| exec.exec_redirs_permanent = on);
6900 Value::Status(0)
6901 });
6902 // Bare-exec redirect epilogue — see the const's doc block.
6903 // c:Src/exec.c:252-259 (execerr) + c:4367-4386 (done: POSIX gate).
6904 vm.register_builtin(BUILTIN_EXEC_REDIR_DONE, |vm, _argc| {
6905 use std::sync::atomic::Ordering;
6906 let failed = with_executor(|exec| {
6907 let f = exec.redirect_failed;
6908 exec.redirect_failed = false;
6909 f
6910 });
6911 if !failed {
6912 return Value::Status(0);
6913 }
6914 // c:255 — `redir_err = lastval = 1`.
6915 vm.last_status = 1;
6916 if isset(crate::ported::zsh_h::POSIXBUILTINS)
6917 && !isset(crate::ported::zsh_h::INTERACTIVE)
6918 {
6919 // c:4379-4383 — non-interactive POSIX fatal: exit(1).
6920 // In-process equivalent: arm EXIT_PENDING/EXIT_VAL so the
6921 // next BUILTIN_ERREXIT_CHECK (trigger 2) unwinds the
6922 // script with status 1 — same deferred-exit shape the
6923 // `exit` builtin uses inside subshell contexts.
6924 crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
6925 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
6926 }
6927 Value::Status(1)
6928 });
6929 // c:Src/exec.c:3722-3724 — see the const's doc block. No args.
6930 vm.register_builtin(BUILTIN_PIPE_OUTPUT_MARK, |_vm, _argc| {
6931 with_executor(|exec| exec.pipe_output_pending = true);
6932 Value::Status(0)
6933 });
6934 // c:Src/exec.c — block-level redirect-failure gate. When a
6935 // compound command (`{ … } < file`, `( … ) > file`, etc.) has a
6936 // failing redirect (e.g. `< /nonexistent`), zsh skips the entire
6937 // body AND sets lastval to 1. The simple-command path's
6938 // redirect_failed check (line 215-221 above) only catches the
6939 // failure when a builtin dispatches and is consumed by that
6940 // single builtin call — so a multi-statement block kept running
6941 // its remaining statements after the redir error. Emit-side at
6942 // compile_zsh.rs::compile_command's Redirected arm pairs this
6943 // with a JumpIfTrue → WithRedirectsEnd to abandon the body.
6944 vm.register_builtin(BUILTIN_REDIRECT_FAILED_CHECK, |vm, _argc| {
6945 let failed = with_executor(|exec| {
6946 let f = exec.redirect_failed;
6947 exec.redirect_failed = false;
6948 f
6949 });
6950 if failed {
6951 vm.last_status = 1;
6952 Value::Int(1)
6953 } else {
6954 Value::Int(0)
6955 }
6956 });
6957 // c:Src/exec.c — drop-in replacement for fusevm's Op::Exec used by
6958 // the dynamic-first-word path (`$cmd`, `$(cmd)`, glob-named cmds).
6959 // fusevm's Op::Exec returns Value::Status(0) when post-expansion
6960 // argv is empty (vm.rs:1722) — that clobbers \$? for the
6961 // `\$(exit 1); echo \$?` case where the cmd-subst left
6962 // last_status = 1 but the empty expansion gets exec'd to 0.
6963 // Mirror C zsh: when the word list is empty after expansion,
6964 // \$? becomes whatever the inner cmd-subst's last_status is
6965 // (preserved here by returning Value::Status(last_status)).
6966 // c:Src/cond.c:308-316 — `if (!(pprog = patcompile(right, ...)))
6967 // { zwarnnam(fromtest, "bad pattern: %s", right); return 2; }`.
6968 // The cond path must NOT use str_match/glob_match_static: the
6969 // case-statement consumer of those follows Src/loop.c:667 zerr
6970 // semantics (errflag abort), while cond is a zwarn + status-2
6971 // soft failure. COND_BAD_PATTERN carries the 2 across the
6972 // Bool-shaped stack contract (so `!=`'s LogNot can't lose it).
6973 thread_local! {
6974 static COND_BAD_PATTERN: std::cell::Cell<bool> =
6975 const { std::cell::Cell::new(false) };
6976 }
6977 vm.register_builtin(BUILTIN_COND_STRMATCH, |vm, _argc| {
6978 let pat = vm.pop().to_str();
6979 let s = vm.pop().to_str();
6980 let mut pat_tok = pat.clone();
6981 crate::ported::glob::tokenize(&mut pat_tok);
6982 if crate::ported::pattern::patcompile(
6983 &pat_tok,
6984 crate::ported::zsh_h::PAT_STATIC as i32,
6985 None,
6986 )
6987 .is_none()
6988 {
6989 // c:314 — zwarnnam(fromtest, "bad pattern: %s", right).
6990 crate::ported::utils::zwarn(&format!("bad pattern: {}", pat));
6991 COND_BAD_PATTERN.with(|c| c.set(true));
6992 return Value::Bool(false);
6993 }
6994 // Match via the shared engine so `(#b)`/`(#m)` backref and
6995 // MATCH-variable population stays in one place.
6996 Value::Bool(crate::vm_helper::glob_match_static(&s, &pat))
6997 });
6998 vm.register_builtin(BUILTIN_COND_UNKNOWN, |vm, _argc| {
6999 // c:Src/cond.c:150-188 — `zwarnnam(fromtest, "unknown condition: %s",
7000 // name)` for a `-X` op with no matching cond module. Like a cond
7001 // syntax error it yields status 2 and aborts: arm COND_BAD_PATTERN so
7002 // the downstream BUILTIN_COND_STATUS_FROM_BOOL carries the 2 across the
7003 // Bool-shaped stack and runs the shared errflag+set_last_status(2)+abort
7004 // path (c:Src/exec.c:5216-5221). Returns Bool(false) as the operand.
7005 let op = vm.pop().to_str();
7006 crate::ported::utils::zerr(&format!("unknown condition: {}", op));
7007 COND_BAD_PATTERN.with(|c| c.set(true));
7008 Value::Bool(false)
7009 });
7010 vm.register_builtin(BUILTIN_COND_STATUS_FROM_BOOL, |vm, _argc| {
7011 let ok = vm.pop().to_int() != 0;
7012 let bad = COND_BAD_PATTERN.with(|c| {
7013 let b = c.get();
7014 c.set(false);
7015 b
7016 });
7017 if bad {
7018 // c:Src/exec.c:5216-5221 — `stat = evalcond(...);
7019 // /* 2 indicates a syntax error. For compatibility,
7020 // turn this into a shell error. */
7021 // if (stat == 2) errflag |= ERRFLAG_ERROR;`
7022 // The errflag abort exits the script with lastval (2),
7023 // matching `zsh -fc '[[ x == [a- ]]; print rc=$?'`
7024 // printing nothing after the diagnostic and exiting 2.
7025 crate::ported::utils::errflag.fetch_or(
7026 crate::ported::zsh_h::ERRFLAG_ERROR,
7027 std::sync::atomic::Ordering::Relaxed,
7028 );
7029 with_executor(|exec| exec.set_last_status(2));
7030 return Value::Int(2); // c:Src/cond.c:316 `return 2;`
7031 }
7032 Value::Int(if ok { 0 } else { 1 })
7033 });
7034 vm.register_builtin(BUILTIN_EXEC_DYNAMIC, |vm, argc| {
7035 let raw = pop_args(vm, argc);
7036 // Flatten Array entries into argv slots (matches fusevm
7037 // Op::Exec's flatten at vm.rs:1660-1665) so `${arr[@]}` /
7038 // splice expansions produce one argv slot per element.
7039 let args: Vec<String> = raw.into_iter().collect();
7040 // c:Src/subst.c paramsubst — when `${var:?msg}` or
7041 // `${var?msg}` set errflag, the expansion may produce empty
7042 // argv[0] which would fall into the EACCES/permission-denied
7043 // path below, masking the real paramsubst diagnostic with a
7044 // spurious "permission denied:" line and rc=126. Honour
7045 // errflag so the simple command ends with the paramsubst
7046 // error as the sole diagnostic, rc=1. Bug #86.
7047 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
7048 & crate::ported::zsh_h::ERRFLAG_ERROR)
7049 != 0
7050 {
7051 return Value::Status(1);
7052 }
7053 if args.is_empty() {
7054 // c:Src/exec.c — empty argv preserves prior \$?. The
7055 // cmd-subst inside the word already set last_status; just
7056 // round-trip it back through SetStatus.
7057 return Value::Status(vm.last_status);
7058 }
7059 if args[0].is_empty() {
7060 // Explicit empty command word — exec returns EACCES.
7061 let script_name =
7062 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
7063 let lineno: u64 = with_executor(|exec| {
7064 exec.scalar("LINENO")
7065 .and_then(|s| s.parse::<u64>().ok())
7066 .unwrap_or(1)
7067 });
7068 eprintln!("{}:{}: permission denied: ", script_name, lineno);
7069 return Value::Status(126);
7070 }
7071 // AOP intercepts (zshrs extension, no C counterpart) — same
7072 // gate as host_exec_external (the static-head path): dynamic
7073 // command names (`cmd=/bin/echo; $cmd payload`) must consult
7074 // registered intercepts before dispatch, else `intercept
7075 // before /bin/echo ...` fires for the literal spelling but
7076 // not the variable one. run_intercepts runs before-advice
7077 // in-place and returns None to continue; Some(status) means
7078 // an around/after advice fully handled the command.
7079 let intercepted = with_executor(|exec| {
7080 if exec.intercepts.is_empty() {
7081 return None;
7082 }
7083 let full_cmd = if args.len() == 1 {
7084 args[0].clone()
7085 } else {
7086 args.join(" ")
7087 };
7088 let rest: Vec<String> = args[1..].to_vec();
7089 exec.run_intercepts(&args[0], &full_cmd, &rest)
7090 });
7091 if let Some(result) = intercepted {
7092 return Value::Status(result.unwrap_or(127));
7093 }
7094 // c:Src/exec.c:2900 execcmd_exec — canonical simple-command
7095 // dispatcher. Runs precmd-modifier walk (c:3013-3091), then
7096 // dispatches to execbuiltin (c:4233) / runshfunc (c:3431+) /
7097 // execute (c:4314) per the resolved head. zshrs's bytecode VM
7098 // expanded the args before reaching here; we feed them in via
7099 // eparams.args and let execcmd_exec do the rest exactly as C
7100 // does for static heads. Without this, `c=builtin; $c source X`
7101 // skipped the precmd walk and emitted "command not found:
7102 // builtin".
7103 let mut state = crate::ported::zsh_h::estate {
7104 prog: Box::<crate::ported::zsh_h::eprog>::default(),
7105 pc: 0,
7106 strs: None,
7107 strs_offset: 0,
7108 };
7109 let mut eparams = crate::ported::zsh_h::execcmd_params {
7110 args: Some(args),
7111 redir: None,
7112 beg: 0,
7113 varspc: None,
7114 assignspc: None,
7115 typ: crate::ported::zsh_h::WC_SIMPLE as i32,
7116 postassigns: 0,
7117 htok: 0,
7118 };
7119 // input/output=0 → no pipe redirection (use shell stdio
7120 // directly); `output != 0` at c:2988 forks immediately. last1=2
7121 // (c:Src/exec.c:2014 `last1 ? 1 : 2`): terminal pipe stage but
7122 // the shell IS needed afterward — the VM keeps executing
7123 // bytecode after this op. last1=1 would arm the fake-exec
7124 // optimization (c:3646-3651, gate at c:3662 `last1 != 1`),
7125 // making `execute()` execve THIS process for external heads:
7126 // `p=/bin/echo; $p hi; echo after` replaced the shell and
7127 // `after` never ran (D04parameter chunk 11 shell-killer).
7128 // c:Src/exec.c:1690-1700 — execpline's job frame: save thisjob
7129 // (`pj = thisjob`) and allocate the jobtab slot that
7130 // execcmd_fork's addproc (c:2853) hangs the child pid off.
7131 // Without a live thisjob, the fork at c:3662 (last1 != 1 →
7132 // external must fork) registers no proc, nothing waits, and
7133 // the child races the rest of the script.
7134 let pj = {
7135 use crate::ported::jobs;
7136 *jobs::THISJOB
7137 .get_or_init(|| std::sync::Mutex::new(-1))
7138 .lock()
7139 .unwrap_or_else(|e| e.into_inner())
7140 };
7141 let newjob = {
7142 use crate::ported::jobs;
7143 let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
7144 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
7145 jobs::initjob(&mut tab) // c:1700 `thisjob = newjob = initjob()`
7146 };
7147 {
7148 use crate::ported::jobs;
7149 *jobs::THISJOB
7150 .get_or_init(|| std::sync::Mutex::new(-1))
7151 .lock()
7152 .unwrap_or_else(|e| e.into_inner()) = newjob as i32;
7153 }
7154 crate::ported::exec::execcmd_exec(
7155 &mut state,
7156 &mut eparams,
7157 0, // input (c:2989)
7158 0, // output (c:2988)
7159 crate::ported::zsh_h::Z_SYNC as i32, // how
7160 2, // last1=2 — shell continues (c:2014)
7161 -1, // close_if_forked
7162 );
7163 // c:Src/exec.c:1828-1835 — execpline's Z_SYNC tail: waitjobs()
7164 // reaps the forked external. c:Src/jobs.c:487-495 + 551-552 —
7165 // the job's LAST proc sets lastval (0200|sig when signalled,
7166 // else WEXITSTATUS). Builtin/shfunc heads never forked (job
7167 // has no procs) — LASTVAL was already set by execbuiltin /
7168 // doshfunc inside execcmd_exec; skip the wait.
7169 {
7170 use crate::ported::jobs;
7171 let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
7172 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
7173 if jobs::hasprocs(&tab, newjob) {
7174 jobs::waitjobs(&mut tab, newjob); // c:1835
7175 if let Some(p) = tab[newjob].procs.last() {
7176 let val = if p.is_signaled() {
7177 0o200 | p.term_sig() // c:Src/jobs.c:489-490
7178 } else {
7179 p.exit_status() // c:Src/jobs.c:494
7180 };
7181 crate::ported::builtin::LASTVAL
7182 .store(val, std::sync::atomic::Ordering::Relaxed);
7183 }
7184 }
7185 // c:1977-1979 — `deletejob(jn, 0)` once done; c:1981
7186 // `thisjob = pj` restores the caller's job.
7187 if newjob < tab.len() {
7188 jobs::deletejob(&mut tab[newjob], false);
7189 }
7190 *jobs::THISJOB
7191 .get_or_init(|| std::sync::Mutex::new(-1))
7192 .lock()
7193 .unwrap_or_else(|e| e.into_inner()) = pj;
7194 }
7195 let status = crate::ported::builtin::LASTVAL
7196 .load(std::sync::atomic::Ordering::Relaxed);
7197 let mut synth = crate::ported::zsh_h::job::default();
7198 crate::ported::jobs::waitonejob(&mut synth);
7199 Value::Status(status)
7200 });
7201 // c:Src/exec.c:3340-3364 — `< file` / `> file` with no command
7202 // word. Resolves NULLCMD/READNULLCMD at runtime then routes
7203 // through host_exec_external. Redirects are already applied by
7204 // the surrounding WithRedirectsBegin scope.
7205 vm.register_builtin(BUILTIN_NULLCMD_EXEC, |vm, argc| {
7206 let args = pop_args(vm, argc);
7207 let is_single_read = args
7208 .first()
7209 .map(|s| s != "0" && !s.is_empty())
7210 .unwrap_or(false);
7211 // c:Src/exec.c — when the surrounding redir-open failed
7212 // (e.g. `< /nonexistent`), zerr already printed the diag
7213 // and set redirect_failed. Don't invoke NULLCMD — return
7214 // status 1 like the wordcode path does.
7215 let redir_failed = with_executor(|exec| {
7216 let f = exec.redirect_failed;
7217 exec.redirect_failed = false;
7218 f
7219 });
7220 if redir_failed {
7221 crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
7222 return Value::Status(1);
7223 }
7224 let nullcmd = crate::ported::params::getsparam("NULLCMD");
7225 let nc_str = nullcmd.as_deref().unwrap_or("");
7226 let nc_empty = nc_str.is_empty();
7227 // c:3340-3344 — CSHNULLCMD or no NULLCMD set → diagnostic.
7228 if nc_empty || crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLCMD) {
7229 let script_name =
7230 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
7231 let lineno: u64 = with_executor(|exec| {
7232 exec.scalar("LINENO")
7233 .and_then(|s| s.parse::<u64>().ok())
7234 .unwrap_or(1)
7235 });
7236 eprintln!("{}:{}: redirection with no command", script_name, lineno);
7237 return Value::Status(1);
7238 }
7239 // c:3350 — SHNULLCMD → run `:`.
7240 let cmd: String = if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHNULLCMD) {
7241 ":".to_string()
7242 } else if is_single_read {
7243 // c:3354-3359 — single REDIR_READ + READNULLCMD set → readnullcmd.
7244 let rnc = crate::ported::params::getsparam("READNULLCMD");
7245 let rnc_str = rnc.as_deref().unwrap_or("");
7246 if !rnc_str.is_empty() {
7247 rnc_str.to_string()
7248 } else {
7249 nc_str.to_string() // c:3360-3363 fallback
7250 }
7251 } else {
7252 nc_str.to_string() // c:3360-3363
7253 };
7254 let status = with_executor(|exec| exec.host_exec_external(&[cmd]));
7255 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
7256 Value::Status(status)
7257 });
7258 // c:Src/exec.c:3342 — `zerr("redirection with no command")`.
7259 // Bare prefix-keyword (`builtin`, `command`, `exec`, `noglob`,
7260 // `nocorrect`) with a redirect but no command word. Emits the
7261 // canonical diagnostic via zerr (which sets errflag) and
7262 // returns Status(1). Bug #534.
7263 vm.register_builtin(BUILTIN_REDIR_NO_CMD, |_vm, _argc| {
7264 crate::ported::utils::zerr("redirection with no command");
7265 Value::Status(1)
7266 });
7267 vm.register_builtin(BUILTIN_DEBUG_TRAP, |vm, _argc| {
7268 // c:Src/signals.c:1245 dotrap(SIGDEBUG) — fires the DEBUG
7269 // trap body once per statement. The body sees the parent
7270 // shell's $? (LASTVAL). Guard against re-entry: commands
7271 // inside the DEBUG trap body would otherwise trigger
7272 // DEBUG_TRAP recursively → stack overflow. zsh guards via
7273 // its in_trap counter; we mirror with a thread-local Cell.
7274 //
7275 // c:Src/exec.c::trapcmd — before dotrap, the C source sets
7276 // `ZSH_DEBUG_CMD` to the about-to-run command text via
7277 // `dupstring(text)`. The trap body reads the parameter;
7278 // C unsets it after the trap returns. compile_list emits
7279 // the rendered statement text as the single arg here so the
7280 // shell-visible parameter reflects the command. Bug #263 in
7281 // docs/BUGS.md.
7282 let cmd_text = vm.pop().to_str();
7283 DEBUG_TRAP_REENTRY.with(|c| {
7284 if c.get() {
7285 return Value::Status(0);
7286 }
7287 // c:Src/exec.c:1423 — `if (sigtrapped[SIGDEBUG] &&
7288 // isset(DEBUGBEFORECMD) && !intrap)`. Bug #573: without
7289 // this gate, every sublist boundary called
7290 // setsparam("ZSH_DEBUG_CMD", ...) even when no DEBUG trap
7291 // was set, polluting the param table and (under
7292 // WARN_CREATE_GLOBAL) emitting a spurious
7293 // `scalar parameter ZSH_DEBUG_CMD created globally`
7294 // warning at every function call.
7295 //
7296 // Two trap registries exist (per signals.rs:1481-1511 dotrap):
7297 // - settrap path → sigtrapped[SIGDEBUG] bits set
7298 // - bin_trap path → traps_table["DEBUG"] populated, sigtrapped untouched
7299 // Mirror the dotrap dispatch decision: skip only when BOTH
7300 // are absent.
7301 let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
7302 let debug_trapped = crate::ported::signals::sigtrapped
7303 .lock()
7304 .map(|v| v.get(sig_debug).copied().unwrap_or(0))
7305 .unwrap_or(0);
7306 let debug_in_table = crate::ported::builtin::traps_table()
7307 .lock()
7308 .map(|t| t.contains_key("DEBUG"))
7309 .unwrap_or(false);
7310 if debug_trapped == 0 && !debug_in_table {
7311 return Value::Status(0);
7312 }
7313 c.set(true);
7314 // c:Src/exec.c — set ZSH_DEBUG_CMD scalar (PM_READONLY
7315 // is NOT set on ZSH_DEBUG_CMD, so the canonical
7316 // setsparam path is fine here — no direct paramtab
7317 // mutation needed).
7318 crate::ported::params::setsparam("ZSH_DEBUG_CMD", &cmd_text);
7319 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGDEBUG);
7320 // c:Src/exec.c::trapcmd — `unsetparam("ZSH_DEBUG_CMD")`
7321 // after the trap returns. Mirror that.
7322 crate::ported::params::unsetparam("ZSH_DEBUG_CMD");
7323 c.set(false);
7324 Value::Status(0)
7325 })
7326 });
7327
7328 vm.register_builtin(BUILTIN_ERREXIT_CHECK, |vm, _argc| {
7329 // Returns Value::Int(1) when the caller should jump to the
7330 // current scope's return-patch landing (subshell-end / func-
7331 // end / chunk-end). Returns Value::Int(0) otherwise. Emit
7332 // side at `emit_errexit_check` pairs this with a JumpIfTrue
7333 // → return_patches pattern so the caller can short-circuit.
7334 //
7335 // Four triggers:
7336 // 1. RETFLAG set by a nested `return` / `exit` (eval,
7337 // sourced file, called function). Unwind THIS scope so
7338 // the flag propagates outward until something clears it.
7339 // 2. EXIT_PENDING set (mostly subshell-context exits). Same
7340 // propagation logic.
7341 // 3. `set -e` + nonzero status — the classic errexit path.
7342 // 4. errflag set in non-interactive mode — readonly
7343 // reassign, bad redirect, parse error mid-expansion etc.
7344 // Aborts the script (c:Src/init.c loop()).
7345 use std::sync::atomic::Ordering;
7346 let retflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
7347 let exit_pending = crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed);
7348 if retflag != 0 || exit_pending != 0 {
7349 if exit_pending != 0 {
7350 // c:Src/builtin.c zexit — the deferred exit carries its
7351 // status in EXIT_VAL; sync it into the VM counter so
7352 // the top-level unwind reports it as the script's exit
7353 // (run_chunk returns vm.last_status). Without this, a
7354 // POSIX-fatal `.` failure exited 127 (bin_dot's return)
7355 // instead of C's exit(1) at Src/exec.c:4383.
7356 vm.last_status =
7357 crate::ported::builtin::EXIT_VAL.load(Ordering::Relaxed) & 0xFF;
7358 }
7359 return Value::Int(1);
7360 }
7361 let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
7362 & crate::ported::zsh_h::ERRFLAG_ERROR)
7363 != 0;
7364 // c:Src/init.c:1931 — `if (errflag && !interact &&
7365 // !isset(CONTINUEONERROR)) { errexit = 1; break; }` — with
7366 // CONTINUE_ON_ERROR set, the top-level do-while re-enters
7367 // loop() and the NEXT list runs instead of the shell exiting.
7368 // Clear the flag so the next statement starts clean (the
7369 // failed statement's lastval is already in place).
7370 if errflag_set
7371 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
7372 && crate::ported::zsh_h::isset(crate::ported::zsh_h::CONTINUEONERROR)
7373 {
7374 crate::ported::utils::errflag
7375 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
7376 return Value::Int(0);
7377 }
7378 if errflag_set && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE) {
7379 // c:Src/exec.c execlist — every enclosing list loop runs
7380 // `while (... && !errflag)`, so a set errflag breaks the
7381 // CURRENT scope and the check in the enclosing scope
7382 // breaks THAT one, all the way out. Leave errflag SET —
7383 // do NOT convert it to EXIT_PENDING: a process-exit
7384 // signal tunnels through the containment boundaries C
7385 // has, namely eval (Src/builtin.c:6221 `errflag &=
7386 // ~ERRFLAG_ERROR`), source (Src/init.c:1663 same), fork
7387 // boundaries (subshell/cmdsubst — child's errflag dies
7388 // with the child), and the interactive toplevel
7389 // (Src/init.c:139). Those boundaries clear errflag
7390 // themselves and execution continues past them; with
7391 // EXIT_PENDING armed here, `eval 'assoc=(odd)'; echo
7392 // after` aborted the whole script where zsh 5.9 prints
7393 // `after` (eval status 1). Bug #74's function case
7394 // (`f() { local -r x=5; x=10; }; f; echo after`) still
7395 // aborts: the function scope unwinds on THIS check, and
7396 // the caller's next ERREXIT_CHECK sees the still-set
7397 // errflag and unwinds too — exactly C's propagation.
7398 //
7399 // c:Src/init.c:234 — loop() BREAKS on errflag and
7400 // zsh_main exits with the UNTOUCHED lastval, NOT a
7401 // forced 1: `typeset -i x=3#8` (math error during the
7402 // assignment, before typeset sets a status) exits 0 in
7403 // zsh; a cond syntax error set lastval=2 (exec.c:5216-
7404 // 5221) and zsh exits 2; the readonly-reassign case
7405 // exits 1 because ITS lastval is 1. Sync the VM counter
7406 // from the executor's live lastval instead of
7407 // overwriting.
7408 vm.last_status = with_executor(|exec| exec.last_status());
7409 return Value::Int(1);
7410 }
7411 let last = vm.last_status;
7412 if last == 0 {
7413 return Value::Int(0);
7414 }
7415 // c:Src/exec.c:1598 `if (!this_noerrexit && !donetrap &&
7416 // !this_donetrap)` — gate the ZERR trap fire on DONETRAP so
7417 // an inner sublist (e.g. `false` inside a function) that
7418 // already fired ZERR doesn't fire it AGAIN at the outer
7419 // sublist's post-command check (after the function
7420 // returned non-zero). Bug #303 in docs/BUGS.md. DONETRAP
7421 // is reset at top-level statement boundaries via
7422 // BUILTIN_DONETRAP_RESET (compile_list emit at
7423 // compile_zsh.rs).
7424 let already_done =
7425 crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
7426 if !already_done {
7427 // c:Src/signals.c:1245 dotrap(SIGZERR) — canonical ZERR
7428 // trap dispatch. Fires whenever a command exits
7429 // non-zero.
7430 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR);
7431 // c:1602 — `donetrap = 1;` after firing.
7432 crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed);
7433 }
7434 // c:Src/exec.c:1605-1610 — compute errreturn / errexit.
7435 // errreturn = ERRRETURN && (INTERACTIVE || locallevel || sourcelevel)
7436 // && !(noerrexit & NOERREXIT_RETURN)
7437 // errexit = (ERREXIT || (ERRRETURN && !errreturn))
7438 // && !(noerrexit & NOERREXIT_EXIT)
7439 let no_err = crate::ported::exec::noerrexit.load(Ordering::Relaxed);
7440 let locallvl = crate::ported::params::locallevel.load(Ordering::Relaxed);
7441 let sourcelvl = crate::ported::init::sourcelevel.load(Ordering::Relaxed);
7442 let errreturn_opt = isset(crate::ported::zsh_h::ERRRETURN);
7443 let in_unwindable_scope = isset(crate::ported::zsh_h::INTERACTIVE)
7444 || locallvl != 0
7445 || sourcelvl != 0;
7446 let errreturn = errreturn_opt
7447 && in_unwindable_scope
7448 && (no_err & crate::ported::zsh_h::NOERREXIT_RETURN) == 0;
7449 if errreturn {
7450 // c:1620-1623 — `retflag = 1; breaks = loops;` — unwind to
7451 // function boundary without exiting the shell.
7452 crate::ported::builtin::RETFLAG.store(1, Ordering::Relaxed);
7453 let loops = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
7454 crate::ported::builtin::BREAKS.store(loops, Ordering::Relaxed);
7455 return Value::Int(1);
7456 }
7457 let (errexit_on, in_subshell) = with_executor(|exec| {
7458 let on_canonical = isset(ERREXIT)
7459 || (errreturn_opt && !errreturn); // c:1608-1609
7460 let on_legacy = opt_state_get("errexit").unwrap_or(false);
7461 (
7462 (on_canonical || on_legacy)
7463 && (no_err & crate::ported::zsh_h::NOERREXIT_EXIT) == 0,
7464 !exec.subshell_snapshots.is_empty(),
7465 )
7466 });
7467 if !errexit_on {
7468 return Value::Int(0);
7469 }
7470 // c:Src/exec.c — `set -e` fires shell exit, NOT a function-only
7471 // unwind. When LOCAL_OPTIONS restores the option mid-fn, the
7472 // restoration would otherwise mask the trigger and let the
7473 // outer scope continue. Setting EXIT_PENDING + EXIT_VAL here
7474 // (for ALL scope kinds, not just subshells) makes the fn-exit
7475 // path propagate to the shell-exit boundary at c:6135-6155.
7476 crate::ported::builtin::EXIT_VAL.store(last, Ordering::Relaxed);
7477 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
7478 let _ = in_subshell;
7479 // Function scope and top-level scope both branch to their
7480 // respective return_patches; top-level lands at chunk-end,
7481 // so execute_script returns `last` as the script's exit
7482 // status (same observable behavior as a process::exit).
7483 Value::Int(1)
7484 });
7485
7486 // BUILTIN_ASSIGN_ONLY_STATUS — status of an assignment-only
7487 // simple command. c:Src/exec.c:3393-3396 (execcmd_exec, no
7488 // command word + varspc): `if (errflag) lastval = 1; else
7489 // lastval = cmdoutval;`; same shape at c:1322 (execsimple
7490 // WC_ASSIGN: `lv = (errflag ? errflag : cmdoutval)`) and
7491 // c:3977 (nullexec=2 redir variant). cmdoutval is the exit of
7492 // a `$()` that ran in an RHS (already in vm.last_status via
7493 // compile_assign's per-assign SetStatus), 0 otherwise. The
7494 // store goes to the canonical LASTVAL too — that IS C's single
7495 // `lastval` global; without it the errflag-abort path
7496 // (BUILTIN_ERREXIT_CHECK trigger 4) syncs vm.last_status from
7497 // a stale LASTVAL and `readonly r=1; r=2` exited 0, not 1.
7498 vm.register_builtin(BUILTIN_ASSIGN_ONLY_STATUS, |vm, _argc| {
7499 use std::sync::atomic::Ordering;
7500 let had_cmd_subst = vm.pop().to_int() != 0;
7501 let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
7502 & crate::ported::zsh_h::ERRFLAG_ERROR)
7503 != 0;
7504 // c:Src/exec.c addvars — `if (!pm) { lastval = 1; if
7505 // (!cmdoutval) cmdoutval = 1; }` (assignment-failed cheat).
7506 let assign_failed = ASSIGN_FAILED_FLAG.swap(false, std::sync::atomic::Ordering::Relaxed);
7507 let status = if errflag_set || assign_failed {
7508 1 // c:Src/exec.c:3394 `lastval = 1` / addvars cmdoutval=1
7509 } else if had_cmd_subst {
7510 vm.last_status // c:3396 `lastval = cmdoutval` (subst exit)
7511 } else {
7512 0 // c:3396 `lastval = cmdoutval` (cmdoutval = 0)
7513 };
7514 with_executor(|exec| exec.set_last_status(status));
7515 Value::Status(status)
7516 });
7517
7518 // `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
7519 // Pops [name, op_byte, rhs] (rhs popped first). Returns the modified
7520 // value as Value::Str. Handles unset/empty distinction (`:-` etc.
7521 // treat empty same as unset, matching POSIX).
7522 // BUILTIN_PARAM_DEFAULT_FAMILY — `${var-x}` / `${var:-x}` / `${var=x}` /
7523 // `${var:=x}` / `${var?x}` / `${var:?x}` / `${var+x}` / `${var:+x}`.
7524 // PURE PASSTHRU: pop name + op + rhs, reconstruct the canonical
7525 // brace expression, hand to `subst::paramsubst` (C port of
7526 // `Src/subst.c::paramsubst`). All "missing vs empty" gating,
7527 // nounset suppression, default-evaluation, and elide-empty-words
7528 // semantics live inside paramsubst.
7529 vm.register_builtin(BUILTIN_PARAM_DEFAULT_FAMILY, |vm, _argc| {
7530 let rhs = vm.pop().to_str();
7531 let op = vm.pop().to_int() as u8;
7532 let name = vm.pop().to_str();
7533 // op=8 is the `${+name}` set-test prefix form (distinct from the
7534 // `${name+rhs}` substitute-if-set suffix form which is op=7).
7535 // Per compile_zsh.rs::parse_param_modifier: the `+` is emitted as
7536 // a leading sigil and `rhs` is empty.
7537 let body = if op == 8 {
7538 format!("${{+{}}}", name)
7539 } else {
7540 let op_str = match op {
7541 0 => ":-",
7542 1 => ":=",
7543 2 => ":?",
7544 3 => ":+",
7545 4 => "-",
7546 5 => "=",
7547 6 => "?",
7548 7 => "+",
7549 _ => "-",
7550 };
7551 format!("${{{}{}{}}}", name, op_str, rhs)
7552 };
7553 paramsubst_to_value(&body)
7554 });
7555
7556 // `${var:offset[:length]}` — substring. Pops [name, offset, length].
7557 // length == -1 means "rest of string". Negative offset counts from end.
7558 // BUILTIN_PARAM_SUBSTRING — `${var:offset:length}` literal-int form.
7559 // PURE PASSTHRU: reconstruct `${name:offset:length}` and route
7560 // through `subst::paramsubst`. Length sentinel `i64::MIN` =
7561 // "no length given" (omit the `:length` portion).
7562 //
7563 // c:Src/subst.c:1571,3781 — `${name:-N}` is the colon-default
7564 // operator, NOT a substring with negative offset. zsh's lexical
7565 // rule disambiguates via a literal space: `${name: -N}` (space
7566 // before `-`) is the substring form. The reconstructed body MUST
7567 // preserve that space when offset < 0; otherwise paramsubst's
7568 // `:-` dispatch fires on the synthesized `${name:-N}` body and
7569 // returns N as the unset-default instead of slicing the last N
7570 // chars. Length-form `${name:-N:M}` has the same trap.
7571 vm.register_builtin(BUILTIN_PARAM_SUBSTRING, |vm, _argc| {
7572 let length = vm.pop().to_int();
7573 let offset = vm.pop().to_int();
7574 let name = vm.pop().to_str();
7575 let off_sep = if offset < 0 { " " } else { "" };
7576 let body = if length == i64::MIN {
7577 format!("${{{}:{}{}}}", name, off_sep, offset)
7578 } else {
7579 format!("${{{}:{}{}:{}}}", name, off_sep, offset, length)
7580 };
7581 paramsubst_to_value(&body)
7582 });
7583
7584 // BUILTIN_PARAM_SUBSTRING_EXPR — `${var:offset_expr[:length_expr]}` form.
7585 // PURE PASSTHRU: rebuild `${name:offset:length}` using the
7586 // expression text verbatim (paramsubst's offset/length
7587 // parser evaluates arith / param refs itself).
7588 //
7589 // c:Src/subst.c:1571,3781 — same `:-` disambiguation trap as
7590 // BUILTIN_PARAM_SUBSTRING. The expression text may itself start
7591 // with `-` (e.g. `${VAR:$((-1))}` arith resolves at the body-
7592 // assembly layer in some upstream paths, leaving `-1` in
7593 // off_expr). Insert a leading space when off_expr starts with
7594 // `-` so paramsubst's check_colon_subscript (subst.c:1571)
7595 // accepts the operand as a math expression instead of the
7596 // `:-` operator catching it.
7597 vm.register_builtin(BUILTIN_PARAM_SUBSTRING_EXPR, |vm, _argc| {
7598 let has_len = vm.pop().to_int() != 0;
7599 let len_expr = vm.pop().to_str();
7600 let off_expr = vm.pop().to_str();
7601 let name = vm.pop().to_str();
7602 let off_sep = if off_expr.starts_with('-') { " " } else { "" };
7603 let body = if has_len {
7604 format!("${{{}:{}{}:{}}}", name, off_sep, off_expr, len_expr)
7605 } else {
7606 format!("${{{}:{}{}}}", name, off_sep, off_expr)
7607 };
7608 paramsubst_to_value(&body)
7609 });
7610
7611 // `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}`
7612 // Pops [name, pattern, op_byte]. op: 0=`#` short-prefix, 1=`##` long,
7613 // 2=`%` short-suffix, 3=`%%` long. Glob-pattern matching via the
7614 // existing glob_match_static helper.
7615 // BUILTIN_PARAM_STRIP — `${var#pat}` / `${var##pat}` / `${var%pat}` /
7616 // `${var%%pat}`. PURE PASSTHRU: reconstruct the brace expression
7617 // and route through `subst::paramsubst`. (M)/(S) flags arrive
7618 // through SUB_FLAGS (already inside paramsubst's scope), so we
7619 // just clear the bridge-side cached read.
7620 vm.register_builtin(BUILTIN_PARAM_STRIP, |vm, _argc| {
7621 let _dq_flag = vm.pop().to_int() != 0;
7622 let op = vm.pop().to_int() as u8;
7623 let pattern = vm.pop().to_str();
7624 let name = vm.pop().to_str();
7625 let op_str = match op {
7626 0 => "#",
7627 1 => "##",
7628 2 => "%",
7629 3 => "%%",
7630 _ => "#",
7631 };
7632 let body = format!("${{{}{}{}}}", name, op_str, pattern);
7633 paramsubst_to_value(&body)
7634 });
7635
7636 // `$((expr))` — pops [expr_string], evaluates via MathEval which
7637 // honors integer-vs-float distinction (zsh-compatible). Returns
7638 // the result as Value::Str so it can be Concat'd into surrounding
7639 // word context.
7640 vm.register_builtin(BUILTIN_ARITH_EVAL, |vm, _argc| {
7641 // Pure path: evaluate expr, return string. errflag may be
7642 // set by arithsubst on math error; the caller decides
7643 // whether to clear it. For `(( ... ))` (math command) the
7644 // compile_arith path clears via BUILTIN_ARITH_CMD_FINISH;
7645 // for `$((... ))` (substitution inside another command)
7646 // errflag stays set so the surrounding command aborts —
7647 // matches c:Src/math.c "math errors propagate as errflag
7648 // through the containing word expansion".
7649 let expr = vm.pop().to_str();
7650 let result = crate::ported::subst::arithsubst(&expr, "", "");
7651 let _ = vm; // silence unused warning when no math error path mutates
7652 Value::str(result)
7653 });
7654
7655 // After-call hook used by compile_arith's `(( ... ))` path: when
7656 // arithsubst set errflag (math error), clear it and signal
7657 // status=2 in vm.last_status — matches zsh's c:exec.c arith-
7658 // failure: the math command exits 2 and the script continues.
7659 vm.register_builtin(BUILTIN_ARITH_CMD_FINISH, |vm, _argc| {
7660 use std::sync::atomic::Ordering;
7661 let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
7662 let err = live & crate::ported::zsh_h::ERRFLAG_ERROR;
7663 let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
7664 if err != 0 {
7665 // c:Src/subst.c:3344 — when `${var:?msg}` fires, errflag
7666 // is OR'd with ERRFLAG_HARD to signal a script-abort
7667 // error (vs a recoverable math error like `$((1/0))`).
7668 // Clear only the ERRFLAG_ERROR bit; preserve
7669 // ERRFLAG_HARD so the next ERREXIT_CHECK aborts the
7670 // script. Bug #193 in docs/BUGS.md.
7671 if hard != 0 {
7672 // Keep ERRFLAG_HARD AND ERRFLAG_ERROR set so the
7673 // script-abort gate downstream still fires.
7674 vm.last_status = 2;
7675 Value::Status(2)
7676 } else {
7677 crate::ported::utils::errflag
7678 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
7679 vm.last_status = 2;
7680 Value::Status(2)
7681 }
7682 } else {
7683 Value::Status(vm.last_status)
7684 }
7685 });
7686
7687 // `$(cmd)` — pops [cmd_string], routes through
7688 // run_command_substitution which performs an in-process pipe-capture.
7689 // Avoids the Op::CmdSubst sub-chunk word-emit bug
7690 // (`printf "a\nb"` produced "anb" via that path). Returns trimmed
7691 // output (trailing newlines stripped per POSIX cmd-sub semantics).
7692 vm.register_builtin(BUILTIN_CMD_SUBST_TEXT, |vm, _argc| {
7693 let cmd = vm.pop().to_str();
7694 // Inherit live $? into the inner shell so cmd-subst sees the
7695 // parent's most recent exit. Same rationale as the mode-3
7696 // backtick path above.
7697 let live_status = vm.last_status;
7698 let result = with_executor(|exec| {
7699 exec.set_last_status(live_status);
7700 exec.run_command_substitution(&cmd)
7701 });
7702 // Mirror run_command_substitution's exec.last_status side
7703 // effect into the VM's live counter so a containing
7704 // assignment's BUILTIN_SET_VAR — which reads vm.last_status
7705 // — sees the cmd-subst's exit. Without this, `a=$(false);
7706 // echo $?` reads stale 0 (vm.last_status was zeroed by
7707 // compile_assign's prelude SetStatus, and run_cmd_subst only
7708 // updated exec.last_status). Pull the value back through
7709 // exec since it owns the canonical post-subst record.
7710 let cs_status = with_executor(|exec| exec.last_status());
7711 vm.last_status = cs_status;
7712 Value::str(result)
7713 });
7714
7715 // Text-based word expansion. Pops [preserved_text, mode_byte].
7716 // mode_byte:
7717 // 0 = Default — expand_string + xpandbraces + expand_glob
7718 // 1 = DoubleQuoted — strip outer `"…"`, expand_string only
7719 // (no brace, no glob — DQ semantics)
7720 // 2 = SingleQuoted — strip outer `'…'`, no expansion
7721 // (kept for symmetry; Snull early-return covers most SQ)
7722 // 3 = AltBackquote — strip backticks, run as cmd-sub
7723 // 7 = RedirTarget — same as Default but glob gated on MULTIOS
7724 // (c:Src/glob.c:2161-2167 xpandredir)
7725 // Single result → Value::str; multi → Value::Array.
7726 vm.register_builtin(BUILTIN_EXPAND_TEXT, |vm, _argc| {
7727 let mode = vm.pop().to_int() as u8;
7728 let text = vm.pop().to_str();
7729 // Sync vm.last_status → exec.last_status so cmd-subst (mode 3)
7730 // and any nested $? reads inside singsub see the live `$?`
7731 // from the most recent VM op. Without this, cmd-subst inside
7732 // arg-eval saw a stale exec.last_status that was zeroed at
7733 // the start of the current statement. Direct port of zsh's
7734 // pre-cmdsubst lastval propagation per Src/exec.c:4770.
7735 let live_status = vm.last_status;
7736 with_executor(|exec| exec.set_last_status(live_status));
7737 let result_value = with_executor(|exec| match mode {
7738 // Mode 1 = DoubleQuoted (argument context).
7739 // Mode 5 = DoubleQuoted in scalar-assignment context.
7740 // Both share the same DQ unescape pre-processing; mode 5
7741 // additionally bumps `in_scalar_assign` so subst_port's
7742 // paramsubst sees ssub=true and suppresses split flags
7743 // `(f)` / `(s:STR:)` / `(0)` per Src/subst.c:1759 +
7744 // Src/exec.c::addvars line 2546 (the PREFORK_SINGLE bit
7745 // C zsh sets when prefork-ing the assignment RHS).
7746 1 | 5 => {
7747 // DoubleQuoted: strip outer `"…"` if present. In DQ
7748 // context, `\` escapes the DQ-special chars `$`, `` ` ``,
7749 // `"`, `\`. zsh's expand_string expects the lexer's
7750 // `\0X` literal-marker for an already-escaped char, so
7751 // we pre-process: `\$` → `\0$`, `\\` → `\0\`, etc. Then
7752 // expand_string handles the rest.
7753 let inner = if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
7754 &text[1..text.len() - 1]
7755 } else {
7756 text.as_str()
7757 };
7758 // The lexer's dquote_parse (Src/lex.c) already tokenized
7759 // DQ contents: `$` → Qstring (\u{8c}), `\$`/`\\`/`\"`/
7760 // `` \` `` → Bnull (\u{9f}) + literal. Stringsubst /
7761 // multsub recognize these markers natively. We pass
7762 // `inner` through verbatim — no re-tokenization needed.
7763 let prepped: String = inner.to_string();
7764 // Tell parameter-flag application that we're inside
7765 // double quotes — array-only flags ((o), (O), (n),
7766 // (i), (M), (u)) must be no-ops here per zsh.
7767 exec.in_dq_context += 1;
7768 if mode == 5 {
7769 exec.in_scalar_assign += 1;
7770 }
7771 // Mode 1 = argv DQ word; mode 5 = scalar-assign RHS.
7772 // In C zsh, the corresponding prefork-on-list paths
7773 // are: argv → `prefork(argv_list, 0)` returns multi-
7774 // word LinkList (Src/exec.c::execcmd), assignment →
7775 // `prefork(rhs_list, PREFORK_SINGLE|PREFORK_ASSIGN)`
7776 // returns single-word (Src/exec.c::addvars line
7777 // 2546). zshrs's `multsub` (Src/subst.c:544) is the
7778 // multi-result variant; `singsub` (Src/subst.c:514)
7779 // asserts ≤1 node. Mode 5 keeps singsub; mode 1
7780 // switches to multsub so `"${(@)arr}"`/`"$@"`/
7781 // `"${arr[@]}"` in argv context emit multiple words
7782 // as the C path would.
7783 // c:Src/lex.c untokenize — the final argv pass C runs
7784 // on every expanded word (glob.c:1862 / exec.c) drops
7785 // the Nularg empty-word sentinel remnulargs left in
7786 // place and folds any remaining token chars. Without
7787 // it, quoted splits with empty pieces
7788 // ("${(s:|:)x}" on "|a|b|") leak U+00A1 into argv.
7789 let result_value = if mode == 5 {
7790 let out = crate::ported::subst::singsub(&prepped);
7791 Value::str(crate::ported::lex::untokenize(&out))
7792 } else {
7793 let (_first, nodes, _ms_ws, _ret) = crate::ported::subst::multsub(&prepped, 0);
7794 // c:Src/subst.c:655 — multsub returns Vec::new()
7795 // for zero-word results (quoted array splat that
7796 // resolved to empty array). Surface as
7797 // Value::Array(vec![]) so the downstream array
7798 // assignment / argv flattening sees ZERO args.
7799 // Previous Rust port returned Value::str("") which
7800 // surfaced as ONE empty arg. Bug #120 in
7801 // docs/BUGS.md.
7802 if nodes.is_empty() {
7803 Value::Array(Vec::new())
7804 } else if nodes.len() == 1 {
7805 Value::str(crate::ported::lex::untokenize(
7806 &nodes.into_iter().next().unwrap(),
7807 ))
7808 } else {
7809 Value::Array(
7810 nodes
7811 .into_iter()
7812 .map(|n| Value::str(crate::ported::lex::untokenize(&n)))
7813 .collect(),
7814 )
7815 }
7816 };
7817 if mode == 5 {
7818 exec.in_scalar_assign -= 1;
7819 }
7820 exec.in_dq_context -= 1;
7821 result_value
7822 }
7823 2 => {
7824 // SingleQuoted: pure literal, strip outer `'…'`.
7825 let inner = if text.len() >= 2 && text.starts_with('\'') && text.ends_with('\'') {
7826 &text[1..text.len() - 1]
7827 } else {
7828 text.as_str()
7829 };
7830 Value::str(inner.to_string())
7831 }
7832 3 => {
7833 // Backquote command sub: strip outer backticks.
7834 // Word-split the result on IFS when the surrounding
7835 // word is unquoted — zsh: `print -l \`echo a b c\``
7836 // emits one arg per word. The $(…) path applies the
7837 // same split via BUILTIN_WORD_SPLIT after capture; do
7838 // the equivalent here for the `…` form.
7839 let inner = if text.len() >= 2 && text.starts_with('`') && text.ends_with('`') {
7840 &text[1..text.len() - 1]
7841 } else {
7842 text.as_str()
7843 };
7844 // Apply the live VM status before running the inner
7845 // shell so the inherited $? matches zsh's lastval
7846 // propagation.
7847 exec.set_last_status(live_status);
7848 let captured = exec.run_command_substitution(inner);
7849 let trimmed = captured.trim_end_matches('\n');
7850 if exec.in_dq_context > 0 {
7851 Value::str(trimmed.to_string())
7852 } else {
7853 let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
7854 let parts: Vec<Value> = trimmed
7855 .split(|c: char| ifs.contains(c))
7856 .filter(|s| !s.is_empty())
7857 .map(|s| Value::str(s.to_string()))
7858 .collect();
7859 if parts.is_empty() {
7860 Value::str(String::new())
7861 } else if parts.len() == 1 {
7862 parts.into_iter().next().unwrap()
7863 } else {
7864 Value::Array(parts)
7865 }
7866 }
7867 }
7868 4 => {
7869 // HeredocBody: expand variables / command-subst / arith
7870 // but NOT glob or brace. Heredoc lines like `[42]` must
7871 // pass through verbatim — running them through the
7872 // default pipeline triggers NOMATCH on the literal.
7873 Value::str(crate::ported::subst::singsub(&text))
7874 }
7875 _ => {
7876 // Default (unquoted): the lexer's gettokstr already
7877 // tokenized backslash-escapes (`\$` → Bnull+$, etc).
7878 // Pass `text` through verbatim — multsub/stringsubst
7879 // recognize the markers natively. No bridge-side
7880 // re-tokenization needed.
7881 //
7882 // Mode 6 = unquoted RHS in scalar-assign context.
7883 // Pass PREFORK_ASSIGN so prefork's filesub colon-walk
7884 // fires per c:Src/exec.c:2546.
7885 let prepped: String = text.clone();
7886 if std::env::var("ZSHRS_TRACE_DEFP").is_ok() {
7887 eprintln!(
7888 "[TRACE_DEFP] text={:?} prepped={:?} mode={}",
7889 text, prepped, mode
7890 );
7891 }
7892 let pf_flags = if mode == 6 {
7893 crate::ported::zsh_h::PREFORK_ASSIGN
7894 } else {
7895 0
7896 };
7897 // c:Src/subst.c:544+ — `multsub(&prepped, 0)` is the
7898 // unquoted-argv equivalent of zsh's `prefork(list,
7899 // 0, NULL)` for a single-element list. Returns the
7900 // post-expansion node list (Vec<String>) so array-
7901 // shape results (e.g. `${a:e}`, `${a[@]}`,
7902 // `${(s::)str}`) splat into multiple argv words.
7903 // singsub() collapses to one string and discards the
7904 // splat — parity bug #28 (whole-array modifier).
7905 let (_first, nodes, _ms_ws, _ret) =
7906 crate::ported::subst::multsub(&prepped, pf_flags);
7907 if std::env::var("ZSHRS_TRACE_MULTSUB").is_ok() {
7908 eprintln!("[TRACE_MULTSUB] prepped={:?} nodes={:?}", prepped, nodes);
7909 }
7910 // c:Src/subst.c:166 — xpandbraces runs AFTER prefork's
7911 // substitution pass and BEFORE untokenize/glob. Per
7912 // word, scan for Inbrace TOKEN and expand. Words that
7913 // don't contain Inbrace TOKEN pass through unchanged.
7914 // Brace expansion is done here (inside the bridge
7915 // default arm) instead of via a post-EXPAND_TEXT
7916 // BRACE_EXPAND emit because untokenize (line below)
7917 // strips TOKEN bytes, after which the strict-TOKEN
7918 // xpandbraces gate would no longer match.
7919 let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
7920 // c:Src/options.c — `no_brace_expand` (negated
7921 // `braceexpand`) gates brace expansion entirely.
7922 // When off, `{a,b}` stays literal.
7923 let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
7924 let pre_brace: Vec<String> = if nodes.is_empty() {
7925 vec![String::new()]
7926 } else {
7927 nodes
7928 };
7929 let brace_expanded: Vec<String> = pre_brace
7930 .into_iter()
7931 .flat_map(|w| {
7932 if brace_expand && w.contains('\u{8f}') {
7933 crate::ported::glob::xpandbraces(&w, brace_ccl)
7934 } else {
7935 vec![w]
7936 }
7937 })
7938 .collect();
7939 // zsh stores the option as `glob` (default ON);
7940 // `setopt noglob` writes `glob=false`. Honor either
7941 // form so the dispatcher behaves the same as zsh.
7942 // Mode 7 = redirect-target word: glob only under
7943 // MULTIOS (c:Src/glob.c:2161-2167 xpandredir,
7944 // "Globbing is only done for multios.").
7945 let noglob = opt_state_get("noglob").unwrap_or(false)
7946 || opt_state_get("GLOB").map(|v| !v).unwrap_or(false)
7947 || !opt_state_get("glob").unwrap_or(true)
7948 || (mode == 7 && !opt_state_get("multios").unwrap_or(true));
7949 let parts: Vec<String> = brace_expanded
7950 .into_iter()
7951 .flat_map(|s| {
7952 // The lexer leaves glob metacharacters in their
7953 // META-encoded form: `*` → `\u{87}`, `?` →
7954 // `\u{86}`, `[` → `\u{91}`, etc. expand_string
7955 // doesn't untokenize them, so the literal-char
7956 // checks below (`s.contains('*')`) would miss
7957 // every real glob and skip expand_glob — that
7958 // bug let `echo *.toml` print the literal
7959 // `*.toml` because the META `\u{87}` never
7960 // matched the literal `*`. Untokenize once so
7961 // the metacharacter checks see the canonical
7962 // form. zsh's pattern.c expects `*` etc. as
7963 // bare chars at the glob layer.
7964 // c:Src/pattern.c:4306 haswilds on the still-
7965 // TOKENIZED word (pre-untokenize), matching C's
7966 // zglob entry gate (Src/glob.c:1230) which runs
7967 // on the lexer-tokenized string. haswilds
7968 // matches ONLY token codes: source-level
7969 // `*.toml` carries Star and fires; bare literal
7970 // `[`/`*`/`?` from `$'...'` decode, `:-`
7971 // default values, or nested-substitution
7972 // results were never shtokenize'd (C
7973 // subst.c:3231 sets globsubst=0 in the `:-`
7974 // arm) and stay literal — bug #625. Plain
7975 // multibyte text (`↔`) never matches a token
7976 // codepoint — bug #627.
7977 let is_glob_pre = !noglob && crate::ported::pattern::haswilds(&s);
7978 let s = crate::lex::untokenize(&s);
7979 // Skip glob expansion for assignment-shaped
7980 // words (`NAME=value`). zsh doesn't expand the
7981 // RHS of an assignment as a path glob unless
7982 // `setopt globassign` is set, and feeding such
7983 // words through expand_glob makes NOMATCH
7984 // (default ON) fire spuriously on
7985 // `integer i=2*3+1`, `path=*.rs`, etc.
7986 let is_assignment_shape = {
7987 let bytes = s.as_bytes();
7988 let mut i = 0;
7989 if !bytes.is_empty()
7990 && (bytes[0] == b'_' || bytes[0].is_ascii_alphabetic())
7991 {
7992 i += 1;
7993 while i < bytes.len()
7994 && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric())
7995 {
7996 i += 1;
7997 }
7998 i < bytes.len() && bytes[i] == b'='
7999 } else {
8000 false
8001 }
8002 };
8003 // Glob-trigger decision: pre-untokenize
8004 // haswilds_tokens_only result (computed above
8005 // before the untokenize that collapses META
8006 // tokens to their ASCII forms). The TOKEN-only
8007 // gate matches C `Src/pattern.c:4306-4376`
8008 // exactly — only Inbrack/Star/Quest/Inpar/Bar/
8009 // Inang/Pound/Hat token codes count as wild,
8010 // not their literal ASCII counterparts. Source-
8011 // level `*.toml` carries Star token so globs;
8012 // `$'…'`-decoded `[abc]` carries bare `[` so
8013 // stays literal. Bug #625.
8014 if is_glob_pre && !is_assignment_shape {
8015 exec.expand_glob(&s)
8016 } else {
8017 vec![s]
8018 }
8019 })
8020 .collect();
8021 if parts.len() == 1 {
8022 let only = parts.into_iter().next().unwrap_or_default();
8023 // Empty unquoted expansion → drop the arg entirely
8024 // (zsh "remove empty unquoted words" rule). Returning
8025 // an empty Value::Array makes pop_args contribute zero
8026 // items. Direct port of subst.c's empty-elide pass at
8027 // the end of multsub which removes empty linknodes
8028 // from unquoted contexts. Quoted DQ/SQ paths (modes
8029 // 1/2/5) take separate arms above and always emit
8030 // Value::Str so the empty arg survives.
8031 if only.is_empty() {
8032 Value::Array(Vec::new())
8033 } else {
8034 Value::str(only)
8035 }
8036 } else {
8037 Value::Array(parts.into_iter().map(Value::str).collect())
8038 }
8039 }
8040 });
8041 // Pull any inner cmd-subst (`` `cmd` `` via mode 3 or via
8042 // mode 0/6 multsub → getoutput, `$(cmd)` via the default
8043 // arm's multsub path, nested `$()`s reached through
8044 // stringsubst) back into vm.last_status so a containing
8045 // assignment's BUILTIN_SET_VAR — which reads vm.last_status —
8046 // sees the cmd-subst's exit. Without this, backtick
8047 // assignments (`a=\`false\`; echo $?`) reported 0 because the
8048 // ported LASTVAL update never reached the VM-side counter.
8049 let cs_status = with_executor(|exec| exec.last_status());
8050 vm.last_status = cs_status;
8051 result_value
8052 });
8053
8054 // `${#name}` — pops [name]. Returns the value's element count for
8055 // arrays (indexed and assoc) or character length for scalars.
8056 // BUILTIN_PARAM_LENGTH — `${#name}`. PURE PASSTHRU.
8057 vm.register_builtin(BUILTIN_PARAM_LENGTH, |vm, _argc| {
8058 let name = vm.pop().to_str();
8059 // PARAM_LENGTH's empty-result semantics differ from
8060 // paramsubst_to_value: 0 nodes → "0" (numeric length), not
8061 // empty array. paramsubst on `${#X}` always returns at least
8062 // one node in practice (the length string); the empty case
8063 // is defensive.
8064 let mut ret_flags: i32 = 0;
8065 let (_full, _pos, nodes) = crate::ported::subst::paramsubst(
8066 &format!("${{#{}}}", name),
8067 0,
8068 false,
8069 0i32,
8070 &mut ret_flags,
8071 );
8072 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
8073 with_executor(|exec| exec.set_last_status(1));
8074 }
8075 if nodes.is_empty() {
8076 Value::str("0")
8077 } else {
8078 nodes_to_value(nodes)
8079 }
8080 });
8081
8082 // `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
8083 // `${var/%pat/repl}` — Pops [name, pattern, replacement, op_byte].
8084 // op: 0=first, 1=all, 2=anchor-prefix (`/#`), 3=anchor-suffix (`/%`).
8085 // BUILTIN_PARAM_REPLACE — `${var/pat/repl}` / `${var//pat/repl}` /
8086 // `${var/#pat/repl}` / `${var/%pat/repl}`. PURE PASSTHRU.
8087 vm.register_builtin(BUILTIN_PARAM_REPLACE, |vm, _argc| {
8088 let dq_flag = vm.pop().to_int() != 0;
8089 let op = vm.pop().to_int() as u8;
8090 let repl = vm.pop().to_str();
8091 let pattern = vm.pop().to_str();
8092 let name = vm.pop().to_str();
8093 // DQ context: C's lexer marks every `$` inside double quotes
8094 // as the Qstring token (Src/lex.c dquote_parse) and keeps `'`
8095 // a plain char — so a DQ replacement's `$'…'` is LITERAL in
8096 // C (Src/subst.c:301 decodes only the tokenized Snull form;
8097 // `"${a/X/$'\0'}"` keeps the five chars `$'\0'`). The body
8098 // rebuilt below re-enters stringsubst as raw text, which
8099 // would mis-decode `$'…'` as ANSI-C; stamp the Qstring
8100 // marker on the repl's `$` so stringsubst sees the same DQ
8101 // signal C's tokens carry. The PATTERN side keeps decoding
8102 // (matches observed zsh: the pattern's `$'\0'` matches a
8103 // real NUL while the repl's stays literal).
8104 let repl = if dq_flag {
8105 repl.replace('$', "\u{8c}")
8106 } else {
8107 repl
8108 };
8109 // op encoding: 0 = first `/`, 1 = all `//`, 2 = anchor-prefix
8110 // `/#`, 3 = anchor-suffix `/%`. The brace form distinguishes
8111 // first-vs-all by single vs doubled slash, and anchored by
8112 // a `#` or `%` immediately after the slash(es).
8113 let body = match op {
8114 0 => format!("${{{}/{}/{}}}", name, pattern, repl),
8115 1 => format!("${{{}//{}/{}}}", name, pattern, repl),
8116 2 => format!("${{{}/#{}/{}}}", name, pattern, repl),
8117 3 => format!("${{{}/%{}/{}}}", name, pattern, repl),
8118 _ => format!("${{{}/{}/{}}}", name, pattern, repl),
8119 };
8120 // c:Src/subst.c:1625 — paramsubst's qt flag. The compiler
8121 // threads the word's DQ context onto the stack; dropping it
8122 // (the old `let _dq_flag`) ran the rebuilt body with qt=false
8123 // whenever the opcode fired outside an EXPAND_TEXT scope, so
8124 // DQ-only semantics inside the replacement (e.g. `$'` staying
8125 // literal per Src/subst.c:301 — `"${a/x/$'\t'q}"`) were lost.
8126 // Bump in_dq_context exactly like EXPAND_TEXT mode 1 so
8127 // paramsubst_to_value's qt probe sees the right context.
8128 if dq_flag {
8129 with_executor(|exec| exec.in_dq_context += 1);
8130 }
8131 let ret = paramsubst_to_value(&body);
8132 if dq_flag {
8133 with_executor(|exec| exec.in_dq_context -= 1);
8134 }
8135 ret
8136 });
8137
8138 vm.register_builtin(BUILTIN_REGISTER_COMPILED_FN, |vm, argc| {
8139 let args = pop_args(vm, argc);
8140 let mut iter = args.into_iter();
8141 let name = iter.next().unwrap_or_default();
8142 let body_b64 = iter.next().unwrap_or_default();
8143 let body_source = iter.next().unwrap_or_default();
8144 let line_base_str = iter.next().unwrap_or_default();
8145 let line_base: i64 = line_base_str.parse().unwrap_or(0);
8146 let bytes = base64_decode(&body_b64);
8147 let status = match bincode::deserialize::<fusevm::Chunk>(&bytes) {
8148 Ok(chunk) => with_executor(|exec| {
8149 // c:Src/exec.c:5383 — `shf->filename =
8150 // ztrdup(scriptfilename);` — the function's
8151 // definition-file is read from the canonical
8152 // file-scope `scriptfilename` global at compile
8153 // time, NOT from a per-executor struct field.
8154 // exec.scriptfilename is seeded once at
8155 // bins/zshrs.rs:1717 to the bin basename ("zsh")
8156 // and never updates on source/dot, so reading from
8157 // it left every user function's def_file as "zsh".
8158 // Route through scriptfilename_get() so source /
8159 // dot's set_scriptfilename calls propagate.
8160 let def_file = crate::ported::utils::scriptfilename_get()
8161 .or_else(|| exec.scriptfilename.clone());
8162 if !body_source.is_empty() {
8163 exec.function_source
8164 .insert(name.clone(), body_source.clone());
8165 }
8166 exec.function_line_base.insert(name.clone(), line_base);
8167 exec.function_def_file.insert(name.clone(), def_file);
8168 // PFA-SMR aspect: every `name() {}` / `function name { }`
8169 // funnels through here at compile time. Emit one record
8170 // with the function name + raw body source.
8171 #[cfg(feature = "recorder")]
8172 if crate::recorder::is_enabled() {
8173 let ctx = exec.recorder_ctx();
8174 let body = if body_source.is_empty() {
8175 None
8176 } else {
8177 Some(body_source.as_str())
8178 };
8179 crate::recorder::emit_function(&name, body, ctx);
8180 }
8181 // Mirror into canonical shfunctab so scanfunctions /
8182 // ${(k)functions} / functions builtin see user defs.
8183 // C: exec.c:funcdef → shfunctab->addnode(ztrdup(name),shf).
8184 if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
8185 let mut shf = crate::ported::hashtable::shfunc_with_body(&name, &body_source);
8186 // c:Src/exec.c:5409 — `shf->lineno = lineno;`. Use
8187 // the same max(1, line_base) clamp as the synth_shf
8188 // in vm_helper::dispatch_function_call. Bug #396.
8189 shf.lineno = std::cmp::max(1, line_base);
8190 tab.add(shf);
8191 }
8192 // c:Src/exec.c:5460-5475 — `TRAP<SIG>() { ... }` is the
8193 // function-named trap install. zsh detects the `TRAP`
8194 // prefix at func-def time and calls
8195 // `settrap(signum, NULL, ZSIG_FUNC)` so the next
8196 // dispatch of that signal routes to the named shfunc.
8197 // Bug #157 in docs/BUGS.md — fusevm_bridge's funcdef
8198 // opcode skipped this dispatch entirely, so TRAPEXIT /
8199 // TRAPUSR1 / TRAPZERR / TRAPDEBUG never fired.
8200 if name.len() > 4 && name.starts_with("TRAP") {
8201 if let Some(sn) = crate::ported::jobs::getsigidx(&name[4..]) {
8202 let _ = crate::ported::signals::settrap(
8203 sn,
8204 None,
8205 crate::ported::zsh_h::ZSIG_FUNC as i32,
8206 );
8207 }
8208 }
8209 exec.functions_compiled.insert(name, chunk);
8210 0
8211 }),
8212 Err(_) => 1,
8213 };
8214 Value::Status(status)
8215 });
8216
8217 // Wire the ShellHost so direct shell ops (Op::Glob, Op::TildeExpand,
8218 // Op::ExpandParam, Op::CmdSubst, Op::CallFunction, etc.) route through
8219 // ZshrsHost back into the executor.
8220 vm.set_shell_host(Box::new(ZshrsHost));
8221}
8222
8223impl ZshrsHost {
8224 /// True iff `c` can be a `(j:…:)` / `(s:…:)` delimiter — non-alphanumeric,
8225 /// non-underscore. Restricting to punctuation avoids `(jL)` consuming `L`
8226 /// as a delim instead of as the next flag.
8227 fn is_zsh_flag_delim(c: char) -> bool {
8228 !c.is_ascii_alphanumeric() && c != '_'
8229 }
8230}
8231
8232/// Shared `${name[idx]}` subscript dispatch for BUILTIN_ARRAY_INDEX
8233/// and the KSHARRAYS-unset arm of BUILTIN_ARRAY_INDEX_UNBRACED.
8234///
8235/// c:Src/subst.c subscript parsing — when paramsubst re-parses the
8236/// synthesized `${name[idx]}` body, characters like `'` `"` `\` `$`
8237/// etc. are LEXER-active inside the `[…]` and get reinterpreted
8238/// (quote-strip, paramsubst recursion, …). For PRE-EVALUATED key
8239/// strings (the dynamic-key fast path at compile_zsh.rs:3234 already
8240/// expanded `$k` via EXPAND_TEXT), the idx is a literal string that
8241/// must match the stored key byte-for-byte — no further
8242/// reinterpretation. Direct assoc lookup bypasses the lexer for this
8243/// case, avoiding the quote-strip bug where `h[a'b]` failed to
8244/// resolve because paramsubst's subscript lexer treated the `'` as a
8245/// quote. Bug #338. Only fires for simple assoc-name + non-flag idx
8246/// (no outer-flag sentinels, no `(…)` flag prefix on idx, no splat
8247/// operator). Other paths (slice, splat, flag-based search,
8248/// magic-assoc) still flow through paramsubst.
8249fn array_index_lookup(name: &str, idx: &str) -> Value {
8250 let idx_is_simple = !idx.starts_with('(') && idx != "@" && idx != "*" && !idx.contains(',');
8251 if idx_is_simple {
8252 if let Some(v) =
8253 with_executor(|exec| exec.assoc(name).and_then(|m| m.get(idx).cloned()))
8254 {
8255 return Value::str(v);
8256 }
8257 }
8258 let body = format!("${{{}[{}]}}", name, idx);
8259 paramsubst_to_value(&body)
8260}
8261
8262/// KSHARRAYS bare-`$name` expansion words for the unbraced
8263/// no-subscript form (BUILTIN_ARRAY_INDEX_UNBRACED's KSHARRAYS arm).
8264///
8265/// - `@` / `*` stay the full positional list (the c:Src/params.c:
8266/// 2293-2296 first-element collapse is gated on
8267/// `itype_end(t, IIDENT, 1) != t` — an identifier-shaped name —
8268/// which `@`/`*` are not). The literal `[idx]` then joins the LAST
8269/// word, matching zsh 5.9: `setopt ksharrays; set -- p q;
8270/// print -- $@[0]` → `zsh:1: no matches found: q[0]`.
8271/// - Identifier-named arrays collapse to the FIRST element
8272/// (c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`).
8273/// - Assocs collapse to the first value in scan order; the `options`
8274/// magic assoc's scan order is `OPTIONTAB` bucket order (first key
8275/// `posixargzero`), matching zsh 5.9: `emulate sh -L;
8276/// print $options[posixargzero]` → `off[posixargzero]`.
8277/// - Scalars / unset names expand to their value / empty (zsh 5.9:
8278/// `setopt ksharrays; print -- $unsetvar[0]` →
8279/// `zsh:1: no matches found: [0]`).
8280fn ksharrays_bare_words(name: &str) -> Vec<String> {
8281 if name == "@" || name == "*" {
8282 return with_executor(|exec| exec.pparams());
8283 }
8284 // Magic special-parameter lookups first — mirrors the
8285 // BUILTIN_GET_VAR precedence (partab before executor tables).
8286 if let Some(vals) = crate::vm_helper::partab_array_get(name) {
8287 return vec![vals.into_iter().next().unwrap_or_default()];
8288 }
8289 if let Some(keys) = crate::vm_helper::partab_scan_keys(name) {
8290 let v = keys
8291 .first()
8292 .and_then(|k| crate::vm_helper::partab_get(name, k))
8293 .unwrap_or_default();
8294 return vec![v];
8295 }
8296 let arr_or_assoc = with_executor(|exec| {
8297 if let Some(arr) = exec.array(name) {
8298 // c:Src/params.c:2293-2296 — first element only.
8299 return Some(arr.first().cloned().unwrap_or_default());
8300 }
8301 if let Some(map) = exec.assoc(name) {
8302 // Mirrors BUILTIN_GET_VAR's bare-assoc ordering
8303 // (sorted keys, first value).
8304 let mut keys: Vec<&String> = map.keys().collect();
8305 keys.sort();
8306 return Some(
8307 keys.first()
8308 .and_then(|k| map.get(*k).cloned())
8309 .unwrap_or_default(),
8310 );
8311 }
8312 None
8313 });
8314 if let Some(v) = arr_or_assoc {
8315 return vec![v];
8316 }
8317 vec![with_executor(|exec| exec.get_variable(name))]
8318}
8319
8320/// Run `body` through `crate::ported::subst::paramsubst` and convert
8321/// the resulting node list into a fusevm `Value`. Centralises the
8322/// pattern duplicated across ~10 BUILTIN_* handlers:
8323/// - build a `${...}` body string from opcode operands
8324/// - paramsubst the body
8325/// - propagate errflag to `exec.last_status`
8326/// - delegate the LinkList → Value conversion to `nodes_to_value`
8327///
8328/// **Extension** — Rust-only helper. No direct C analog because C
8329/// zsh uses LinkList everywhere; the conversion happens at the
8330/// boundary back into the VM's stack.
8331fn paramsubst_to_value(body: &str) -> Value {
8332 // c:Src/subst.c:1625 paramsubst's `qt` flag is the C signal that
8333 // the current expansion is inside `"…"`. The fast-path bridges
8334 // (BUILTIN_PARAM_*, BUILTIN_BRIDGE_BRACE_ARRAY) used to hardcode
8335 // qt=false, which silently broke DQ-only semantics inside
8336 // `${arr:^other}` / `${arr:^^other}` (Src/subst.c:3456-3520).
8337 // The executor's `in_dq_context` counter is bumped by EXPAND_TEXT
8338 // mode 1 / mode 5 before the bridge fires, so reading it here
8339 // propagates the DQ flag without changing every bridge call site.
8340 let qt = with_executor(|exec| exec.in_dq_context > 0);
8341 let mut ret_flags: i32 = 0;
8342 let (_full, _pos, nodes) = crate::ported::subst::paramsubst(body, 0, qt, 0i32, &mut ret_flags);
8343 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
8344 with_executor(|exec| exec.set_last_status(1));
8345 }
8346 // c:Src/lex.c untokenize — the final argv pass C runs on every
8347 // expanded word (glob.c:1862 / exec.c) DROPS the Nularg
8348 // empty-word sentinel (c:2089 `if (c != Nularg)`) that
8349 // remnulargs faithfully leaves in place (glob.c:3673 re-adds it
8350 // for all-empty results). These fast-path bridges are terminal —
8351 // their output lands directly in argv slots — so apply it here.
8352 // Without it, quoted splits with empty pieces ("${(s:|:)x}" on
8353 // "|a|b|") leak U+00A1 into argv.
8354 let nodes: Vec<String> = nodes
8355 .into_iter()
8356 .map(|n| crate::ported::lex::untokenize(&n))
8357 .collect();
8358 nodes_to_value(nodes)
8359}
8360
8361/// Wrap a `Vec<String>` (e.g. paramsubst nodes, multsub parts,
8362/// xpandbraces output) into a fusevm `Value`: 0 → empty Array, 1 →
8363/// Str, >1 → Array. Same unwrap idiom every handler that calls a
8364/// canonical Vec-returning fn does.
8365fn nodes_to_value(nodes: Vec<String>) -> Value {
8366 // c:Src/glob.c:3649 remnulargs — strip the Nularg (`\u{a1}`)
8367 // sentinel and other INULL bytes that paramsubst's splat block
8368 // emits for empty array elements (so prefork's empty-node-delete
8369 // pass doesn't drop them). Downstream consumers (cond `-z`/`-n`,
8370 // command args, etc.) must see the post-remnulargs strings. Bug
8371 // #185 in docs/BUGS.md: `[[ -z "${b[@]}" ]]` for b=("") returned
8372 // false because the leftover `\u{a1}` had StringLen=1.
8373 let stripped: Vec<String> = nodes
8374 .into_iter()
8375 .map(|mut s| {
8376 crate::ported::glob::remnulargs(&mut s);
8377 s
8378 })
8379 .collect();
8380 if stripped.is_empty() {
8381 Value::Array(Vec::new())
8382 } else if stripped.len() == 1 {
8383 let only = stripped.into_iter().next().unwrap();
8384 // c:Src/subst.c:183-186 — `else if (!(flags & PREFORK_SINGLE)
8385 // && !(*ret_flags & PREFORK_KEY_VALUE) && !keep)
8386 // uremnode(list, node);`
8387 // C zsh's prefork removes empty linknodes from the result
8388 // list when in non-SINGLE (argv-context) mode. The ported
8389 // prefork at subst.rs:388-396 honors the same delete-empty
8390 // pass, but some paramsubst paths land here with a single-
8391 // empty-string Vec instead of an empty Vec (paramsubst's
8392 // slice / substring / parameter-flag branches allocate a
8393 // result before checking emptiness). Mirror the prefork
8394 // drop at this layer: single-empty under !in_dq_context
8395 // collapses to Value::Array(empty), and pop_args (line 6243)
8396 // splats the empty Array → zero argv words. DQ context
8397 // (in_dq_context > 0) keeps the empty string so
8398 // `echo "${UNSET}"` still produces an empty arg per zsh's
8399 // quoting rules (c:Src/subst.c:1650-1656 isarr comment).
8400 if only.is_empty() {
8401 let in_dq = with_executor(|exec| exec.in_dq_context > 0);
8402 if !in_dq {
8403 return Value::Array(Vec::new());
8404 }
8405 }
8406 Value::str(only)
8407 } else {
8408 Value::Array(stripped.into_iter().map(Value::str).collect())
8409 }
8410}
8411
8412fn pop_args(vm: &mut fusevm::VM, argc: u8) -> Vec<String> {
8413 let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
8414 for _ in 0..argc {
8415 popped.push(vm.pop());
8416 }
8417 popped.reverse();
8418 let mut args: Vec<String> = Vec::with_capacity(popped.len());
8419 for v in popped {
8420 match v {
8421 Value::Array(items) => {
8422 for item in items {
8423 args.push(item.to_str());
8424 }
8425 }
8426 other => args.push(other.to_str()),
8427 }
8428 }
8429 // `expand_glob` set the glob-failed cell when a no-match glob
8430 // triggered nomatch (c:Src/glob.c:1877). Signal the failure via
8431 // last_status + the per-command glob_failed cell; the dispatcher
8432 // (`host_exec_external`) consumes + clears it and returns status 1
8433 // without running the command body.
8434 if with_executor(|exec| exec.current_command_glob_failed.get()) {
8435 with_executor(|exec| exec.set_last_status(1));
8436 }
8437 // `$_` tracks the last argument of the PREVIOUSLY executed
8438 // command (zsh / bash convention). Promote the deferred value
8439 // into `$_` BEFORE this command runs (so `echo $_` reads the
8440 // prior command's last arg) then stash THIS command's last arg
8441 // for the next dispatch.
8442 let new_last = args.last().cloned();
8443 with_executor(|exec| {
8444 if let Some(prev) = exec.pending_underscore.take() {
8445 exec.set_scalar("_".to_string(), prev);
8446 }
8447 if let Some(last) = new_last {
8448 exec.pending_underscore = Some(last);
8449 }
8450 });
8451 args
8452}
8453
8454/// zsh dispatch order is alias → function → builtin → external. The
8455/// compiler emits direct CallBuiltin ops for known builtin names for
8456/// perf, which silently skips a user function that shadows the same
8457/// name (e.g. `echo() { ... }; echo hi` would run the C builtin
8458/// without this check). Returns Some(status) when the call is routed
8459/// to the user function; the builtin handler should fall through to
8460/// its native impl when None.
8461/// Fork+exec a system binary by name. Used by `reg_overridable!` as
8462/// the fall-through path when `[builtins].coreutils_shadows = off`
8463/// (the default) — runs the canonical `/bin/X` instead of zshrs's
8464/// in-process shadow so old scripts hit zero behavioral divergence.
8465///
8466/// Inherits stdin/stdout/stderr from the parent so pipelines work
8467/// transparently. Resolves the binary via PATH; mirrors what zsh's
8468/// own external-command dispatch would do. Returns the child's exit
8469/// status (or 127 if PATH lookup fails — the standard "command not
8470/// found" code).
8471fn exec_system_command(name: &str, args: &[String]) -> i32 {
8472 // c:Src/jobs.c — count the fork so `time` reports for an
8473 // overridable coreutils shadow run as an external (`time sleep 0`,
8474 // `time cat …`). This is a distinct spawn path from
8475 // execute_external_bg; without the bump BUILTIN_TIME_SUBLIST saw no
8476 // job and stayed silent. (Builtins that don't reach a spawn never
8477 // hit this fn.)
8478 crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
8479 let status = std::process::Command::new(name)
8480 .args(args)
8481 .stdin(std::process::Stdio::inherit())
8482 .stdout(std::process::Stdio::inherit())
8483 .stderr(std::process::Stdio::inherit())
8484 .status();
8485 match status {
8486 Ok(s) => s.code().unwrap_or(if s.success() { 0 } else { 1 }),
8487 Err(e) => {
8488 eprintln!("zshrs: {}: {}", name, e);
8489 127
8490 }
8491 }
8492}
8493
8494fn try_user_fn_override(name: &str, args: &[String]) -> Option<i32> {
8495 let has_fn = with_executor(|exec| {
8496 exec.functions_compiled.contains_key(name) || exec.function_exists(name)
8497 });
8498 if !has_fn {
8499 return None;
8500 }
8501 Some(with_executor(|exec| {
8502 exec.dispatch_function_call(name, args).unwrap_or(127)
8503 }))
8504}
8505
8506/// Builtin ID for `${name}` reads — routes through canonical
8507/// `getsparam` (Src/params.c:3076) via paramtab + env walk so nested
8508/// VMs (function calls) see the same storage.
8509pub const BUILTIN_GET_VAR: u16 = 283;
8510
8511/// Builtin ID for `name=value` assignments — pops [name, value] and
8512/// routes through canonical `setsparam` (Src/params.c:3350).
8513pub const BUILTIN_SET_VAR: u16 = 284;
8514
8515/// Builtin ID for pipeline execution. Pops N sub-chunk indices from the stack;
8516/// each index points into `vm.chunk.sub_chunks` (compiled stage bodies). Forks
8517/// N children, wires stdin/stdout between them via pipes, runs each stage's
8518/// bytecode on a fresh VM in its child, parent waits for all and pushes the
8519/// last stage's exit status. This is bytecode-native pipeline execution —
8520/// no tree-walker delegation.
8521pub const BUILTIN_RUN_PIPELINE: u16 = 285;
8522
8523/// Builtin ID for `Array → String` joining. Pops one value: if it's an Array,
8524/// joins its string-coerced elements with a single space; otherwise passes
8525/// through. Used after `Op::Glob` to convert the pattern's matched paths into
8526/// the single argv-token form the bytecode word model expects (no per-word
8527/// splitting yet — that's a future phase).
8528pub const BUILTIN_ARRAY_JOIN: u16 = 286;
8529
8530/// Builtin ID for `cmd &` background execution. IDs 287/288/289 are reserved
8531/// for the planned array work in Phase G1 (SET_ARRAY/SET_ASSOC/ARRAY_INDEX),
8532/// so this lands at 290. Pops the sub-chunk index then the job text; forks;
8533/// child detaches (`setsid`), runs the sub-chunk on a fresh VM, exits with
8534/// last_status; parent registers the job in the canonical JOBTAB
8535/// (initjob/addproc/spawnjob per c:Src/exec.c:1700-1758) so `jobs` / `wait
8536/// %N` / `kill %N` / `disown` and the zsh/parameter assocs all see it, then
8537/// returns Status(0) immediately.
8538pub const BUILTIN_RUN_BG: u16 = 290;
8539
8540/// Indexed-array assignment: `arr=(a b c)`. Compile_simple emits N element
8541/// pushes followed by name push, then `CallBuiltin(BUILTIN_SET_ARRAY, N+1)`.
8542/// The handler pops args (last popped = name in our pushing order) and stores
8543/// `Vec<String>` into `executor.arrays`. Tree-walker callers see the same
8544/// storage. Any prior scalar binding in `executor.variables` for `name` is
8545/// removed so `${name}` (scalar context) consistently reflects the array's
8546/// first element via `get_variable`.
8547pub const BUILTIN_SET_ARRAY: u16 = 287;
8548
8549/// Single-key set on an associative array: `foo[key]=val`. Stack (top-down):
8550/// [name, key, value]. Stores `value` into `executor.assoc_arrays[name][key]`,
8551/// creating the outer entry if missing. compile_simple detects `var[...]=...`
8552/// in assignments and emits this builtin.
8553pub const BUILTIN_SET_ASSOC: u16 = 288;
8554
8555/// `${arr[idx]}` — single-element array index. Pops two args:
8556/// stack: [name, idx_str]
8557/// Returns the indexed element as Value::str. Indexing semantics: zsh is
8558/// 1-based by default; bash is 0-based. We follow zsh.
8559/// Special idx values: `@` and `*` return the whole array as Value::Array
8560/// (which fuses correctly via the Op::Exec splice for argv splice).
8561pub const BUILTIN_ARRAY_INDEX: u16 = 289;
8562
8563/// `${#arr[@]}` and `${#arr}` (when arr is an array name) — array length.
8564/// Pops one arg: name. Returns Value::str of len.
8565
8566/// `${arr[@]}` — splice all elements as a Value::Array. Pops one arg: name.
8567/// The Array gets flattened by Op::Exec/ExecBg/CallFunction into argv.
8568pub const BUILTIN_ARRAY_ALL: u16 = 292;
8569
8570/// Flatten one level of Value::Array nesting. Pops N values; for each, if it's
8571/// a Value::Array, its elements are appended directly; otherwise the value is
8572/// appended as-is. Pushes a single Value::Array of the flattened result. Used
8573/// by the for-loop word-list compile path: when a word like `${arr[@]}`
8574/// produces a nested Array, this lets `for i in ${arr[@]}` iterate over the
8575/// inner elements rather than the outer single-element array.
8576pub const BUILTIN_ARRAY_FLATTEN: u16 = 293;
8577
8578/// `coproc [name] { body }` — bidirectional pipe to async child. Pops a name
8579/// (optional, "" for default) and a sub-chunk index. Creates two pipes, forks,
8580/// child redirects its fd 0/1 to the inner ends and runs the body, parent
8581/// stores [write_fd, read_fd] into the named array (default `COPROC`). Caller
8582/// closes the fds and `wait`s when done. Job-table integration deferred to
8583/// Phase G6 alongside the bg `&` work.
8584pub const BUILTIN_RUN_COPROC: u16 = 294;
8585
8586/// `arr+=(d e f)` — append N elements to an existing indexed array. Compile
8587/// emits N element pushes + name push, then `CallBuiltin(295, N+1)`. Handler
8588/// drains args (last popped = name), extends `executor.arrays[name]` (creates
8589/// the entry if missing). Mirrors zsh's `+=` semantics for indexed arrays.
8590pub const BUILTIN_APPEND_ARRAY: u16 = 295;
8591
8592/// `name[@]=(...)` / `name[*]=(...)` whole-array SET. Identical to
8593/// BUILTIN_SET_ARRAY for an indexed array / scalar (whole replace), but
8594/// rejects an associative target with "attempt to set slice of
8595/// associative array" (c:Src/params.c:3324-3327).
8596pub const BUILTIN_SET_ARRAY_AT: u16 = 633;
8597
8598/// `name[@]+=(...)` / `name[*]+=(...)` whole-array APPEND. Indexed
8599/// append (push), assoc target → same slice-of-assoc error as 633.
8600pub const BUILTIN_APPEND_ARRAY_AT: u16 = 634;
8601
8602/// `select var in words; do body; done` — interactive numbered-menu loop.
8603/// Compile emits N word pushes + var-name push + sub-chunk index push, then
8604/// `CallBuiltin(296, N+2)`. Handler prints `1) word1\n2) word2\n...` to
8605/// stderr, prints `$PROMPT3` (default `?# `) to stderr, reads a line from
8606/// stdin. On EOF returns 0. On a valid 1-based number, sets `var` to the
8607/// chosen word, runs the sub-chunk, then redisplays the menu and loops. On
8608/// invalid input redraws the menu without running the body. `break` from
8609/// inside the body exits the loop (handled by the body's own bytecode).
8610pub const BUILTIN_RUN_SELECT: u16 = 296;
8611
8612/// `m[k]+=value` — append onto an existing assoc-array value (string concat).
8613/// If the key doesn't exist, behaves like SET_ASSOC. Stack: [name, key, value].
8614
8615/// `break` from inside a body that runs on a sub-VM (select, future
8616/// loop-via-builtin constructs). Writes the canonical
8617/// `crate::ported::builtin::BREAKS` atomic (port of `Src/loop.c:46
8618/// breaks`). Outer-loop builtins drain BREAKS/CONTFLAG after each
8619/// body run, matching the loop.c:529-534 drain pattern.
8620pub const BUILTIN_SET_BREAK: u16 = 299;
8621
8622/// `continue` from inside a sub-VM body. Sets CONTFLAG=1 + bumps
8623/// BREAKS, matching `bin_break`'s WC_CONTINUE arm at Src/builtin.c
8624/// c:5836 `contflag = 1; FALLTHROUGH; breaks++;`.
8625pub const BUILTIN_SET_CONTINUE: u16 = 300;
8626
8627/// Brace expansion: `{a,b,c}` → 3 values, `{1..5}` → 5 values, `{01..05}` →
8628/// zero-padded numerics, `{a..e}` → letter range. Pops one string, returns
8629/// Value::Array of expansions (empty array → original string preserved).
8630pub const BUILTIN_BRACE_EXPAND: u16 = 301;
8631
8632/// Glob qualifier filter: `*(qualifier)` filters glob results by predicate.
8633/// Pops [pattern, qualifier_string]. Returns Value::Array of matching paths.
8634
8635/// Re-export the regex_match host method as a builtin so `[[ s =~ pat ]]`
8636/// works even when fusevm's Op::RegexMatch isn't routed (compat fallback).
8637
8638/// Word-split a string on IFS (default: whitespace). Pops one string,
8639/// returns Value::Array of fields. Used in array-literal context where
8640/// `arr=($(cmd))` should expand cmd's stdout into multiple elements.
8641pub const BUILTIN_WORD_SPLIT: u16 = 304;
8642
8643/// Register a pre-compiled fusevm chunk as a function. Stack: [name,
8644/// base64-bincode-of-Chunk]. Used by compile_zsh's compile_funcdef to
8645/// register functions parsed via parse_init+parse without going through the
8646/// ShellCommand JSON serialization path.
8647pub const BUILTIN_REGISTER_COMPILED_FN: u16 = 305;
8648/// `BUILTIN_VAR_EXISTS` constant.
8649pub const BUILTIN_VAR_EXISTS: u16 = 306;
8650/// Native param-modifier builtins. Each takes a fixed argv shape and
8651/// returns the modified value as Value::Str.
8652///
8653/// `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
8654/// — pop [name, op_byte, rhs]. op_byte: 0=`:-`, 1=`:=`, 2=`:?`, 3=`:+`.
8655pub const BUILTIN_PARAM_DEFAULT_FAMILY: u16 = 307;
8656/// `${var:offset[:length]}` — pop [name, offset, length] (length=-1 means
8657/// "rest of value"; negative offset counts from end).
8658pub const BUILTIN_PARAM_SUBSTRING: u16 = 308;
8659/// `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}` — pop
8660/// [name, pattern, op_byte]. op_byte: 0=`#`, 1=`##`, 2=`%`, 3=`%%`.
8661pub const BUILTIN_PARAM_STRIP: u16 = 309;
8662/// `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
8663/// `${var/%pat/repl}` — pop [name, pattern, replacement, op_byte].
8664/// op_byte: 0=first, 1=all, 2=anchor-prefix, 3=anchor-suffix.
8665pub const BUILTIN_PARAM_REPLACE: u16 = 310;
8666/// `${#name}` — character length of a scalar value, or element count
8667/// of an indexed/assoc array. Pops \[name\], returns count as Value::Str.
8668pub const BUILTIN_PARAM_LENGTH: u16 = 311;
8669/// `$((expr))` arithmetic substitution. Pops \[expr_string\], evaluates
8670/// via the executor's MathEval (integer-aware), returns result as
8671/// Value::Str. Bypasses ArithCompiler's float-only Op::Div path so
8672/// `$((10/3))` returns "3" not "3.333...".
8673pub const BUILTIN_ARITH_EVAL: u16 = 312;
8674/// `(( ... ))` math command post-eval status hook. Pops nothing,
8675/// pushes Value::Status. If errflag is set (math error in the
8676/// preceding BUILTIN_ARITH_EVAL call), clears it and emits status=2
8677/// matching c:Src/math.c arith-failure semantics. Otherwise emits
8678/// the current vm.last_status. Used by compile_arith's `(( ... ))`
8679/// path so the math command swallows errors without halting the
8680/// script — `$((... ))` substitutions skip this hook so their
8681/// errflag propagates up to the containing command.
8682pub const BUILTIN_ARITH_CMD_FINISH: u16 = 527;
8683/// `$(cmd)` command substitution. Pops \[cmd_string\], runs through
8684/// `run_command_substitution` which compiles via parse_init+parse + ZshCompiler
8685/// and captures stdout via an in-process pipe. Returns trimmed output
8686/// as Value::Str. Avoids the sub-chunk word-emit quoting bug in the
8687/// raw Op::CmdSubst path.
8688pub const BUILTIN_CMD_SUBST_TEXT: u16 = 313;
8689/// Text-based word expansion. Pops \[preserved_text\]: the word with
8690/// quotes preserved (Dnull→`"`, Snull→`'`, Bnull→`\`), runs
8691/// `expand_string` (variable + cmd-sub + arith) then `xpandbraces`
8692/// then `expand_glob`. Returns Value::str (single match) or
8693/// Value::Array (multi-match brace/glob).
8694pub const BUILTIN_EXPAND_TEXT: u16 = 314;
8695
8696/// `[[ a -ef b ]]` — same-inode test. Stack: [a, b]. Pushes Bool true iff
8697/// both paths resolve to the same `(dev, inode)` pair (zsh + bash semantics).
8698pub const BUILTIN_SAME_FILE: u16 = 315;
8699
8700/// `[[ a -nt b ]]` — file `a` newer than file `b` (mtime strict).
8701/// Stack: [path_a, path_b]. Pushes Bool. zsh-compatible "missing"
8702/// rules: if both exist, compare mtime; if only `a` exists → true;
8703/// otherwise false.
8704pub const BUILTIN_FILE_NEWER: u16 = 324;
8705
8706/// `[[ a -ot b ]]` — mirror of `-nt`. If both exist, compare mtime;
8707/// if only `b` exists → true; otherwise false.
8708pub const BUILTIN_FILE_OLDER: u16 = 325;
8709
8710/// `[[ -k path ]]` — sticky bit (S_ISVTX) set on path.
8711pub const BUILTIN_HAS_STICKY: u16 = 326;
8712/// `[[ -u path ]]` — setuid bit (S_ISUID).
8713pub const BUILTIN_HAS_SETUID: u16 = 327;
8714/// `[[ -g path ]]` — setgid bit (S_ISGID).
8715pub const BUILTIN_HAS_SETGID: u16 = 328;
8716/// `[[ -O path ]]` — owned by effective UID.
8717pub const BUILTIN_OWNED_BY_USER: u16 = 329;
8718/// `[[ -G path ]]` — owned by effective GID.
8719pub const BUILTIN_OWNED_BY_GROUP: u16 = 330;
8720/// `[[ -N path ]]` — file modified since last accessed (atime <= mtime).
8721pub const BUILTIN_FILE_MODIFIED_SINCE_ACCESS: u16 = 341;
8722
8723/// `name+=val` (no parens) — runtime-dispatched append.
8724/// If name is an indexed array → push val as element.
8725/// If name is an assoc array → error (zsh requires `(k v)` form).
8726/// Else → scalar concat (existing SET_VAR behavior).
8727pub const BUILTIN_APPEND_SCALAR_OR_PUSH: u16 = 331;
8728
8729/// `[[ -c path ]]` — character device.
8730pub const BUILTIN_IS_CHARDEV: u16 = 332;
8731/// `[[ -b path ]]` — block device.
8732pub const BUILTIN_IS_BLOCKDEV: u16 = 333;
8733/// `[[ -p path ]]` — FIFO / named pipe.
8734pub const BUILTIN_IS_FIFO: u16 = 334;
8735/// `[[ -S path ]]` — socket.
8736pub const BUILTIN_IS_SOCKET: u16 = 335;
8737/// `BUILTIN_ERREXIT_CHECK` constant.
8738pub const BUILTIN_ERREXIT_CHECK: u16 = 336;
8739/// Post-`always`-arm checks for the canonical RETFLAG / BREAKS /
8740/// CONTFLAG atomics that mark try-block escapes. Each returns
8741/// Value::Int(1) when the corresponding atomic is set (and consumes
8742/// it so the next escape doesn't re-fire) and Value::Int(0) otherwise.
8743/// Paired with JumpIfFalse + Jump to outer return_patches /
8744/// break_patches / continue_patches by compile_zsh's `Try` arm.
8745pub const BUILTIN_RETFLAG_CHECK: u16 = 600;
8746/// `BUILTIN_BREAKS_CHECK` constant.
8747pub const BUILTIN_BREAKS_CHECK: u16 = 601;
8748/// `BUILTIN_CONTFLAG_CHECK` constant.
8749pub const BUILTIN_CONTFLAG_CHECK: u16 = 602;
8750/// Fire the DEBUG trap (SIGDEBUG) before each statement.
8751/// c:Src/exec.c:1357-1500 DEBUGBEFORECMD — when a "DEBUG" entry is
8752/// installed via `trap '...' DEBUG`, run the body just before the
8753/// next command. Cheap when no DEBUG trap is set (one hashmap lookup
8754/// returns None and we early-out).
8755pub const BUILTIN_DEBUG_TRAP: u16 = 603;
8756/// `set -n` / `set -o noexec` — parse but don't execute. Returns
8757/// Value::Int(1) when the noexec option is set so the caller's
8758/// JumpIfTrue skips the statement body. c:Src/exec.c:1390 main loop
8759/// check.
8760pub const BUILTIN_NOEXEC_CHECK: u16 = 604;
8761/// Block-level redirect-failure gate. Reads exec.redirect_failed
8762/// (set by host.redirect when a redirect open fails); returns
8763/// Value::Int(1) AND clears the flag if set, else 0. Emit-side at
8764/// compile_zsh.rs::compile_command's Redirected arm pairs with a
8765/// JumpIfTrue → WithRedirectsEnd to abandon the body. Without this,
8766/// a multi-statement block after a failed redir kept running every
8767/// statement after the first (the first builtin consumed the flag,
8768/// subsequent statements ran unimpeded).
8769pub const BUILTIN_REDIRECT_FAILED_CHECK: u16 = 605;
8770/// Drop-in replacement for fusevm's Op::Exec for the dynamic-first-
8771/// word path (`$cmd`, `$(cmd)`, `~/bin/foo`). Returns
8772/// Value::Status(vm.last_status) when post-expansion argv is empty
8773/// (preserves the inner cmd-subst's exit), Value::Status(126) with
8774/// "permission denied" when `argv[0]` is empty, otherwise routes
8775/// through executor.host_exec_external like Op::Exec did.
8776pub const BUILTIN_EXEC_DYNAMIC: u16 = 606;
8777/// `[[ lhs == pat ]]` / `!=` glob compare — cond-specific so the
8778/// bad-pattern diagnostic follows Src/cond.c:308-316: zwarnnam
8779/// "bad pattern: %s" WITHOUT errflag (the script continues) and the
8780/// cond statement exits 2. Stack: [lhs, pat] → Bool. On compile
8781/// failure pushes Bool(false) and arms COND_BAD_PATTERN so
8782/// BUILTIN_COND_STATUS_FROM_BOOL reports 2.
8783pub const BUILTIN_COND_STRMATCH: u16 = 624;
8784/// Pops the cond result Bool → Int status per Src/cond.c: true→0,
8785/// false→1, but 2 when COND_BAD_PATTERN was armed during this cond
8786/// (covers `!=` where LogNot flips the Bool before status time).
8787pub const BUILTIN_COND_STATUS_FROM_BOOL: u16 = 625;
8788/// `[[ ]]` unknown condition. Pops \[op_name\], emits `zerr("unknown
8789/// condition: %s")` and sets ERRFLAG_ERROR so the next BUILTIN_ERREXIT_CHECK
8790/// (trigger 4) aborts the input — matching zsh's COND_MODI "unknown condition"
8791/// path (Src/cond.c:150-188) for a `-X` op with no matching loadable module.
8792/// Returns Bool(false). Replaces a compile-time `eprintln!` hack that printed
8793/// the message but never set errflag (so the line didn't abort).
8794pub const BUILTIN_COND_UNKNOWN: u16 = 632;
8795/// Bare-`exec` redirect epilogue. Consumes `exec.redirect_failed` and
8796/// applies the C `done:` tail of execcmd_exec:
8797/// - c:Src/exec.c:252-259 execerr — `redir_err = lastval = 1` (the
8798/// failed redirect makes the exec statement exit 1, NOT fatal by
8799/// itself);
8800/// - c:Src/exec.c:4367-4386 — `if (isset(POSIXBUILTINS) && (cflags
8801/// & (BINF_PSPECIAL|BINF_EXEC)) ...) { if (redir_err || errflag)
8802/// { if (!isset(INTERACTIVE)) exit(1); } }` — POSIX_BUILTINS makes
8803/// a failed exec redirect fatal in a non-interactive shell.
8804/// Returns Value::Status(0|1) for the trailing SetStatus.
8805pub const BUILTIN_EXEC_REDIR_DONE: u16 = 626;
8806/// Assignment-prefix epilogue for bare `exec` redirects
8807/// (`x=$(cmd) exec >file`). c:Src/exec.c:3969-3976 — nullexec==1
8808/// runs addvars THEN, without POSIX_BUILTINS, restores the params
8809/// (`save_params` / `restore_params`): the RHS side effects fire but
8810/// the values don't persist. With POSIX_BUILTINS the assignments
8811/// stick. Pops the BEGIN_INLINE_ENV frame either way.
8812pub const BUILTIN_EXEC_INLINE_ENV_DONE: u16 = 627;
8813
8814/// `< file` / `> file` with no command word (NULLCMD path).
8815/// Resolves NULLCMD (default "cat") / READNULLCMD (default "more")
8816/// at runtime per Src/exec.c:3340-3364 and exec's it through
8817/// host_exec_external. Argc is 1: the int (0 or 1) on the stack
8818/// indicates whether this is a single REDIR_READ redirect
8819/// (selects READNULLCMD when set + non-empty).
8820pub const BUILTIN_NULLCMD_EXEC: u16 = 607;
8821/// `.` (dot) — alias of source/bin_dot but dispatches with the
8822/// literal name "." so the diagnostic prefix matches zsh's
8823/// (`zsh:.:1: …` vs source's `zsh:source:1: …`).
8824/// c:Src/builtin.c:9308 — `BUILTIN(".", BINF_PSPECIAL, bin_dot, …)`.
8825pub const BUILTIN_DOT: u16 = 608;
8826/// `logout` — fusevm maps this to BUILTIN_EXIT alongside `exit`/`bye`,
8827/// which drops the name and dispatches with BIN_EXIT funcid. zsh's
8828/// `logout` outside a login shell must emit "not login shell" + exit 1,
8829/// which only fires when bin_break sees BIN_LOGOUT funcid. Dedicated
8830/// opcode dispatches via BUILTINS table by literal name "logout".
8831pub const BUILTIN_LOGOUT: u16 = 610;
8832/// `BUILTIN_PARAM_SUBSTRING_EXPR` constant.
8833pub const BUILTIN_PARAM_SUBSTRING_EXPR: u16 = 337;
8834/// `BUILTIN_XTRACE_LINE` constant.
8835pub const BUILTIN_XTRACE_LINE: u16 = 338;
8836/// `BUILTIN_ARRAY_JOIN_STAR` constant.
8837pub const BUILTIN_ARRAY_JOIN_STAR: u16 = 339;
8838/// `BUILTIN_SET_RAW_OPT` constant.
8839pub const BUILTIN_SET_RAW_OPT: u16 = 340;
8840
8841/// `time { compound; ... }` — wall-clock-time the sub-chunk and print
8842/// elapsed seconds. Stack: [sub_chunk_idx as Int]. Runs the sub-chunk
8843/// on the current VM (so positional/local state is shared) and prints
8844/// the timing summary to stderr in zsh's format. Pushes Status.
8845pub const BUILTIN_TIME_SUBLIST: u16 = 316;
8846
8847/// `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocation.
8848/// Stack: [path, varid, op_byte]. Opens `path` per `op_byte`, gets the
8849/// new fd (≥10 in zsh; we use libc::open with O_CLOEXEC bit cleared so
8850/// the inherited fd survives Command::new spawns), stores the fd number
8851/// as a string in `$varid`. Pushes Status (0 success, 1 error).
8852pub const BUILTIN_OPEN_NAMED_FD: u16 = 317;
8853
8854/// Word-segment concat that does cartesian-product distribution over
8855/// arrays. Stack: [lhs, rhs]. Used for RC_EXPAND_PARAM `${arr}` and
8856/// explicit-distribute forms (`${^arr}`, `${(@)…}`).
8857///
8858/// - both scalar: `Value::str(a + b)` (fast path, identical to Op::Concat)
8859/// - lhs Array, rhs scalar: `Value::Array([a + rhs for a in lhs])`
8860/// - lhs scalar, rhs Array: `Value::Array([lhs + b for b in rhs])`
8861/// - both Array: cartesian product `[a + b for a in lhs for b in rhs]`
8862pub const BUILTIN_CONCAT_DISTRIBUTE: u16 = 318;
8863
8864/// Forced-distribute concat — like `BUILTIN_CONCAT_DISTRIBUTE` but
8865/// always distributes cartesian regardless of the `rcexpandparam`
8866/// option. Emitted by the segments fast-path when an
8867/// `is_distribute_expansion` segment is present (`${^arr}`,
8868/// `${(@)arr}`, `${(s.…)arr}` etc.) per zsh: the source-level
8869/// distribution flag overrides the option default.
8870/// Direct port of Src/subst.c:1875 `case Hat: nojoin = 1` and the
8871/// `rcexpandparam` test bypass for the explicit-distribute flags.
8872pub const BUILTIN_CONCAT_DISTRIBUTE_FORCED: u16 = 522;
8873
8874/// Capture current `last_status` into the `TRY_BLOCK_ERROR` variable.
8875/// Emitted between the try block and the always block of `{ … } always
8876/// { … }` so the finally arm can read $TRY_BLOCK_ERROR.
8877pub const BUILTIN_SET_TRY_BLOCK_ERROR: u16 = 320;
8878/// `BUILTIN_RESTORE_TRY_BLOCK_STATUS` constant.
8879pub const BUILTIN_RESTORE_TRY_BLOCK_STATUS: u16 = 432;
8880/// `BUILTIN_BEGIN_INLINE_ENV` constant.
8881pub const BUILTIN_BEGIN_INLINE_ENV: u16 = 433;
8882/// `BUILTIN_END_INLINE_ENV` constant.
8883pub const BUILTIN_END_INLINE_ENV: u16 = 434;
8884
8885/// `[[ -o option ]]` — shell-option-set test. Stack: \[option_name\].
8886/// Normalizes the name (strip underscores, lowercase) and reads
8887/// `exec.options`. Pushes Bool.
8888pub const BUILTIN_OPTION_SET: u16 = 321;
8889/// Tri-state `[[ -o NAME ]]` — same lookup as BUILTIN_OPTION_SET
8890/// but returns a Value::Int (0=set, 1=unset, 3=invalid-name). The
8891/// 3-state code matches zsh's `[[ -o invalid ]]` exit (Src/cond.c
8892/// :502 `optison()`). Used by compile_cond's `-o` arm to skip the
8893/// generic bool→status conversion and preserve the invalid-name
8894/// signal in `$?`.
8895pub const BUILTIN_OPTION_CHECK_TRISTATE: u16 = 609;
8896
8897/// `${var:#pattern}` — array filter: remove elements matching `pattern`.
8898/// Stack: [name, pattern]. For scalar `var`, returns empty if it matches
8899/// the pattern, else the value. For array `var`, returns Array of
8900/// non-matching elements.
8901pub const BUILTIN_PARAM_FILTER: u16 = 322;
8902
8903/// `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()` —
8904/// subscripted-array assign with array-literal RHS. Stack:
8905/// [...elements, name, key]. Empty elements + single-int key `a[i]=()`
8906/// removes that element. Comma-key `a[i,j]=(...)` splices.
8907pub const BUILTIN_SET_SUBSCRIPT_RANGE: u16 = 323;
8908
8909/// `[[ -X file ]]` for unknown unary test op `-X`. Stack: \[op_name\].
8910/// Emits zsh's `unknown condition: -X` diagnostic to stderr and
8911/// pushes Bool(false). Without this, unknown conditions silently
8912/// returned false matching neither zsh's error format nor the
8913/// expected status code (zsh returns 2 for parse error).
8914
8915/// `[[ -t fd ]]` — fd-is-a-tty check. Stack: \[fd_string\].
8916/// Routes through libc::isatty. Pushes Bool.
8917pub const BUILTIN_IS_TTY: u16 = 325;
8918
8919/// Update `$LINENO` to track the source line of the next statement.
8920/// Stack: \[n\] (the line number from `ZshPipe.lineno`). Direct port
8921/// of zsh's `lineno` global tracking (Src/input.c:330) — the
8922/// compiler emits one of these per top-level pipe so `$LINENO`
8923/// reflects the source position at runtime. ID 342 picked because
8924/// the previous `326` collided with `BUILTIN_HAS_STICKY` (the file
8925/// has several other duplicate IDs — 325 has two as well — but
8926/// fixing those is out of scope for this port).
8927pub const BUILTIN_SET_LINENO: u16 = 342;
8928
8929/// Pop a scalar from the VM stack, run expand_glob on it, push the
8930/// result as Value::Array. Used by the segment-concat compile path
8931/// when var refs concatenate with glob meta literals (`$D/*`,
8932/// `${prefix}*`, etc.) — those skip the bridge's pathname-expansion
8933/// pass and would otherwise leak the glob meta to argv as a literal.
8934pub const BUILTIN_GLOB_EXPAND: u16 = 343;
8935
8936/// MULTIOS-gated glob expansion for redirect-target words
8937/// (c:Src/glob.c:2161-2167 xpandredir: "Globbing is only done for
8938/// multios."). Same stack shape as BUILTIN_GLOB_EXPAND; additionally
8939/// passes the word through literally when `unsetopt multios`.
8940// 624 is BUILTIN_COND_STRMATCH — the VM's builtin table is
8941// last-registration-wins, so a duplicate id silently shadows the
8942// earlier handler.
8943pub const BUILTIN_REDIR_GLOB_EXPAND: u16 = 628;
8944
8945/// Reset the default-word glob-pending carrier at the START of a word
8946/// whose source contains a glob metachar (so the flag never leaks from a
8947/// prior word/statement). Paired with BUILTIN_DEFAULT_WORD_GLOB.
8948pub const BUILTIN_DEFAULT_WORD_GLOB_RESET: u16 = 635;
8949
8950/// Filename-generate the ASSEMBLED word ONLY when the default/alternate
8951/// paramsubst arm took a SOURCE word carrying glob metachars
8952/// (subst::DEFAULT_WORD_GLOB_PENDING). A parameter VALUE never sets the
8953/// flag, so `x='*file'; ${x:-d}` stays literal while `${x:-*file}` /
8954/// `${x:-a*}bar` glob. Reads+clears the flag. c:Src/subst.c → globlist.
8955pub const BUILTIN_DEFAULT_WORD_GLOB: u16 = 636;
8956/// `BUILTIN_SET_LOOP_VAR` constant — for-loop variable binding via
8957/// `setloopvar` (Src/params.c:6362): a PM_NAMEREF loop var REBINDS
8958/// to each word (SETREFNAME + setscope) instead of assigning
8959/// through the chain. Returns Bool(false) when zerr fired
8960/// (read-only reference / invalid self reference) so the loop
8961/// driver aborts, mirroring C execfor's errflag check.
8962pub const BUILTIN_SET_LOOP_VAR: u16 = 629;
8963
8964/// EXTEND step of typeset paren-init packing. Pops `argc` values:
8965/// [base, e1, …, eN] — base is either the opener (`name=(` /
8966/// `name+=(`) or a previous EXTEND result. Pushes base with
8967/// `\u{1f}` + element appended per element. Array values SPLICE
8968/// their items as separate elements (`typeset b=( x $arr )` splat);
8969/// an empty Array contributes nothing (unquoted-empty elision).
8970/// CallBuiltin's argc is u8, so the compiler emits one EXTEND per
8971/// ≤200-element chunk — p10k's 408-element `__p9k_colors=( … )`
8972/// overflowed a single-shot pack (argc wrapped mod 256 and the
8973/// stack spilled into the arg list: "not an identifier: 173…").
8974/// BUILTIN_TYPESET_PAREN_CLOSE appends the final `\u{1f})`,
8975/// yielding the exact REJOIN_SEP-delimited one-arg form
8976/// bin_typeset's single-arg splitter consumes (builtin.rs ~4891,
8977/// empties preserved, leading/trailing sentinel-empties trimmed
8978/// once). One arg in → one arg out: bin_typeset's multi-arg rejoin
8979/// (paren-depth scan, unsafe on EXPANDED paren-literal elements
8980/// like p10k's `')' ''`) never runs.
8981pub const BUILTIN_TYPESET_PAREN_PACK: u16 = 630;
8982
8983/// CLOSE step — pops the EXTEND chain's result, pushes it with
8984/// `\u{1f})` appended. See BUILTIN_TYPESET_PAREN_PACK.
8985pub const BUILTIN_TYPESET_PAREN_CLOSE: u16 = 631;
8986
8987/// Shared body of BUILTIN_GLOB_EXPAND / BUILTIN_REDIR_GLOB_EXPAND.
8988/// c:Src/glob.c:1872 — `zglob` runs per-word in the argv pipeline.
8989/// When the upstream EXPAND_TEXT returned an array (e.g. `${a:e}`
8990/// splat → ["txt","md"]), glob each element separately, not a
8991/// sepjoin'd scalar. `skip_glob` short-circuits to a literal
8992/// pass-through (noglob, or a redirect target under
8993/// `unsetopt multios`).
8994fn glob_expand_word_value(raw: Value, skip_glob: bool) -> Value {
8995 let patterns: Vec<String> = match raw {
8996 Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
8997 other => vec![other.to_str()],
8998 };
8999 if skip_glob {
9000 return if patterns.is_empty() {
9001 Value::Array(Vec::new())
9002 } else if patterns.len() == 1 {
9003 Value::str(patterns.into_iter().next().unwrap())
9004 } else {
9005 Value::Array(patterns.into_iter().map(Value::str).collect())
9006 };
9007 }
9008 let mut out: Vec<String> = Vec::with_capacity(patterns.len());
9009 for pattern in &patterns {
9010 let matches = with_executor(|exec| exec.expand_glob(pattern));
9011 if matches.is_empty() {
9012 // c:1872 nullglob — drop this word, don't emit a hole
9013 continue;
9014 }
9015 for m in matches {
9016 out.push(m);
9017 }
9018 }
9019 if out.is_empty() {
9020 return Value::Array(Vec::new());
9021 }
9022 if patterns.len() == 1 && out.len() == 1 && out[0] == patterns[0] {
9023 // No real matches; expand_glob returned the literal. Pass
9024 // back as scalar so downstream ops don't re-flatten.
9025 return Value::str(out.into_iter().next().unwrap());
9026 }
9027 Value::Array(out.into_iter().map(Value::str).collect())
9028}
9029
9030/// Push a `CmdState` token onto the command-context stack. Direct
9031/// port of zsh's `cmdpush(int cmdtok)` (Src/prompt.c:1623). The
9032/// stack is consulted by `%_` in PS4/prompt expansion to produce
9033/// the cumulative control-flow-context labels (`if`, `then`,
9034/// `cmdand`, `cmdor`, `cmdsubst`, …) that `zsh -x` xtrace shows
9035/// in the trace prefix. Compile_zsh emits push/pop pairs around
9036/// each compound command (if/while/[[…]]/((…))/$(…) etc.).
9037/// Token is a `CmdState as u8`.
9038pub const BUILTIN_CMD_PUSH: u16 = 344;
9039
9040/// Pop the top of the command-context stack. Direct port of zsh's
9041/// `cmdpop(void)` (Src/prompt.c:1631).
9042pub const BUILTIN_CMD_POP: u16 = 345;
9043
9044/// Emit an xtrace line built from the top `argc` values on the VM
9045/// stack, peeked WITHOUT consuming. Used to trace simple commands
9046/// AFTER expansion, so `echo for $i` shows as `echo for a` / `echo
9047/// for b`. Direct port of Src/exec.c:2055-2066.
9048pub const BUILTIN_XTRACE_ARGS: u16 = 346;
9049
9050/// Trace one assignment: emits `name=<quoted-value> ` (no newline)
9051/// to xtrerr if XTRACE is on. Coalesces with subsequent
9052/// XTRACE_ASSIGN / XTRACE_ARGS calls onto the SAME line via the
9053/// `XTRACE_DONE_PS4` flag so `a=1 b=2 echo $a $b` produces:
9054/// `<PS4>a=1 b=2 echo 1 2\n`
9055/// matching C zsh's `execcmd_exec` body (Src/exec.c:2517-2582):
9056/// xtr = isset(XTRACE);
9057/// if (xtr) { printprompt4(); doneps4 = 1; }
9058/// while (assign) {
9059/// if (xtr) fprintf(xtrerr, "%s=", name);
9060/// ... eval value ...
9061/// if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
9062/// }
9063///
9064/// Stack contract on entry: [..., name, value]. Both peeked, NOT
9065/// consumed (the matching SET_VAR call pops them after). argc = 2.
9066pub const BUILTIN_XTRACE_ASSIGN: u16 = 525;
9067
9068/// Emit a trailing `\n` + flush iff XTRACE is on AND PS4 was
9069/// emitted by an earlier XTRACE_ASSIGN this line. Used at the end
9070/// of compile_simple's assignment-only path so the trace line gets
9071/// terminated. Mirrors C's exec.c:3397-3399 (the assign-only return
9072/// path through execcmd_exec which does `fputc('\n', xtrerr);
9073/// fflush(xtrerr)`).
9074///
9075/// Stack: untouched. argc = 0.
9076pub const BUILTIN_XTRACE_NEWLINE: u16 = 526;
9077
9078/// Push the live `xtrace` opt-state as `Value::Int(1)` (on) or
9079/// `Value::Int(0)` (off). Used by `compile_cond` to gate the
9080/// trace-string-building block on xtrace state at runtime — without
9081/// this the trace path's `compile_word_str` on each operand re-
9082/// evaluates side-effectful expressions (`$((i++))`) once for the
9083/// trace string and once for the actual condition, doubling the
9084/// effective increment. Bug #159 in docs/BUGS.md.
9085///
9086/// Stack: pushes Int(0|1). argc = 0.
9087pub const BUILTIN_XTRACE_IS_ON: u16 = 611;
9088
9089/// Reset the `DONETRAP` flag at the start of each top-level statement
9090/// (sublist boundary). Mirrors C `Src/exec.c:1455` — `donetrap = 0`.
9091/// Stack: untouched. argc = 0. Bug #303 in docs/BUGS.md.
9092pub const BUILTIN_DONETRAP_RESET: u16 = 612;
9093
9094/// `[[ -z X ]]` / `[[ -n X ]]` operand-empty test that honours zsh's
9095/// array-splice semantics. C zsh evaluates `[[ -z X ]]` per
9096/// `Src/cond.c:347` (case 'z'): `s` is the SCALAR operand passed
9097/// through `cond_str`'s singsub. For `"${arr[@]}"` zsh expands per
9098/// `Src/subst.c:multsub` which yields each element as its own word
9099/// list node; cond.c then sees the joined-or-single-element form.
9100///
9101/// The compile-side `-z` shortcut at `compile_zsh.rs:5371` used
9102/// `Op::StringLen` which calls `Value::len` — for `Value::Array`
9103/// that returns ARRAY LENGTH, not string length. `b=("")` produced
9104/// `Value::Array([""])` → `len = 1` → `-z` returned false.
9105///
9106/// This builtin pops one `Value` and pushes `1` (empty) or `0`
9107/// (non-empty) per the cond context:
9108/// - `Value::Str(s)` → s.is_empty()
9109/// - `Value::Array([])` → true (zero words → vacuous-empty)
9110/// - `Value::Array([s])` → s.is_empty() (single-word case)
9111/// - `Value::Array([_; n>=2])` → false (multiple non-empty
9112/// words; zsh would raise "unknown condition" but the
9113/// observable test result is non-empty/false)
9114///
9115/// Companion to BUILTIN_COND_STR_NONEMPTY (#185 in docs/BUGS.md).
9116pub const BUILTIN_COND_STR_EMPTY: u16 = 613;
9117
9118/// `[[ -n X ]]` operand-non-empty test (logical complement of
9119/// BUILTIN_COND_STR_EMPTY).
9120pub const BUILTIN_COND_STR_NONEMPTY: u16 = 614;
9121
9122/// `exec N<<<"str"` — herestring redirect to explicit fd, applied
9123/// permanently to the shell (no scope restoration). Pops `[content,
9124/// fd]` from the stack; creates a temp file, writes
9125/// `content + "\n"`, reopens read-only, dup2's to `fd`, unlinks the
9126/// temp path so it disappears on close. Mirrors C `Src/exec.c:4655
9127/// getherestr` + `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)`
9128/// at c:3766-3780 for the bare-exec-redir code path (nullexec=1).
9129/// Bug #205 in docs/BUGS.md.
9130///
9131/// Stack: pushes `Value::Status(0)` on success, `Status(1)` on
9132/// failure. argc = 2.
9133pub const BUILTIN_EXEC_HERESTR_FD: u16 = 615;
9134
9135/// MULTIOS write/append fan-out for `cmd > a > b` / `cmd > a >> b`
9136/// style redirects (Bug #36 in docs/BUGS.md). zsh's MULTIOS option
9137/// (Src/exec.c:2418 `mfds[fd1]` check + addfd splice) creates a
9138/// pipe at fd1, spawns an internal "tee" process that copies
9139/// stdin → every collected target file. Without this, only the
9140/// LAST redirect target survives because each dup2 overwrites the
9141/// previous binding.
9142///
9143/// Stack layout (pushed by compile_zsh's compile_redirs coalescing
9144/// pass): `[target_1, op_byte_1, target_2, op_byte_2, …, target_N,
9145/// op_byte_N, fd]`. Pops 2N+1 elements; `argc = 2*N + 1`. A target
9146/// may be a Value::Array of glob matches (spliced into one member
9147/// per match, c:Src/glob.c:2195-2203); an op may be DUP_WRITE for a
9148/// numeric `>&N` member (c:Src/exec.c:3895-3917).
9149///
9150/// Runtime (MULTIOS set):
9151/// 1. Seed the member list with `dup(1)` when this command's
9152/// stdout is the pipeline output (c:Src/exec.c:3722-3724).
9153/// 2. Open/dup all targets per their op_byte in redirect order
9154/// (WRITE truncate + noclobber gate / APPEND / DUP_WRITE live
9155/// dup); the first member replaces the fd (c:2448-2450).
9156/// 3. Save `dup(fd)` onto the active redirect_scope_stack so
9157/// `host_redirect_scope_end` restores the original fd.
9158/// 4. Create a pipe; spawn a thread that reads from the pipe
9159/// read-end and writes every chunk to every opened target.
9160/// 5. dup2 the pipe write-end onto `fd` so the command's writes
9161/// go through the splitter.
9162/// 6. Track `(pipe_write_fd, JoinHandle)` so scope-end can close
9163/// the pipe (draining the thread) and join before restoring.
9164///
9165/// MULTIOS unset (c:2418 `unset(MULTIOS)` replace arm): each entry
9166/// is applied as a plain sequential replace via host_apply_redirect
9167/// — every file still opened/truncated, last one wins.
9168pub const BUILTIN_MULTIOS_REDIRECT: u16 = 617;
9169
9170/// MULTIOS input-side concatenation for `cmd < a < b` shapes
9171/// (Bug #36 input arm). C zsh's `Src/exec.c:2418` mfds dispatch
9172/// also covers the read direction — when multiple `<` redirects
9173/// target the same fd, mfds[fd] grows and addfd splices a
9174/// concatenating cat into the pipe.
9175///
9176/// Stack layout (mirrors the write side): `[source_1, op_1,
9177/// source_2, op_2, …, source_N, op_N, fd]`. Pops 2N + 1 elements
9178/// (argc = 2N + 1). op is READ for file sources, DUP_READ for
9179/// numeric `<&N` members; a source may be a Value::Array of glob
9180/// matches (spliced, c:Src/glob.c:2195-2203).
9181///
9182/// Runtime (MULTIOS set):
9183/// 1. Open/dup every source in redirect order; first member
9184/// replaces the fd (c:Src/exec.c:2448-2450).
9185/// 2. Save `dup(fd)` onto the redirect_scope_stack.
9186/// 3. Create a pipe; spawn a thread that reads each source in
9187/// order and writes every chunk to the pipe write-end. Close
9188/// write-end when done so the consumer sees EOF.
9189/// 4. dup2 the pipe read-end onto `fd`.
9190/// 5. Track the JoinHandle so scope-end joins (no fd-close needed
9191/// here — the producer thread closes its own pipe write-end
9192/// on exit).
9193///
9194/// MULTIOS unset: sequential replace via host_apply_redirect — last
9195/// source wins (c:2418).
9196pub const BUILTIN_MULTIOS_READ: u16 = 618;
9197
9198/// Toggle `ShellExecutor::exec_redirs_permanent`. Emitted by
9199/// compile_zsh's bare-`exec`-with-redirects arm tightly around each
9200/// `Op::Redirect`: `LoadInt(1); CallBuiltin; …Redirect…; LoadInt(0);
9201/// CallBuiltin`. While set, `host_apply_redirect` skips pushing the
9202/// saved fd into the enclosing redirect scope, making the fd change
9203/// permanent.
9204///
9205/// c:Src/exec.c:3978-3986 — nullexec==1 (`exec` carrying only
9206/// redirections): "If nullexec is 1 we specifically *don't* restore
9207/// the original fd's before returning" — the per-execcmd `save[]`
9208/// dups are closed unrestored. An ENCLOSING group's own saves are a
9209/// different execcmd's `save[]` and still restore (verified:
9210/// `{ exec 2>/dev/null; } 2>&1; ls /nope` prints the ls error in zsh).
9211pub const BUILTIN_EXEC_PERM_REDIRS: u16 = 619;
9212
9213/// Set `ShellExecutor::pipe_output_pending`. Emitted by compile_pipe
9214/// at the head of a NON-LAST pipeline-stage sub-chunk when that
9215/// stage's top-level command carries redirects (`Simple` with redirs
9216/// or `Redirected` compound). The forked stage child runs the chunk
9217/// with stdout already dup2'd onto the pipe; the first
9218/// `host_redirect_scope_begin` (the stage command's own redirect
9219/// list) consumes the flag into `pipe_output_scope`, enabling the
9220/// MULTIOS stream-split for fd-1 write redirects in that list.
9221///
9222/// c:Src/exec.c:3722-3724 — `addfd(forked, save, mfds, 1, output, 1,
9223/// NULL)` registers the pipe in mfds[1] in the SAME execcmd that
9224/// walks the stage command's redirect list; mfds is per-execcmd, so
9225/// nested body commands (`{ echo a > f; } | cat`) never see it.
9226pub const BUILTIN_PIPE_OUTPUT_MARK: u16 = 620;
9227
9228/// Magic-equals prefork for a single arg word of a
9229/// `BINF_MAGICEQUALS` builtin head (`alias`). Direct port of
9230/// c:Src/exec.c:3298-3304 — `esprefork = PREFORK_TYPESET;
9231/// prefork(args, esprefork, NULL)` runs on the argv BEFORE the addfd
9232/// redirect loop at c:3720, so an expansion zerr (`alias bad===` →
9233/// equalsubstr "= not found" at Src/subst.c:726) prints to the
9234/// command's UN-redirected stderr. argc=1: pops the just-pushed
9235/// word value, runs shtokenize → prefork(PREFORK_TYPESET) →
9236/// untokenize on it (each element for Array splices), pushes the
9237/// result back. Emitted by compile_simple per arg word when the
9238/// dispatch head is `alias`; BUILTIN_ALIAS itself no longer
9239/// preforks (it would double-fire the diagnostic).
9240pub const BUILTIN_MAGIC_EQUALS_PREFORK: u16 = 621;
9241
9242/// Bare (unbraced) `$name[idx]` subscript — same dispatch as
9243/// `BUILTIN_ARRAY_INDEX` while KSHARRAYS is unset, but under
9244/// KSHARRAYS the unbraced form does NOT subscript (c:Src/subst.c:
9245/// 2800-2802 + 2867): `$name` expands bare and `[idx]` stays literal
9246/// trailing text that undergoes filename generation. Operands:
9247/// [name, idx, suffix, quoted].
9248pub const BUILTIN_ARRAY_INDEX_UNBRACED: u16 = 622;
9249
9250/// Assignment-only simple-command exit status. Direct port of
9251/// `lv = (errflag ? errflag : cmdoutval)` (c:Src/exec.c:1322,
9252/// execsimple's WC_ASSIGN arm) / `if (errflag) lastval = 1; else
9253/// lastval = cmdoutval;` (c:Src/exec.c:3393-3396, execcmd_exec's
9254/// no-command-word varspc path; redir variant at c:3977). Pops
9255/// [had_cmd_subst]; cmdoutval is the live vm.last_status when a
9256/// `$()` ran in any RHS of the chain, 0 otherwise. Writes the
9257/// canonical LASTVAL (C's single `lastval` global) so the
9258/// non-interactive errflag abort exits with this value per
9259/// Src/init.c:234. Caller pairs with SetStatus.
9260pub const BUILTIN_ASSIGN_ONLY_STATUS: u16 = 623;
9261
9262/// c:Src/exec.c addvars — `if (!pm) { lastval = 1; if (!cmdoutval)
9263/// cmdoutval = 1; }`. Set by BUILTIN_SET_VAR on assignsparam
9264/// failure, consumed by BUILTIN_ASSIGN_ONLY_STATUS so the
9265/// assignment-only command reports status 1. Process-global like
9266/// C's `cmdoutval` (function bodies may run on a different thread
9267/// than the opcode that reads the status back).
9268pub static ASSIGN_FAILED_FLAG: std::sync::atomic::AtomicBool =
9269 std::sync::atomic::AtomicBool::new(false);
9270
9271/// `redirection with no command` parse-time error for bare
9272/// `builtin 2>&1` / `command < file` / `exec >&-` precmd-keyword
9273/// shapes with a redirect but no following command. Direct port
9274/// of `Src/exec.c:3342 zerr("redirection with no command")`.
9275/// argc=0; pushes Value::Status(1).
9276pub const BUILTIN_REDIR_NO_CMD: u16 = 616;
9277
9278/// GLOB_SUBST guard for `[[ x == $pat ]]` pattern RHS coming from
9279/// parameter / command substitution. C-zsh's `[[ == ]]` semantics
9280/// (Src/options.c GLOB_SUBST default OFF + Src/cond.c:552
9281/// `cond_match` + Src/pattern.c patcompile tokenization-based
9282/// meta detection) treat chars from substitution as LITERAL
9283/// unless GLOB_SUBST is on. The Rust patcompile accepts both
9284/// tokenized and raw-ASCII meta chars, losing the distinction,
9285/// so `pat="h*"; [[ hello == $pat ]]` matched in zshrs but not
9286/// in zsh. Bug #116 in docs/BUGS.md.
9287///
9288/// Compile-time signal: emitted by `compile_cond_expr` ONLY when
9289/// the RHS contains `$` or backtick. Runtime checks the live
9290/// option state. If GLOB_SUBST is OFF, the popped string has
9291/// its glob metachars escaped with `\` so the downstream StrMatch
9292/// → patcompile treats them as literals. If GLOB_SUBST is ON,
9293/// the value passes through unchanged so `setopt glob_subst`
9294/// restores zsh's pattern-on-expansion behavior.
9295///
9296/// Stack: pops one string, pushes the (possibly escaped) result.
9297/// argc = 1.
9298pub const BUILTIN_GLOB_SUBST_GUARD: u16 = 528;
9299
9300/// Coerce a string parameter value to a math number (Int or Float)
9301/// for arithmetic-context reads, mirroring C-zsh's `getmathparam`
9302/// (Src/math.c:337). When the variable holds a string like "hello"
9303/// that isn't numeric, C falls back to recursively evaluating the
9304/// raw string as an arith expression; if that fails too, returns 0.
9305///
9306/// Used by the ArithCompiler pre-load path so `(( y = x ))` with
9307/// `x="hello"` reads `x` as integer 0, then assigns y as integer 0
9308/// — matching zsh's behaviour. The previous Rust port used
9309/// BUILTIN_GET_VAR which returned the raw string "hello"; the
9310/// ArithCompiler stored it verbatim in y's slot, and the post-sync
9311/// BUILTIN_SET_VAR wrote y="hello" as scalar instead of y=0 as
9312/// integer. Bug #118 in docs/BUGS.md.
9313///
9314/// Stack: pops `name` (string), pushes coerced numeric Value.
9315/// argc = 1.
9316pub const BUILTIN_GET_MATH_VAR: u16 = 529;
9317
9318/// GLOB_SUBST runtime gate for words containing parameter / command
9319/// substitution. C-zsh's `prefork` (Src/subst.c) runs `shtokenize`
9320/// on the substituted value when `GLOB_SUBST` is set, making the
9321/// substituted chars eligible for filename generation. With the
9322/// option off, substituted chars stay literal.
9323///
9324/// The Rust port's compile_zsh emits `compile_word_str` for words
9325/// like `/tmp/X/$pat`, which returns the post-expansion string but
9326/// never runs glob expansion (no path here triggers
9327/// BUILTIN_GLOB_EXPAND). Bug #119 in docs/BUGS.md: with `setopt
9328/// glob_subst`, `for f in /tmp/X/$pat` (pat="*.txt") never matched
9329/// `*.txt` files.
9330///
9331/// This opcode wraps the substitution result and dispatches at
9332/// runtime: when GLOB_SUBST is OFF, return unchanged; when ON,
9333/// pass the value through `expand_glob` so glob metas become
9334/// active. Emitted by `compile_for_words` (and similar sites)
9335/// after WORD_SPLIT for words with unquoted expansion.
9336///
9337/// Stack: pops a Value (Str or Array of Str), pushes the glob-
9338/// expanded result (still Str or Array depending on input shape).
9339/// argc = 1.
9340pub const BUILTIN_GLOB_SUBST_EXPAND: u16 = 530;
9341/// `BUILTIN_ASSOC_HAS_KEY` constant — `${(k)assoc[name]}` key-existence
9342/// query. Returns the key text on hit, empty string on miss. Bug #145.
9343pub const BUILTIN_ASSOC_HAS_KEY: u16 = 531;
9344/// `BUILTIN_ARRAY_DROP_EMPTY` constant — filter empty elements from
9345/// an Array on the stack. Used by `for x in $@` / `for x in $*`
9346/// unquoted forms. Bug #166.
9347pub const BUILTIN_ARRAY_DROP_EMPTY: u16 = 532;
9348/// `BUILTIN_QUOTEDZPUTS` constant — run top-of-stack value through
9349/// `crate::ported::utils::quotedzputs` and push the quoted result.
9350/// Used by the cond xtrace path so non-printable bytes (e.g.
9351/// `$'\C-[OP'` expanded ESC+OP) get re-wrapped in `$'…'` form for
9352/// the trace prefix line, matching zsh's `Src/exec.c` cond trace
9353/// which calls `quotedzputs(operand, xtrerr)` on each side. Bug
9354/// surfaced when `[[ -n $'\C-[OP' ]]` traced as `[[ -n OP ]]`
9355/// (raw bytes leaked through the terminal) vs zsh's
9356/// `[[ -n $'\C-[OP' ]]` source-form preservation.
9357pub const BUILTIN_QUOTEDZPUTS: u16 = 533;
9358/// `BUILTIN_QUOTE_TOKENIZED_OUTPUT` — port of
9359/// `crate::ported::exec::quote_tokenized_output` (Src/exec.c:2114)
9360/// applied to top-of-stack scalar. Used by cond xtrace for the RHS
9361/// of pattern-context comparisons (`=` / `==` / `!=`) where C zsh
9362/// emits the SOURCE form: untokenize lexer tokens (Star → `*`,
9363/// Inpar → `(`, …) and backslash-escape special chars, but
9364/// preserve literal ASCII unchanged. Distinct from quotedzputs
9365/// which wraps the whole string in `'…'` / `$'…'` based on
9366/// non-printability — that's wrong for `[[ x = a* ]]` which must
9367/// render as `[[ x = a* ]]`, not `'a*'`.
9368pub const BUILTIN_QUOTE_TOKENIZED_OUTPUT: u16 = 534;
9369
9370/// Bridge into subst_port::substitute_brace_array for nested forms
9371/// that need to PRESERVE array shape across the expand_string
9372/// boundary. Stack: `[content_string]`. Returns Value::Array of the
9373/// per-element words. Used by the compile path for
9374/// `${(@)<nested>...##pat}` shapes — the standard substitute_brace
9375/// returns String which collapses array→scalar; this builtin
9376/// preserves the multi-word output via paramsubst's third return
9377/// (`nodes` vec, the C source's `aval` thread).
9378pub const BUILTIN_BRIDGE_BRACE_ARRAY: u16 = 347;
9379
9380/// Word-segment concat with FIRST/LAST sticking. Stack: [lhs, rhs].
9381/// Used for default unquoted splice forms (`${arr[@]}`, `$@`, `$*`)
9382/// where prefix sticks to first element only and suffix to last only.
9383///
9384/// Distribution table:
9385/// - both scalar: `Value::str(a + b)` (fast path)
9386/// - lhs scalar, rhs Array(b₀..bₙ): `Value::Array([lhs+b₀, b₁, …, bₙ])`
9387/// - lhs Array(a₀..aₙ), rhs scalar: `Value::Array([a₀, …, aₙ₋₁, aₙ+rhs])`
9388/// - both Array: `Value::Array([a₀, …, aₙ₋₁, aₙ+b₀, b₁, …, bₙ])`
9389/// (last of lhs merges with first of rhs; the rest stay separate)
9390///
9391/// This is the default zsh semantics for `print -l X${arr[@]}Y` →
9392/// "Xa", "b", "cY" — three distinct args, surrounding text only on ends.
9393pub const BUILTIN_CONCAT_SPLICE: u16 = 319;
9394
9395/// `${(flags)name}` — zsh parameter expansion flags. Stack: [name, flags].
9396/// Flags applied left-to-right. Supported subset (high-value, used by zpwr):
9397///
9398/// `L` — lowercase the value (scalar; or each element if array)
9399/// `U` — uppercase
9400/// `j:sep:` — join array with `sep` (delim is the char after `j`)
9401/// `s:sep:` — split scalar on `sep` (returns Value::Array)
9402/// `f` — split on newlines (shorthand for `s.\n.`)
9403/// `o` — sort array ascending
9404/// `O` — sort array descending
9405/// `P` — indirect: read name's value as another var name, return that's value
9406/// `@` — keep as array (returns Value::Array — useful before `j` etc.)
9407/// `k` — keys of assoc array
9408/// `v` — values of assoc array
9409/// `#` — word count (array length as scalar)
9410///
9411/// Flags can stack: `(jL)` joins then lowercases; `(s.,.U)` splits on `,`
9412/// then uppercases each element. The long-tail flags (`q`, `qq`, `qqq` for
9413/// quoting, `A` for assoc, `%` for prompt expansion, `e`/`g` for re-eval,
9414/// `n`/`p` for numeric, `t` for type, etc.) are deferred — they hit the
9415/// runtime fallback via the catch-all expansion path.
9416pub const BUILTIN_PARAM_FLAG: u16 = 297;
9417
9418/// `ShellHost` implementation that delegates to the current `ShellExecutor`
9419/// via the `with_executor` thread-local.
9420///
9421/// Construct fresh on each VM run (it carries no state itself). The VM
9422/// dispatches host method calls during `vm.run()`, and `with_executor`
9423/// resolves to the executor pointer set by `ExecutorContext::enter`.
9424/// fusevm-host implementation tying bytecode ops to the
9425/// shell executor.
9426/// zshrs-original — no C counterpart. C zsh has no bytecode VM
9427/// to host; everything runs through `execlist()`/`execpline()`
9428/// directly (Src/exec.c lines 1349/1668).
9429pub struct ZshrsHost;
9430
9431impl fusevm::ShellHost for ZshrsHost {
9432 fn glob(&mut self, pattern: &str, _recursive: bool) -> Vec<String> {
9433 with_executor(|exec| exec.expand_glob(pattern))
9434 }
9435
9436 fn tilde_expand(&mut self, s: &str) -> String {
9437 with_executor(|exec| s.to_string())
9438 }
9439
9440 fn brace_expand(&mut self, s: &str) -> Vec<String> {
9441 // Direct call to the canonical brace expander
9442 // (Src/glob.c::xpandbraces port at glob.rs:1678). Was
9443 // routing through singsub which uses PREFORK_SINGLE — that
9444 // flag explicitly suppresses brace expansion in subst.c:166,
9445 // so `print X{1,2,3}Y` returned the literal string.
9446 //
9447 // brace_ccl: respect the BRACE_CCL option which the bracket-
9448 // class form `{a-z}` requires. Pull from executor options.
9449 let brace_ccl = with_executor(|exec| opt_state_get("braceccl").unwrap_or(false));
9450 crate::ported::glob::xpandbraces(s, brace_ccl)
9451 }
9452
9453 fn str_match(&mut self, s: &str, pattern: &str) -> bool {
9454 // Shell glob match — `*`, `?`, `[...]`, alternation. After the
9455 // cond path moved to BUILTIN_COND_STRMATCH, the consumer here
9456 // is the `case` arm dispatch, whose bad-pattern semantics are
9457 // Src/loop.c:663-667: `if (!(pprog = patcompile(pat, ...)))
9458 // zerr("bad pattern: %s", pat);` — errflag set, the arm
9459 // doesn't match, and the script aborts at the next command
9460 // boundary (matching `zsh -fc 'case x in [a-) ...'` printing
9461 // the diagnostic with exit 0 = untouched lastval).
9462 let mut pat_tok = pattern.to_string();
9463 crate::ported::glob::tokenize(&mut pat_tok);
9464 if crate::ported::pattern::patcompile(
9465 &pat_tok,
9466 crate::ported::zsh_h::PAT_STATIC as i32,
9467 None,
9468 )
9469 .is_none()
9470 {
9471 crate::ported::utils::zerr(&format!("bad pattern: {}", pattern)); // c:667
9472 return false;
9473 }
9474 glob_match_static(s, pattern)
9475 }
9476
9477 fn expand_param(&mut self, name: &str, _modifier: u8, _args: &[Value]) -> Value {
9478 // Sole funnel: route through `getsparam` matching C zsh's
9479 // `getsparam(name)` → `getvalue` → `getstrvalue` →
9480 // `Param.gsu->getfn` dispatch (Src/params.c:3076 / 2335).
9481 //
9482 // The lookup chain (GSU dispatch + variables + env + array-
9483 // join) lives in `params::getsparam`; subst.rs and this
9484 // bridge both call into it so the logic is in exactly one
9485 // place — mirroring C's "every read goes through getsparam"
9486 // architecture. fuseVM bytecode triggers this bridge when
9487 // the VM hits a PARAM opcode, equivalent to C's wordcode VM
9488 // resolving a parameter read during `exec.c` execution.
9489 //
9490 // Modifier handling: the `_modifier` / `_args` parameters
9491 // are populated by the bytecode compiler but applied by
9492 // separate VM opcodes (LENGTH/STRIP/SUBST/etc.) downstream
9493 // of this fetch — matching C's split between getsparam
9494 // (value fetch) and paramsubst's modifier-walk loop. This
9495 // bridge is the value-fetch step only.
9496 let val_str = crate::ported::params::getsparam(name).unwrap_or_default();
9497 Value::str(val_str)
9498 }
9499
9500 fn process_sub_in(&mut self, sub: &fusevm::Chunk) -> String {
9501 // c:Src/exec.c::getproc — `<(cmd)` uses pipe + fork + the
9502 // `/dev/fd/N` filesystem entry (where N is the read end of
9503 // the pipe held open in the parent). Consumer opens
9504 // `/dev/fd/N`, reads the cmd's stdout through the pipe.
9505 // Both macOS and Linux expose `/dev/fd` for held-open file
9506 // descriptors. Previous Rust port captured stdout into
9507 // `/tmp/zshrs_psub_*` tempfiles synchronously — works for
9508 // `diff <(a) <(b)` style readers that scan once but diverges
9509 // from zsh's observable path string and breaks any consumer
9510 // that introspects the path or expects a non-seekable pipe.
9511 let mut fds: [libc::c_int; 2] = [-1, -1];
9512 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
9513 // Pipe creation failed — fall back to tempfile so we at
9514 // least return SOMETHING.
9515 let fifo_path = format!(
9516 "/tmp/zshrs_psub_fallback_{}_{}",
9517 std::process::id(),
9518 with_executor(|e| {
9519 let n = e.process_sub_counter;
9520 e.process_sub_counter += 1;
9521 n
9522 })
9523 );
9524 let _ = fs::remove_file(&fifo_path);
9525 return fifo_path;
9526 }
9527 let (read_end, write_end) = (fds[0], fds[1]);
9528 let sub_for_child = sub.clone();
9529 match unsafe { libc::fork() } {
9530 -1 => {
9531 unsafe {
9532 libc::close(read_end);
9533 libc::close(write_end);
9534 }
9535 return String::from("/dev/null");
9536 }
9537 0 => {
9538 // Child: close read end, dup write end to stdout,
9539 // run the sub-chunk, exit. The exit closes the
9540 // write end automatically, so the parent's reader
9541 // gets EOF when the cmd finishes.
9542 unsafe {
9543 libc::close(read_end);
9544 libc::dup2(write_end, libc::STDOUT_FILENO);
9545 libc::close(write_end);
9546 }
9547 crate::fusevm_disasm::maybe_print_stdout("process_subst_in", &sub_for_child);
9548 let mut vm = fusevm::VM::new(sub_for_child);
9549 register_builtins(&mut vm);
9550 vm.set_shell_host(Box::new(ZshrsHost));
9551 let _ = vm.run();
9552 let _ = std::io::stdout().flush();
9553 unsafe { libc::_exit(0) };
9554 }
9555 _ => {
9556 // Parent: close write end, keep read end open under
9557 // the same fd value so `/dev/fd/N` resolves to the
9558 // pipe's read side. NOTE: FD_CLOEXEC must STAY clear
9559 // — consumers like `cat <(cmd)` and `diff <(a) <(b)`
9560 // discover the fd via exec inheritance, so closing
9561 // on exec defeats the whole point. C zsh's getproc
9562 // (Src/exec.c:5045+) leaves the fd open across exec.
9563 unsafe {
9564 libc::close(write_end);
9565 }
9566 }
9567 }
9568 format!("/dev/fd/{}", read_end)
9569 }
9570
9571 fn process_sub_out(&mut self, sub: &fusevm::Chunk) -> String {
9572 // c:Src/exec.c:5025 getproc, PATH_DEV_FD branch — `>(cmd)`
9573 // (out == 0): `mpipe(pipes)`, fork; the CHILD `redup(pipes[0],
9574 // 0)` (pipe read end onto stdin) and `closem` drops the write
9575 // end; the PARENT closes pipes[0] and hands the consumer
9576 // `/dev/fd/<pipes[1]>` (the write end). The previous Rust port
9577 // used mkfifo + a child that BLOCKED in open(FIFO, O_RDONLY)
9578 // before running cmd — with no writer the child never started,
9579 // never exited, and kept its inherited stdout (e.g. a `$()`
9580 // capture pipe) open forever: `a=$(print -r -- >(true))` hung.
9581 // With the pipe shape the child runs immediately and exits,
9582 // releasing inherited fds exactly like zsh (verified: zsh
9583 // blocks ~2s on `a=$(print -r -- >(sleep 2))`, then EOFs).
9584 let mut fds: [libc::c_int; 2] = [-1, -1];
9585 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
9586 // Pipe creation failed — fall back to a plain temp file so
9587 // the consumer at least has a writable path.
9588 let fallback = format!(
9589 "/tmp/zshrs_psub_out_{}_{}",
9590 std::process::id(),
9591 with_executor(|e| {
9592 let n = e.process_sub_counter;
9593 e.process_sub_counter += 1;
9594 n
9595 })
9596 );
9597 let _ = fs::write(&fallback, "");
9598 return fallback;
9599 }
9600 let (read_end, write_end) = (fds[0], fds[1]);
9601 let sub_for_child = sub.clone();
9602 match unsafe { libc::fork() } {
9603 -1 => {
9604 unsafe {
9605 libc::close(read_end);
9606 libc::close(write_end);
9607 }
9608 String::from("/dev/null")
9609 }
9610 0 => {
9611 // Child: close the write end (c: closem after redup),
9612 // dup the read end onto stdin (c: redup(pipes[0], 0)),
9613 // run the sub-chunk, exit. Other std fds stay
9614 // inherited — zsh's child keeps the surrounding
9615 // command's stdout/stderr.
9616 unsafe {
9617 libc::close(write_end);
9618 libc::dup2(read_end, libc::STDIN_FILENO);
9619 libc::close(read_end);
9620 }
9621 crate::fusevm_disasm::maybe_print_stdout("process_subst_out:child", &sub_for_child);
9622 let mut vm = fusevm::VM::new(sub_for_child);
9623 register_builtins(&mut vm);
9624 vm.set_shell_host(Box::new(ZshrsHost));
9625 let _ = vm.run();
9626 unsafe { libc::_exit(0) };
9627 }
9628 _ => {
9629 // Parent: close the read end, keep the write end open
9630 // under its fd value so `/dev/fd/N` resolves to the
9631 // pipe's write side. FD_CLOEXEC must STAY clear —
9632 // consumers (`tee >(cmd)`) discover the fd via exec
9633 // inheritance, matching process_sub_in above and C's
9634 // fdtable[fd] = FDT_PROC_SUBST bookkeeping. Park the
9635 // fd for close-after-consuming-command (c: addfilelist
9636 // (NULL, fd) → deletefilelist) so the child's reader
9637 // EOFs — without this `tee >(wc -c) </dev/null` left
9638 // wc blocked until shell exit.
9639 unsafe {
9640 libc::close(read_end);
9641 }
9642 let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
9643 PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, write_end)));
9644 format!("/dev/fd/{}", write_end)
9645 }
9646 }
9647 }
9648
9649 fn subshell_begin(&mut self) {
9650 with_executor(|exec| {
9651 // libc::umask returns the previous mask AND sets the new
9652 // one; call with current value to read without changing.
9653 let cur_umask = unsafe {
9654 let m = libc::umask(0o022);
9655 libc::umask(m);
9656 m as u32
9657 };
9658 // Snapshot paramtab + hashed-storage too (step 1 of the
9659 // store unification mirrors writes there; restoring only
9660 // the HashMaps leaks subshell-scoped writes to the parent
9661 // via paramtab readers like `paramsubst → vars_get`).
9662 let paramtab_snap = crate::ported::params::paramtab()
9663 .read()
9664 .ok()
9665 .map(|t| t.clone())
9666 .unwrap_or_default();
9667 let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
9668 .lock()
9669 .ok()
9670 .map(|m| m.clone())
9671 .unwrap_or_default();
9672 exec.subshell_snapshots.push(SubshellSnapshot {
9673 paramtab: paramtab_snap,
9674 paramtab_hashed_storage: paramtab_hashed_snap,
9675 positional_params: exec.pparams(),
9676 env_vars: env::vars().collect(),
9677 // Save the LOGICAL pwd ($PWD env), not `current_dir()`'s
9678 // symlink-resolved path. zsh's subshell isolation per
9679 // Src/exec.c at the `entersubsh` path treats `pwd` (the
9680 // shell-tracked logical PWD) as the carrier — see
9681 // `Src/builtin.c:1239-1242` where cd writes the logical
9682 // dest into `pwd`. Falling back to current_dir() only
9683 // when PWD is unset matches `setupvals` at
9684 // `Src/init.c:1100+`.
9685 cwd: env::var("PWD")
9686 .ok()
9687 .map(PathBuf::from)
9688 .or_else(|| env::current_dir().ok()),
9689 umask: cur_umask,
9690 // Snapshot canonical `traps_table` — bin_trap writes
9691 // there (`Src/builtin.c`).
9692 traps: crate::ported::builtin::traps_table()
9693 .lock()
9694 .map(|t| t.clone())
9695 .unwrap_or_default(),
9696 // Snapshot option store so `(set -e)` /
9697 // `(setopt extendedglob)` don't leak to parent.
9698 opts: crate::ported::options::opt_state_snapshot(),
9699 // c:Src/exec.c — fork() copies the alias table to
9700 // the subshell. `(alias x=y)` inside the subshell
9701 // dies with the child; the parent doesn't see x.
9702 // Snapshot here so subshell_end can restore.
9703 // Bug #209 in docs/BUGS.md.
9704 aliases: crate::ported::hashtable::aliastab_lock()
9705 .read()
9706 .ok()
9707 .map(|t| {
9708 t.iter()
9709 .map(|(k, v)| (k.clone(), v.text.clone()))
9710 .collect()
9711 })
9712 .unwrap_or_default(),
9713 // c:Src/exec.c::entersubsh — same fork-copy
9714 // semantics for shfunctab. `(f() { ... })` defined
9715 // inside the subshell dies with the child; parent's
9716 // `type f` reports "not found". Bug #208 in
9717 // docs/BUGS.md.
9718 shfuncs: crate::ported::hashtable::shfunctab_lock()
9719 .read()
9720 .ok()
9721 .map(|t| t.snapshot())
9722 .unwrap_or_default(),
9723 functions_compiled: exec.functions_compiled.clone(),
9724 function_source: exec.function_source.clone(),
9725 // c:Src/exec.c::entersubsh — subshell forks its own
9726 // modulestab. A `(zmodload zsh/X)` inside the
9727 // subshell flips MOD_INIT_B on the CHILD's
9728 // modulestab; when the child exits the change
9729 // dies with it. zshrs's in-process subshell would
9730 // otherwise leak the load to the parent.
9731 // Bug #210 in docs/BUGS.md. Snapshot just the
9732 // (name → flags) pairs since the only mutating
9733 // field is the flags bitmask (MOD_INIT_B for
9734 // loaded, MOD_UNLOAD for unloaded).
9735 modules: crate::ported::module::MODULESTAB
9736 .lock()
9737 .ok()
9738 .map(|t| {
9739 t.modules
9740 .iter()
9741 .map(|(k, v)| (k.clone(), v.node.flags))
9742 .collect()
9743 })
9744 .unwrap_or_default(),
9745 // c:Src/exec.c::entersubsh — fork-copy semantics for
9746 // THINGYTAB (ZLE widget registry). A subshell `zle -N`
9747 // / `zle -D` mutation dies with the child in C zsh;
9748 // mirror via in-process snapshot. Bug #453.
9749 thingytab: crate::ported::zle::zle_thingy::thingytab()
9750 .lock()
9751 .ok()
9752 .map(|t| t.clone())
9753 .unwrap_or_default(),
9754 // c:Src/exec.c::entersubsh — same fork-copy for the
9755 // KEYMAPNAMTAB (named keymap registry). `bindkey -N km`
9756 // / `bindkey -D km` inside a subshell dies with the
9757 // child. Bug #454.
9758 keymapnamtab: crate::ported::zle::zle_keymap::keymapnamtab()
9759 .lock()
9760 .ok()
9761 .map(|t| t.clone())
9762 .unwrap_or_default(),
9763 // c:Src/exec.c::entersubsh fork semantics — `$!`
9764 // (clone::lastpid) set by a `&` INSIDE the subshell
9765 // dies with the child: `( : & ); echo $!` -> 0.
9766 lastpid: crate::ported::modules::clone::lastpid
9767 .load(std::sync::atomic::Ordering::Relaxed),
9768 // c:Src/exec.c::entersubsh fork semantics — the
9769 // subshell gets a COPY of the job table; its disown/
9770 // wait/`&` mutations die with it. Bug #462.
9771 jobtab: crate::ported::jobs::JOBTAB
9772 .get_or_init(|| std::sync::Mutex::new(Vec::new()))
9773 .lock()
9774 .map(|t| t.clone())
9775 .unwrap_or_default(),
9776 curjob: *crate::ported::jobs::CURJOB
9777 .get_or_init(|| std::sync::Mutex::new(-1))
9778 .lock()
9779 .unwrap(),
9780 prevjob: *crate::ported::jobs::PREVJOB
9781 .get_or_init(|| std::sync::Mutex::new(-1))
9782 .lock()
9783 .unwrap(),
9784 maxjob: *crate::ported::jobs::MAXJOB
9785 .get_or_init(|| std::sync::Mutex::new(0))
9786 .lock()
9787 .unwrap(),
9788 thisjob: *crate::ported::jobs::THISJOB
9789 .get_or_init(|| std::sync::Mutex::new(-1))
9790 .lock()
9791 .unwrap(),
9792 // c:Src/exec.c entersubsh — fork copies the fd table;
9793 // the child's `exec >file` / `exec N<&-` mutations die
9794 // with it. Dup each user-range fd to >= 10 so
9795 // subshell_end can restore the parent's exact table.
9796 saved_fds: (0..10)
9797 .map(|fd| {
9798 let dup = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
9799 (fd, dup)
9800 })
9801 .collect(),
9802 });
9803 // C forks for `(...)` — count the fork-equivalent so
9804 // `time (builtin)` reports like zsh (see FORK_EVENTS).
9805 crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9806 // c:Src/exec.c:2862 — subshell fork flags carry ESUB_PGRP,
9807 // so entersubsh runs `clearjobtab(monitor)` (c:1219): the
9808 // child gets an EMPTY job table plus the procless control
9809 // job grabbed at Src/jobs.c:1828 (`thisjob = initjob()`).
9810 // That's why zsh's `(jobs)` prints nothing and `(kill %1)`
9811 // hits the empty control job instead of the parent's job 1.
9812 // The snapshot pushed above restores the parent's table at
9813 // subshell_end. Bug #462.
9814 let monitor =
9815 crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
9816 crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
9817 // clearjobtab left THISJOB on the control job (Src/jobs.c:
9818 // 1828). In C the very next pipeline's execpline reassigns
9819 // thisjob (Src/exec.c:1700 `thisjob = newjob = initjob()`),
9820 // so by the time any builtin runs, thisjob never aliases
9821 // the control job. zshrs has no per-pipeline job slot —
9822 // model the between-pipelines state (-1) so getjob's
9823 // `jobnum != thisjob` (c:jobs.c:2107) doesn't reject %1 and
9824 // setcurjob doesn't demote an inherited curjob that
9825 // collides with the control slot.
9826 *crate::ported::jobs::THISJOB
9827 .get_or_init(|| std::sync::Mutex::new(-1))
9828 .lock()
9829 .unwrap() = -1;
9830 // Subshell starts with EXIT trap cleared so the parent's
9831 // EXIT handler doesn't fire when the subshell ends. zsh:
9832 // each subshell has its own trap context. Other signals
9833 // are inherited (well, parent's are still in place — but
9834 // a trap set INSIDE the subshell shouldn't leak out).
9835 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
9836 t.remove("EXIT");
9837 }
9838 let level = exec
9839 .scalar("ZSH_SUBSHELL")
9840 .and_then(|s| s.parse::<i32>().ok())
9841 .unwrap_or(0);
9842 // c:Src/exec.c — ZSH_SUBSHELL carries PM_READONLY (declared
9843 // in params.rs special_params); setsparam would be rejected
9844 // by assignstrvalue's PM_READONLY guard. Write u_val
9845 // directly — same bypass pattern as BUILTIN_SET_LINENO at
9846 // line 2784. C zsh's PM_SPECIAL GSU vtable handles this
9847 // implicitly via the setfn callback.
9848 let new_level = (level + 1) as i64;
9849 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
9850 if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
9851 pm.u_val = new_level;
9852 pm.u_str = Some(new_level.to_string());
9853 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
9854 }
9855 }
9856 });
9857 // Bump SUBSHELL_DEPTH so zexit defers process::exit (see
9858 // SUBSHELL_DEPTH declaration in src/ported/builtin.rs for
9859 // rationale).
9860 crate::ported::builtin::SUBSHELL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9861 // c:Src/exec.c::entersubsh — C zsh's subshell is a forked
9862 // child process: signals sent to the parent (via `kill $$`
9863 // inside the subshell, where `$$` is the parent's pid)
9864 // never reach the child's signal handlers. zshrs's
9865 // in-process subshell shares the process pid with the
9866 // parent, so without queueing the subshell's trap handler
9867 // fires for signals that zsh would deliver only to the
9868 // parent. Queue signals across the subshell body so the
9869 // parent's restored trap table sees them after
9870 // subshell_end's unqueue drain. Bug #450.
9871 crate::ported::signals_h::queue_signals();
9872 }
9873
9874 fn subshell_end(&mut self) -> Option<i32> {
9875 // Fire subshell's EXIT trap BEFORE restoring parent state so
9876 // the trap body sees the subshell's vars and exit status. zsh
9877 // forks for `(...)` so the trap runs in the child process,
9878 // before exit. We mirror by running it here, just before the
9879 // pop+restore. REMOVE the trap before firing so the inner
9880 // execute_script doesn't fire it again at its own end.
9881 let exit_trap_body = crate::ported::builtin::traps_table()
9882 .lock()
9883 .ok()
9884 .and_then(|mut t| t.remove("EXIT"));
9885 if let Some(body) = exit_trap_body {
9886 // Execute the trap body. Errors during trap execution
9887 // don't bubble — zsh ignores trap-body errors.
9888 with_executor(|exec| {
9889 let _ = exec.execute_script(&body);
9890 });
9891 }
9892 with_executor(|exec| {
9893 if let Some(snap) = exec.subshell_snapshots.pop() {
9894 // Restore paramtab + hashed storage so subshell-scoped
9895 // writes via setsparam/setaparam/sethparam don't leak
9896 // to the parent via paramtab readers.
9897 if let Some(tab) = crate::ported::params::paramtab()
9898 .write()
9899 .ok()
9900 .as_deref_mut()
9901 {
9902 *tab = snap.paramtab;
9903 }
9904 // c:Src/exec.c::entersubsh fork semantics — restore
9905 // the parent's `$!`; a background job inside `(...)`
9906 // dies with the child in C zsh.
9907 crate::ported::modules::clone::lastpid
9908 .store(snap.lastpid, std::sync::atomic::Ordering::Relaxed);
9909 // c:Src/exec.c::entersubsh fork semantics — restore the
9910 // parent's job table + curjob/prevjob/maxjob/thisjob.
9911 // The subshell mutated only its own copy. Bug #462.
9912 if let Ok(mut t) = crate::ported::jobs::JOBTAB
9913 .get_or_init(|| std::sync::Mutex::new(Vec::new()))
9914 .lock()
9915 {
9916 *t = snap.jobtab;
9917 }
9918 *crate::ported::jobs::CURJOB
9919 .get_or_init(|| std::sync::Mutex::new(-1))
9920 .lock()
9921 .unwrap() = snap.curjob;
9922 *crate::ported::jobs::PREVJOB
9923 .get_or_init(|| std::sync::Mutex::new(-1))
9924 .lock()
9925 .unwrap() = snap.prevjob;
9926 *crate::ported::jobs::MAXJOB
9927 .get_or_init(|| std::sync::Mutex::new(0))
9928 .lock()
9929 .unwrap() = snap.maxjob;
9930 *crate::ported::jobs::THISJOB
9931 .get_or_init(|| std::sync::Mutex::new(-1))
9932 .lock()
9933 .unwrap() = snap.thisjob;
9934 if let Some(m) = crate::ported::params::paramtab_hashed_storage()
9935 .lock()
9936 .ok()
9937 .as_deref_mut()
9938 {
9939 *m = snap.paramtab_hashed_storage;
9940 }
9941 exec.set_pparams(snap.positional_params);
9942 // Restore the OS env to its pre-subshell state.
9943 // Removes any `export` writes the subshell made, and
9944 // restores any vars the subshell unset. Without this
9945 // `(export y=sub)` would leak `y` to the parent shell.
9946 let current: HashMap<String, String> = env::vars().collect();
9947 for k in current.keys() {
9948 if !snap.env_vars.contains_key(k) {
9949 env::remove_var(k);
9950 }
9951 }
9952 for (k, v) in &snap.env_vars {
9953 if current.get(k) != Some(v) {
9954 env::set_var(k, v);
9955 }
9956 }
9957 if let Some(cwd) = snap.cwd {
9958 let _ = env::set_current_dir(&cwd);
9959 // Resync $PWD env so a parent `pwd` doesn't read
9960 // the cwd the subshell `cd`'d into.
9961 env::set_var("PWD", &cwd);
9962 }
9963 // Restore umask. zsh's `(umask 077)` doesn't leak to
9964 // parent because the subshell forks; we run in-process
9965 // so we manually reset.
9966 unsafe {
9967 libc::umask(snap.umask as libc::mode_t);
9968 }
9969 // Restore parent's traps (the subshell's own traps die
9970 // with it). zsh: `(trap "X" USR1)` doesn't leak the
9971 // USR1 trap out of the subshell. Write back to the
9972 // canonical `traps_table` (bin_trap writes there).
9973 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
9974 *t = snap.traps;
9975 }
9976 // Restore parent's option store so `(set -e)` /
9977 // `(setopt extendedglob)` don't leak. zsh forks
9978 // subshells so child option changes die with the
9979 // child; we run in-process and must restore.
9980 crate::ported::options::opt_state_restore(snap.opts);
9981 // c:Src/exec.c — fork() means alias mutations in a
9982 // subshell die with the child. Restore parent's
9983 // alias table from snapshot. Clear current entries
9984 // then re-add parent's. Bug #209 in docs/BUGS.md.
9985 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
9986 tab.clear();
9987 for (name, text) in snap.aliases {
9988 tab.add(crate::ported::zsh_h::alias {
9989 node: crate::ported::zsh_h::hashnode {
9990 next: None,
9991 nam: name,
9992 flags: 0,
9993 },
9994 text,
9995 inuse: 0,
9996 });
9997 }
9998 }
9999 // c:Src/exec.c::entersubsh — same fork-copy
10000 // semantics for shfunctab. Restore parent's function
10001 // table from snapshot so `(f() { ... })` definitions
10002 // inside the subshell don't leak to the parent.
10003 // Bug #208 in docs/BUGS.md.
10004 if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
10005 tab.restore(snap.shfuncs);
10006 }
10007 // Restore the runtime dispatch tables (compiled chunks
10008 // + source). Without these, a subshell-defined
10009 // override leaves its bytecode in place even after
10010 // shfunctab is restored — `g` after the subshell would
10011 // still run the override.
10012 exec.functions_compiled = snap.functions_compiled;
10013 exec.function_source = snap.function_source;
10014 // c:Src/exec.c::entersubsh — restore parent's
10015 // modulestab so a subshell `(zmodload zsh/X)` doesn't
10016 // leak to the parent. Bug #210 in docs/BUGS.md.
10017 // Restore via per-module flag write since the
10018 // snapshot is `(name → flags)` only.
10019 if let Ok(mut t) = crate::ported::module::MODULESTAB.lock() {
10020 for (name, saved_flags) in &snap.modules {
10021 if let Some(m) = t.modules.get_mut(name) {
10022 m.node.flags = *saved_flags;
10023 }
10024 }
10025 }
10026 // c:Src/exec.c::entersubsh — restore parent's THINGYTAB
10027 // so a subshell's `zle -N w f` / `zle -D w` doesn't
10028 // affect the parent's widget registry. Bug #453.
10029 if let Ok(mut t) = crate::ported::zle::zle_thingy::thingytab().lock() {
10030 *t = snap.thingytab;
10031 }
10032 // Same for KEYMAPNAMTAB. Bug #454.
10033 if let Ok(mut t) = crate::ported::zle::zle_keymap::keymapnamtab().lock() {
10034 *t = snap.keymapnamtab;
10035 }
10036 // c:Src/exec.c entersubsh fork semantics — restore the
10037 // parent's user-range fd table. A bare `exec >file` /
10038 // `exec N>&-` inside `(...)` died with the C child;
10039 // the in-process subshell must undo it here. Flush
10040 // Rust's stdout buffer FIRST so bytes the subshell
10041 // printed drain to the SUBSHELL's fd 1, not the
10042 // restored parent fd.
10043 {
10044 use std::io::Write;
10045 let _ = std::io::stdout().flush();
10046 }
10047 for (fd, saved) in snap.saved_fds {
10048 unsafe {
10049 if saved >= 0 {
10050 libc::dup2(saved, fd);
10051 libc::close(saved);
10052 } else {
10053 // fd was closed at entry; close whatever
10054 // the subshell opened on that slot.
10055 libc::close(fd);
10056 }
10057 }
10058 }
10059 }
10060 });
10061 // Decrement SUBSHELL_DEPTH. If a deferred subshell exit
10062 // landed inside (EXIT_PENDING set with depth > 0), promote
10063 // the deferred status into the subshell's exit status now
10064 // that we're at the boundary, then clear so the parent
10065 // continues. Matches C zsh's "subshell-exit-via-fork"
10066 // boundary where the child's process::exit(N) becomes
10067 // $WAITSTATUS / $? in the parent.
10068 crate::ported::builtin::SUBSHELL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
10069 // c:Src/exec.c — drain the signal queue against the now-
10070 // restored parent trap table. Pairs with the
10071 // queue_signals() call at the end of subshell_begin.
10072 // Any `kill $$` from inside the subshell is processed
10073 // here against OUTER's trap, matching C zsh's
10074 // signal-delivery-to-parent semantics. Bug #450.
10075 crate::ported::signals_h::unqueue_signals();
10076 // c:Src/exec.c — a `( … )` subshell is a FORK in C: an errflag
10077 // abort inside the child ends the child with its lastval as
10078 // the exit status, and the flag dies with the child process.
10079 // The parent's $? picks up the status and the parent's lists
10080 // keep running. zsh 5.9: `(readonly r=1; r=2); echo "after
10081 // $?"` prints `after 1`. zshrs runs the subshell in-process,
10082 // so mirror the fork isolation by clearing ERRFLAG_ERROR at
10083 // the subshell boundary — exec.last_status() already carries
10084 // the child's lastval (synced by ERREXIT_CHECK trigger 4).
10085 //
10086 // ERRFLAG_HARD must die at this boundary too: `${u:?msg}` sets
10087 // errflag |= ERRFLAG_HARD (c:Src/subst.c:3344) and then, in a
10088 // C forked subshell, `_exit(1)` (c:3353) — the parent never
10089 // sees ANY errflag bit. A leaked HARD bit here made every
10090 // subsequent zerr() take the silent arm (c:Src/utils.c:175-177
10091 // `if (errflag || noerrs) { errflag |= ERRFLAG_ERROR; return; }`),
10092 // so the next eval/source's parse silently "failed" and the
10093 // D04 harness shell wedged after chunk 10's
10094 // `(print ${unset1:?exiting1})`.
10095 crate::ported::utils::errflag.fetch_and(
10096 !(crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
10097 std::sync::atomic::Ordering::Relaxed,
10098 );
10099 let exit_pending =
10100 crate::ported::builtin::EXIT_PENDING.load(std::sync::atomic::Ordering::Relaxed);
10101 if exit_pending != 0 {
10102 // c:Src/builtin.c — `exit N` masks N to 8 bits because
10103 // POSIX _exit takes the low byte as status. `(exit 256)`
10104 // and `(exit 0)` are indistinguishable to the parent;
10105 // `(exit 257)` exits with 1. Without the mask zshrs's
10106 // in-process subshell propagated the full i32 (256) into
10107 // the parent's $?, diverging from zsh.
10108 let raw = crate::ported::builtin::EXIT_VAL.load(std::sync::atomic::Ordering::Relaxed);
10109 let val = raw & 0xFF;
10110 with_executor(|exec| exec.set_last_status(val));
10111 crate::ported::builtin::EXIT_PENDING.store(0, std::sync::atomic::Ordering::Relaxed);
10112 crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
10113 crate::ported::builtin::BREAKS.store(0, std::sync::atomic::Ordering::Relaxed);
10114 // Set the post-subshell-exit guard. The next GET_VAR
10115 // sync_status path consults this to skip its
10116 // vm.last_status→LASTVAL sync (which would overwrite the
10117 // deferred-exit status we just set with stale vm state
10118 // since SubshellEnd doesn't propagate status into the
10119 // VM). Cleared as soon as the next sync_status sees it.
10120 SUBSHELL_EXIT_STATUS_PENDING.with(|c| c.set(true));
10121 // Return the deferred-exit status so the VM updates its
10122 // own `last_status`. Otherwise run_chunk's post-script
10123 // `set_last_status(vm.last_status)` would clobber LASTVAL
10124 // back to the stale pre-subshell value.
10125 return Some(val);
10126 }
10127 None
10128 }
10129
10130 fn redirect(&mut self, fd: u8, op: u8, target: &str) {
10131 // Apply a redirection at the OS level for the next command/builtin.
10132 // The host tracks saved fds in a per-executor stack so a future
10133 // `with_redirects_end` can restore. For now, this is a thin wrapper
10134 // that performs the dup2; pairing with explicit save/restore is
10135 // delivered by `with_redirects_begin/end`.
10136 with_executor(|exec| exec.host_apply_redirect(fd, op, target));
10137 }
10138
10139 fn with_redirects_begin(&mut self, count: u8) {
10140 with_executor(|exec| exec.host_redirect_scope_begin(count));
10141 }
10142
10143 fn regex_match(&mut self, s: &str, regex: &str) -> bool {
10144 // c:Src/Modules/regex.c:54 `zcond_regex_match` — POSIX ERE
10145 // matching + populate `$MATCH` / `$MBEGIN` / `$MEND` /
10146 // `$match[]` / `$mbegin[]` / `$mend[]` (or `$BASH_REMATCH`
10147 // under BASHREMATCH). Direct delegation to the canonical
10148 // port at src/ported/modules/regex.rs:58.
10149 //
10150 // The bridge passthru path delivers TOKEN-form bytes here
10151 // (Inbrack \u{91}, Outbrack \u{92}, Star \u{87}, Quest
10152 // \u{86}, etc.) since the lexer tokenizes regex meta chars
10153 // inside `[[ ]]`. The host regex engine expects ASCII, so
10154 // untokenize the pattern (and subject, for safety) once at
10155 // this boundary. zsh C reaches its POSIX-ERE engine through
10156 // the same untokenize path inside zcond_regex_match.
10157 let s_clean = crate::lex::untokenize(s);
10158 let regex_clean = crate::lex::untokenize(regex);
10159 crate::ported::modules::regex::zcond_regex_match(
10160 &[s_clean.as_str(), regex_clean.as_str()],
10161 crate::ported::modules::regex::ZREGEX_EXTENDED,
10162 ) != 0
10163 }
10164
10165 fn with_redirects_end(&mut self) {
10166 with_executor(|exec| exec.host_redirect_scope_end());
10167 // c:Src/exec.c:5172 — if any redirect in this scope failed
10168 // (noclobber-blocked, ENOENT for read, etc.), the command's
10169 // exit status is forced to 1 regardless of what the (still-
10170 // executed) command's own exit was. C zsh prevents the
10171 // command from running at all when a redirect fails; the
10172 // Rust port still runs it (sinking output to /dev/null in
10173 // the noclobber arm at host_apply_redirect:5481) and then
10174 // overrides $? here. Same observable effect for the common
10175 // pattern `echo x > existing-file` under noclobber.
10176 let failed = with_executor(|exec| {
10177 let f = exec.redirect_failed;
10178 exec.redirect_failed = false;
10179 f
10180 });
10181 if failed {
10182 with_executor(|exec| exec.set_last_status(1));
10183 }
10184 }
10185
10186 fn heredoc(&mut self, content: &str) {
10187 // C `Src/exec.c:4641` — `parsestr(&buf)` runs parameter +
10188 // command substitution on the heredoc body. The lexer's
10189 // quoted-delimiter detection (`<<'EOF'`) routes through the
10190 // `Op::HereDoc` path in `compile_zsh.rs` which short-circuits
10191 // before reaching here; unquoted forms route through the
10192 // BUILTIN_EXPAND_TEXT mode-4 emit path that calls singsub.
10193 // This handler covers the verbatim/quoted case.
10194 with_executor(|exec| exec.host_set_pending_stdin(content.to_string()));
10195 }
10196
10197 fn herestring(&mut self, content: &str) {
10198 // Shell semantics: herestring appends a newline. `<<<` body
10199 // substitution (`Src/exec.c:4655 getherestr` calls
10200 // `quotesubst` + `untokenize`) lands here verbatim; the
10201 // upstream compiler routes through `Op::HereString` after
10202 // BUILTIN_EXPAND_TEXT for the substitution pass, so callers
10203 // of `host.herestring` see the already-expanded form.
10204 let mut s = content.to_string();
10205 s.push('\n');
10206 with_executor(|exec| exec.host_set_pending_stdin(s));
10207 }
10208
10209 fn exec(&mut self, args: Vec<String>) -> i32 {
10210 // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close
10211 // any `>(cmd)` write ends owned by this command once it
10212 // finishes (drops on every return path below).
10213 let _psub_fds = PsubFdGuard;
10214 // c:Src/subst.c paramsubst — when `${var:?msg}` or `${var?msg}`
10215 // triggered the "parameter null or not set" error, errflag
10216 // is raised and zsh aborts the simple command without
10217 // attempting exec. The expansion may have produced empty
10218 // argv[0] which falls into the c:?/permission-denied path
10219 // below, masking the real diagnostic with a spurious
10220 // "permission denied:" line and rc=126 instead of rc=1.
10221 // Honour errflag here so the script ends with the
10222 // paramsubst error as the sole diagnostic. Bug #86.
10223 //
10224 // c:Src/exec.c — C's execlist loop clears ERRFLAG_ERROR
10225 // between sublists when the error came from a NOMATCH-style
10226 // command failure (glob no-match, etc.) so subsequent
10227 // sublists run. zshrs's vm dispatch handles this at the
10228 // post-command-boundary HERE: if THIS command has its
10229 // `current_command_glob_failed` cell set (meaning the glob
10230 // NOMATCH happened during this command's argv prep), surface
10231 // status 1 and clear BOTH the cell AND ERRFLAG_ERROR so the
10232 // NEXT exec call sees a clean state. The errflag from
10233 // genuine script-fatal errors (parse, redirect, paramsubst
10234 // `${:?msg}`) does NOT come paired with glob_failed, so
10235 // those still short-circuit + propagate.
10236 consume_tilde_globsubst_carrier();
10237 let glob_failed = with_executor(|exec| {
10238 let f = exec.current_command_glob_failed.get();
10239 exec.current_command_glob_failed.set(false);
10240 f
10241 });
10242 if glob_failed {
10243 crate::ported::utils::errflag.fetch_and(
10244 !crate::ported::zsh_h::ERRFLAG_ERROR,
10245 std::sync::atomic::Ordering::Relaxed,
10246 );
10247 with_executor(|exec| exec.set_last_status(1));
10248 return 1;
10249 }
10250 // c:Src/subst.c:505-507 — CSH_NULL_GLOB external-path
10251 // boundary: command skipped with `no match` but the NEXT
10252 // sublist runs (zsh -fc 'setopt cshnullglob; ls *nope*;
10253 // print after' prints the error then `after` — verified
10254 // zsh 5.9.1), so clear ERRFLAG like the glob_failed arm.
10255 if consume_badcshglob() {
10256 crate::ported::utils::errflag.fetch_and(
10257 !crate::ported::zsh_h::ERRFLAG_ERROR,
10258 std::sync::atomic::Ordering::Relaxed,
10259 );
10260 with_executor(|exec| exec.set_last_status(1));
10261 return 1;
10262 }
10263 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
10264 & crate::ported::zsh_h::ERRFLAG_ERROR)
10265 != 0
10266 {
10267 return 1;
10268 }
10269 // c:Src/exec.c — two distinct empty-command cases:
10270 //
10271 // 1. args=[""] — an explicit empty-string command word
10272 // (`""`, `"\$unset"`, `\$'\$x'`). zsh attempts exec(2)
10273 // on the empty path → EACCES → "permission denied", \$?
10274 // = 126.
10275 //
10276 // 2. args=[] — the WORD LIST is empty (unquoted \$(\$cmd)
10277 // that produced empty, or an unquoted unset \$var that
10278 // elided). zsh: no exec is attempted; \$? becomes the
10279 // last cmd-subst's exit status (the inner sub-VM
10280 // already set last_status), and the line completes
10281 // silently. Critically NOT 126.
10282 if args.is_empty() {
10283 // c:Src/exec.c — empty word list passes through to a
10284 // no-op; preserve whatever the inner cmd-subst's exit
10285 // is. Return last_status so the caller's SetStatus
10286 // round-trips correctly.
10287 return with_executor(|exec| exec.last_status());
10288 }
10289 if args[0].is_empty() {
10290 let script_name =
10291 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
10292 let lineno: u64 = with_executor(|exec| {
10293 exec.scalar("LINENO")
10294 .and_then(|s| s.parse::<u64>().ok())
10295 .unwrap_or(1)
10296 });
10297 eprintln!("{}:{}: permission denied: ", script_name, lineno);
10298 return 126;
10299 }
10300 // c:Src/exec.c — when any redirect in the current scope
10301 // failed (e.g. noclobber blocked a `>` overwrite), zsh
10302 // refuses to execute the command and exits with status 1.
10303 // The Rust port still applied the command (writing to the
10304 // /dev/null sink installed by host_apply_redirect's
10305 // noclobber arm), but the success status overwrote the
10306 // intended `1`. Short-circuit here so the exec returns 1
10307 // without running the body.
10308 let redir_failed = with_executor(|exec| {
10309 let f = exec.redirect_failed;
10310 exec.redirect_failed = false;
10311 f
10312 });
10313 if redir_failed {
10314 return 1;
10315 }
10316 // Track `$_` as the last argument of the last command (zsh /
10317 // bash convention). Empty arglists leave it untouched.
10318 if let Some(last) = args.last() {
10319 with_executor(|exec| {
10320 exec.set_scalar("_".to_string(), last.clone());
10321 });
10322 }
10323 // Route external command spawning through `executor.execute_external`
10324 // so intercepts (AOP before/after/around), command_hash lookups,
10325 // pre/postexec hooks, and zsh-specific fork-then-exec all apply.
10326 // Without this override, fusevm's default `host.exec` calls
10327 // `Command::new` directly, bypassing zshrs's dispatch logic.
10328 let status = with_executor(|exec| exec.host_exec_external(&args));
10329 // c:Src/jobs.c:1748 waitonejob (no-procs else-branch). zshrs's
10330 // exec model routes external commands through host_exec_external
10331 // (which already waitpid'd in-line); the canonical waitonejob
10332 // expects a Job to derive lastval, but here we already know
10333 // it. Synthesize a procs-less job so waitonejob's no-procs
10334 // branch fires the `pipestats[0]=lastval; numpipestats=1;`
10335 // update via the canonical port.
10336 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
10337 let mut synth = crate::ported::zsh_h::job::default();
10338 crate::ported::jobs::waitonejob(&mut synth);
10339 status
10340 }
10341
10342 fn cmd_subst(&mut self, sub: &fusevm::Chunk) -> String {
10343 // Run the sub-chunk on a nested VM with the same host wired up,
10344 // capturing stdout. The current executor remains active via the
10345 // thread-local — the nested VM uses CallBuiltin to dispatch shell
10346 // ops back through `with_executor`.
10347 let (read_end, write_end) = match os_pipe::pipe() {
10348 Ok(p) => p,
10349 Err(_) => return String::new(),
10350 };
10351 let saved_stdout = unsafe { libc::dup(libc::STDOUT_FILENO) };
10352 if saved_stdout < 0 {
10353 return String::new();
10354 }
10355 let saved_stderr = unsafe { libc::dup(libc::STDERR_FILENO) };
10356 let write_fd = AsRawFd::as_raw_fd(&write_end);
10357 unsafe {
10358 libc::dup2(write_fd, libc::STDOUT_FILENO);
10359 }
10360 drop(write_end);
10361
10362 // c:Bug #56 — publish the saved outer fds so a trap firing
10363 // during the nested VM run can route its body output to the
10364 // PARENT's stdout instead of the cmdsub's pipe-bound fd 1.
10365 // zsh's forked cmdsub gets this for free (trap runs in the
10366 // parent process whose fd 1 is untouched). zshrs's
10367 // in-process cmdsub needs this thread-local stack so the
10368 // trap dispatcher can find the right destination fd.
10369 CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().push((saved_stdout, saved_stderr)));
10370
10371 // Nested scope for `>(cmd)` fd ownership — commands inside
10372 // the cmdsub must not drain the enclosing command's pending
10373 // psub fds (see PSUB_SCOPE_DEPTH).
10374 let _psub_scope = PsubScope::enter();
10375
10376 // c:Src/exec.c:1161 — forked cmdsub child runs entersubsh()
10377 // which does `zsh_subshell++`; in-process equivalent.
10378 let _subshell_bump = CmdSubstSubshellBump::enter();
10379
10380 crate::fusevm_disasm::maybe_print_stdout("host.cmd_subst", sub);
10381 let mut vm = fusevm::VM::new(sub.clone());
10382 register_builtins(&mut vm);
10383 vm.set_shell_host(Box::new(ZshrsHost));
10384 let _ = vm.run();
10385 let cmd_status = vm.last_status;
10386
10387 CMDSUBST_OUTER_FDS.with(|s| {
10388 s.borrow_mut().pop();
10389 });
10390
10391 unsafe {
10392 libc::dup2(saved_stdout, libc::STDOUT_FILENO);
10393 libc::close(saved_stdout);
10394 if saved_stderr >= 0 {
10395 libc::close(saved_stderr);
10396 }
10397 }
10398
10399 // Inner cmd's status not propagated for the same reason as
10400 // run_command_substitution — see GAPS.md.
10401 let _ = cmd_status;
10402
10403 let mut buf = String::new();
10404 let mut reader = read_end;
10405 let _ = reader.read_to_string(&mut buf);
10406 // Strip trailing newlines (POSIX command substitution semantics)
10407 while buf.ends_with('\n') {
10408 buf.pop();
10409 }
10410 buf
10411 }
10412
10413 fn call_function(&mut self, name: &str, args: Vec<String>) -> Option<i32> {
10414 // c:Src/exec.c — when the command word is empty (e.g. `""`
10415 // or `"$nonexistent"`), zsh attempts the exec(2) which
10416 // returns EACCES ("permission denied") and exits 126. The
10417 // Rust port silently treated empty as a no-op (status 0).
10418 // Match zsh by emitting the diagnostic and returning 126.
10419 if name.is_empty() {
10420 let script_name =
10421 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
10422 let lineno: u64 = with_executor(|exec| {
10423 exec.scalar("LINENO")
10424 .and_then(|s| s.parse::<u64>().ok())
10425 .unwrap_or(1)
10426 });
10427 eprintln!("{}:{}: permission denied: ", script_name, lineno);
10428 with_executor(|exec| exec.set_last_status(126));
10429 return Some(126);
10430 }
10431 // c:Src/exec.c — redirect failure in this scope means the
10432 // command should NOT run. The Host::exec path already has
10433 // this gate (at fn exec above); call_function takes external
10434 // commands like `cat <&3` through a different code path, so
10435 // gate here too. Without this, bad-fd redirects produced
10436 // the diagnostic but the external command still ran, so $?
10437 // came out from the command's natural exit instead of the
10438 // forced 1.
10439 let redir_failed = with_executor(|exec| {
10440 let f = exec.redirect_failed;
10441 exec.redirect_failed = false;
10442 f
10443 });
10444 if redir_failed {
10445 with_executor(|exec| exec.set_last_status(1));
10446 return Some(1);
10447 }
10448 // zsh-bundled rename helpers + zcalc: short-circuit BEFORE the
10449 // function/autoload lookup so the autoloaded zsh source (which
10450 // can hang zshrs's parser on zsh-specific syntax) never runs.
10451 // Native Rust impls live in builtin_zmv / builtin_zcalc.
10452 match name {
10453 "zmv" => {
10454 return Some(crate::extensions::ext_builtins::zmv(&args, "mv"));
10455 }
10456 "zcp" => {
10457 return Some(crate::extensions::ext_builtins::zmv(&args, "cp"));
10458 }
10459 "zln" => {
10460 return Some(crate::extensions::ext_builtins::zmv(&args, "ln"));
10461 }
10462 "zcalc" => {
10463 return Some(crate::extensions::ext_builtins::zcalc(&args));
10464 }
10465 // ztest framework (src/extensions/ztest.rs — port of
10466 // ../strykelang's unit-test framework). All zassert_*/
10467 // ztest_* names route through the single try_dispatch
10468 // helper so adding/removing assertions only touches
10469 // ztest.rs.
10470 n if crate::extensions::ztest::try_dispatch_known(n) => {
10471 let status = with_executor(|exec| {
10472 crate::extensions::ztest::try_dispatch(exec, n, &args).unwrap_or(1)
10473 });
10474 return Some(status);
10475 }
10476 // Daemon-managed z* builtins — thin IPC wrappers. Short-circuit BEFORE
10477 // the function-lookup path so a missing daemon doesn't fall through to
10478 // "command not found". The name list is owned by the daemon crate
10479 // (zshrs_daemon::builtins::ZSHRS_BUILTIN_NAMES); routing through
10480 // try_dispatch keeps this site zero-touch as new z* builtins land.
10481 n if crate::daemon::builtins::is_zshrs_builtin(n) => {
10482 let argv: Vec<String> = std::iter::once(name.to_string()).chain(args).collect();
10483 return Some(crate::daemon::builtins::try_dispatch(n, &argv).unwrap_or(1));
10484 }
10485 _ => {}
10486 }
10487
10488 // c:Src/exec.c:3050-3068 — module-provided builtins (registered
10489 // via each module's `bintab` and folded into the canonical
10490 // `builtintab` by `createbuiltintable`) must dispatch BEFORE
10491 // PATH lookup. fusevm's `shell_builtins::builtin_id` doesn't
10492 // know about per-module entries like `log`
10493 // (Src/Modules/watch.c:693) — they reach call_function as
10494 // CallFunction ops. Consult the merged builtintab here so
10495 // `log` runs the canonical `bin_log` instead of falling
10496 // through to `/usr/bin/log` on macOS. Bug #72 in docs/BUGS.md.
10497 //
10498 // User-defined functions still take precedence over builtins
10499 // (zsh's `alias → function → builtin → external` resolution
10500 // order, c:Src/exec.c:3038-3068). Check `functions_compiled`
10501 // first so a user `log() { ... }` shadows the module bin_log.
10502 // c:Src/exec.c — shfunctab->getnode (the DISABLED-filtering
10503 // accessor) returns NULL for entries flipped to DISABLED via
10504 // `disable -f NAME`. functions_compiled holds the body
10505 // independently of the DISABLED flag, so check shfunctab first
10506 // and mask the lookup when the entry is disabled. Bug #221
10507 // in docs/BUGS.md.
10508 let user_fn_disabled = crate::ported::hashtable::shfunctab_lock()
10509 .read()
10510 .ok()
10511 .and_then(|t| {
10512 let entry = t.get_including_disabled(name)?;
10513 Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
10514 })
10515 .unwrap_or(false);
10516 let has_user_fn = !user_fn_disabled
10517 && with_executor(|exec| exec.functions_compiled.contains_key(name));
10518 if !has_user_fn {
10519 // c:Src/exec.c:3056 — `builtintab->getnode(builtintab,
10520 // cmdarg)` returns NULL for DISABLED entries, falling
10521 // execcmd through to PATH lookup. Mirror by gating the
10522 // bn_in_tab match on the BUILTINS_DISABLED set. Bug #106
10523 // in docs/BUGS.md.
10524 let disabled = crate::ported::builtin::BUILTINS_DISABLED
10525 .lock()
10526 .map(|s| s.contains(name))
10527 .unwrap_or(false);
10528 let bn_in_tab = !disabled
10529 && crate::ported::builtin::createbuiltintable().contains_key(name);
10530 if bn_in_tab {
10531 return Some(dispatch_builtin_raw(name, args));
10532 }
10533 }
10534
10535 // c:Src/lex.c — alias expansion is a LEXER-TIME pass, not a
10536 // run-time lookup. zsh parses the whole `-c` argument (or
10537 // script) before executing, so aliases defined in the same
10538 // parse unit don't apply to commands parsed earlier. Only at
10539 // an INTERACTIVE prompt does each line parse separately with
10540 // the latest aliastab visible.
10541 //
10542 // Gate the run-time alias-rewrite path on `interactive` so
10543 // `alias hi='echo hello'; hi` in `-c` mode falls through to
10544 // "command not found" (matching zsh) while interactive REPL
10545 // input still re-parses with the live aliastab.
10546 let interactive = crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE);
10547 let already_expanding = if interactive {
10548 crate::ported::hashtable::aliastab_lock()
10549 .read()
10550 .ok()
10551 .and_then(|tab| tab.get(name).map(|a| a.inuse != 0))
10552 .unwrap_or(false)
10553 } else {
10554 true // suppress lookup entirely in non-interactive mode
10555 };
10556 let alias_body = if already_expanding {
10557 None
10558 } else {
10559 with_executor(|exec| exec.alias(name))
10560 };
10561 if let Some(body) = alias_body {
10562 let combined = if args.is_empty() {
10563 body
10564 } else {
10565 let quoted: Vec<String> = args
10566 .iter()
10567 .map(|a| {
10568 let escaped = a.replace('\'', "'\\''");
10569 format!("'{}'", escaped)
10570 })
10571 .collect();
10572 format!("{} {}", body, quoted.join(" "))
10573 };
10574 // Bump inuse → run → clear, matching C's lexer behavior.
10575 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
10576 if let Some(a) = tab.get_mut(name) {
10577 a.inuse += 1;
10578 }
10579 }
10580 let status = with_executor(|exec| exec.execute_script(&combined).unwrap_or(1));
10581 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
10582 if let Some(a) = tab.get_mut(name) {
10583 a.inuse = (a.inuse - 1).max(0);
10584 }
10585 }
10586 return Some(status);
10587 }
10588
10589 // $_ pre-body bump and pending-underscore tracking are
10590 // ZshrsHost-only concerns (prompt rendering). Apply BEFORE
10591 // delegating to dispatch_function_call so the body sees the
10592 // bumped value.
10593 //
10594 // c:Src/exec.c:3491 — `setunderscore((args && nonempty(args))
10595 // ? ((char *) getdata(lastnode(args))) : "")`. C sets $_ to
10596 // the LAST node of the WHOLE args list (which includes argv[0]
10597 // == the function name). So for a no-arg `f`, $_ becomes "f"
10598 // inside the function body. The Rust port at the CallFunction
10599 // op-handler receives `args` WITHOUT the command name
10600 // (compile_zsh.rs:1571 only pushes simple.words[1..]). The
10601 // last() fallback `|| fn_name.clone()` already covers the
10602 // no-arg case, but `exec.set_scalar("_", ...)` writes paramtab
10603 // — the canonical `$_` read goes through `underscoregetfn`
10604 // (params.rs:7836) which reads the `zunderscore` Mutex.
10605 // setsparam("_") doesn't update that mutex, so the body's
10606 // `${_}` returned empty. Bug #279 in docs/BUGS.md. Mirror the
10607 // C `setunderscore` by writing via `set_zunderscore` directly.
10608 let fn_name = name.to_string();
10609 with_executor(|exec| {
10610 let dollar_underscore = args.last().cloned().unwrap_or_else(|| fn_name.clone());
10611 exec.set_scalar("_".to_string(), dollar_underscore.clone());
10612 crate::ported::params::set_zunderscore(std::slice::from_ref(&dollar_underscore));
10613 exec.pending_underscore = Some(dollar_underscore);
10614 });
10615
10616 // Delegate the actual function dispatch to the canonical
10617 // `dispatch_function_call` (which itself wraps the canonical
10618 // `doshfunc` port from `Src/exec.c:5823`). Single doshfunc
10619 // call-site keeps scope-mgmt invariants in one place.
10620 let status = with_executor(|exec| exec.dispatch_function_call(&fn_name, &args));
10621
10622 // $_ post-body — last call-arg or function name. Mirrors the
10623 // C `setunderscore` invocation after the body returns.
10624 with_executor(|exec| {
10625 let last_call_arg = args.last().cloned().unwrap_or_else(|| fn_name.clone());
10626 exec.set_scalar("_".to_string(), last_call_arg.clone());
10627 exec.pending_underscore = Some(last_call_arg);
10628 });
10629
10630 status
10631 }
10632}
10633
10634// ───────────────────────────────────────────────────────────────────────────
10635// Host-routed shell ops: ShellExecutor methods invoked by ZshrsHost from the
10636// fusevm VM. Not a port of Src/exec.c (see file-level docs above) — they're
10637// the bridge between fusevm opcodes and ShellExecutor state.
10638// ───────────────────────────────────────────────────────────────────────────
10639impl ShellExecutor {
10640 // ─── Host-routed shell ops (called by ZshrsHost from fusevm) ────────────
10641
10642 /// Apply a single redirection. The current scope's saved-fd vec gets a
10643 /// dup of the original fd so it can be restored by `host_redirect_scope_end`.
10644 /// `op_byte` matches `fusevm::op::redirect_op::*`.
10645 /// Apply a file-open result to a redirect fd; on error, emit
10646 /// zsh-format diagnostic, set redirect_failed, sink fd to /dev/null.
10647 /// Shared between WRITE/APPEND/READ/CLOBBER arms in
10648 /// host_apply_redirect to keep the error-handling identical.
10649 fn redir_open_or_fail(
10650 fd: i32,
10651 result: std::io::Result<fs::File>,
10652 target: &str,
10653 redirect_failed: &mut bool,
10654 ) -> bool {
10655 match result {
10656 Ok(file) => {
10657 let new_fd = file.into_raw_fd();
10658 unsafe {
10659 libc::dup2(new_fd, fd);
10660 libc::close(new_fd);
10661 }
10662 true
10663 }
10664 Err(e) => {
10665 let msg = match e.kind() {
10666 std::io::ErrorKind::PermissionDenied => "permission denied",
10667 std::io::ErrorKind::NotFound => "no such file or directory",
10668 std::io::ErrorKind::IsADirectory => "is a directory",
10669 _ => "redirect failed",
10670 };
10671 // c:Src/exec.c:3741 — zwarn with real lineno prefix.
10672 crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
10673 *redirect_failed = true;
10674 // The /dev/null sink keeps a failed scoped redirect
10675 // from leaking the aborted command's output to the
10676 // wrong fd until scope-end restores it. For a bare
10677 // `exec` redirect (permanent, no scope restore) C
10678 // leaves the fd UNTOUCHED — execerr() aborts the
10679 // statement and the original fd 1 keeps flowing
10680 // (A04redirect: `exec >./nonexistent/x` then `echo
10681 // output` still prints). c:Src/exec.c:3735-3742.
10682 let permanent = with_executor(|exec| exec.exec_redirs_permanent);
10683 if !permanent {
10684 if let Ok(devnull) = fs::OpenOptions::new()
10685 .read(true)
10686 .write(true)
10687 .open("/dev/null")
10688 {
10689 let new_fd = devnull.into_raw_fd();
10690 unsafe {
10691 libc::dup2(new_fd, fd);
10692 libc::close(new_fd);
10693 }
10694 }
10695 }
10696 false
10697 }
10698 }
10699 }
10700 /// `host_apply_redirect` — see implementation.
10701 pub fn host_apply_redirect(&mut self, fd: u8, op_byte: u8, target: &str) {
10702 // `&>` / `&>>` always target both fd 1 and fd 2 regardless of the
10703 // fd byte the parser supplied (the lexer's tokfd clamp makes the
10704 // raw value unreliable for these forms).
10705 let fd: i32 = if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
10706 1
10707 } else {
10708 fd as i32
10709 };
10710 // c:Src/exec.c — for DUP_READ / DUP_WRITE forms (<&N / >&N),
10711 // validate the source fd is open BEFORE the save-and-dup
10712 // dance below. The save's `dup(fd)` reclaims the lowest free
10713 // fd, which on closed-fd reuse would let dup2(src=N, …)
10714 // succeed against the freshly-claimed slot — masking the
10715 // user's "bad file descriptor" error. Check src_fd first.
10716 if matches!(op_byte, r::DUP_READ | r::DUP_WRITE) {
10717 let n_check = target.trim_start_matches('&');
10718 if n_check != "-" {
10719 if let Ok(src_fd) = n_check.parse::<i32>() {
10720 if unsafe { libc::fcntl(src_fd, libc::F_GETFD) } == -1 {
10721 // c:Src/exec.c — zwarn with real lineno prefix.
10722 crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src_fd));
10723 self.set_last_status(1);
10724 self.redirect_failed = true;
10725 return;
10726 }
10727 }
10728 }
10729 }
10730 // c:Src/exec.c:3978-3986 — bare `exec` redirects (nullexec==1)
10731 // skip the save entirely: "we specifically *don't* restore the
10732 // original fd's". C's save[] is per-execcmd, so exec's redirs
10733 // never enter an enclosing group's save list either; pushing
10734 // into `redirect_scope_stack.last_mut()` here (the enclosing
10735 // group's scope) made `{ exec 1>&-; … } 2>/dev/null` restore
10736 // stdout at group end — diverging from zsh, which keeps fd 1
10737 // closed for the rest of the script.
10738 if !self.exec_redirs_permanent {
10739 let saved = unsafe { libc::dup(fd) };
10740 if saved >= 0 {
10741 if let Some(top) = self.redirect_scope_stack.last_mut() {
10742 top.push((fd, saved));
10743 } else {
10744 // No scope — leave saved fd open and let the next scope
10745 // reclaim it. (Caller without a scope leaks the dup; this
10746 // matches `WithRedirects` parser construction always wrapping.)
10747 unsafe { libc::close(saved) };
10748 }
10749 }
10750 // For `&>` / `&>>` also save fd 2 so the scope restores it after
10751 // the body. Otherwise stderr stays redirected past the command.
10752 if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
10753 let saved2 = unsafe { libc::dup(2) };
10754 if saved2 >= 0 {
10755 if let Some(top) = self.redirect_scope_stack.last_mut() {
10756 top.push((2, saved2));
10757 } else {
10758 unsafe { libc::close(saved2) };
10759 }
10760 }
10761 }
10762 }
10763 // c:Src/exec.c:3722-3724 + 2447-2480 — MULTIOS split when this
10764 // command's stdout IS the pipeline output. C registers the pipe
10765 // in mfds[1] (`addfd(forked, save, mfds, 1, output, 1, NULL)`)
10766 // BEFORE walking the explicit redirect list, so a write-side
10767 // redirect of fd 1 finds mfds[1] occupied and, with MULTIOS
10768 // set, "split[s] the stream": fd 1 becomes the write end of an
10769 // internal pipe whose reader tees every chunk to BOTH the
10770 // pipeline pipe and the new target. That is why
10771 // `{ echo a; echo b >&2; } 3>&1 1>&2 2>&3 3>&- | cat` sends
10772 // `a` to the pipe (via the tee) AND to stderr — plain dup2
10773 // replacement loses the pipe stream. The scope-depth gate
10774 // mirrors mfds being per-execcmd: only the redirect list
10775 // attached to the stage's own command joins the pipe; nested
10776 // commands inside the body (`{ echo a > f; } | cat`) get a
10777 // fresh "mfds" and replace as usual.
10778 if fd == 1
10779 && self
10780 .pipe_output_scope
10781 .is_some_and(|d| d + 1 == self.redirect_scope_stack.len())
10782 && crate::ported::options::opt_state_get("multios").unwrap_or(true)
10783 {
10784 // Resolve the new write target exactly as the plain arms
10785 // below would, but as a raw fd for the tee.
10786 let new_target_fd: i32 = match op_byte {
10787 r::DUP_WRITE => {
10788 // Numeric `>&N` only; `-` (close) and `p` (coproc)
10789 // fall through to the plain arms.
10790 target
10791 .trim_start_matches('&')
10792 .parse::<i32>()
10793 .map(|src| unsafe { libc::dup(src) })
10794 .unwrap_or(-1)
10795 }
10796 r::WRITE | r::CLOBBER => fs::File::create(target)
10797 .map(|f| f.into_raw_fd())
10798 .unwrap_or(-1),
10799 r::APPEND => fs::OpenOptions::new()
10800 .create(true)
10801 .append(true)
10802 .open(target)
10803 .map(|f| f.into_raw_fd())
10804 .unwrap_or(-1),
10805 _ => -1,
10806 };
10807 if new_target_fd >= 0 {
10808 let pipe_dup = unsafe { libc::dup(1) };
10809 match (pipe_dup >= 0).then(os_pipe::pipe) {
10810 Some(Ok((read_end, write_end))) => {
10811 // Splitter: same read-loop shape as
10812 // BUILTIN_MULTIOS_REDIRECT, with one ordering
10813 // refinement. C's tee is a forked process
10814 // (closemn → teeproc) whose wakeup latency lets
10815 // the stage's DIRECT pipe writes land first —
10816 // observed zsh output for `{ echo a; echo b >&2; }
10817 // 3>&1 1>&2 2>&3 3>&- | cat` is `b` then `a`,
10818 // 15/15 runs. A Rust thread wakes faster than
10819 // the debug-build VM dispatches the next echo,
10820 // inverting the order. Emulate the C timing
10821 // observably: stream to the NEW target (file /
10822 // stderr dup) immediately, but defer the
10823 // pipe-bound copy until EOF (or a 64KB cap so a
10824 // long-running stream still flows instead of
10825 // growing memory unboundedly).
10826 let write_now = |tfd: i32, data: &[u8]| {
10827 let mut off = 0;
10828 while off < data.len() {
10829 let w = unsafe {
10830 libc::write(
10831 tfd,
10832 data[off..].as_ptr() as *const libc::c_void,
10833 data.len() - off,
10834 )
10835 };
10836 if w <= 0 {
10837 break;
10838 }
10839 off += w as usize;
10840 }
10841 };
10842 let handle = std::thread::spawn(move || {
10843 let mut rd = read_end;
10844 let mut buf = [0u8; 8192];
10845 let mut pipe_pending: Vec<u8> = Vec::new();
10846 loop {
10847 match std::io::Read::read(&mut rd, &mut buf) {
10848 Ok(0) | Err(_) => break,
10849 Ok(n) => {
10850 write_now(new_target_fd, &buf[..n]);
10851 pipe_pending.extend_from_slice(&buf[..n]);
10852 if pipe_pending.len() >= 65536 {
10853 write_now(pipe_dup, &pipe_pending);
10854 pipe_pending.clear();
10855 }
10856 }
10857 }
10858 }
10859 write_now(pipe_dup, &pipe_pending);
10860 unsafe {
10861 libc::close(pipe_dup);
10862 libc::close(new_target_fd);
10863 }
10864 });
10865 let write_raw = AsRawFd::as_raw_fd(&write_end);
10866 unsafe { libc::dup2(write_raw, 1) };
10867 drop(write_end);
10868 // Scope-end closes this dup (the last writer once
10869 // the saved fd 1 is restored) → EOF → join.
10870 let close_on_end = unsafe { libc::dup(1) };
10871 if let Some(top) = self.multios_scope_stack.last_mut() {
10872 top.push((close_on_end, handle));
10873 } else {
10874 unsafe { libc::close(close_on_end) };
10875 let _ = handle.join();
10876 }
10877 return;
10878 }
10879 _ => unsafe {
10880 // pipe()/dup failure — fall through to plain replace.
10881 if pipe_dup >= 0 {
10882 libc::close(pipe_dup);
10883 }
10884 libc::close(new_target_fd);
10885 },
10886 }
10887 }
10888 }
10889 match op_byte {
10890 r::WRITE => {
10891 // Honor `setopt noclobber`: refuse to overwrite an
10892 // existing regular file unless `>!` / `>|` (CLOBBER).
10893 // zsh internally stores the inverted-name `clobber`
10894 // (default ON); `setopt noclobber` writes
10895 // `clobber=false`. Honor both keys.
10896 //
10897 // c:Src/exec.c:2241-2245 clobber_open recover path:
10898 // after O_EXCL fails, reopen and `if (!S_ISREG(...))
10899 // return fd;` — non-regular targets (char/block-
10900 // special, FIFO, socket) bypass the noclobber check.
10901 // Bug #30 in docs/BUGS.md: this bridge-side check did
10902 // a bare `Path::exists()` and treated `/dev/null` as
10903 // a protected file, breaking `setopt no_clobber; echo
10904 // hi > /dev/null` and every `2> /dev/null` idiom.
10905 // Add a regular-file stat gate that matches the C
10906 // semantic. The canonical clobber_open at
10907 // src/ported/exec.rs:2123 already handles this; the
10908 // bridge duplicates a stripped-down version here and
10909 // must mirror the same check.
10910 let noclobber = opt_state_get("noclobber").unwrap_or(false)
10911 || !opt_state_get("clobber").unwrap_or(true);
10912 let target_meta = std::fs::metadata(target).ok();
10913 let target_is_regular_file = target_meta
10914 .as_ref()
10915 .map(|m| m.file_type().is_file())
10916 .unwrap_or(false);
10917 // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY permits
10918 // re-using an EMPTY regular file under noclobber: `setopt
10919 // noclobber clobberempty; : >f; echo hi >f` overwrites f.
10920 // The inline bridge check ignored this and errored.
10921 let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
10922 && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
10923 if noclobber && target_is_regular_file && !clobber_empty_ok {
10924 eprintln!("{}:1: file exists: {}", shname(), target);
10925 self.set_last_status(1);
10926 // c:Src/exec.c — set redirect_failed so the scope-end
10927 // hook (`with_redirects_end` in this file) forces
10928 // $? to 1 regardless of the still-running command's
10929 // own exit. Without this the next command (e.g.
10930 // `echo x` writing to /dev/null below) succeeds
10931 // and overwrites the redirect-failure status,
10932 // making noclobber unobservable from $?.
10933 self.redirect_failed = true;
10934 // Sink the upcoming command's stdout to /dev/null
10935 // so we don't leak its output to the terminal.
10936 // zsh skips the command entirely; we approximate by
10937 // discarding the output (the redirect target was
10938 // the user's chosen sink, but with noclobber the
10939 // file is protected — discarding matches the
10940 // user's intent better than printing to terminal).
10941 if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
10942 let new_fd = file.into_raw_fd();
10943 unsafe {
10944 libc::dup2(new_fd, fd);
10945 libc::close(new_fd);
10946 }
10947 }
10948 return;
10949 }
10950 if !Self::redir_open_or_fail(
10951 fd,
10952 fs::File::create(target),
10953 target,
10954 &mut self.redirect_failed,
10955 ) {
10956 self.set_last_status(1);
10957 }
10958 }
10959 r::CLOBBER => {
10960 if !Self::redir_open_or_fail(
10961 fd,
10962 fs::File::create(target),
10963 target,
10964 &mut self.redirect_failed,
10965 ) {
10966 self.set_last_status(1);
10967 }
10968 }
10969 r::APPEND => {
10970 // c:Src/exec.c:3924-3927 — `>>` honors NO_CLOBBER+!APPENDCREATE
10971 // by opening O_APPEND|O_WRONLY WITHOUT O_CREAT, so missing
10972 // files yield ENOENT. zsh source:
10973 // if (!isset(CLOBBER) && !isset(APPENDCREATE) &&
10974 // !IS_CLOBBER_REDIR(fn->type))
10975 // mode = O_WRONLY|O_APPEND|O_NOCTTY;
10976 // else mode = O_WRONLY|O_APPEND|O_CREAT|O_NOCTTY;
10977 // (IS_CLOBBER_REDIR — `>>!`/`>>|` — is currently flattened
10978 // to plain APPEND at compile time in
10979 // src/extensions/compile_zsh.rs:1654-1655, so the bang/pipe
10980 // forms can't be distinguished here yet.)
10981 let noclobber = opt_state_get("noclobber").unwrap_or(false)
10982 || !opt_state_get("clobber").unwrap_or(true);
10983 let append_create = opt_state_get("appendcreate").unwrap_or(false)
10984 || opt_state_get("append_create").unwrap_or(false);
10985 let open_result = if noclobber && !append_create {
10986 fs::OpenOptions::new().append(true).open(target) // no create
10987 } else {
10988 fs::OpenOptions::new()
10989 .create(true)
10990 .append(true)
10991 .open(target)
10992 };
10993 if !Self::redir_open_or_fail(
10994 fd,
10995 open_result,
10996 target,
10997 &mut self.redirect_failed,
10998 ) {
10999 self.set_last_status(1);
11000 }
11001 }
11002 r::READ => {
11003 if !Self::redir_open_or_fail(
11004 fd,
11005 fs::File::open(target),
11006 target,
11007 &mut self.redirect_failed,
11008 ) {
11009 self.set_last_status(1);
11010 }
11011 }
11012 r::READ_WRITE => {
11013 if let Ok(file) = fs::OpenOptions::new()
11014 .create(true)
11015 .truncate(false) // <> opens existing-or-new without truncating
11016 .read(true)
11017 .write(true)
11018 .open(target)
11019 {
11020 let new_fd = file.into_raw_fd();
11021 unsafe {
11022 libc::dup2(new_fd, fd);
11023 libc::close(new_fd);
11024 }
11025 }
11026 }
11027 r::DUP_READ | r::DUP_WRITE => {
11028 // Target is a numeric fd reference like `&3`. The parser
11029 // strips the `&` prefix before we get here in some paths,
11030 // others retain it — accept both. Also support `-` for
11031 // close-fd (`<&-` / `>&-`) per POSIX. The src_fd
11032 // validity check ran above before the save-and-dup.
11033 let n = target.trim_start_matches('&');
11034 if n == "-" {
11035 unsafe { libc::close(fd) };
11036 } else if n == "p" {
11037 // c:Src/exec.c — `<&p` / `>&p` route through the
11038 // coprocin / coprocout globals. zsh's `coproc CMD`
11039 // launch publishes those fds; the canonical
11040 // bin_print / bin_read `-p` arms already consume
11041 // them. The DUP redirect form is the third
11042 // consumer: it must dup the coproc fd onto the
11043 // target slot so the next command's stdin/stdout
11044 // is wired to the running coprocess. Bug #388.
11045 let coproc_fd = if op_byte == r::DUP_READ {
11046 crate::ported::modules::clone::coprocin
11047 .load(std::sync::atomic::Ordering::Relaxed)
11048 } else {
11049 crate::ported::modules::clone::coprocout
11050 .load(std::sync::atomic::Ordering::Relaxed)
11051 };
11052 if coproc_fd < 0 {
11053 eprintln!("{}:1: no coprocess", shname());
11054 self.set_last_status(1);
11055 self.redirect_failed = true;
11056 } else {
11057 unsafe {
11058 libc::dup2(coproc_fd, fd);
11059 }
11060 }
11061 } else if let Ok(src_fd) = n.parse::<i32>() {
11062 unsafe { libc::dup2(src_fd, fd) };
11063 } else if op_byte == r::DUP_WRITE {
11064 // c:Src/glob.c:2184-2187 xpandredir — a MERGEOUT
11065 // word that expands to a non-number becomes
11066 // REDIR_ERRWRITE: `cmd >& word` opens `word` and
11067 // routes BOTH fd 1 and fd 2 there. Reached only
11068 // for dynamic words (`>&$var`); static filenames
11069 // were converted at compile time.
11070 if let Ok(file) = fs::File::create(target) {
11071 let new_fd = file.into_raw_fd();
11072 unsafe {
11073 libc::dup2(new_fd, 1);
11074 libc::dup2(new_fd, 2);
11075 libc::close(new_fd);
11076 }
11077 }
11078 } else {
11079 // c:Src/glob.c:2185 — MERGEIN non-number:
11080 // `zerr("file number expected")`.
11081 crate::ported::utils::zerr("file number expected");
11082 self.set_last_status(1);
11083 self.redirect_failed = true;
11084 }
11085 }
11086 r::WRITE_BOTH => {
11087 if let Ok(file) = fs::File::create(target) {
11088 let new_fd = file.into_raw_fd();
11089 unsafe {
11090 libc::dup2(new_fd, 1);
11091 libc::dup2(new_fd, 2);
11092 libc::close(new_fd);
11093 }
11094 }
11095 }
11096 r::APPEND_BOTH => {
11097 if let Ok(file) = fs::OpenOptions::new()
11098 .create(true)
11099 .append(true)
11100 .open(target)
11101 {
11102 let new_fd = file.into_raw_fd();
11103 unsafe {
11104 libc::dup2(new_fd, 1);
11105 libc::dup2(new_fd, 2);
11106 libc::close(new_fd);
11107 }
11108 }
11109 }
11110 _ => {}
11111 }
11112 }
11113
11114 /// Push a fresh redirect scope. `_count` is informational — the actual
11115 /// saved fds are appended by host_apply_redirect into the top scope.
11116 pub fn host_redirect_scope_begin(&mut self, _count: u8) {
11117 // c:Src/exec.c:3722-3724 — the pipeline child set
11118 // `pipe_output_pending` right after dup2'ing its stdout onto
11119 // the pipe; the FIRST redirect scope opened in that child is
11120 // the stage command's own redirect list (same execcmd as the
11121 // pipe's addfd into mfds[1]). Capture the depth so only THAT
11122 // list's fd-1 write redirects MULTIOS-join the pipe.
11123 if self.pipe_output_pending {
11124 self.pipe_output_pending = false;
11125 self.pipe_output_scope = Some(self.redirect_scope_stack.len());
11126 }
11127 self.redirect_scope_stack.push(Vec::new());
11128 self.multios_scope_stack.push(Vec::new());
11129 }
11130
11131 /// Pop the top redirect scope, restoring saved fds.
11132 pub fn host_redirect_scope_end(&mut self) {
11133 // c:Src/exec.c — restore saved fds FIRST so the multios
11134 // pipe-write end is released from `fd`, then close our
11135 // tracked close_on_end (the last surviving writer dup), then
11136 // join the splitter thread. If we closed close_on_end before
11137 // restoring saved, `fd` would still hold a pipe writer and
11138 // the thread would block forever waiting for EOF.
11139 if let Some(saved) = self.redirect_scope_stack.pop() {
11140 for (fd, saved_fd) in saved.into_iter().rev() {
11141 unsafe {
11142 libc::dup2(saved_fd, fd);
11143 libc::close(saved_fd);
11144 }
11145 }
11146 }
11147 if let Some(scope) = self.multios_scope_stack.pop() {
11148 // Close ALL tracked writer dups BEFORE joining any
11149 // thread. When one splitter holds a dup of another's
11150 // pipe write-end (two multios in one scope where a later
11151 // one duped fd 1 while an earlier splitter owned it),
11152 // joining in push order deadlocks: splitter A's EOF
11153 // waits on splitter B's writer dup, which only closes
11154 // after B's thread exits — blocked behind A's join.
11155 let mut handles = Vec::with_capacity(scope.len());
11156 for (write_fd, handle) in scope {
11157 if write_fd >= 0 {
11158 unsafe {
11159 libc::close(write_fd);
11160 }
11161 }
11162 handles.push(handle);
11163 }
11164 for handle in handles {
11165 let _ = handle.join();
11166 }
11167 }
11168 // The scope that captured the pipeline-output marker is gone;
11169 // deeper-nested future scopes must not re-match its depth.
11170 if self.pipe_output_scope == Some(self.redirect_scope_stack.len()) {
11171 self.pipe_output_scope = None;
11172 }
11173 }
11174
11175 /// Set up `content` as stdin (fd 0) for the next command.
11176 /// Used by `Op::HereDoc(idx)` and `Op::HereString`.
11177 ///
11178 /// c:Src/exec.c:4655 getherestr — C writes the body to a TEMP
11179 /// FILE (gettempfile → write_loop → close → reopen O_RDONLY →
11180 /// unlink), NOT a pipe. The previous pipe+writer-thread shape
11181 /// SIGPIPE'd the whole shell when the consumer never read the
11182 /// body (`: <<< ${(F)x/y}` — D04parameter chunk 211, flaky
11183 /// rc=141): the redirect-scope teardown closed the read end
11184 /// while the detached thread was still in write_all, and the
11185 /// shell's SIGPIPE disposition is SIG_DFL. A temp file has no
11186 /// reader/writer coupling — matching C exactly, including
11187 /// lseek-ability of fd 0, which pipes don't give.
11188 pub fn host_set_pending_stdin(&mut self, content: String) {
11189 // c:4673 — `gettempfile(NULL, 1, &s)`.
11190 let mut tmp = std::env::temp_dir();
11191 tmp.push(format!(
11192 "zshrs-herestr-{}-{:x}",
11193 std::process::id(),
11194 std::time::SystemTime::now()
11195 .duration_since(std::time::UNIX_EPOCH)
11196 .map(|d| d.as_nanos())
11197 .unwrap_or(0)
11198 ));
11199 // c:4675 — `write_loop(fd, t, len); close(fd);`
11200 if std::fs::write(&tmp, content.as_bytes()).is_err() {
11201 return; // c:4674 — tempfile failure → no redirect
11202 }
11203 // c:Src/utils.c gettempfile → mkstemp creates the temp file mode
11204 // 0600 IGNORING the umask, so the O_RDONLY reopen below always
11205 // succeeds. `std::fs::write` honors the umask, so under `umask
11206 // 0777` the file landed mode 0000 and the reopen failed with
11207 // EACCES — `cat <<<x` then read empty stdin. Force 0600 to match
11208 // mkstemp's umask-independent permissions.
11209 let _ = std::fs::set_permissions(
11210 &tmp,
11211 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
11212 );
11213 // c:4677 — `fd = open(s, O_RDONLY | O_NOCTTY);`
11214 let file = match std::fs::File::open(&tmp) {
11215 Ok(f) => f,
11216 Err(_) => {
11217 let _ = std::fs::remove_file(&tmp);
11218 return;
11219 }
11220 };
11221 // c:4678 — `unlink(s);` — fd stays valid, name disappears.
11222 let _ = std::fs::remove_file(&tmp);
11223 let saved = unsafe { libc::dup(libc::STDIN_FILENO) };
11224 if saved >= 0 {
11225 if let Some(top) = self.redirect_scope_stack.last_mut() {
11226 top.push((libc::STDIN_FILENO, saved));
11227 } else {
11228 unsafe { libc::close(saved) };
11229 }
11230 }
11231 let read_fd = AsRawFd::as_raw_fd(&file);
11232 unsafe { libc::dup2(read_fd, libc::STDIN_FILENO) };
11233 drop(file);
11234 }
11235
11236 /// Spawn an external command using zshrs's full dispatch logic
11237 /// (intercepts, command_hash, redirect handling). Used by
11238 /// `ZshrsHost::exec` so the bytecode VM's `Op::Exec` and
11239 /// `Op::CallFunction` external fallback get the same semantics as
11240 /// the tree-walker's `execute_external` rather than a plain
11241 /// `Command::new` shortcut. Returns the exit status.
11242 pub fn host_exec_external(&mut self, args: &[String]) -> i32 {
11243 // If a glob expansion in this command's argv triggered the
11244 // nomatch error path, suppress the actual exec and return
11245 // status 1 — mirrors zsh's command-aborted-on-glob-error
11246 // behaviour. The flag is reset BEFORE returning so the next
11247 // command starts clean.
11248 //
11249 // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH sets
11250 // ERRFLAG_ERROR but C's execlist clears the bit per-sublist
11251 // so subsequent commands run. Symmetric with the builtin
11252 // dispatcher's clear at fusevm_bridge.rs:299 — clear it here
11253 // too at the external-command post-command-boundary.
11254 consume_tilde_globsubst_carrier();
11255 if self.current_command_glob_failed.get() {
11256 self.current_command_glob_failed.set(false);
11257 crate::ported::utils::errflag.fetch_and(
11258 !crate::ported::zsh_h::ERRFLAG_ERROR,
11259 std::sync::atomic::Ordering::Relaxed,
11260 );
11261 self.set_last_status(1);
11262 return 1;
11263 }
11264 // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the
11265 // NOMATCH gate above, same external-path semantics (skip
11266 // command, `no match`, clear ERRFLAG so the next sublist
11267 // runs).
11268 if consume_badcshglob() {
11269 crate::ported::utils::errflag.fetch_and(
11270 !crate::ported::zsh_h::ERRFLAG_ERROR,
11271 std::sync::atomic::Ordering::Relaxed,
11272 );
11273 self.set_last_status(1);
11274 return 1;
11275 }
11276 let Some((cmd, rest)) = args.split_first() else {
11277 return 0;
11278 };
11279 // Empty command name (e.g. result of an empty `$(false)`
11280 // command-sub being the only word) — zsh: no command runs,
11281 // exit status preserved from prior step. Was hitting the
11282 // "command not found: " path with empty name.
11283 if cmd.is_empty() && rest.is_empty() {
11284 return self.last_status();
11285 }
11286 let rest_vec: Vec<String> = rest.to_vec();
11287 // Update `$_` with the just-arriving argv so the next command
11288 // reads `_=<last_arg>`. Mirrors C zsh's writeback in
11289 // `execcmd_exec` (Src/exec.c). Per `args.last()` semantics,
11290 // when invoked as `cmd a b c`, `$_` becomes "c" — for a bare
11291 // command with no args, `$_` becomes the command name itself.
11292 crate::ported::params::set_zunderscore(args);
11293
11294 // Builtins not in fusevm's name→id table fall through to
11295 // host.exec. Catch them here before the OS-level exec attempts
11296 // to spawn a non-existent binary.
11297 match cmd.as_str() {
11298 "sched" => return dispatch_builtin("sched", rest_vec.clone()),
11299 "echotc" => return dispatch_builtin("echotc", rest_vec.clone()),
11300 "echoti" => return dispatch_builtin("echoti", rest_vec.clone()),
11301 "zpty" => return dispatch_builtin("zpty", rest_vec.clone()),
11302 "ztcp" => return dispatch_builtin("ztcp", rest_vec.clone()),
11303 "zsocket" => {
11304 // c:Src/Modules/socket.c:276 BUILTIN spec — BUILTINS["zsocket"]
11305 // optstr "ad:ltv" parsed by execbuiltin.
11306 return dispatch_builtin("zsocket", rest_vec.clone());
11307 }
11308 "private" => {
11309 // c:Src/Modules/param_private.c:217 — bin_private via
11310 // BUILTINS["private"]. The autoload require_module
11311 // (exec.c:2700-2717) fires inside
11312 // dispatch_builtin_raw, the chokepoint for all routes.
11313 return dispatch_builtin("private", rest_vec.clone());
11314 }
11315 "zformat" => return dispatch_builtin("zformat", rest_vec.clone()),
11316 "zregexparse" => return dispatch_builtin("zregexparse", rest_vec.clone()),
11317 // `unalias`/`unhash`/`unfunction` share `bin_unhash` but
11318 // each carries its own funcid (BIN_UNALIAS / BIN_UNHASH /
11319 // BIN_UNFUNCTION) — dispatch_builtin handles the BUILTINS
11320 // lookup + funcid propagation via execbuiltin.
11321 "unalias" | "unhash" | "unfunction" => {
11322 return dispatch_builtin(cmd.as_str(), rest_vec.clone());
11323 }
11324 // zsh-bundled rename helpers — implemented natively in
11325 // Rust so `autoload -U zmv` works without shipping the
11326 // function source. (Without this, the autoload path hangs.)
11327 "zmv" => return crate::extensions::ext_builtins::zmv(&rest_vec, "mv"),
11328 "zcp" => return crate::extensions::ext_builtins::zmv(&rest_vec, "cp"),
11329 "zln" => return crate::extensions::ext_builtins::zmv(&rest_vec, "ln"),
11330 "zcalc" => return crate::extensions::ext_builtins::zcalc(&rest_vec),
11331 "zselect" => {
11332 // Route through canonical dispatch_builtin which goes
11333 // via execbuiltin → BUILTINS["zselect"] (zselect.c:272).
11334 return dispatch_builtin("zselect", rest_vec.clone());
11335 }
11336 "cap" => return dispatch_builtin("cap", rest_vec.clone()),
11337 "getcap" => return dispatch_builtin("getcap", rest_vec.clone()),
11338 "setcap" => return dispatch_builtin("setcap", rest_vec.clone()),
11339 "yes" => return self.builtin_yes(&rest_vec),
11340 "nl" => return self.builtin_nl(&rest_vec),
11341 "env" => return self.builtin_env(&rest_vec),
11342 "printenv" => return self.builtin_printenv(&rest_vec),
11343 "tty" => return self.builtin_tty(&rest_vec),
11344 // c:Src/Modules/files.c:806 — BUILTINS["chgrp"] with
11345 // BIN_CHGRP funcid + "hRs" optstr.
11346 "chgrp" => return dispatch_builtin("chgrp", rest_vec.clone()),
11347 "nproc" => return self.builtin_nproc(&rest_vec),
11348 "expr" => return self.builtin_expr(&rest_vec),
11349 "sha256sum" => return self.builtin_sha256sum(&rest_vec),
11350 "base64" => return self.builtin_base64(&rest_vec),
11351 "tac" => return self.builtin_tac(&rest_vec),
11352 "expand" => return self.builtin_expand(&rest_vec),
11353 "unexpand" => return self.builtin_unexpand(&rest_vec),
11354 "paste" => return self.builtin_paste(&rest_vec),
11355 "fold" => return self.builtin_fold(&rest_vec),
11356 "shuf" => return self.builtin_shuf(&rest_vec),
11357 "comm" => return self.builtin_comm(&rest_vec),
11358 "cksum" => return self.builtin_cksum(&rest_vec),
11359 "factor" => return self.builtin_factor(&rest_vec),
11360 "tsort" => return self.builtin_tsort(&rest_vec),
11361 "sum" => return self.builtin_sum(&rest_vec),
11362 "mkfifo" => return self.builtin_mkfifo(&rest_vec),
11363 "link" => return self.builtin_link(&rest_vec),
11364 "unlink" => return self.builtin_unlink(&rest_vec),
11365 "dircolors" => return self.builtin_dircolors(&rest_vec),
11366 "groups" => return self.builtin_groups(&rest_vec),
11367 "arch" => return self.builtin_arch(&rest_vec),
11368 "nice" => return self.builtin_nice(&rest_vec),
11369 "logname" => return self.builtin_logname(&rest_vec),
11370 "tput" => return self.builtin_tput(&rest_vec),
11371 "users" => return self.builtin_users(&rest_vec),
11372 // "sync" => return self.bin_sync(&rest_vec),
11373 "zbuild" => return self.builtin_zbuild(&rest_vec),
11374 // `zf_*` aliases from `zsh/files` (Src/Modules/files.c
11375 // BUILTIN table at line 816-824). The C source binds
11376 // both unprefixed (`chmod`) and prefixed (`zf_chmod`)
11377 // names to the SAME `bin_chmod` etc. handlers — the
11378 // prefixed forms exist so a script can portably reach
11379 // the builtin even when a function or alias has shadowed
11380 // the bare name. Each arm routes through the canonical
11381 // zf_* aliases route through canonical BUILTINS entries
11382 // (files.c:816-824) — execbuiltin parses each fn's optstr
11383 // automatically.
11384 "mkdir" | "zf_mkdir" | "zf_rm" | "zf_rmdir" | "zf_chmod" | "zf_chown" | "zf_chgrp"
11385 | "zf_ln" | "zf_mv" | "zf_sync"
11386 // `--zsh` parity gate: zsh -fc has zsh/files UNLOADED
11387 // — bare `mkdir` is /bin/mkdir (so `command mkdir -p`
11388 // honors the system flag set; zconvey.plugin.zsh:44
11389 // got "File exists" from the in-process bin_mkdir
11390 // that this arm intercepted) and `zf_*` names are
11391 // command-not-found 127 until `zmodload zsh/files`.
11392 // Fall through to the external/exec path in --zsh
11393 // mode; default zshrs mode keeps the anti-fork
11394 // intercept.
11395 if !crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) =>
11396 {
11397 return dispatch_builtin(cmd.as_str(), rest_vec.clone());
11398 }
11399 // `zstat` — port of zsh/stat module (Src/Modules/stat.c
11400 // BUILTIN("zstat", …)). Returns file metadata as
11401 // `field value` pairs / an assoc / a plus-separated
11402 // list depending on flags. zsh ALSO registers `stat`
11403 // bound to the same handler, but that name conflicts
11404 // with the system `stat(1)` binary (every script that
11405 // calls `stat -f '%Lp' …` would break). zsh resolves
11406 // this through opt-in `zmodload`; zshrs's modules are
11407 // statically linked so we keep `stat` routing to the
11408 // external command and only intercept the unambiguous
11409 // `zstat` name.
11410 "zstat" => {
11411 // Canonical bin_stat per stat.c:638 via BUILTINS["zstat"].
11412 return dispatch_builtin("zstat", rest_vec.clone());
11413 }
11414 _ => {}
11415 }
11416
11417 // AOP intercepts: when an `intercept :before/:around/:after foo` block
11418 // is registered, dynamic-command-name dispatch must consult it before
11419 // spawning. Without this, `cmd=ls; $cmd` bypasses every intercept that
11420 // a literal `ls` would trigger. The full_cmd string mirrors what the
11421 // tree-walker era passed (cmd + args joined by space) so existing
11422 // pattern matchers continue to work.
11423 if !self.intercepts.is_empty() {
11424 let full_cmd = if rest_vec.is_empty() {
11425 cmd.clone()
11426 } else {
11427 format!("{} {}", cmd, rest_vec.join(" "))
11428 };
11429 if let Some(intercept_result) = self.run_intercepts(cmd, &full_cmd, &rest_vec) {
11430 return intercept_result.unwrap_or(127);
11431 }
11432 }
11433
11434 // User-defined function lookup before OS-level exec. zsh's
11435 // dynamic-command-name dispatch (`cmd=hook1; $cmd`) checks
11436 // the function table FIRST — without this, `$f` for a
11437 // function-name `f` was always falling through to
11438 // `execute_external` and erroring "command not found".
11439 // Plugin code uses this pattern constantly:
11440 // for f in "${precmd_functions[@]}"; do "$f"; done
11441 if self.function_exists(cmd) {
11442 if let Some(status) = self.dispatch_function_call(cmd, &rest_vec) {
11443 return status;
11444 }
11445 }
11446
11447 self.execute_external(cmd, &rest_vec, &[]).unwrap_or(127)
11448 }
11449}