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}
117
118/// Peek the outermost cmdsub-saved (stdout, stderr) fds, if any.
119/// Returns None when no cmdsub is currently capturing. Used by the
120/// trap dispatcher in `src/ported/signals.rs::dotrap` to route trap
121/// body output to the parent's real stdout (matching zsh's forked
122/// cmdsub behaviour) instead of the cmdsub's pipe-bound fd 1.
123/// Bug #56 in docs/BUGS.md.
124pub fn cmdsubst_outer_stdout() -> Option<i32> {
125 CMDSUBST_OUTER_FDS.with(|s| s.borrow().last().map(|(o, _)| *o))
126}
127
128// Thread-local pointer to the current ShellExecutor.
129// Set before VM execution, cleared after. Used by builtin handlers.
130thread_local! {
131 static CURRENT_EXECUTOR: RefCell<Option<*mut ShellExecutor>> = const { RefCell::new(None) };
132 /// Set by subshell_end after a deferred subshell `exit N` lands.
133 /// Read + cleared by the next GET_VAR sync_status path so the
134 /// vm.last_status → LASTVAL sync doesn't clobber the deferred
135 /// exit status. RUST-ONLY: needed because zshrs runs subshells
136 /// in-process (no fork) so vm.last_status doesn't track the
137 /// subshell's exit; C zsh's subshell forks and the child's
138 /// process::exit(N) becomes $? in the parent automatically.
139 static SUBSHELL_EXIT_STATUS_PENDING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
140}
141
142/// RAII guard that sets/clears the thread-local executor pointer.
143///
144/// Idempotent: calling `enter` when a context is already active is a no-op
145/// for the entry side, and the guard's drop only clears the thread-local if
146/// *this* call was the one that set it. Nested `execute_command` invocations
147/// (e.g. from inside a builtin handler) reuse the outer pointer instead of
148/// stomping it.
149pub(crate) struct ExecutorContext {
150 we_set_it: bool,
151}
152
153impl ExecutorContext {
154 pub(crate) fn enter(executor: &mut ShellExecutor) -> Self {
155 let we_set_it = CURRENT_EXECUTOR.with(|cell| {
156 let mut slot = cell.borrow_mut();
157 if slot.is_some() {
158 false
159 } else {
160 *slot = Some(executor as *mut ShellExecutor);
161 true
162 }
163 });
164 ExecutorContext { we_set_it }
165 }
166}
167
168impl Drop for ExecutorContext {
169 fn drop(&mut self) {
170 if self.we_set_it {
171 CURRENT_EXECUTOR.with(|cell| {
172 *cell.borrow_mut() = None;
173 });
174 }
175 }
176}
177
178/// Access the current executor from a builtin handler.
179/// # Safety
180/// Only call this from within a VM execution context (after ExecutorContext::enter).
181#[inline]
182pub(crate) fn with_executor<F, R>(f: F) -> R
183where
184 F: FnOnce(&mut ShellExecutor) -> R,
185{
186 CURRENT_EXECUTOR.with(|cell| {
187 let ptr = cell
188 .borrow()
189 .expect("with_executor called outside VM context");
190 // SAFETY: The pointer is valid for the duration of VM execution,
191 // and we're single-threaded within the executor.
192 let executor = unsafe { &mut *ptr };
193 f(executor)
194 })
195}
196
197/// Look up a canonical builtin by name in `BUILTINS` and dispatch
198/// via `execbuiltin` (Src/builtin.c:250). NO shadow check — calls the
199/// builtin even if a user function with the same name exists. Used by
200/// the `builtin foo` prefix opcode (which explicitly bypasses function
201/// lookup per zsh semantics) and by internal call sites where shadowing
202/// is unwanted. For zsh's normal name-resolution order (function shadows
203/// builtin), use `dispatch_builtin` instead.
204/// Shell-identifier prefix for diagnostic lines. Reads the canonical
205/// scriptname (`zsh` in `--zsh` parity mode, `zshrs` otherwise) so a
206/// single helper replaces hardcoded `"zshrs:"` literals across the
207/// file's eprintln paths.
208fn shname() -> String {
209 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string())
210}
211
212/// Map a builtin name to the zsh module that owns it, IFF zsh does
213/// not auto-load that builtin on first use. Used by
214/// `dispatch_builtin_raw` to gate `--zsh` mode dispatch behind
215/// `zmodload`, mirroring `zsh -fc <name>` returning 127 for these
216/// names without an explicit module load.
217///
218/// Returns `Some(module_name)` if `name` belongs to a non-auto-load
219/// module per the per-module `Src/Modules/<x>.c` `bintab[]` plus
220/// the auto-load flag set at module-build time. `None` for core
221/// builtins and for auto-loaded module builtins (sched, log, echotc,
222/// echoti, zformat, zparseopts, zregexparse, zstyle, strftime,
223/// private, vared, zle, bindkey, comp*) which work without zmodload.
224fn module_bound_builtin_module(name: &str) -> Option<&'static str> {
225 match name {
226 "zftp" => Some("zsh/zftp"),
227 "zsocket" => Some("zsh/net/socket"),
228 "ztcp" => Some("zsh/net/tcp"),
229 "zstat" => Some("zsh/stat"),
230 "zselect" => Some("zsh/zselect"),
231 "zpty" => Some("zsh/zpty"),
232 "zprof" => Some("zsh/zprof"),
233 "zsystem" | "syserror" => Some("zsh/system"),
234 "clone" => Some("zsh/clone"),
235 "zcurses" => Some("zsh/curses"),
236 "ztie" | "zuntie" | "zgdbmpath" => Some("zsh/db/gdbm"),
237 "pcre_compile" | "pcre_match" | "pcre_study" => Some("zsh/pcre"),
238 "example" => Some("zsh/example"),
239 "cap" | "getcap" | "setcap" => Some("zsh/cap"),
240 "zgetattr" | "zsetattr" | "zdelattr" | "zlistattr" => Some("zsh/attr"),
241 // c:Src/Modules/datetime.c — `strftime` is registered via
242 // partab[] when zsh/datetime loads. Verified by
243 // `zsh -fc 'strftime -s s %Y 0'` → 127 "command not found".
244 "strftime" => Some("zsh/datetime"),
245 _ => None,
246 }
247}
248
249pub(crate) fn dispatch_builtin_raw(name: &str, args: Vec<String>) -> i32 {
250 // c:Bugs #475/#504/#555 — bash-only builtins (`mapfile`,
251 // `readarray`, `compopt`) should emit "command not found" in
252 // `--zsh` mode matching zsh's external-command-lookup miss.
253 // The per-opcode closures for caller/help/complete/compgen
254 // already gate via IS_ZSH_MODE at their registration sites;
255 // names without dedicated opcodes (compopt/mapfile/readarray)
256 // route through this generic builtintab lookup and need the
257 // gate here.
258 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
259 && matches!(name, "compopt" | "mapfile" | "readarray")
260 {
261 eprintln!("zsh:1: command not found: {}", name);
262 let _ = args;
263 return 127;
264 }
265 // c:Src/Modules/<mod>.c boot_/setup_ chain — module-bound builtins
266 // (zftp, zsocket, ztcp, zstat, etc.) are only registered into
267 // `builtintab` when their module is loaded via `zmodload`. In
268 // zsh `-fc` (the parity test harness's invocation), the modules
269 // are NOT pre-loaded, so each name reports "command not found"
270 // with exit 127. zshrs intentionally pre-loads all module bintabs
271 // in `createbuiltintable` (builtin.rs:131-152) for the default
272 // mode so users can call these without `zmodload`; that auto-load
273 // diverges from zsh's gate behavior. Match zsh's stance only when
274 // the user explicitly asked for parity via `--zsh`.
275 //
276 // The list is the union of builtins from modules that zsh does
277 // NOT auto-load (verified via `zsh -fc <name>` returning 127):
278 // zsh/zftp → zftp
279 // zsh/net/socket → zsocket
280 // zsh/net/tcp → ztcp
281 // zsh/stat → zstat (NOT `stat`; that name resolves to
282 // /bin/stat on PATH per zsh's setup)
283 // zsh/zselect → zselect
284 // zsh/zpty → zpty
285 // zsh/zprof → zprof
286 // zsh/system → zsystem, syserror
287 // zsh/clone → clone
288 // zsh/curses → zcurses
289 // zsh/db/gdbm → ztie, zuntie, zgdbmpath
290 // zsh/pcre → pcre_compile, pcre_match, pcre_study
291 // zsh/example → example
292 // zsh/cap → cap, getcap, setcap
293 // zsh/attr → zgetattr, zsetattr, zdelattr, zlistattr
294 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
295 && module_bound_builtin_module(name)
296 .map(|m| {
297 !crate::ported::module::MODULESTAB
298 .lock()
299 .map(|t| t.is_loaded(m))
300 .unwrap_or(false)
301 })
302 .unwrap_or(false)
303 {
304 eprintln!("zsh:1: command not found: {}", name);
305 let _ = args;
306 return 127;
307 }
308 // c:Src/Modules/files.c:806-824 — zsh/files registers `chmod`,
309 // `chown`, `chgrp`, `ln`, `mkdir`, `mv`, `rm`, `rmdir`, `sync`
310 // (plus their `zf_*` aliases) into builtintab on module load.
311 // Without an explicit `zmodload zsh/files`, zsh resolves the
312 // names through PATH lookup — `zsh -fc 'chmod +x f'` runs
313 // `/bin/chmod`, whose argv-parser accepts symbolic modes like
314 // `+x` that bin_chmod's octal-only parser rejects with
315 // "invalid mode `+x'". The shadow-aware wrapper at
316 // `dispatch_builtin` (line 438) already has this gate, but the
317 // direct `dispatch_builtin_raw` path used by fusevm's
318 // CallBuiltin opcode bypasses it. Mirror the gate here so the
319 // low-level dispatch matches C's PATH-fall-through behavior.
320 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
321 && module_gated_files_builtin(name)
322 && !crate::ported::module::MODULESTAB
323 .lock()
324 .map(|t| t.is_loaded("zsh/files"))
325 .unwrap_or(false)
326 {
327 // Strip the `zf_` prefix when routing to PATH (Src/Modules/
328 // files.c:816-824 — `zf_*` aliases point at the same handler
329 // as the bare name; PATH only has `/bin/rm`, not `/bin/zf_rm`).
330 let path_name = name.strip_prefix("zf_").unwrap_or(name);
331 let status = with_executor(|exec| exec.execute_external(path_name, &args, &[]))
332 .unwrap_or(127);
333 return status;
334 }
335 // c:Src/exec.c:3050-3068 — builtin lookup hits `builtintab` (the
336 // merged table containing module-provided builtins). The previous
337 // port walked only the core `BUILTINS` slice, so per-module
338 // entries like `log` (Src/Modules/watch.c:693 `BUILTIN("log", …,
339 // bin_log, …)`) were registered into builtintab via
340 // createbuiltintable but never reached at dispatch — `log` fell
341 // through to PATH and ran `/usr/bin/log`. Bug #72 in docs/BUGS.md.
342 let tab = crate::ported::builtin::createbuiltintable();
343 if let Some(bn_static) = tab.get(name) {
344 let bn_ptr = *bn_static as *const _ as *mut _;
345 return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
346 }
347 1
348}
349
350/// Shadow-aware dispatch matching zsh's name-resolution order:
351/// alias → reserved word → **function (shadows builtin)** → builtin →
352/// external. All `BUILTIN_X` opcode handlers route through here so a
353/// user-defined `cd () { … }` (or `r`, `fc`, `which`, … anything in
354/// fusevm's name→opcode map) takes precedence over the C builtin —
355/// matching `Src/exec.c:execcmd_exec`'s dispatch at c:3050-3068.
356/// Without this, compile-time builtin resolution silently ignored
357/// user wrappers (e.g. ZPWR's `cd () { builtin cd "$@"; … }`).
358/// True for builtins that are bound by zsh/files's boot_/setup_
359/// chain (Src/Modules/files.c:806-824). These are the bare-name
360/// `mkdir`/`rm`/`mv`/`ln`/`chmod`/`chown`/`chgrp`/`sync`/`rmdir`
361/// AND their `zf_*` aliases at c:816-824. Without explicit
362/// `zmodload zsh/files`, the names fall through to PATH lookup
363/// (zsh's `type rm` reports `/bin/rm`). Bug #28.
364fn module_gated_files_builtin(name: &str) -> bool {
365 matches!(
366 name,
367 "mkdir" | "rmdir" | "rm" | "mv" | "ln" | "chmod" | "chown" | "chgrp" | "sync"
368 | "zf_mkdir" | "zf_rmdir" | "zf_rm" | "zf_mv" | "zf_ln" | "zf_chmod"
369 | "zf_chown" | "zf_chgrp" | "zf_sync"
370 )
371}
372
373pub(crate) fn dispatch_builtin(name: &str, args: Vec<String>) -> i32 {
374 // c:Src/exec.c — when any redirect in the current scope failed
375 // (e.g. noclobber blocked a `>` overwrite), zsh refuses to
376 // execute the command and exits with status 1. The Rust port
377 // still applied the command (writing to the /dev/null sink
378 // installed by host_apply_redirect's noclobber arm) so the
379 // success status overwrote the intended 1. Short-circuit here
380 // for builtins (the external-exec equivalent lives in
381 // ZshrsHost::exec).
382 let redir_failed = with_executor(|exec| {
383 let f = exec.redirect_failed;
384 exec.redirect_failed = false;
385 f
386 });
387 if redir_failed {
388 return 1;
389 }
390 // c:Src/glob.c:1876-1880 NOMATCH path — when expand_glob() failed
391 // on a no-match glob, zsh aborts the simple command after zerr()
392 // printed "no matches found". In C, this works because zerr()
393 // sets ERRFLAG_ERROR (Src/utils.c) and execcmd_exec()
394 // (Src/exec.c:3050+) checks errflag before invoking the builtin
395 // table. Rust's builtin dispatch doesn't sit on the same errflag
396 // gate, so we explicitly consume the per-command glob-fail cell
397 // and short-circuit with status 1. Mirrors the external-path
398 // guard at host_exec_external (line 5167). Without this:
399 // `echo /never/*` would print empty (silently rolled back to ""
400 // by the empty glob expansion). Parity bug #13.
401 let glob_failed = with_executor(|exec| {
402 let f = exec.current_command_glob_failed.get();
403 exec.current_command_glob_failed.set(false); // c:1879 cleanup
404 f
405 });
406 if glob_failed {
407 // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH zerr sets
408 // ERRFLAG_ERROR (via utils.c:184); the C execlist loop's per-
409 // sublist post-exec path then resets the bit so subsequent
410 // sublists continue (verified: `zsh -fc 'ls /nope_*; echo
411 // after'` prints `after`). zshrs's vm dispatch doesn't have
412 // C's central execlist loop — the post-command-boundary
413 // equivalent is right HERE, where the dispatcher consumes
414 // `current_command_glob_failed` and surfaces status 1 for THIS
415 // command. Clear ERRFLAG_ERROR at the same boundary so the
416 // next command runs (while leaving ERRFLAG_INT etc. alone so
417 // ctrl-c still propagates).
418 crate::ported::utils::errflag.fetch_and(
419 !crate::ported::zsh_h::ERRFLAG_ERROR,
420 std::sync::atomic::Ordering::Relaxed,
421 );
422 return 1; // c:1880 — command aborted, status 1
423 }
424 if let Some(status) = try_user_fn_override(name, &args) {
425 // c:Src/jobs.c:1748 waitonejob — canonical single-command
426 // pipestats update via the no-procs else-branch.
427 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
428 let mut synth = crate::ported::zsh_h::job::default();
429 crate::ported::jobs::waitonejob(&mut synth);
430 return status;
431 }
432 // c:Src/builtin.c:587 + Src/exec.c:3056 — a builtin disabled via
433 // `disable <name>` has its `DISABLED` flag set in `builtintab`;
434 // `builtintab->getnode` (the DISABLED-filtering accessor) returns
435 // NULL for it at lookup time, so execcmd_exec falls through to
436 // PATH lookup and runs the external. The Rust port stores the
437 // disabled set in `BUILTINS_DISABLED`; the previous dispatcher
438 // only checked the immutable `createbuiltintable` HashMap which
439 // never reflects disablement — so `disable echo; echo hi` kept
440 // running the bin_echo builtin. Bug #106 in docs/BUGS.md.
441 //
442 // dispatch_builtin (the high-level wrapper used by the BUILTIN_*
443 // opcode handlers and reg_passthru! callsites) is the correct
444 // gate: `dispatch_builtin_raw` is the low-level entry point
445 // used by `bin_builtin` itself which MUST bypass the disabled
446 // set (man zshbuiltins: `builtin name` runs the builtin
447 // regardless of disable state). Place the check here so the
448 // bypass path stays clean.
449 let disabled = crate::ported::builtin::BUILTINS_DISABLED
450 .lock()
451 .map(|s| s.contains(name))
452 .unwrap_or(false);
453 if disabled {
454 let status = with_executor(|exec| exec.execute_external(name, &args, &[]))
455 .unwrap_or(127);
456 crate::ported::builtin::LASTVAL
457 .store(status, std::sync::atomic::Ordering::Relaxed);
458 let mut synth = crate::ported::zsh_h::job::default();
459 crate::ported::jobs::waitonejob(&mut synth);
460 return status;
461 }
462 // c:Src/Modules/files.c:806-814 — `mkdir`, `rm`, `mv`, `ln`, `chmod`,
463 // `chown`, `chgrp`, `sync`, `rmdir` are bound by the `zsh/files`
464 // module's boot_/setup_ chain. Without explicit `zmodload zsh/files`,
465 // these bare names fall through to PATH (`/bin/rm`, `/usr/bin/chmod`,
466 // etc.) in zsh; `type rm` reports `rm is /bin/rm`. The `zf_*`
467 // aliases (`zf_rm`, `zf_chmod`, …) are bound by the same module
468 // and gated the same way. Bug #28 in docs/BUGS.md.
469 if module_gated_files_builtin(name) {
470 if !crate::ported::module::MODULESTAB.lock().unwrap().is_loaded("zsh/files") {
471 // Strip the `zf_` prefix when routing to PATH so `zf_rm`
472 // (when zsh/files isn't loaded) still finds /bin/rm.
473 let path_name = name.strip_prefix("zf_").unwrap_or(name);
474 let status = with_executor(|exec| exec.execute_external(path_name, &args, &[]))
475 .unwrap_or(127);
476 crate::ported::builtin::LASTVAL
477 .store(status, std::sync::atomic::Ordering::Relaxed);
478 let mut synth = crate::ported::zsh_h::job::default();
479 crate::ported::jobs::waitonejob(&mut synth);
480 return status;
481 }
482 }
483 let status = dispatch_builtin_raw(name, args);
484 // c:Src/jobs.c:1748 waitonejob — canonical single-command pipestats update.
485 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
486 let mut synth = crate::ported::zsh_h::job::default();
487 crate::ported::jobs::waitonejob(&mut synth);
488 status
489}
490
491/// Install the `crate::ported::exec_hooks` fn-pointer registry so
492/// code under `src/ported/` can dispatch to operations owned by
493/// `ShellExecutor` (array/assoc storage, script eval, function
494/// dispatch, command substitution) WITHOUT a direct executor
495/// reference or `with_executor` call from inside src/ported/.
496///
497/// Idempotent — each hook uses `OnceLock::set`, so calling this
498/// multiple times (once per `ShellExecutor::new`) is safe; the second
499/// and later calls are no-ops.
500///
501/// **Extension** — no C analog. Bridges the Rust-only `src/ported/`
502/// → executor boundary that the user pinned as forbidden via memory
503/// `feedback_no_exec_script_from_ported` /
504/// `feedback_no_shellexecutor_in_ported`.
505pub(crate) fn install_exec_hooks() {
506 use crate::ported::exec_hooks as h;
507 h::install_array_get(|name| with_executor(|exec| exec.array(name)));
508 h::install_assoc_get(|name| with_executor(|exec| exec.assoc(name)));
509 h::install_array_set(|name, val| {
510 with_executor(|exec| exec.set_array(name.to_string(), val));
511 });
512 h::install_assoc_set(|name, val| {
513 with_executor(|exec| exec.set_assoc(name.to_string(), val));
514 });
515 h::install_scalar_unset(|name| {
516 with_executor(|exec| exec.unset_scalar(name));
517 });
518 h::install_array_unset(|name| {
519 with_executor(|exec| exec.unset_array(name));
520 });
521 h::install_assoc_unset(|name| {
522 with_executor(|exec| exec.unset_assoc(name));
523 });
524 h::install_dispatch_function_call(|name, args| {
525 with_executor(|exec| exec.dispatch_function_call(name, args))
526 });
527 h::install_run_function_body(|name, args| {
528 with_executor(|exec| exec.run_function_body_only(name, args))
529 });
530 h::install_execute_script(|src| with_executor(|exec| exec.execute_script(src)));
531 h::install_execute_script_zsh_pipeline(|src| {
532 with_executor(|exec| exec.execute_script_zsh_pipeline(src))
533 });
534 h::install_run_command_substitution(|cmd| {
535 with_executor(|exec| exec.run_command_substitution(cmd))
536 });
537 h::install_pparams_get(|| with_executor(|exec| exec.pparams()));
538 h::install_pparams_set(|v| {
539 with_executor(|exec| exec.set_pparams(v));
540 });
541 h::install_unregister_function(|name| {
542 with_executor(|exec| {
543 let a = exec.functions_compiled.remove(name).is_some();
544 let b = exec.function_source.remove(name).is_some();
545 a || b
546 })
547 });
548}
549
550/// Register all zsh builtins with the VM.
551pub(crate) fn register_builtins(vm: &mut fusevm::VM) {
552 // exec_hooks fn-ptrs MUST be installed before any builtin can
553 // reach into src/ported/ code that consults them (e.g.
554 // `BUILTIN_ERREXIT_CHECK` → `dotrap` → `exec_hooks::dispatch_function_call`).
555 // OnceLock makes the call idempotent — repeated invocations from
556 // every `ShellExecutor::new` are no-ops.
557 install_exec_hooks();
558 // Engage fusevm's tiered JIT (block + tracing) so hot, fully-eligible
559 // numeric chunks run in native code and — with the `jit-disk-cache`
560 // feature (on by default) — persist that native code to
561 // `~/.cache/fusevm-jit`, letting repeated zsh invocations skip Cranelift
562 // codegen. fusevm gates the JIT on per-chunk eligibility and warms up by
563 // an invocation threshold, falling back to the interpreter for any chunk
564 // it cannot compile (e.g. host-builtin/`Extended` command dispatch), so
565 // enabling it here never changes observable behaviour — it only caches
566 // the numeric hot path. Idempotent: re-enabling on each VM is a no-op.
567 vm.enable_tracing_jit();
568 // Macro for builtins that user functions are allowed to shadow.
569 // zsh dispatch order is alias → function → builtin; without the
570 // try_user_fn_override probe a `cat() { ... }; cat` would silently
571 // run the C builtin and ignore the user function.
572 macro_rules! reg_overridable {
573 ($vm:expr, $id:expr, $name:literal, $method:ident) => {
574 $vm.register_builtin($id, |vm, argc| {
575 let args = pop_args(vm, argc);
576 if let Some(s) = try_user_fn_override($name, &args) {
577 return Value::Status(s);
578 }
579 // c:Src/exec.c — redirect failure in the current
580 // scope means the command must NOT run. coreutils
581 // shadows (cat / head / tail / etc.) take a separate
582 // dispatch path from dispatch_builtin, so they need
583 // their own gate. Without this `cat <&3` after a
584 // closed-fd diagnostic still ran the shadow and
585 // overwrote $? from the forced 1.
586 let redir_failed = with_executor(|exec| {
587 let f = exec.redirect_failed;
588 exec.redirect_failed = false;
589 f
590 });
591 if redir_failed {
592 return Value::Status(1);
593 }
594 // `[builtins].coreutils_shadows = off` in
595 // ~/.zshrs/zshrs.toml (or `ZSHRS_NO_COREUTILS_SHADOWS=1`
596 // env override) bypasses the in-process shadow and
597 // fork-execs the real /bin/X. Safety valve for any
598 // script that hits an edge-case divergence between
599 // the zshrs shadow and system coreutils. Cached
600 // after first call, so the hot path is one atomic
601 // load per shadowed-builtin invocation.
602 if !crate::daemon_presence::coreutils_shadows_enabled() {
603 return Value::Status(exec_system_command($name, &args));
604 }
605 let status = with_executor(|exec| exec.$method(&args));
606 Value::Status(status)
607 });
608 };
609 }
610
611 // Pure-passthru builtin: pops args, routes to canonical
612 // `dispatch_builtin(name, args)` (which goes via execbuiltin →
613 // BUILTINS[name] → bin_X). No pre/post bridge work. Used by
614 // ~25 handlers that were 4-line copy-paste boilerplate.
615 macro_rules! reg_passthru {
616 ($vm:expr, $id:expr, $name:literal) => {
617 $vm.register_builtin($id, |vm, argc| {
618 Value::Status(dispatch_builtin($name, pop_args(vm, argc)))
619 });
620 };
621 }
622
623 // Core builtins
624 vm.register_builtin(BUILTIN_CD, |vm, argc| {
625 let args = pop_args(vm, argc);
626 if let Some(s) = try_user_fn_override("cd", &args) {
627 return Value::Status(s);
628 }
629 let status = dispatch_builtin("cd", args);
630 // c:Src/builtin.c:1258 — `callhookfunc("chpwd", NULL, 1, NULL)`
631 // after cd succeeds. The canonical port at
632 // src/ported/utils.rs:1532 handles both the `chpwd` shfunc
633 // dispatch AND the `chpwd_functions` array walk.
634 if status == 0 {
635 crate::ported::utils::callhookfunc("chpwd", None, 1, std::ptr::null_mut());
636 }
637 Value::Status(status)
638 });
639
640 vm.register_builtin(BUILTIN_PWD, |vm, argc| {
641 let args = pop_args(vm, argc);
642 if let Some(s) = try_user_fn_override("pwd", &args) {
643 return Value::Status(s);
644 }
645 // Route through the canonical execbuiltin path so the `rLP`
646 // optstr at BUILTINS["pwd"] is parsed into `ops`.
647 let status = dispatch_builtin("pwd", args);
648 Value::Status(status)
649 });
650
651 vm.register_builtin(BUILTIN_ECHO, |vm, argc| {
652 let args = pop_args(vm, argc);
653 if let Some(s) = try_user_fn_override("echo", &args) {
654 return Value::Status(s);
655 }
656 // Update `$_` to the last arg before running. C zsh sets
657 // zunderscore in execcmd_exec for every simple command,
658 // including builtins.
659 crate::ported::params::set_zunderscore(&args);
660 let status = dispatch_builtin("echo", args);
661 Value::Status(status)
662 });
663
664 vm.register_builtin(BUILTIN_PRINT, |vm, argc| {
665 let args = pop_args(vm, argc);
666 if let Some(s) = try_user_fn_override("print", &args) {
667 return Value::Status(s);
668 }
669 crate::ported::params::set_zunderscore(&args);
670 let status = dispatch_builtin("print", args);
671 Value::Status(status)
672 });
673
674 reg_passthru!(vm, BUILTIN_PRINTF, "printf");
675 reg_passthru!(vm, BUILTIN_EXPORT, "export");
676 reg_passthru!(vm, BUILTIN_UNSET, "unset");
677 // `source` (Src/builtin.c c:116) wired to bin_dot via BUILTINS.
678 reg_passthru!(vm, BUILTIN_SOURCE, "source");
679 reg_passthru!(vm, BUILTIN_DOT, ".");
680 reg_passthru!(vm, BUILTIN_LOGOUT, "logout");
681
682 vm.register_builtin(BUILTIN_EXIT, |vm, argc| {
683 let args = pop_args(vm, argc);
684 let status = dispatch_builtin("exit", args);
685 Value::Status(status)
686 });
687
688 vm.register_builtin(BUILTIN_RETURN, |vm, argc| {
689 let args = pop_args(vm, argc);
690 // zsh: bare `return` (no arg) returns with the status of
691 // the most recently executed command — `false; return`
692 // returns 1, not 0. Direct port of zsh's bin_break/RETURN.
693 // The executor's `last_status` is stale here (synced at
694 // statement boundaries, not after each VM op), so read
695 // the live `vm.last_status` instead.
696 let live_status = vm.last_status;
697 let status = {
698 // Sync canonical LASTVAL to the VM's view BEFORE
699 // bin_break("return") reads it for the no-arg fallback.
700 with_executor(|exec| exec.set_last_status(live_status));
701 dispatch_builtin("return", args)
702 };
703 Value::Status(status)
704 });
705
706 vm.register_builtin(BUILTIN_TRUE, |vm, argc| {
707 let args = pop_args(vm, argc);
708 if let Some(s) = try_user_fn_override("true", &args) {
709 return Value::Status(s);
710 }
711 // c:Src/exec.c:1257 — zsh sets `zunderscore` AT THE END of
712 // each command (the `if (!noerrs)` block runs `zsfree(prev_argv0); …;
713 // zunderscore = …`). For no-arg `true`, $_ becomes the
714 // command name itself. Set DIRECTLY (not via pending_underscore)
715 // so the NEXT command's argv-expansion of `$_` reads "true",
716 // not the stale prior value — pending_underscore is consumed
717 // by pop_args which runs AFTER argv expansion, too late.
718 // c:Src/exec.c:1257 — `zunderscore = …` at end-of-command.
719 // With args, $_ = args.last(). Without args, $_ = command name.
720 // Write DIRECTLY to the canonical zunderscore static (the
721 // underscoregetfn at params.rs:7003 reads from there); the
722 // paramtab "_" slot is shadowed by lookup_special_var so
723 // set_scalar on it has no effect on `$_` reads.
724 if args.is_empty() {
725 crate::ported::params::set_zunderscore(&["true".to_string()]);
726 } else {
727 crate::ported::params::set_zunderscore(&args);
728 }
729 // Route through canonical execbuiltin so PS4 xtrace fires
730 // via the c:442 printprompt4 path.
731 Value::Status(dispatch_builtin("true", args))
732 });
733 vm.register_builtin(BUILTIN_FALSE, |vm, argc| {
734 let args = pop_args(vm, argc);
735 if let Some(s) = try_user_fn_override("false", &args) {
736 return Value::Status(s);
737 }
738 // Direct set; see BUILTIN_TRUE above for rationale.
739 if args.is_empty() {
740 crate::ported::params::set_zunderscore(&["false".to_string()]);
741 } else {
742 crate::ported::params::set_zunderscore(&args);
743 }
744 // Route through canonical execbuiltin — see BUILTIN_TRUE
745 // above for the same rationale (xtrace + fast-path removal).
746 let status = dispatch_builtin("false", args);
747 Value::Status(status)
748 });
749 vm.register_builtin(BUILTIN_COLON, |vm, argc| {
750 let args = pop_args(vm, argc);
751 // Direct set; see BUILTIN_TRUE above for rationale.
752 if args.is_empty() {
753 crate::ported::params::set_zunderscore(&[":".to_string()]);
754 } else {
755 crate::ported::params::set_zunderscore(&args);
756 }
757 let status = dispatch_builtin(":", args);
758 Value::Status(status)
759 });
760
761 vm.register_builtin(BUILTIN_TEST, |vm, argc| {
762 let args = pop_args(vm, argc);
763 // Distinguish `[ … ]` from `test …` by sniffing the trailing
764 // `]` — `[` requires it (c:Src/builtin.c:7241), `test` rejects
765 // it. The compile path emits BUILTIN_TEST for both, so the
766 // dispatch name carries the `[` vs `test` semantic for
767 // execbuiltin's funcid (BIN_BRACKET=21 vs BIN_TEST=20). Without
768 // this, bin_test's `if func == BIN_BRACKET` arm (which pops
769 // the trailing `]`) never fired for `[` calls, so the `]`
770 // leaked into evalcond as a positional and silently changed
771 // the result. Bug surfaced via test_test_dashdash_unknown_condition.
772 let name = if args.last().map(|s| s.as_str()) == Some("]") {
773 "["
774 } else {
775 "test"
776 };
777 let status = dispatch_builtin(name, args);
778 Value::Status(status)
779 });
780
781 // Variable declaration. `local` (Src/builtin.c bin_local) handles
782 // the scope chain (`pm->old = oldpm` at Src/params.c:1137 inside
783 // createparam, `pm->level = locallevel` at Src/builtin.c:2576).
784 // `typeset` / `declare` are aliases — fusevm maps both to
785 // BUILTIN_TYPESET; compile_zsh special-cases `declare` to keep
786 // the `declare:` error prefix.
787 reg_passthru!(vm, BUILTIN_LOCAL, "local");
788 reg_passthru!(vm, BUILTIN_TYPESET, "typeset");
789
790 reg_passthru!(vm, BUILTIN_DECLARE, "declare");
791 reg_passthru!(vm, BUILTIN_READONLY, "readonly");
792 reg_passthru!(vm, BUILTIN_INTEGER, "integer");
793 reg_passthru!(vm, BUILTIN_FLOAT, "float");
794 reg_passthru!(vm, BUILTIN_READ, "read");
795 // c:Bug #504 — fusevm reserves BUILTIN_MAPFILE for the bash
796 // mapfile/readarray builtins. Neither exists in zsh; in --zsh
797 // parity mode the dispatch must emit "command not found" + rc=127
798 // matching zsh's external-command-lookup miss. The previous wiring
799 // left BUILTIN_MAPFILE unregistered, so fusevm's VM treated the op
800 // as a no-op rc=0 — `mapfile` (and `readarray`) silently succeeded
801 // in --zsh mode. The host gate in `dispatch_builtin_raw` never
802 // fired because the compile path emitted `Op::CallBuiltin(31, ..)`
803 // directly. Register the slot so the gate runs (or a future
804 // non-zsh mode can wire in a real impl).
805 vm.register_builtin(fusevm::shell_builtins::BUILTIN_MAPFILE, |vm, argc| {
806 let args = pop_args(vm, argc);
807 // The fusevm name→id map collapses both `mapfile` and
808 // `readarray` to the same opcode; pick the right diagnostic
809 // by sniffing the user's actual invocation. The xtrace ARGS
810 // push earlier records the cmd-prefix as the bottom of the
811 // popped argv, but `args` here excludes the prefix — so we
812 // can't recover the user-typed name from the stack. Default
813 // to `mapfile` (the more-common spelling); both produce
814 // identical diagnostics in any case.
815 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
816 eprintln!("zsh:1: command not found: mapfile");
817 let _ = args;
818 return Value::Status(127);
819 }
820 // Non-zsh modes (bash drop-in) get a passthru to the canonical
821 // dispatcher in case a future port adds a real mapfile builtin
822 // — until then the rc=1 unknown-command default applies.
823 Value::Status(dispatch_builtin("mapfile", args))
824 });
825 reg_passthru!(vm, BUILTIN_BREAK, "break");
826 reg_passthru!(vm, BUILTIN_CONTINUE, "continue");
827 reg_passthru!(vm, BUILTIN_SHIFT, "shift");
828
829 vm.register_builtin(BUILTIN_EVAL, |vm, argc| {
830 // Direct port of `bin_eval(UNUSED(char *nam), char **argv, UNUSED(Options ops), UNUSED(int func))` body from Src/builtin.c:6151:
831 // `if (!*argv) return 0;`
832 // `prog = parse_string(zjoin(argv, ' ', 1), 1);`
833 // `execode(prog, 1, 0, "eval");`
834 // The execode invocation lives here (not in the canonical
835 // free-fn) because it must run through the bytecode VM's
836 // current executor — the same VM that's mid-dispatch.
837 let mut args = pop_args(vm, argc);
838 // c:Src/builtin.c:407-411 — generic `--` end-of-options
839 // strip applied by `execbuiltin` for builtins that have
840 // NULL optstr AND no BINF_HANDLES_OPTS. `eval` qualifies
841 // (Src/builtin.c:65 `BUILTIN("eval", BINF_PSPECIAL, ...,
842 // NULL, NULL)`). The BUILTIN_EVAL fast-path bypasses
843 // execbuiltin, so we mirror the strip inline. Bug #319.
844 if args.first().is_some_and(|s| s == "--") {
845 args.remove(0);
846 }
847 if args.is_empty() {
848 return Value::Status(0); // c:6160
849 }
850 let src = args.join(" "); // c:6166
851 // c:Src/builtin.c:6164-6165 — `if (!ineval) scriptname =
852 // "(eval)";`. Diagnostics emitted while the eval body runs
853 // (command-not-found, parse errors, etc.) use scriptname as
854 // the source-context prefix. Without setting it here the
855 // BUILTIN_EVAL fast-path leaked the outer "zsh" prefix
856 // through, breaking the `(eval):N:` convention zsh uses
857 // for in-eval errors. Bug #420.
858 let oscriptname = crate::ported::utils::scriptname_get();
859 crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
860 let status = with_executor(|exec| {
861 // c:6175 execode
862 exec.execute_script(&src).unwrap_or(1)
863 });
864 crate::ported::utils::set_scriptname(oscriptname);
865 Value::Status(status)
866 });
867
868 // `builtin foo args…`: precmd-modifier that forces builtin dispatch,
869 // bypassing alias AND function lookup. Without this, `builtin cd /`
870 // inside a user `cd () { … }` wrapper recurses (real-world ZPWR pattern).
871 // Handler pops argc args from the stack, treats args[0] as the builtin
872 // name, and dispatches the rest via `dispatch_builtin` → `execbuiltin`
873 // → `bin_*` directly. No function/alias lookup happens.
874 vm.register_builtin(BUILTIN_BUILTIN, |vm, argc| {
875 let args = pop_args(vm, argc);
876 let Some((name, rest)) = args.split_first() else {
877 // `builtin` with no args → list builtins (zsh emits nothing,
878 // exit 0). Match that behavior; the BIN_BUILTIN bin_* in C
879 // does the same default-list-nothing.
880 return Value::Status(0);
881 };
882 // c:Src/exec.c:3435-3436 — `builtin NAME` with NAME not in
883 // builtintab emits `zwarn("no such builtin: %s", cmdarg)`
884 // and returns 1. zshrs's dispatch_builtin_raw bare-returned 1
885 // silently. Probe the table here so the diagnostic fires
886 // before dispatch.
887 let tab = crate::ported::builtin::createbuiltintable();
888 if !tab.contains_key(name.as_str()) {
889 eprintln!("zshrs:1: no such builtin: {}", name);
890 return Value::Status(1);
891 }
892 // `builtin foo` MUST bypass function shadow — that's the whole
893 // point of the prefix. Use the _raw helper, not the shadow-aware
894 // one. Without this, `cd () { builtin cd "$@"; }` recurses.
895 Value::Status(dispatch_builtin_raw(name, rest.to_vec()))
896 });
897
898 // `command foo args…` — BINF_COMMAND prefix (Src/builtin.c:44). Zsh
899 // semantic: bypass alias+function lookup, search builtin then $PATH.
900 // Without this, `cd () { command cd "$@" }` would re-invoke the user
901 // wrapper (same root cause as the `builtin` bug). Flags `-p`/`-v`/`-V`
902 // route to bin_whence with BIN_COMMAND funcid; bare `command foo`
903 // dispatches builtin if present, else external (no fork — direct
904 // spawn via execute_external since zshrs is non-forking).
905 // BUILTIN_COMMAND — `command [-p] [-v|-V] cmd args…` BIN_PREFIX
906 // (Src/builtin.c:45). PURE PASSTHRU: prepend "command" and hand
907 // to `exec::execcmd_compile_head` (the fusevm-bytecode-time head
908 // resolver mirroring `Src/exec.c::execcmd_exec` precommand-modifier
909 // walk at c:3104-3187). That helper already does the -p / -v / -V
910 // option parsing, surfaces `has_command_vv` for the whence
911 // redirect, and reports the dispatch shape (is_builtin vs external).
912 vm.register_builtin(BUILTIN_COMMAND, |vm, argc| {
913 let args = pop_args(vm, argc);
914 let mut full = Vec::with_capacity(args.len() + 1);
915 full.push("command".to_string());
916 full.extend(args.clone());
917 let dispatch =
918 crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
919 let post = &full[dispatch.precmd_skip..];
920 // c:Src/builtin.c:4500 — `command -p` resets PATH for the
921 // exec to the POSIX-defined default (`getconf PATH`), so
922 // standard utilities resolve even when the caller has
923 // emptied $PATH. zsh restores the original PATH after the
924 // command returns. Mirror via a scoped env::set_var.
925 let dash_p = args.iter().any(|a| {
926 a == "-p"
927 || a == "-pv"
928 || a == "-pV"
929 || (a.starts_with('-') && a.contains('p') && !a.starts_with("--"))
930 });
931 // The post slice from execcmd_compile_head may still contain
932 // `-p` as the first element because precmd-modifier opt
933 // parsing isn't wired here. Strip it manually so the dispatch
934 // below sees the real command name.
935 let mut post: Vec<String> = if dash_p {
936 post.iter()
937 .filter(|a| {
938 let s = a.as_str();
939 !(s.starts_with('-')
940 && s.len() >= 2
941 && s[1..].chars().all(|c| c == 'p' || c == 'v' || c == 'V'))
942 })
943 .cloned()
944 .collect()
945 } else {
946 post.to_vec()
947 };
948 // c:Src/exec.c:3176-3177 — `BINF_COMMAND` arm strips a single
949 // leading `--` end-of-options marker.
950 // `execcmd_compile_head` (src/ported/exec.rs:1042) performs
951 // this removal on its LOCAL `preargs` Vec but doesn't surface
952 // the modified args; the caller still sees `--` in `full` and
953 // tried to dispatch it as the command name. Bug #251. Mirror
954 // the C strip here so `command -- echo hi` and
955 // `command -p -- echo hi` route correctly.
956 if let Some(first) = post.first() {
957 if first == "--" {
958 post.remove(0);
959 }
960 }
961 let post = post.as_slice();
962 let _path_guard = if dash_p {
963 let saved = env::var("PATH").ok();
964 let default_path = std::process::Command::new("getconf")
965 .arg("PATH")
966 .output()
967 .ok()
968 .and_then(|o| String::from_utf8(o.stdout).ok())
969 .map(|s| s.trim().to_string())
970 .filter(|s| !s.is_empty())
971 .unwrap_or_else(|| "/usr/bin:/bin:/usr/sbin:/sbin".to_string());
972 env::set_var("PATH", &default_path);
973 crate::ported::params::setsparam("PATH", &default_path);
974 Some(saved)
975 } else {
976 None
977 };
978 struct PathGuard {
979 saved: Option<String>,
980 active: bool,
981 }
982 impl Drop for PathGuard {
983 fn drop(&mut self) {
984 if !self.active {
985 return;
986 }
987 match self.saved.take() {
988 Some(p) => {
989 env::set_var("PATH", &p);
990 crate::ported::params::setsparam("PATH", &p);
991 }
992 None => {
993 env::remove_var("PATH");
994 crate::ported::params::setsparam("PATH", "");
995 }
996 }
997 }
998 }
999 let _restore = PathGuard {
1000 saved: _path_guard.unwrap_or(None),
1001 active: dash_p,
1002 };
1003 if dispatch.has_command_vv {
1004 // `-v` / `-V` → bin_whence with BIN_COMMAND funcid.
1005 let mut ops = options {
1006 ind: [0u8; MAX_OPS],
1007 args: Vec::new(),
1008 argscount: 0,
1009 argsalloc: 0,
1010 };
1011 let mut name_pos = 0usize;
1012 let mut flag_byte = b'v';
1013 for (i, a) in post.iter().enumerate() {
1014 if a.starts_with('-') && a.len() >= 2 {
1015 let body = &a.as_bytes()[1..];
1016 if body.contains(&b'V') {
1017 flag_byte = b'V';
1018 }
1019 name_pos = i + 1;
1020 } else {
1021 name_pos = i;
1022 break;
1023 }
1024 }
1025 ops.ind[flag_byte as usize] = 1;
1026 let whence_args: Vec<String> = post[name_pos..].to_vec();
1027 return Value::Status(crate::ported::builtin::bin_whence(
1028 "command",
1029 &whence_args,
1030 &ops,
1031 crate::ported::hashtable_h::BIN_COMMAND,
1032 ));
1033 }
1034 if dispatch.is_empty_command {
1035 return Value::Status(0);
1036 }
1037 let Some((name, rest)) = post.split_first() else {
1038 return Value::Status(0);
1039 };
1040 // c:Src/exec.c:3275-3278 — `execcmd_compile_head` cleared
1041 // hn for the BINF_COMMAND + !POSIXBUILTINS case, surfacing
1042 // is_builtin=false. Run as external. Under POSIXBUILTINS
1043 // dispatch.is_builtin would be true; honour it.
1044 let n = name.clone();
1045 let r = rest.to_vec();
1046 if dispatch.is_builtin
1047 && crate::ported::builtin::BUILTINS
1048 .iter()
1049 .any(|b| b.node.nam == n.as_str())
1050 {
1051 return Value::Status(dispatch_builtin_raw(&n, r));
1052 }
1053 Value::Status(with_executor(|exec| exec.execute_external(&n, &r, &[])).unwrap_or(127))
1054 });
1055
1056 // `exec cmd args…` — BINF_EXEC prefix (Src/builtin.c:45). Zsh
1057 // semantic: replace the current shell process with `cmd`. On Unix
1058 // this is `execvp(2)`; the call only returns on error. zshrs is
1059 // non-forking, so the shell process IS the calling process —
1060 // execvp here directly replaces it. Options `-a name` (override
1061 // argv[0]), `-c` (clean env), `-l` (login shell — prepend `-`)
1062 // ported minimally; advanced redirect-only `exec >file` is handled
1063 // upstream by compile_zsh and never reaches this handler.
1064 vm.register_builtin(BUILTIN_EXEC, |vm, argc| {
1065 let mut args = pop_args(vm, argc);
1066 let mut argv0_override: Option<String> = None;
1067 let mut clean_env = false;
1068 let mut login = false;
1069 let mut i = 0;
1070 // c:Src/builtin.c:1075-1080 — track if any flag was consumed.
1071 // `exec -c`, `exec -l`, `exec -a NAME` without a following
1072 // command emit "exec requires a command to execute" rc=1.
1073 // Bare `exec` (no args at all) is the silent-redirect-apply
1074 // form per POSIX.
1075 let mut saw_flag = false;
1076 while i < args.len() {
1077 let a = &args[i];
1078 if a == "--" {
1079 args.remove(i);
1080 break;
1081 }
1082 // c:Src/builtin.c:42 `BIN_PREFIX("-", BINF_DASH)`. A bare
1083 // `-` is its own BINF_PREFIX builtin (BINF_DASH flag —
1084 // "login shell, prepend `-` to argv[0]"). In the canonical
1085 // precmd-walk at Src/exec.c:3056-3091 a bare `-` after
1086 // `exec` is recognized AS a builtin and stripped from
1087 // preargs (precmd_skip++), accumulating BINF_DASH into
1088 // cflags. The fast-path here bypasses execcmd_compile_head,
1089 // so we mirror the strip locally: bare `-` → set login,
1090 // remove, continue. Without this `exec -` (with no command
1091 // following) tried to exec `-` as a literal command and
1092 // exited the shell. Bug #252.
1093 if a == "-" {
1094 saw_flag = true;
1095 login = true;
1096 args.remove(i);
1097 continue;
1098 }
1099 if !a.starts_with('-') || a.len() < 2 {
1100 break;
1101 }
1102 match a.as_str() {
1103 "-a" => {
1104 saw_flag = true;
1105 args.remove(i);
1106 if i < args.len() {
1107 argv0_override = Some(args.remove(i));
1108 }
1109 }
1110 "-c" => {
1111 saw_flag = true;
1112 clean_env = true;
1113 args.remove(i);
1114 }
1115 "-l" => {
1116 saw_flag = true;
1117 login = true;
1118 args.remove(i);
1119 }
1120 _ => {
1121 // c:Src/exec.c:3196-3208 — when an unrecognized
1122 // `-X`-style arg has NO following arg, the lexer's
1123 // IS_DASH walk hits the "no next node" branch at
1124 // c:3199 before the unknown-flag-letter switch at
1125 // c:3249, so the canonical message is "exec
1126 // requires a command to execute" rc=1 (verified vs
1127 // `/opt/homebrew/bin/zsh -fc 'exec --bad'`).
1128 // Consume the lone flag so the post-loop check
1129 // fires. When a following arg exists, leave the
1130 // unknown-flag arg in place — that arg becomes
1131 // the command name and execution proceeds.
1132 if args.len() == 1 {
1133 saw_flag = true;
1134 args.remove(i);
1135 continue;
1136 }
1137 break;
1138 }
1139 }
1140 }
1141 let Some(cmd) = args.first().cloned() else {
1142 if saw_flag {
1143 // c:Src/builtin.c:1078-1080 — flags consumed but no
1144 // command follows → "exec requires a command to
1145 // execute" rc=1.
1146 eprintln!("zshrs:1: exec requires a command to execute");
1147 return Value::Status(1);
1148 }
1149 // `exec` with no command + no redirects = no-op success.
1150 return Value::Status(0);
1151 };
1152 let rest: Vec<String> = args[1..].to_vec();
1153 let display_argv0 = match argv0_override {
1154 Some(a) => a,
1155 None => {
1156 if login {
1157 format!("-{}", cmd)
1158 } else {
1159 cmd.clone()
1160 }
1161 }
1162 };
1163 // c:Src/exec.c::execcmd — `exec funcname` runs the function
1164 // in-process as the shell's last act, then exits with the
1165 // function's status. zsh's dispatcher falls through from the
1166 // BINF_EXEC prefix into the normal Builtin/External/Function
1167 // resolution and only execvp's if the target ISN'T a
1168 // function. Bug #101 in docs/BUGS.md: zshrs's exec went
1169 // straight to execvp and errored `not found` for shell
1170 // functions.
1171 //
1172 // For both subshell and top-level contexts: dispatch through
1173 // the function/builtin lookup first; only fall through to
1174 // execvp/spawn if the name isn't shell-resolvable.
1175 let has_user_fn = with_executor(|exec| exec.functions_compiled.contains_key(&cmd));
1176 if has_user_fn {
1177 let status = with_executor(|exec| {
1178 exec.dispatch_function_call(&cmd, &rest).unwrap_or(127)
1179 });
1180 // Top-level `exec funcname` — exit the shell with the
1181 // function's status (mirrors C's "exec replaces shell as
1182 // last act"). Subshell `(exec funcname)` — return through
1183 // the EXIT_PENDING path so the subshell body aborts and
1184 // the parent resumes via subshell_end.
1185 let in_subshell_now =
1186 with_executor(|exec| !exec.subshell_snapshots.is_empty());
1187 if in_subshell_now {
1188 crate::ported::builtin::EXIT_VAL
1189 .store(status, std::sync::atomic::Ordering::Relaxed);
1190 crate::ported::builtin::EXIT_PENDING
1191 .store(1, std::sync::atomic::Ordering::Relaxed);
1192 return Value::Status(status);
1193 }
1194 std::process::exit(status);
1195 }
1196 // c:Src/exec.c — builtin path: `exec builtin` runs the
1197 // builtin in-process and exits.
1198 let bn_in_tab = crate::ported::builtin::createbuiltintable().contains_key(&cmd);
1199 if bn_in_tab {
1200 let status = dispatch_builtin_raw(&cmd, rest.clone());
1201 let in_subshell_now =
1202 with_executor(|exec| !exec.subshell_snapshots.is_empty());
1203 if in_subshell_now {
1204 crate::ported::builtin::EXIT_VAL
1205 .store(status, std::sync::atomic::Ordering::Relaxed);
1206 crate::ported::builtin::EXIT_PENDING
1207 .store(1, std::sync::atomic::Ordering::Relaxed);
1208 return Value::Status(status);
1209 }
1210 std::process::exit(status);
1211 }
1212 // c:Src/exec.c — `exec` inside a subshell (`(exec cmd)`)
1213 // replaces ONLY the subshell child process; the parent shell
1214 // continues. C zsh always forks for `(...)`, so the actual
1215 // execvp lands in the forked child. zshrs runs subshells via
1216 // a snapshot/restore pattern in the SAME process — calling
1217 // execvp here would replace the parent too. Bug #94 in
1218 // docs/BUGS.md.
1219 //
1220 // Detect subshell context via the non-empty
1221 // `subshell_snapshots` stack. When in a subshell: spawn the
1222 // command as a child, wait for it, then signal the subshell
1223 // body to abort (return Status(N) and the caller's
1224 // subshell_end will pop the snapshot and resume the parent).
1225 let in_subshell = with_executor(|exec| !exec.subshell_snapshots.is_empty());
1226 if in_subshell {
1227 let mut command = std::process::Command::new(&cmd);
1228 command.arg0(&display_argv0);
1229 command.args(&rest);
1230 if clean_env {
1231 command.env_clear();
1232 }
1233 let status = match command.spawn() {
1234 Ok(mut child) => match child.wait() {
1235 Ok(s) => s.code().unwrap_or(127),
1236 Err(_) => 127,
1237 },
1238 Err(e) => {
1239 // c:Src/exec.c:797 — `zerr("%e: %s", lerrno, arg0)`
1240 // when arg0 contains `/`.
1241 // c:872-876 — when arg0 has no `/` (PATH search
1242 // path), C tracks the "good" errno
1243 // via `isgooderr`; if all PATH entries
1244 // were ENOENT-not-good, eno stays 0
1245 // and C emits `command not found: %s`
1246 // instead of strerror.
1247 // %e expands to strerror(errno) with the first
1248 // letter lowercased (unless errno == EIO; see
1249 // Src/utils.c:362-368). `zerr` prepends the
1250 // scriptname:lineno: prefix — matching zsh's
1251 // canonical `zsh:N: <errmsg>: <cmd>` pattern.
1252 // Previously emitted `zshrs: exec: {}: not found`
1253 // (wrong prefix, hardcoded message, missing
1254 // lineno). Bug #140 in docs/BUGS.md.
1255 let errno = e.raw_os_error().unwrap_or(libc::ENOENT);
1256 let has_slash = cmd.contains('/');
1257 if !has_slash && errno == libc::ENOENT {
1258 // c:876 — PATH search exhausted with no good
1259 // errno → `command not found: arg0`.
1260 crate::ported::utils::zerr(&format!(
1261 "command not found: {}",
1262 cmd
1263 ));
1264 } else {
1265 let mut errmsg = crate::ported::compat::strerror(errno);
1266 if errno != libc::EIO {
1267 if let Some(c) = errmsg.chars().next() {
1268 errmsg = format!(
1269 "{}{}",
1270 c.to_ascii_lowercase(),
1271 &errmsg[c.len_utf8()..]
1272 );
1273 }
1274 }
1275 crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
1276 }
1277 // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
1278 if errno == libc::EACCES || errno == libc::ENOEXEC {
1279 126
1280 } else {
1281 127
1282 }
1283 }
1284 };
1285 // Mark the subshell as exec-replaced so subsequent body
1286 // commands skip — mirrors the post-execvp "child process
1287 // is gone" reality in C. EXIT_PENDING + EXIT_VAL drive
1288 // the next ERREXIT_CHECK to unwind to the subshell-end
1289 // patch.
1290 crate::ported::builtin::EXIT_VAL
1291 .store(status, std::sync::atomic::Ordering::Relaxed);
1292 crate::ported::builtin::EXIT_PENDING
1293 .store(1, std::sync::atomic::Ordering::Relaxed);
1294 return Value::Status(status);
1295 }
1296 let mut command = std::process::Command::new(&cmd);
1297 command.arg0(&display_argv0);
1298 command.args(&rest);
1299 if clean_env {
1300 command.env_clear();
1301 }
1302 use std::os::unix::process::CommandExt;
1303 // `exec` returns the OS error iff exec(2) failed; on success
1304 // it never returns. Match zsh: print the error to stderr with
1305 // the `exec` prefix and exit 127 (cmd not found) or 126 (not
1306 // executable).
1307 let err = command.exec();
1308 // c:Src/exec.c:797 / c:872-876 — same format as in-subshell
1309 // branch. arg0-has-/ → `<strerror>: <cmd>`; arg0-no-/ +
1310 // ENOENT → `command not found: <cmd>`. Lowercase strerror
1311 // first letter unless EIO. Bug #140 in docs/BUGS.md.
1312 let errno = err.raw_os_error().unwrap_or(libc::ENOENT);
1313 let has_slash = cmd.contains('/');
1314 if !has_slash && errno == libc::ENOENT {
1315 crate::ported::utils::zerr(&format!("command not found: {}", cmd));
1316 } else {
1317 let mut errmsg = crate::ported::compat::strerror(errno);
1318 if errno != libc::EIO {
1319 if let Some(c) = errmsg.chars().next() {
1320 errmsg = format!(
1321 "{}{}",
1322 c.to_ascii_lowercase(),
1323 &errmsg[c.len_utf8()..]
1324 );
1325 }
1326 }
1327 crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
1328 }
1329 // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
1330 let code = if errno == libc::EACCES || errno == libc::ENOEXEC {
1331 126
1332 } else {
1333 127
1334 };
1335 std::process::exit(code);
1336 });
1337
1338 reg_passthru!(vm, BUILTIN_LET, "let");
1339
1340 // Job control
1341 reg_passthru!(vm, BUILTIN_JOBS, "jobs");
1342 reg_passthru!(vm, BUILTIN_FG, "fg");
1343 reg_passthru!(vm, BUILTIN_BG, "bg");
1344 reg_passthru!(vm, BUILTIN_KILL, "kill");
1345 reg_passthru!(vm, BUILTIN_DISOWN, "disown");
1346 reg_passthru!(vm, BUILTIN_WAIT, "wait");
1347 reg_passthru!(vm, BUILTIN_SUSPEND, "suspend");
1348
1349 // History — `fc` / `history` / `r` all route to `bin_fc` (zsh
1350 // registers them as aliases of the same builtin per Src/builtin.c).
1351 reg_passthru!(vm, BUILTIN_FC, "fc");
1352 reg_passthru!(vm, BUILTIN_HISTORY, "history");
1353 reg_passthru!(vm, BUILTIN_R, "r");
1354
1355 // Aliases
1356 reg_passthru!(vm, BUILTIN_ALIAS, "alias");
1357
1358 // Options. `setopt` (BIN_SETOPT=0) / `unsetopt` (BIN_UNSETOPT=1)
1359 // share bin_setopt (options.c:580) — funcid bit discriminates
1360 // the polarity via BUILTINS table entries.
1361 reg_passthru!(vm, BUILTIN_SET, "set");
1362 reg_passthru!(vm, BUILTIN_SETOPT, "setopt");
1363 reg_passthru!(vm, BUILTIN_UNSETOPT, "unsetopt");
1364
1365 vm.register_builtin(BUILTIN_SHOPT, |vm, argc| {
1366 let args = pop_args(vm, argc);
1367 let status = crate::extensions::ext_builtins::shopt(&args);
1368 Value::Status(status)
1369 });
1370
1371 reg_passthru!(vm, BUILTIN_EMULATE, "emulate");
1372 reg_passthru!(vm, BUILTIN_GETOPTS, "getopts");
1373 reg_passthru!(vm, BUILTIN_AUTOLOAD, "autoload");
1374 reg_passthru!(vm, BUILTIN_FUNCTIONS, "functions");
1375 reg_passthru!(vm, BUILTIN_TRAP, "trap");
1376 reg_passthru!(vm, BUILTIN_DIRS, "dirs");
1377 // pushd / popd dispatch through canonical bin_cd via execbuiltin
1378 // — the BUILTINS table at src/ported/builtin.rs:9298 wires
1379 // `pushd` to bin_cd with funcid=BIN_PUSHD, and `popd` similarly
1380 // with BIN_POPD. Without these reg_passthru lines the fusevm
1381 // BUILTIN_PUSHD/POPD opcodes had no handler installed, so the
1382 // emitted CallBuiltin(110, …) silently returned a no-op and the
1383 // dirstack/$dirstack/pwd all stayed unchanged.
1384 reg_passthru!(vm, BUILTIN_PUSHD, "pushd");
1385 reg_passthru!(vm, BUILTIN_POPD, "popd");
1386 // type / whence / where / which all route through `bin_whence`
1387 // (canonical port at `src/ported/builtin.rs:3734` of
1388 // `Src/builtin.c:3975`). Each gets its own opcode so funcid +
1389 // defopts come from the BUILTINS table entry — execbuiltin
1390 // applies them correctly via the module-level dispatch_builtin.
1391 reg_passthru!(vm, BUILTIN_WHENCE, "whence");
1392 reg_passthru!(vm, BUILTIN_TYPE, "type");
1393 reg_passthru!(vm, BUILTIN_WHICH, "which");
1394 reg_passthru!(vm, BUILTIN_WHERE, "where");
1395 reg_passthru!(vm, BUILTIN_HASH, "hash");
1396 reg_passthru!(vm, BUILTIN_REHASH, "rehash");
1397
1398 // `unhash`/`unalias`/`unfunction` share `bin_unhash` (Src/builtin.c
1399 // c:4350) but each carries its own funcid (BIN_UNHASH /
1400 // BIN_UNALIAS / BIN_UNFUNCTION) in the BUILTINS table.
1401 reg_passthru!(vm, BUILTIN_UNHASH, "unhash");
1402 vm.register_builtin(BUILTIN_UNALIAS, |vm, argc| {
1403 let args = pop_args(vm, argc);
1404 Value::Status(dispatch_builtin("unalias", args))
1405 });
1406 vm.register_builtin(BUILTIN_UNFUNCTION, |vm, argc| {
1407 let args = pop_args(vm, argc);
1408 Value::Status(dispatch_builtin("unfunction", args))
1409 });
1410
1411 // Completion
1412 vm.register_builtin(BUILTIN_COMPGEN, |vm, argc| {
1413 let args = pop_args(vm, argc);
1414 // c:Bug #475/#555 — `compgen` is a bash-only builtin. In
1415 // `--zsh` mode emit "command not found" matching zsh's
1416 // external-command lookup miss.
1417 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1418 eprintln!("zsh:1: command not found: compgen");
1419 let _ = args;
1420 return Value::Status(127);
1421 }
1422 let status = with_executor(|exec| exec.builtin_compgen(&args));
1423 Value::Status(status)
1424 });
1425
1426 vm.register_builtin(BUILTIN_COMPLETE, |vm, argc| {
1427 let args = pop_args(vm, argc);
1428 // c:Bug #475 — `complete` is a bash-only builtin. Same gate
1429 // as BUILTIN_COMPGEN above.
1430 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1431 eprintln!("zsh:1: command not found: complete");
1432 let _ = args;
1433 return Value::Status(127);
1434 }
1435 let status = with_executor(|exec| exec.builtin_complete(&args));
1436 Value::Status(status)
1437 });
1438
1439 reg_passthru!(vm, BUILTIN_COMPADD, "compadd");
1440 reg_passthru!(vm, BUILTIN_COMPSET, "compset");
1441
1442 vm.register_builtin(BUILTIN_COMPDEF, |vm, argc| {
1443 let args = pop_args(vm, argc);
1444 Value::Status(with_executor(|exec| exec.builtin_compdef(&args)))
1445 });
1446
1447 vm.register_builtin(BUILTIN_COMPINIT, |vm, argc| {
1448 let args = pop_args(vm, argc);
1449 Value::Status(with_executor(|exec| exec.builtin_compinit(&args)))
1450 });
1451
1452 vm.register_builtin(BUILTIN_CDREPLAY, |vm, argc| {
1453 let args = pop_args(vm, argc);
1454 Value::Status(with_executor(|exec| exec.builtin_cdreplay(&args)))
1455 });
1456
1457 // Zsh-specific
1458 reg_passthru!(vm, BUILTIN_ZSTYLE, "zstyle");
1459 reg_passthru!(vm, BUILTIN_ZMODLOAD, "zmodload");
1460 reg_passthru!(vm, BUILTIN_BINDKEY, "bindkey");
1461 reg_passthru!(vm, BUILTIN_ZLE, "zle");
1462 reg_passthru!(vm, BUILTIN_VARED, "vared");
1463 reg_passthru!(vm, BUILTIN_ZCOMPILE, "zcompile");
1464 reg_passthru!(vm, BUILTIN_ZFORMAT, "zformat");
1465 reg_passthru!(vm, BUILTIN_ZPARSEOPTS, "zparseopts");
1466 reg_passthru!(vm, BUILTIN_ZREGEXPARSE, "zregexparse");
1467
1468 // Resource limits
1469 reg_passthru!(vm, BUILTIN_ULIMIT, "ulimit");
1470 reg_passthru!(vm, BUILTIN_LIMIT, "limit");
1471 reg_passthru!(vm, BUILTIN_UNLIMIT, "unlimit");
1472 reg_passthru!(vm, BUILTIN_UMASK, "umask");
1473
1474 // Misc
1475 reg_passthru!(vm, BUILTIN_TIMES, "times");
1476
1477 vm.register_builtin(BUILTIN_CALLER, |vm, argc| {
1478 let args = pop_args(vm, argc);
1479 // c:Bug #475 — `caller` is a bash-only builtin. In `--zsh`
1480 // mode emit the canonical "command not found" diagnostic
1481 // and rc=127 matching zsh's external-command-lookup miss.
1482 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1483 eprintln!("zsh:1: command not found: caller");
1484 let _ = args;
1485 return Value::Status(127);
1486 }
1487 Value::Status(with_executor(|exec| exec.builtin_caller(&args)))
1488 });
1489
1490 vm.register_builtin(BUILTIN_HELP, |vm, argc| {
1491 let args = pop_args(vm, argc);
1492 // c:Bug #475 — `help` is a bash-only builtin. Same gate as
1493 // BUILTIN_CALLER above.
1494 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1495 eprintln!("zsh:1: command not found: help");
1496 let _ = args;
1497 return Value::Status(127);
1498 }
1499 Value::Status(with_executor(|exec| exec.builtin_help(&args)))
1500 });
1501
1502 reg_passthru!(vm, BUILTIN_ENABLE, "enable");
1503 reg_passthru!(vm, BUILTIN_DISABLE, "disable");
1504 reg_passthru!(vm, BUILTIN_TTYCTL, "ttyctl");
1505 reg_passthru!(vm, BUILTIN_SYNC, "sync");
1506 reg_passthru!(vm, BUILTIN_MKDIR, "mkdir");
1507 reg_passthru!(vm, BUILTIN_STRFTIME, "strftime");
1508
1509 vm.register_builtin(BUILTIN_ZSLEEP, |vm, argc| {
1510 Value::Status(crate::extensions::ext_builtins::zsleep(&pop_args(vm, argc)))
1511 });
1512
1513 reg_passthru!(vm, BUILTIN_ZSYSTEM, "zsystem");
1514
1515 // PCRE
1516 reg_passthru!(vm, BUILTIN_PCRE_COMPILE, "pcre_compile");
1517 reg_passthru!(vm, BUILTIN_PCRE_MATCH, "pcre_match");
1518 reg_passthru!(vm, BUILTIN_PCRE_STUDY, "pcre_study");
1519
1520 // Database (GDBM)
1521 reg_passthru!(vm, BUILTIN_ZTIE, "ztie");
1522 reg_passthru!(vm, BUILTIN_ZUNTIE, "zuntie");
1523 reg_passthru!(vm, BUILTIN_ZGDBMPATH, "zgdbmpath");
1524
1525 // Prompt
1526 vm.register_builtin(BUILTIN_PROMPTINIT, |vm, argc| {
1527 let args = pop_args(vm, argc);
1528 Value::Status(crate::extensions::ext_builtins::promptinit(&args))
1529 });
1530
1531 vm.register_builtin(BUILTIN_PROMPT, |vm, argc| {
1532 let args = pop_args(vm, argc);
1533 Value::Status(crate::extensions::ext_builtins::prompt(&args))
1534 });
1535
1536 // Async / Parallel (zshrs extensions)
1537 vm.register_builtin(BUILTIN_ASYNC, |vm, argc| {
1538 let args = pop_args(vm, argc);
1539 let status = with_executor(|exec| exec.builtin_async(&args));
1540 Value::Status(status)
1541 });
1542
1543 vm.register_builtin(BUILTIN_AWAIT, |vm, argc| {
1544 let args = pop_args(vm, argc);
1545 let status = with_executor(|exec| exec.builtin_await(&args));
1546 Value::Status(status)
1547 });
1548
1549 vm.register_builtin(BUILTIN_PMAP, |vm, argc| {
1550 let args = pop_args(vm, argc);
1551 let status = with_executor(|exec| exec.builtin_pmap(&args));
1552 Value::Status(status)
1553 });
1554
1555 vm.register_builtin(BUILTIN_PGREP, |vm, argc| {
1556 let args = pop_args(vm, argc);
1557 let status = with_executor(|exec| exec.builtin_pgrep(&args));
1558 Value::Status(status)
1559 });
1560
1561 vm.register_builtin(BUILTIN_PEACH, |vm, argc| {
1562 let args = pop_args(vm, argc);
1563 let status = with_executor(|exec| exec.builtin_peach(&args));
1564 Value::Status(status)
1565 });
1566
1567 vm.register_builtin(BUILTIN_BARRIER, |vm, argc| {
1568 let args = pop_args(vm, argc);
1569 let status = with_executor(|exec| exec.builtin_barrier(&args));
1570 Value::Status(status)
1571 });
1572
1573 // Intercept (AOP)
1574 vm.register_builtin(BUILTIN_INTERCEPT, |vm, argc| {
1575 let args = pop_args(vm, argc);
1576 let status = with_executor(|exec| exec.builtin_intercept(&args));
1577 Value::Status(status)
1578 });
1579
1580 vm.register_builtin(BUILTIN_INTERCEPT_PROCEED, |vm, argc| {
1581 let args = pop_args(vm, argc);
1582 let status = with_executor(|exec| exec.builtin_intercept_proceed(&args));
1583 Value::Status(status)
1584 });
1585
1586 // Debug / Profile
1587 vm.register_builtin(BUILTIN_DOCTOR, |vm, argc| {
1588 let args = pop_args(vm, argc);
1589 let status = with_executor(|exec| exec.builtin_doctor(&args));
1590 Value::Status(status)
1591 });
1592
1593 vm.register_builtin(BUILTIN_DBVIEW, |vm, argc| {
1594 let args = pop_args(vm, argc);
1595 let status = with_executor(|exec| exec.builtin_dbview(&args));
1596 Value::Status(status)
1597 });
1598
1599 vm.register_builtin(BUILTIN_PROFILE, |vm, argc| {
1600 let args = pop_args(vm, argc);
1601 let status = with_executor(|exec| exec.builtin_profile(&args));
1602 Value::Status(status)
1603 });
1604
1605 reg_passthru!(vm, BUILTIN_ZPROF, "zprof");
1606
1607 // ═══════════════════════════════════════════════════════════════════════
1608 // Coreutils builtins (anti-fork, gated by !posix_mode)
1609 //
1610 // All of these are routinely wrapped by user functions in real
1611 // dotfiles (zpwr, oh-my-zsh, etc.) — `cat() { ... }`, `ls() { ... }`,
1612 // `find() { ... }`. Each handler MUST consult try_user_fn_override
1613 // first (via reg_overridable!) so the user definition wins, matching
1614 // zsh's alias → function → builtin dispatch order.
1615 // ═══════════════════════════════════════════════════════════════════════
1616
1617 reg_overridable!(vm, BUILTIN_CAT, "cat", builtin_cat);
1618 reg_overridable!(vm, BUILTIN_HEAD, "head", builtin_head);
1619 reg_overridable!(vm, BUILTIN_TAIL, "tail", builtin_tail);
1620 reg_overridable!(vm, BUILTIN_WC, "wc", builtin_wc);
1621 reg_overridable!(vm, BUILTIN_BASENAME, "basename", builtin_basename);
1622 reg_overridable!(vm, BUILTIN_DIRNAME, "dirname", builtin_dirname);
1623 reg_overridable!(vm, BUILTIN_TOUCH, "touch", builtin_touch);
1624 reg_overridable!(vm, BUILTIN_REALPATH, "realpath", builtin_realpath);
1625 reg_overridable!(vm, BUILTIN_SORT, "sort", builtin_sort);
1626 reg_overridable!(vm, BUILTIN_FIND, "find", builtin_find);
1627 reg_overridable!(vm, BUILTIN_UNIQ, "uniq", builtin_uniq);
1628 reg_overridable!(vm, BUILTIN_CUT, "cut", builtin_cut);
1629 reg_overridable!(vm, BUILTIN_TR, "tr", builtin_tr);
1630 reg_overridable!(vm, BUILTIN_SEQ, "seq", builtin_seq);
1631 reg_overridable!(vm, BUILTIN_REV, "rev", builtin_rev);
1632 reg_overridable!(vm, BUILTIN_TEE, "tee", builtin_tee);
1633 reg_overridable!(vm, BUILTIN_SLEEP, "sleep", builtin_sleep);
1634 reg_overridable!(vm, BUILTIN_WHOAMI, "whoami", builtin_whoami);
1635 reg_overridable!(vm, BUILTIN_ID, "id", builtin_id);
1636
1637 reg_overridable!(vm, BUILTIN_HOSTNAME, "hostname", builtin_hostname);
1638 reg_overridable!(vm, BUILTIN_UNAME, "uname", builtin_uname);
1639 reg_overridable!(vm, BUILTIN_DATE, "date", builtin_date);
1640 reg_overridable!(vm, BUILTIN_MKTEMP, "mktemp", builtin_mktemp);
1641 // `cp` — zshrs extension (NOT in upstream zsh; upstream's
1642 // zsh/files module ships `ln`/`mv`/`rm`/`chmod`/`chown` but no
1643 // `cp`). In-process implementation in
1644 // `ext_builtins::cp_impl` — recursive copy with -r/-R, -f, -i,
1645 // -n, -p (chown + utimensat), -v. ID 263 is the first slot
1646 // past fusevm's built-in range (260-262) and before BUILTIN_MAX
1647 // (280).
1648 /// `BUILTIN_CP` constant.
1649 pub const BUILTIN_CP: u16 = 263;
1650 reg_overridable!(vm, BUILTIN_CP, "cp", builtin_cp);
1651
1652 // Pipeline execution — bytecode-native fork-per-stage. Pops N sub-chunk
1653 // indices, forks N children with stdin/stdout wired through N-1 pipes,
1654 // each child runs its stage's compiled bytecode and exits. Parent waits
1655 // and returns the last stage's status.
1656 //
1657 // Caveats: post-fork in a multi-threaded program, only async-signal-safe
1658 // ops are POSIX-safe. We violate this (running the bytecode VM after fork
1659 // touches mutexes like REGEX_CACHE). In practice, most pipeline stages
1660 // don't touch shared mutex state — externals fork/exec away, builtins do
1661 // pure I/O. Risks are bounded; if a stage does touch a held mutex, the
1662 // child deadlocks.
1663 vm.register_builtin(BUILTIN_RUN_PIPELINE, |vm, argc| {
1664 let n = argc as usize;
1665 if n == 0 {
1666 return Value::Status(0);
1667 }
1668
1669 // Pop N sub-chunk indices (LIFO → reverse to stage order)
1670 let mut indices: Vec<u16> = Vec::with_capacity(n);
1671 for _ in 0..n {
1672 indices.push(vm.pop().to_int() as u16);
1673 }
1674 indices.reverse();
1675
1676 // Clone each stage's sub-chunk
1677 let stages: Vec<fusevm::Chunk> = indices
1678 .iter()
1679 .filter_map(|&i| vm.chunk.sub_chunks.get(i as usize).cloned())
1680 .collect();
1681 if stages.len() != n {
1682 return Value::Status(1);
1683 }
1684
1685 // Single stage — no pipe, just run inline
1686 if n == 1 {
1687 let stage = stages.into_iter().next().unwrap();
1688 crate::fusevm_disasm::maybe_print_stdout("pipeline:single", &stage);
1689 let mut stage_vm = fusevm::VM::new(stage);
1690 register_builtins(&mut stage_vm);
1691 let _ = stage_vm.run();
1692 return Value::Status(stage_vm.last_status);
1693 }
1694
1695 // Build N-1 pipes
1696 let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(n - 1);
1697 for _ in 0..n - 1 {
1698 let mut fds = [0i32; 2];
1699 if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
1700 // Cleanup any pipes we already created
1701 for (r, w) in &pipes {
1702 unsafe {
1703 libc::close(*r);
1704 libc::close(*w);
1705 }
1706 }
1707 return Value::Status(1);
1708 }
1709 pipes.push((fds[0], fds[1]));
1710 }
1711
1712 // zsh runs the LAST stage of a pipeline in the CURRENT shell
1713 // (not a forked child) so a trailing `read x` keeps its
1714 // assignment in the parent. Other shells (bash) fork every
1715 // stage. Honor zsh by leaving stage N-1 inline. Forks the
1716 // first N-1 stages with fork(); runs the last in this process
1717 // with stdin dup2'd to the last pipe's read end and stdout
1718 // restored after.
1719 let last_idx = n - 1;
1720 let stages_vec: Vec<fusevm::Chunk> = stages.into_iter().collect();
1721
1722 let mut child_pids: Vec<libc::pid_t> = Vec::with_capacity(n - 1);
1723 for (i, chunk) in stages_vec.iter().take(last_idx).enumerate() {
1724 match unsafe { libc::fork() } {
1725 -1 => {
1726 // fork failed — kill any children we already started
1727 for pid in &child_pids {
1728 unsafe { libc::kill(*pid, libc::SIGTERM) };
1729 }
1730 for (r, w) in &pipes {
1731 unsafe {
1732 libc::close(*r);
1733 libc::close(*w);
1734 }
1735 }
1736 return Value::Status(1);
1737 }
1738 0 => {
1739 // Reset SIGPIPE to default so a broken-pipe write
1740 // kills the child cleanly instead of triggering a
1741 // Rust println! panic. The parent shell ignores
1742 // SIGPIPE so it can handle EPIPE itself, but child
1743 // pipeline stages should die quietly when their
1744 // downstream stage closes early (e.g. `seq | head -3`).
1745 unsafe {
1746 libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1747 }
1748 // c:Src/exec.c — pipeline children are forked
1749 // subshells; their EXIT trap context is reset so
1750 // the parent's `trap '...' EXIT` doesn't fire when
1751 // the child exits. Mirror by dropping EXIT from
1752 // the inherited traps_table inside the child.
1753 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
1754 t.remove("EXIT");
1755 }
1756 // Child: wire stdin from previous pipe's read end
1757 if i > 0 {
1758 unsafe {
1759 libc::dup2(pipes[i - 1].0, libc::STDIN_FILENO);
1760 }
1761 }
1762 // Wire stdout to next pipe's write end
1763 unsafe {
1764 libc::dup2(pipes[i].1, libc::STDOUT_FILENO);
1765 }
1766 // Close all original pipe fds (keeping stdin/stdout dups)
1767 for (r, w) in &pipes {
1768 unsafe {
1769 libc::close(*r);
1770 libc::close(*w);
1771 }
1772 }
1773
1774 // Run this stage's bytecode on a fresh VM
1775 crate::fusevm_disasm::maybe_print_stdout(
1776 &format!("pipeline:child:stage:{i}"),
1777 chunk,
1778 );
1779 let mut stage_vm = fusevm::VM::new(chunk.clone());
1780 register_builtins(&mut stage_vm);
1781 let _ = stage_vm.run();
1782 // Flush any buffered output before exiting
1783 let _ = std::io::stdout().flush();
1784 let _ = std::io::stderr().flush();
1785 std::process::exit(stage_vm.last_status);
1786 }
1787 pid => {
1788 child_pids.push(pid);
1789 }
1790 }
1791 }
1792
1793 // Parent runs the LAST stage inline. Save stdin, dup the last
1794 // pipe's read end onto fd 0, run the chunk, restore stdin.
1795 // Close every other pipe fd so the producer side gets EOF
1796 // when the last upstream stage exits.
1797 let saved_stdin = unsafe { libc::dup(libc::STDIN_FILENO) };
1798 if last_idx > 0 {
1799 let read_fd = pipes[last_idx - 1].0;
1800 unsafe {
1801 libc::dup2(read_fd, libc::STDIN_FILENO);
1802 }
1803 }
1804 // Close all pipe fds in the parent now that stdin is wired.
1805 // (Children already have their own copies. The dup2 above
1806 // already gave us a fresh fd 0 if needed.)
1807 for (r, w) in &pipes {
1808 unsafe {
1809 libc::close(*r);
1810 libc::close(*w);
1811 }
1812 }
1813
1814 // Run the last stage's bytecode on a sub-VM with the host
1815 // wired up. The host points back at the executor so reads
1816 // (`read x`) update the parent's variables directly.
1817 let last_stage_status = {
1818 let last_chunk = stages_vec.into_iter().last().unwrap();
1819 crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
1820 let mut stage_vm = fusevm::VM::new(last_chunk);
1821 register_builtins(&mut stage_vm);
1822 stage_vm.set_shell_host(Box::new(ZshrsHost));
1823 let _ = stage_vm.run();
1824 let _ = std::io::stdout().flush();
1825 let _ = std::io::stderr().flush();
1826 stage_vm.last_status
1827 };
1828
1829 // Restore stdin
1830 if saved_stdin >= 0 {
1831 unsafe {
1832 libc::dup2(saved_stdin, libc::STDIN_FILENO);
1833 libc::close(saved_stdin);
1834 }
1835 }
1836
1837 // Wait for all forked stages, capture per-stage statuses for PIPESTATUS.
1838 let mut pipestatus: Vec<i32> = Vec::with_capacity(n);
1839 for pid in child_pids {
1840 let mut status: i32 = 0;
1841 unsafe {
1842 libc::waitpid(pid, &mut status, 0);
1843 }
1844 let s = if libc::WIFEXITED(status) {
1845 libc::WEXITSTATUS(status)
1846 } else if libc::WIFSIGNALED(status) {
1847 128 + libc::WTERMSIG(status)
1848 } else {
1849 1
1850 };
1851 pipestatus.push(s);
1852 }
1853 // Append the in-parent last-stage status so `pipestatus` ends
1854 // with N entries (one per stage).
1855 pipestatus.push(last_stage_status);
1856 // Pipeline exit status: by default, the LAST stage's status.
1857 // With `setopt pipefail` (or `set -o pipefail`), use the
1858 // first non-zero stage status (so failures earlier in the
1859 // pipeline propagate even if the last stage succeeded).
1860 let pipefail_on = with_executor(|exec| opt_state_get("pipefail").unwrap_or(false));
1861 let last_status = if pipefail_on {
1862 pipestatus
1863 .iter()
1864 .copied()
1865 .rfind(|&s| s != 0)
1866 .or_else(|| pipestatus.last().copied())
1867 .unwrap_or(0)
1868 } else {
1869 *pipestatus.last().unwrap_or(&0)
1870 };
1871
1872 // c:Src/params.c:265,438 — only `pipestatus` (lowercase) is the
1873 // zsh special parameter; bash's `PIPESTATUS` doesn't exist in
1874 // zsh's special-params table. Prior port also populated
1875 // `PIPESTATUS` "for portability" — but that's a real divergence
1876 // from zsh: a script doing `[[ -z $PIPESTATUS ]]` to detect
1877 // zsh-vs-bash would mis-classify. Bug #64 in docs/BUGS.md.
1878 with_executor(|exec| {
1879 let strs: Vec<String> = pipestatus.iter().map(|s| s.to_string()).collect();
1880 exec.set_array("pipestatus".to_string(), strs);
1881 });
1882
1883 Value::Status(last_status)
1884 });
1885
1886 // Array→String join. Pops one value; if it's an Array (e.g. from Op::Glob),
1887 // joins string-coerced elements with a single space. Pass-through for
1888 // non-arrays so the op is safe to chain after any String-or-Array producer.
1889 vm.register_builtin(BUILTIN_ARRAY_JOIN, |vm, _argc| {
1890 let val = vm.pop();
1891 match val {
1892 Value::Array(items) => {
1893 let parts: Vec<String> = items.iter().map(|v| v.to_str()).collect();
1894 Value::str(parts.join(" "))
1895 }
1896 other => other,
1897 }
1898 });
1899
1900 // `cmd &` background execution. Compile_list emits this for any item
1901 // followed by ListOp::Amp: the cmd is compiled into a sub-chunk, its index
1902 // pushed, then this builtin pops the index, looks up the chunk, forks. The
1903 // child detaches via setsid (so SIGINT to the foreground job doesn't kill
1904 // it), runs the bytecode on a fresh VM with builtins re-registered, exits
1905 // with the last status. The parent returns Status(0) immediately. Job
1906 // tracking via JobTable is deferred to Phase G6 — JobTable::add_job
1907 // currently requires a std::process::Child, which a libc::fork doesn't
1908 // produce. Until then, `jobs`/`fg`/`wait` can't see these pids.
1909 //WARNING FAKE AND MUST BE DELETED
1910 vm.register_builtin(BUILTIN_RUN_BG, |vm, _argc| {
1911 let sub_idx = vm.pop().to_int() as usize;
1912 let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
1913 Some(c) => c,
1914 None => return Value::Status(1),
1915 };
1916
1917 match unsafe { libc::fork() } {
1918 -1 => Value::Status(1),
1919 0 => {
1920 // Child: detach and run.
1921 unsafe { libc::setsid() };
1922 crate::fusevm_disasm::maybe_print_stdout("background_job", &chunk);
1923 let mut bg_vm = fusevm::VM::new(chunk);
1924 register_builtins(&mut bg_vm);
1925 let _ = bg_vm.run();
1926 let _ = std::io::stdout().flush();
1927 let _ = std::io::stderr().flush();
1928 std::process::exit(bg_vm.last_status);
1929 }
1930 pid => {
1931 // Parent: record the PID into `$!` (most recent
1932 // backgrounded job's pid). zsh exposes this for any
1933 // script that needs `wait $!`. Also register the
1934 // bare-pid job so a no-args `wait` can synchronize.
1935 // c:Src/jobs.c:73 — `lastpid = pid;` after a
1936 // background fork. zshrs's `$!` getter
1937 // (params.rs::lookup_special_var "!") reads from
1938 // the same atomic, so a single store here is the
1939 // canonical writer.
1940 crate::ported::modules::clone::lastpid
1941 .store(pid, std::sync::atomic::Ordering::Relaxed);
1942 with_executor(|exec| {
1943 exec.jobs.add_pid_job(pid, String::new(), JobState::Running);
1944 });
1945 Value::Status(0)
1946 }
1947 }
1948 });
1949
1950 // ── Indexed-array storage ─────────────────────────────────────────────
1951 //
1952 // Stack: pushed values then name (LAST). `arr=(a b c)` → 4 args
1953 // (a, b, c, arr). `arr=($(cmd))` → 2 args (FlatArray, arr).
1954 //
1955 // PURE PASSTHRU: pop name + values, dispatch to canonical
1956 // `setaparam` / `sethparam` (C port of `Src/params.c:3595/3602`).
1957 // assignaparam already handles PM_UNIQUE dedupe, type-flag flip,
1958 // PM_NAMEREF rejection, ASSPM_AUGMENT prepend, and createparam
1959 // for fresh names.
1960 vm.register_builtin(BUILTIN_SET_ARRAY, |vm, argc| {
1961 let n = argc as usize;
1962 let mut popped: Vec<Value> = Vec::with_capacity(n);
1963 for _ in 0..n {
1964 popped.push(vm.pop());
1965 }
1966 popped.reverse();
1967 if popped.is_empty() {
1968 return Value::Status(1);
1969 }
1970 let name = popped.pop().unwrap().to_str();
1971 let mut values: Vec<String> = Vec::new();
1972 for v in popped {
1973 match v {
1974 Value::Array(items) => {
1975 for it in items {
1976 values.push(it.to_str());
1977 }
1978 }
1979 other => values.push(other.to_str()),
1980 }
1981 }
1982 let blocked = with_executor(|exec| {
1983 // Assoc init `typeset -A m; m=(k v k v ...)` — route to
1984 // canonical sethparam (Src/params.c:3602) which parses the
1985 // flat (k,v) pair list internally.
1986 if exec.assoc(&name).is_some() {
1987 if !values.len().is_multiple_of(2) {
1988 eprintln!(
1989 "{}:1: bad set of key/value pairs for associative array",
1990 shname()
1991 );
1992 return true;
1993 }
1994 let _ = crate::ported::params::sethparam(&name, values.clone());
1995 #[cfg(feature = "recorder")]
1996 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
1997 let ctx = exec.recorder_ctx();
1998 let attrs = exec.recorder_attrs_for(&name);
1999 let mut pairs: Vec<(String, String)> = Vec::with_capacity(values.len() / 2);
2000 let mut iter = values.iter().cloned();
2001 while let Some(k) = iter.next() {
2002 if let Some(v) = iter.next() {
2003 pairs.push((k, v));
2004 }
2005 }
2006 crate::recorder::emit_assoc_assign(&name, pairs, attrs, false, ctx);
2007 }
2008 return false;
2009 }
2010 // Indexed-array: setaparam (Src/params.c:3766) wraps
2011 // assignaparam with ASSPM_WARN — handles PM_UNIQUE dedupe,
2012 // type-flag flip, PM_READONLY rejection.
2013 //
2014 // The tied-array mirror to a PM_TIED scalar
2015 // (`typeset -T PATH path`) lives canonically in
2016 // setarrvalue's dispatch in C zsh; until that wires
2017 // through assignaparam, mirror here so PATH stays in sync
2018 // after `path=(/x)`.
2019 if let Some((scalar_name, sep)) = exec.tied_array_to_scalar.get(&name).cloned() {
2020 let joined = values.join(&sep);
2021 exec.set_scalar(scalar_name, joined);
2022 }
2023 let _ = crate::ported::params::setaparam(&name, values.clone());
2024 #[cfg(feature = "recorder")]
2025 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
2026 let ctx = exec.recorder_ctx();
2027 let attrs = exec.recorder_attrs_for(&name);
2028 emit_path_or_assign(&name, &values, attrs, false, &ctx);
2029 }
2030 false
2031 });
2032 Value::Status(if blocked { 1 } else { 0 })
2033 });
2034 // `arr+=(d e f)` — array append. Same calling conventions as SET_ARRAY.
2035 //
2036 // PURE PASSTHRU shape: pop name + values, dispatch through the
2037 // canonical assoc / array setter. assignaparam's ASSPM_AUGMENT
2038 // flag handles the C-source-equivalent "preserve prior value"
2039 // semantics; for now we read the current array, extend with
2040 // new values, write through set_array (which routes to
2041 // setaparam → assignaparam where PM_UNIQUE dedupe lands).
2042 vm.register_builtin(BUILTIN_APPEND_ARRAY, |vm, argc| {
2043 let n = argc as usize;
2044 let mut popped: Vec<Value> = Vec::with_capacity(n);
2045 for _ in 0..n {
2046 popped.push(vm.pop());
2047 }
2048 popped.reverse();
2049 if popped.is_empty() {
2050 return Value::Status(1);
2051 }
2052 let name = popped.pop().unwrap().to_str();
2053 let mut values: Vec<String> = Vec::new();
2054 for v in popped {
2055 match v {
2056 Value::Array(items) => {
2057 for it in items {
2058 values.push(it.to_str());
2059 }
2060 }
2061 other => values.push(other.to_str()),
2062 }
2063 }
2064 with_executor(|exec| {
2065 // Assoc append `m+=(k1 v1 ...)`: merge the (k,v) pairs into
2066 // the existing map and write back via canonical sethparam
2067 // (Src/params.c:3602). The canonical C path would go
2068 // assignaparam(ASSPM_AUGMENT) → arrhashsetfn(ASSPM_AUGMENT)
2069 // at Src/params.c:3850, but the zshrs port of
2070 // arrhashsetfn doesn't yet implement value-storage
2071 // (pending Param.u_hash backend wireup) — until that
2072 // lands, do the augment + write here so the storage
2073 // actually mutates.
2074 if exec.assoc(&name).is_some() {
2075 let mut map = exec.assoc(&name).unwrap_or_default();
2076 let mut it = values.into_iter();
2077 while let Some(k) = it.next() {
2078 if let Some(v) = it.next() {
2079 map.insert(k, v);
2080 }
2081 }
2082 exec.set_assoc(name, map);
2083 return;
2084 }
2085 // Indexed-array append `arr+=(d e f)` — route directly
2086 // through canonical assignaparam with ASSPM_AUGMENT
2087 // (`Src/params.c:3570-3585` append-on-array branch).
2088 // assignaparam reads the prior array internally and
2089 // appends the new values, so the bridge no longer needs
2090 // to pre-concat manually.
2091 let _ = crate::ported::params::assignaparam(
2092 &name,
2093 values.clone(),
2094 crate::ported::zsh_h::ASSPM_AUGMENT,
2095 );
2096 #[cfg(feature = "recorder")]
2097 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
2098 let ctx = exec.recorder_ctx();
2099 let attrs = exec.recorder_attrs_for(&name);
2100 emit_path_or_assign(&name, &values, attrs, true, &ctx);
2101 }
2102 // Tied-scalar mirror — TODO faithful: should live in
2103 // setarrvalue's gsu dispatch once boot_ paramtab wiring
2104 // lands (Task #16). Re-read the canonical post-augment
2105 // array so the joined scalar matches.
2106 let tied_scalar = exec.tied_array_to_scalar.get(&name).cloned();
2107 if let Some((scalar_name, sep)) = tied_scalar {
2108 let merged = exec.array(&name).unwrap_or_default();
2109 let joined = merged.join(&sep);
2110 exec.set_scalar(scalar_name.clone(), joined.clone());
2111 env::set_var(&scalar_name, &joined);
2112 }
2113 });
2114 Value::Status(0)
2115 });
2116 vm.register_builtin(BUILTIN_RUN_SELECT, |vm, argc| {
2117 if argc < 2 {
2118 return Value::Status(1);
2119 }
2120 let n = argc as usize;
2121 let mut popped: Vec<Value> = Vec::with_capacity(n);
2122 for _ in 0..n {
2123 popped.push(vm.pop());
2124 }
2125 // popped: [sub_idx, name, word_N, ..., word_1] (popping from top)
2126 let sub_idx_val = popped.remove(0);
2127 let name_val = popped.remove(0);
2128 // c:Src/loop.c — `select` flattens Array values (from `$@`,
2129 // `${arr[@]}`, etc.) into the menu. Without per-element
2130 // splice, `select x do ... done` (bare, iterating $@)
2131 // collapsed all positionals into one joined entry.
2132 let mut words: Vec<String> = Vec::new();
2133 for v in popped.into_iter().rev() {
2134 match v {
2135 Value::Array(items) => {
2136 for item in items {
2137 words.push(item.to_str());
2138 }
2139 }
2140 other => words.push(other.to_str()),
2141 }
2142 }
2143
2144 let sub_idx = sub_idx_val.to_int() as usize;
2145 let name = name_val.to_str();
2146
2147 // c:Src/loop.c:248-252 — `if (!args || empty(args)) {
2148 // state->pc = end; ... return 0; }`. An empty option list
2149 // skips the body entirely; without this gate the prompt loop
2150 // runs indefinitely (or twice on the EOF stdin case before
2151 // exiting). Bug #401.
2152 if words.is_empty() {
2153 return Value::Status(0);
2154 }
2155
2156 let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
2157 Some(c) => c,
2158 None => return Value::Status(1),
2159 };
2160
2161 let prompt =
2162 with_executor(|exec| exec.scalar("PROMPT3").unwrap_or_else(|| "?# ".to_string()));
2163
2164 let stdin = std::io::stdin();
2165 let mut reader = stdin.lock();
2166 let mut last_status: i32 = 0;
2167
2168 loop {
2169 // Direct port of zsh's selectlist from
2170 // src/zsh/Src/loop.c:347-409. Layout is column-major
2171 // ("down columns, then across") — NOT row-major. With
2172 // 6 items in 3 cols zsh produces:
2173 // 1 3 5
2174 // 2 4 6
2175 // The previous Rust impl walked row-major which
2176 // produced 1 2 3 / 4 5 6 (visually similar but wrong
2177 // for prompts that mention ordering and breaks scripts
2178 // that rely on column count == ceil(N/rows)).
2179 //
2180 // C variable mapping:
2181 // ct -> word count (n)
2182 // longest -> max item width + 1, then plus digits-of-ct
2183 // fct -> column count
2184 // fw -> per-column width
2185 // colsz -> row count = ceil(ct / fct)
2186 // t1 -> row index, walks 0..colsz
2187 // ap -> item pointer; advances by colsz to step
2188 // DOWN a column.
2189 let term_width: usize = env::var("COLUMNS")
2190 .ok()
2191 .and_then(|v| v.parse().ok())
2192 .unwrap_or(80);
2193 let ct = words.len();
2194 // loop.c:354-363 — find longest item width.
2195 let mut longest = 1usize;
2196 for w in &words {
2197 let aplen = w.chars().count();
2198 if aplen > longest {
2199 longest = aplen;
2200 }
2201 }
2202 // loop.c:365-367 — `longest++` then add digits of `ct`.
2203 longest += 1;
2204 let mut t0 = ct;
2205 while t0 > 0 {
2206 t0 /= 10;
2207 longest += 1;
2208 }
2209 // loop.c:369-373 — fct = (cols - 1) / (longest + 3); if
2210 // 0, fct = 1; else fw = (cols - 1) / fct.
2211 let raw_fct = (term_width.saturating_sub(1)) / (longest + 3);
2212 let (fct, fw) = if raw_fct == 0 {
2213 (1, longest + 3)
2214 } else {
2215 (raw_fct, (term_width.saturating_sub(1)) / raw_fct)
2216 };
2217 // loop.c:374 — colsz = (ct + fct - 1) / fct.
2218 let colsz = ct.div_ceil(fct);
2219 // loop.c:375-395 — for each row t1, walk down columns.
2220 for t1 in 0..colsz {
2221 let mut ap_idx = t1;
2222 while ap_idx < ct {
2223 let w = &words[ap_idx];
2224 let n = ap_idx + 1;
2225 let _ = write!(std::io::stderr(), "{}) {}", n, w);
2226 let mut t2 = w.chars().count() + 2;
2227 let mut t3 = n;
2228 while t3 > 0 {
2229 t2 += 1;
2230 t3 /= 10;
2231 }
2232 // Pad to fw (loop.c:389-390).
2233 while t2 < fw {
2234 let _ = write!(std::io::stderr(), " ");
2235 t2 += 1;
2236 }
2237 ap_idx += colsz;
2238 }
2239 let _ = writeln!(std::io::stderr());
2240 }
2241 let _ = write!(std::io::stderr(), "{}", prompt);
2242 let _ = std::io::stderr().flush();
2243
2244 let mut line = String::new();
2245 match reader.read_line(&mut line) {
2246 Ok(0) => {
2247 // EOF — emit the final newline that zsh prints
2248 // after the prompt-then-EOF sequence (c:Src/loop.c
2249 // selectlist falls through to fputc('\n', stderr)
2250 // at the end of the read failure path). Without
2251 // this the next process's output runs directly
2252 // after `-->>>> ` on the same line.
2253 let _ = writeln!(std::io::stderr());
2254 let _ = std::io::stderr().flush();
2255 break;
2256 }
2257 Ok(_) => {}
2258 Err(_) => break,
2259 }
2260 let trimmed = line.trim_end_matches(['\n', '\r'][..].as_ref()).to_string();
2261
2262 with_executor(|exec| {
2263 exec.set_scalar("REPLY".to_string(), trimmed.clone());
2264 });
2265
2266 if trimmed.is_empty() {
2267 // Empty input → redraw menu without running body.
2268 continue;
2269 }
2270
2271 let chosen = match trimmed.parse::<usize>() {
2272 Ok(n) if n >= 1 && n <= words.len() => words[n - 1].clone(),
2273 _ => String::new(),
2274 };
2275
2276 with_executor(|exec| {
2277 exec.set_scalar(name.clone(), chosen);
2278 });
2279
2280 // Reset canonical BREAKS/CONTFLAG before running the body
2281 // so a stale value from a sibling construct doesn't leak in.
2282 crate::ported::builtin::BREAKS.store(0, SeqCst);
2283 crate::ported::builtin::CONTFLAG.store(0, SeqCst);
2284
2285 // c:Src/loop.c — `select` increments LOOPS for the body so
2286 // `break` / `continue` inside the body see loops > 0 and
2287 // don't emit `not in while, until, select, or repeat loop`.
2288 // Mirrors execwhile/execrepeat's `LOOPS.fetch_add` pattern.
2289 // The decrement happens after the body call so a body that
2290 // explicitly returns / errors still leaves the counter
2291 // balanced for the next iteration.
2292 crate::ported::builtin::LOOPS.fetch_add(1, SeqCst);
2293
2294 crate::fusevm_disasm::maybe_print_stdout("select:body", &chunk);
2295 let mut body_vm = fusevm::VM::new(chunk.clone());
2296 register_builtins(&mut body_vm);
2297 let _ = body_vm.run();
2298 last_status = body_vm.last_status;
2299
2300 crate::ported::builtin::LOOPS.fetch_sub(1, SeqCst);
2301
2302 // Drain the canonical BREAKS/CONTFLAG counters. Mirrors
2303 // loop.c:529-534's `if (breaks) { breaks--; if (breaks ||
2304 // !contflag) break; contflag = 0; }` drain pattern.
2305 // The legacy `BREAK_SELECT=1` env-var sentinel is still
2306 // honored for backward compat.
2307 let break_legacy = with_executor(|exec| {
2308 let v = exec.scalar("BREAK_SELECT");
2309 exec.unset_scalar("BREAK_SELECT");
2310 v.map(|s| s != "0" && !s.is_empty()).unwrap_or(false)
2311 });
2312 use std::sync::atomic::Ordering::SeqCst;
2313 let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
2314 if breaks > 0 {
2315 let cont = crate::ported::builtin::CONTFLAG.load(SeqCst);
2316 crate::ported::builtin::BREAKS.fetch_sub(1, SeqCst);
2317 if breaks - 1 > 0 || cont == 0 {
2318 break;
2319 }
2320 crate::ported::builtin::CONTFLAG.store(0, SeqCst);
2321 continue;
2322 }
2323 if break_legacy {
2324 break;
2325 }
2326 }
2327
2328 Value::Status(last_status)
2329 });
2330
2331 // Magic special-parameter assoc lookup. Synthesizes values from
2332 // shell state for zsh's shell-introspection assocs:
2333 // commands, aliases, galiases, saliases, dis_aliases, dis_galiases,
2334 // dis_saliases, functions, dis_functions, builtins, dis_builtins,
2335 // reswords, options, parameters, jobtexts, jobdirs, jobstates,
2336 // nameddirs, userdirs, modules.
2337 // Returns None if `name` isn't a recognized magic name.
2338
2339 // `${arr[idx]}` — pop name, then idx_str. zsh is 1-based for positive
2340 // indices; we honor that. `@`/`*` return the whole array as Value::Array
2341 // so Op::Exec splice produces N argv slots. For `${foo[key]}` where foo
2342 // is an assoc, the idx is a string key — we check assoc_arrays first
2343 // when the idx isn't `@`/`*` and the name has an assoc binding.
2344 // BUILTIN_ARRAY_INDEX — `${name[idx]}` paramsubst dispatch.
2345 // PURE PASSTHRU: pops the idx + name, hands the canonical
2346 // `${name[idx]}` form to `subst::paramsubst` (C port of
2347 // `Src/subst.c::paramsubst`). All subscript-flag dispatch
2348 // ((I)pat / (R)pat / (i)/(r)/(K)/(k), range slices `[N,M]`,
2349 // negative indices, magic-assoc shape lookup, DQ-join collapse)
2350 // lives inside paramsubst → fetchvalue → getarg in params.rs.
2351 //
2352 // Outer-flag dispatch (`(@)` / `(@k)` / `(v)NAME[(I)pat]` / etc.)
2353 // routes through BUILTIN_BRIDGE_BRACE_ARRAY at the compile path
2354 // (canonical paramsubst flag parser owns dispatch at Src/subst.c:2147+),
2355 // so BUILTIN_ARRAY_INDEX receives clean name+key with no sentinel
2356 // prefixes.
2357 vm.register_builtin(BUILTIN_ARRAY_INDEX, |vm, _argc| {
2358 let idx = vm.pop().to_str();
2359 let name = vm.pop().to_str();
2360 // c:Src/subst.c subscript parsing — when paramsubst re-parses
2361 // the synthesized `${name[idx]}` body, characters like `'`
2362 // `"` `\` `$` etc. are LEXER-active inside the `[…]` and get
2363 // reinterpreted (quote-strip, paramsubst recursion, …). For
2364 // PRE-EVALUATED key strings (the dynamic-key fast path at
2365 // compile_zsh.rs:3234 already expanded `$k` via EXPAND_TEXT),
2366 // the idx is a literal string that must match the stored key
2367 // byte-for-byte — no further reinterpretation. Direct assoc
2368 // lookup bypasses the lexer for this case, avoiding the
2369 // quote-strip bug where `h[a'b]` failed to resolve because
2370 // paramsubst's subscript lexer treated the `'` as a quote.
2371 // Bug #338. Only fires for simple assoc-name + non-flag idx
2372 // (no outer-flag sentinels, no `(…)` flag prefix on idx, no
2373 // splat operator). Other paths (slice, splat, flag-based
2374 // search, magic-assoc) still flow through paramsubst.
2375 let idx_is_simple = !idx.starts_with('(')
2376 && idx != "@"
2377 && idx != "*"
2378 && !idx.contains(',');
2379 if idx_is_simple {
2380 if let Some(v) = with_executor(|exec| {
2381 exec.assoc(&name).and_then(|m| m.get(&idx).cloned())
2382 }) {
2383 return Value::str(v);
2384 }
2385 }
2386 let body = format!("${{{}[{}]}}", name, idx);
2387 paramsubst_to_value(&body)
2388 });
2389 // BUILTIN_ASSOC_HAS_KEY — `${(k)assoc[name]}` key-existence query.
2390 // Pops [assoc_name, key]; returns key (Str) if present in the
2391 // assoc, empty Str otherwise. Mirrors zsh's `${(k)h[name]}`
2392 // documented semantics in zshparam(1) "Parameter Expansion Flags".
2393 // Distinct from BUILTIN_ARRAY_INDEX (which returns the VALUE) and
2394 // from `${+h[name]}` (which returns "0"/"1"). Bug #145.
2395 vm.register_builtin(BUILTIN_ASSOC_HAS_KEY, |vm, _argc| {
2396 let key = vm.pop().to_str();
2397 let name = vm.pop().to_str();
2398 let exists = crate::ported::params::gethkparam(&name)
2399 .map(|keys| keys.iter().any(|k| k == &key))
2400 .unwrap_or(false);
2401 if exists {
2402 Value::str(key)
2403 } else {
2404 Value::str("")
2405 }
2406 });
2407 vm.register_builtin(BUILTIN_BRIDGE_BRACE_ARRAY, |vm, _argc| {
2408 // Inner body of `${(...)...}` (already stripped of `${`/`}` by
2409 // the caller). The compiler optionally prefixes Qstring
2410 // (\u{8c}) to signal "expanded in DQ context" — strip it
2411 // here and bump in_dq_context for the paramsubst call so the
2412 // SUB_ZIP and other qt-aware paths fire.
2413 let body = vm.pop().to_str();
2414 let (dq, inner) = if let Some(rest) = body.strip_prefix('\u{8c}') {
2415 (true, rest.to_string())
2416 } else {
2417 (false, body)
2418 };
2419 if dq {
2420 with_executor(|exec| exec.in_dq_context += 1);
2421 }
2422 let v = paramsubst_to_value(&format!("${{{}}}", inner));
2423 if dq {
2424 with_executor(|exec| exec.in_dq_context -= 1);
2425 }
2426 v
2427 });
2428
2429 // BUILTIN_PARAM_FLAG — `${(flags)name}` paramsubst dispatch.
2430 // PURE PASSTHRU: pops sentinel-tagged flags + name, hands the
2431 // canonical `${(flags)name}` form to `subst::paramsubst` (C port
2432 // of `Src/subst.c::paramsubst`). The bridge does no flag
2433 // walking, no DQ-context branching, no array/scalar shape
2434 // selection — all of that lives inside paramsubst. Compile-time
2435 // context (DQ / scalar-assign-RHS) flows through executor cells
2436 // (in_dq_context, in_scalar_assign) bumped by BUILTIN_EXPAND_TEXT.
2437 vm.register_builtin(BUILTIN_PARAM_FLAG, |vm, _argc| {
2438 let flags = vm.pop().to_str();
2439 let name = vm.pop().to_str();
2440 let body = format!("${{({}){}}}", flags, name);
2441 paramsubst_to_value(&body)
2442 });
2443
2444 // `foo[key]=val` — single-key set on an assoc array. Stack: [name, key, value].
2445 // PURE PASSTHRU: assignsparam with `name[key]` form (C port of
2446 // `Src/params.c::assignsparam` subscript path at c:3210-3231)
2447 // already does the indexed-array vs assoc decision, PM_HASHED
2448 // auto-vivification, numeric-subscript bounds handling, and
2449 // PM_READONLY rejection.
2450 vm.register_builtin(BUILTIN_SET_ASSOC, |vm, _argc| {
2451 let value = vm.pop().to_str();
2452 let key = vm.pop().to_str();
2453 let name = vm.pop().to_str();
2454 with_executor(|exec| {
2455 #[cfg(feature = "recorder")]
2456 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
2457 let ctx = exec.recorder_ctx();
2458 let attrs = exec.recorder_attrs_for(&name);
2459 crate::recorder::emit_assoc_assign(
2460 &name,
2461 vec![(key.clone(), value.clone())],
2462 attrs,
2463 true,
2464 ctx,
2465 );
2466 }
2467 let _ = exec;
2468 });
2469 // Build `name[key]=value` shape for assignsparam's subscript
2470 // dispatch. Arith-evaluate numeric subscripts on an existing
2471 // indexed array (`a[i+1]=v` form) before handing off — the
2472 // canonical port currently only handles literal int / string
2473 // keys, so pre-resolve here.
2474 let resolved_key = with_executor(|exec| {
2475 let is_indexed = exec.array(&name).is_some();
2476 let is_assoc = exec.assoc(&name).is_some();
2477 let is_scalar = !is_indexed && !is_assoc && exec.scalar(&name).is_some();
2478 // c:Src/params.c::getindex — `(i)pat` / `(I)pat` / `(R)pat`
2479 // / `(r)pat` subscript flags on an indexed array LHS resolve
2480 // to a numeric index (first / last match of pat). On a
2481 // SCALAR LHS the same flags resolve to a CHAR position
2482 // (1-based first/last match of pat in the scalar string)
2483 // for the c:2748+ char-splice assignment. zshrs's
2484 // read-form `${a[(i)pat]}` already implements both shapes;
2485 // the LHS assignment path silently stored the literal
2486 // "(i)pat" as an assoc key (for scalar: auto-vivified to
2487 // PM_HASHED via the assignsparam unknown-subscript
2488 // fallback). Bug #293 (array) / scalar sibling.
2489 //
2490 // Detect the `(flags)pat` shape and resolve to a numeric
2491 // index before assignsparam.
2492 if is_indexed || is_scalar {
2493 if let Some(rest) = key.strip_prefix('(') {
2494 if let Some(close) = rest.find(')') {
2495 let flags = &rest[..close];
2496 let pat = &rest[close + 1..];
2497 if !flags.is_empty()
2498 && flags
2499 .chars()
2500 .all(|c| matches!(c, 'I' | 'R' | 'i' | 'r' | 'n' | 'e'))
2501 {
2502 // Resolve via the array's contents.
2503 if let Some(arr) = exec.array(&name) {
2504 let return_index = true; // LHS write — index needed
2505 let down = flags.contains('I') || flags.contains('R');
2506 let exact = flags.contains('e');
2507 let iter: Box<dyn Iterator<Item = (usize, &String)>> = if down {
2508 Box::new(arr.iter().enumerate().rev())
2509 } else {
2510 Box::new(arr.iter().enumerate())
2511 };
2512 let mut found: Option<usize> = None;
2513 for (idx, elem) in iter {
2514 let matched = if exact {
2515 elem == pat
2516 } else {
2517 crate::ported::pattern::patcompile(
2518 pat,
2519 crate::ported::zsh_h::PAT_HEAPDUP as i32,
2520 None,
2521 )
2522 .map_or(false, |p| {
2523 crate::ported::pattern::pattry(&p, elem)
2524 })
2525 };
2526 if matched {
2527 found = Some(idx);
2528 break;
2529 }
2530 }
2531 let _ = return_index;
2532 // (i)/(r) return 1-based index of match,
2533 // arr.len()+1 (or 1 for I/R) on miss
2534 // per zsh docs. We mirror the read-form
2535 // semantics from subst.rs.
2536 let idx_1based = match found {
2537 Some(i) => (i + 1) as i64,
2538 None => (arr.len() + 1) as i64,
2539 };
2540 return idx_1based.to_string();
2541 }
2542 // Scalar LHS — resolve to a CHAR position
2543 // (1-based first/last match of pat in the
2544 // string). c:Src/params.c:1411-1418 — the
2545 // scalar path returns the char index from
2546 // sliding-window pattern match against
2547 // pm.u_str. Same algorithm as the read-form
2548 // at subst.rs:5283-5306. Bug (scalar
2549 // sibling of #293): `a=hello; a[(I)l]=X`
2550 // previously auto-vivified `a` into
2551 // PM_HASHED with key "(I)l" instead of
2552 // splicing X at the last 'l' position
2553 // (yielding "helXo").
2554 if is_scalar {
2555 let s = exec.scalar(&name).unwrap_or_default();
2556 let s_chars: Vec<char> = s.chars().collect();
2557 let n = s_chars.len();
2558 let want_last = flags.contains('I') || flags.contains('R');
2559 let exact = flags.contains('e');
2560 let mut found: Option<usize> = None;
2561 'outer: for start in 0..=n {
2562 let lengths: Box<dyn Iterator<Item = usize>> = if want_last {
2563 Box::new((1..=(n - start)).rev())
2564 } else {
2565 Box::new(1..=(n - start))
2566 };
2567 for len in lengths {
2568 let cand: String =
2569 s_chars[start..start + len].iter().collect();
2570 let matched = if exact {
2571 cand == pat
2572 } else {
2573 crate::ported::pattern::patcompile(
2574 pat,
2575 crate::ported::zsh_h::PAT_HEAPDUP as i32,
2576 None,
2577 )
2578 .map_or(false, |p| {
2579 crate::ported::pattern::pattry(&p, &cand)
2580 })
2581 };
2582 if matched {
2583 found = Some(start);
2584 if !want_last {
2585 break 'outer;
2586 }
2587 break;
2588 }
2589 }
2590 }
2591 // (I)/(R): scan again to find LAST.
2592 if want_last {
2593 let mut last_found: Option<usize> = found;
2594 for start in (0..=n).rev() {
2595 for len in 1..=(n - start) {
2596 let cand: String =
2597 s_chars[start..start + len].iter().collect();
2598 let matched = if exact {
2599 cand == pat
2600 } else {
2601 crate::ported::pattern::patcompile(
2602 pat,
2603 crate::ported::zsh_h::PAT_HEAPDUP as i32,
2604 None,
2605 )
2606 .map_or(false, |p| {
2607 crate::ported::pattern::pattry(&p, &cand)
2608 })
2609 };
2610 if matched {
2611 last_found = Some(start);
2612 break;
2613 }
2614 }
2615 if last_found.is_some()
2616 && last_found.unwrap() >= start
2617 {
2618 break;
2619 }
2620 }
2621 found = last_found;
2622 }
2623 let idx_1based = match found {
2624 Some(i) => (i + 1) as i64,
2625 // (i) miss → len+1 (one past end).
2626 None => (n + 1) as i64,
2627 };
2628 return idx_1based.to_string();
2629 }
2630 }
2631 }
2632 }
2633 }
2634 if is_indexed && key.trim().parse::<i64>().is_err() {
2635 crate::ported::math::mathevali(&crate::ported::subst::singsub(&key))
2636 .map(|n| n.to_string())
2637 .unwrap_or(key.clone())
2638 } else {
2639 key.clone()
2640 }
2641 });
2642 let subscripted = format!("{}[{}]", name, resolved_key);
2643 crate::ported::params::assignsparam(&subscripted, &value, crate::ported::zsh_h::ASSPM_WARN);
2644 Value::Status(0)
2645 });
2646
2647 // Brace expansion. Routes through executor.xpandbraces (already
2648 // implemented for the pre-fusevm executor). Returns Value::Array.
2649 // BUILTIN_ARRAY_DROP_EMPTY — filter out empty Value::Str entries
2650 // from a Value::Array on the stack. Used by `for x in $@` /
2651 // `for x in $*` unquoted forms which drop empty positionals
2652 // (POSIX-like) but do NOT IFS-split each element internally
2653 // (zsh-specific — scalar word splitting is off by default).
2654 // Distinct from BUILTIN_WORD_SPLIT which routes through
2655 // multsub PREFORK_SPLIT (full IFS-split). Bug #166.
2656 vm.register_builtin(BUILTIN_ARRAY_DROP_EMPTY, |vm, _argc| {
2657 let v = vm.pop();
2658 match v {
2659 Value::Array(items) => {
2660 let filtered: Vec<Value> = items
2661 .into_iter()
2662 .filter(|x| !x.to_str().is_empty())
2663 .collect();
2664 Value::Array(filtered)
2665 }
2666 Value::Str(s) if s.is_empty() => Value::Array(Vec::new()),
2667 other => other,
2668 }
2669 });
2670
2671 // BUILTIN_QUOTEDZPUTS — re-wrap top-of-stack scalar via the
2672 // canonical quotedzputs (Src/utils.c:6464). Non-printable bytes
2673 // come back as `$'…'` C-string form so the cond xtrace prefix
2674 // line preserves the source-quoting form for `[[ -n $'\C-[OP' ]]`
2675 // instead of leaking raw ESC + "OP" bytes through the terminal.
2676 vm.register_builtin(BUILTIN_QUOTEDZPUTS, |vm, _argc| {
2677 let s = vm.pop().to_str();
2678 Value::str(crate::ported::utils::quotedzputs(&s))
2679 });
2680
2681 // BUILTIN_QUOTE_TOKENIZED_OUTPUT — char-aware mirror of
2682 // c:Src/exec.c:2114 `quote_tokenized_output`. The canonical
2683 // port at exec::quote_tokenized_output operates on bytes
2684 // (zsh's metafied encoding); zshrs strings are UTF-8 so
2685 // `\u{87}` Star is `[0xC2, 0x87]`, and a byte walk writes
2686 // 0xC2 raw (invalid UTF-8 lead → U+FFFD on lossy decode).
2687 // Walk by char and dispatch the same switch the byte port
2688 // uses, but with the token chars matching the UTF-8 form.
2689 vm.register_builtin(BUILTIN_QUOTE_TOKENIZED_OUTPUT, |vm, _argc| {
2690 let s = vm.pop().to_str();
2691 let mut out = String::with_capacity(s.len());
2692 let chars: Vec<char> = s.chars().collect();
2693 let mut i = 0;
2694 while i < chars.len() {
2695 let c = chars[i];
2696 // c:2120 — Meta-quoted byte: emit `*++s ^ 32`.
2697 // In UTF-8 strings Meta is `\u{83}`; the next char is
2698 // the metafied payload.
2699 if c == '\u{83}' {
2700 if let Some(&n) = chars.get(i + 1) {
2701 if (n as u32) < 0x80 {
2702 out.push(((n as u8) ^ 32) as char);
2703 } else {
2704 out.push(n);
2705 }
2706 i += 2;
2707 continue;
2708 }
2709 i += 1;
2710 continue;
2711 }
2712 // c:2124 — Nularg: skip.
2713 if c == '\u{a1}' {
2714 i += 1;
2715 continue;
2716 }
2717 // c:2128-2143 — ASCII specials get backslash-prefixed
2718 // then fall through to emit the literal char.
2719 match c {
2720 '\\' | '<' | '>' | '(' | '|' | ')' | '^' | '#' | '~'
2721 | '[' | ']' | '*' | '?' | '$' | ' ' => {
2722 out.push('\\');
2723 out.push(c);
2724 i += 1;
2725 continue;
2726 }
2727 '\t' => {
2728 out.push_str("$'\\t'");
2729 i += 1;
2730 continue;
2731 }
2732 '\n' => {
2733 out.push_str("$'\\n'");
2734 i += 1;
2735 continue;
2736 }
2737 '\r' => {
2738 out.push_str("$'\\r'");
2739 i += 1;
2740 continue;
2741 }
2742 '=' => {
2743 if i == 0 {
2744 out.push('\\');
2745 }
2746 out.push(c);
2747 i += 1;
2748 continue;
2749 }
2750 _ => {}
2751 }
2752 // c:2163 — `if (itok(*s)) putc(ztokens[*s - Pound]);`
2753 // Map zsh token chars (`\u{84}`..`\u{a1}` range, the
2754 // ones the lexer emits for `#$^*()…`) back to their
2755 // source ASCII via the `ztokens` table.
2756 let cp = c as u32;
2757 if (0x84..=0xa1).contains(&cp) {
2758 let idx = (cp - 0x84) as usize;
2759 let ztokens = crate::ported::lex::ztokens.as_bytes();
2760 if idx < ztokens.len() {
2761 out.push(ztokens[idx] as char);
2762 i += 1;
2763 continue;
2764 }
2765 }
2766 out.push(c);
2767 i += 1;
2768 }
2769 Value::str(out)
2770 });
2771
2772 // BUILTIN_WORD_SPLIT — `${=var}` IFS-split runtime.
2773 // PURE PASSTHRU: route through canonical `subst::multsub` with
2774 // PREFORK_SPLIT flag (C port of `Src/subst.c::multsub` at c:544
2775 // — the IFS-split walker with whitespace-vs-non-whitespace
2776 // gating, quote-aware parsing, and empty-field handling).
2777 vm.register_builtin(BUILTIN_WORD_SPLIT, |vm, _argc| {
2778 let s = vm.pop().to_str();
2779 let (_joined, parts, _isarr, _flags) =
2780 crate::ported::subst::multsub(&s, crate::ported::zsh_h::PREFORK_SPLIT);
2781 // Empty single-string special case → empty Array (drop empty arg).
2782 if parts.len() == 1 && parts[0].is_empty() {
2783 return Value::Array(Vec::new());
2784 }
2785 nodes_to_value(parts)
2786 });
2787
2788 vm.register_builtin(BUILTIN_BRACE_EXPAND, |vm, _argc| {
2789 // c:Src/glob.c::xpandbraces — brace expansion runs per word.
2790 // When the upstream produced an array (e.g. `${a:e}` splat),
2791 // expand braces on each element separately so the splat
2792 // survives. `pop().to_str()` would join with space and lose
2793 // the array shape. Parity bug #28 cousin: the BRACE_EXPAND
2794 // emit always fires for any word containing `{` (including
2795 // `${...}` param-expansion braces), so its collapse hit even
2796 // pure-paramsubst args.
2797 let raw = vm.pop();
2798 // c:Src/options.c — `no_brace_expand` (negated braceexpand)
2799 // disables brace expansion entirely. When set, `{a,b}` stays
2800 // literal. Mirror by short-circuiting xpandbraces; pass the
2801 // input through unchanged.
2802 let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
2803 let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
2804 let inputs: Vec<String> = match raw {
2805 Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
2806 other => vec![other.to_str()],
2807 };
2808 if !brace_expand {
2809 return nodes_to_value(inputs);
2810 }
2811 let mut out: Vec<String> = Vec::with_capacity(inputs.len());
2812 for s in inputs {
2813 for w in crate::ported::glob::xpandbraces(&s, brace_ccl) {
2814 out.push(w);
2815 }
2816 }
2817 nodes_to_value(out)
2818 });
2819
2820 // `*(qual)` glob qualifier filter. Stack: [pattern, qualifier].
2821 // Pattern is glob-expanded normally, then each result is filtered by the
2822 // qualifier predicate. Common qualifiers:
2823 // . — regular files only
2824 // / — directories only
2825 // @ — symlinks
2826 // x — executable
2827 // r/w/x — readable/writable/executable
2828 // N — nullglob (no error if no match)
2829 // L+N / L-N — size > N / size < N (in bytes)
2830 // mh-N / mh+N — modified within N hours / older than N hours
2831 // md-N / md+N — modified within N days / older than N days
2832 // on/On — sort by name asc/desc (default)
2833 // oL/OL — sort by length
2834 // om/Om — sort by mtime
2835 // Pop a scalar pattern, run expand_glob, push Value::Array. Used
2836 // by the segment-concat compile path for `$D/*`-style words.
2837 vm.register_builtin(BUILTIN_GLOB_EXPAND, |vm, _argc| {
2838 // c:Src/glob.c:1872 — `zglob` runs per-word in the argv
2839 // pipeline. When the upstream EXPAND_TEXT returned an array
2840 // (e.g. `${a:e}` splat → ["txt","md"]), we must glob each
2841 // element separately, not collapse to a sepjoin'd scalar.
2842 // Without this, `print -l ${a:e}` saw the array stringified
2843 // by `pop().to_str()` and emitted one joined arg.
2844 let raw = vm.pop();
2845 let patterns: Vec<String> = match raw {
2846 Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
2847 other => vec![other.to_str()],
2848 };
2849 // c:Src/glob.c:1872 — honour `setopt noglob` / `noglob CMD`
2850 // precommand. When the option is on, the word stays literal
2851 // (zsh skips the glob expansion entirely). Without this, the
2852 // segment-fast-path BUILTIN_GLOB_EXPAND fired even after
2853 // `noglob` set the option, so `noglob echo *.xyz` saw the
2854 // NOMATCH error instead of the literal pass-through.
2855 let noglob =
2856 opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
2857 if noglob {
2858 return if patterns.is_empty() {
2859 Value::Array(Vec::new())
2860 } else if patterns.len() == 1 {
2861 Value::str(patterns.into_iter().next().unwrap())
2862 } else {
2863 Value::Array(patterns.into_iter().map(Value::str).collect())
2864 };
2865 }
2866 let mut out: Vec<String> = Vec::with_capacity(patterns.len());
2867 for pattern in &patterns {
2868 let matches = with_executor(|exec| exec.expand_glob(pattern));
2869 if matches.is_empty() {
2870 // c:1872 nullglob — drop this word, don't emit a hole
2871 continue;
2872 }
2873 for m in matches {
2874 out.push(m);
2875 }
2876 }
2877 if out.is_empty() {
2878 return Value::Array(Vec::new());
2879 }
2880 if patterns.len() == 1 && out.len() == 1 && out[0] == patterns[0] {
2881 // No real matches; expand_glob returned the literal. Pass
2882 // back as scalar so downstream ops don't re-flatten.
2883 return Value::str(out.into_iter().next().unwrap());
2884 }
2885 Value::Array(out.into_iter().map(Value::str).collect())
2886 });
2887
2888 // `break`/`continue` from a sub-VM body. The compile path emits
2889 // these when the keyword appears at chunk top-level (no enclosing
2890 // for/while in the current chunk's patch lists). Outer-loop
2891 // builtins (BUILTIN_RUN_SELECT and any future loop-via-builtin
2892 // construct) drain canonical BREAKS/CONTFLAG after each iteration.
2893 //
2894 // Writes match `bin_break`'s c:5836+ pattern:
2895 // continue: contflag = 1; breaks++ (Src/builtin.c::bin_break)
2896 // break: breaks++
2897 vm.register_builtin(BUILTIN_SET_BREAK, |_vm, _argc| {
2898 use std::sync::atomic::Ordering::SeqCst;
2899 crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
2900 Value::Status(0)
2901 });
2902 vm.register_builtin(BUILTIN_SET_CONTINUE, |_vm, _argc| {
2903 use std::sync::atomic::Ordering::SeqCst;
2904 crate::ported::builtin::CONTFLAG.store(1, SeqCst);
2905 crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
2906 Value::Status(0)
2907 });
2908
2909 // `${arr[*]}` — join array elements with the first IFS char into
2910 // a single string. Matches zsh: in DQ context this preserves the
2911 // join; in array context too the result is one Value::Str.
2912 // Set or clear a shell option directly. Used by `noglob CMD ...`
2913 // precommand wrapping — the compiler emits SET_RAW_OPT to flip the
2914 // option ON before compiling the inner words and OFF after, so glob
2915 // expansion of the inner args sees the temporary state.
2916 vm.register_builtin(BUILTIN_SET_RAW_OPT, |vm, _argc| {
2917 let on = vm.pop().to_int() != 0;
2918 let opt = vm.pop().to_str();
2919 // Pure passthru: canonical port lives in
2920 // src/ported/options.rs::opt_state_set_via_alias and
2921 // handles negation-alias resolution per c:Src/options.c.
2922 crate::ported::options::opt_state_set_via_alias(&opt, on);
2923 Value::Status(0)
2924 });
2925
2926 // c:Src/options.c GLOB_SUBST — runtime glob expansion of
2927 // substituted words. Pop a Value (Str or Array); when
2928 // GLOB_SUBST is ON, run expand_glob on each string element;
2929 // when OFF, pass through unchanged. Bug #119 in docs/BUGS.md.
2930 vm.register_builtin(BUILTIN_GLOB_SUBST_EXPAND, |vm, _argc| {
2931 let raw = vm.pop();
2932 let glob_subst =
2933 crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
2934 if !glob_subst {
2935 return raw;
2936 }
2937 // Collect input strings (Str → vec![s]; Array → multiple).
2938 let inputs: Vec<String> = match raw {
2939 Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
2940 other => vec![other.to_str()],
2941 };
2942 // Run expand_glob on each. Empty matches collapse to a
2943 // single literal pass-through to mirror nullglob-off default.
2944 let mut out: Vec<String> = Vec::with_capacity(inputs.len());
2945 for pattern in inputs {
2946 let matches = with_executor(|exec| exec.expand_glob(&pattern));
2947 if matches.is_empty() {
2948 // No match: keep the literal (like nullglob off).
2949 out.push(pattern);
2950 } else {
2951 for m in matches {
2952 out.push(m);
2953 }
2954 }
2955 }
2956 if out.len() == 1 {
2957 Value::str(out.into_iter().next().unwrap())
2958 } else {
2959 Value::Array(out.into_iter().map(Value::str).collect())
2960 }
2961 });
2962
2963 // c:Src/math.c:337 — `getmathparam` for ArithCompiler pre-load.
2964 // Pop a variable name, return its math-coerced value. Mirrors
2965 // the routing in math::getmathparam: try i64, then f64, then
2966 // recursive arith-eval, else 0. Bug #118 in docs/BUGS.md.
2967 vm.register_builtin(BUILTIN_GET_MATH_VAR, |vm, _argc| {
2968 let name = vm.pop().to_str();
2969 let raw = crate::ported::params::getsparam(&name).unwrap_or_default();
2970 // Empty / unset → 0.
2971 if raw.is_empty() {
2972 return Value::Int(0);
2973 }
2974 // Direct int / float parse.
2975 if let Ok(n) = raw.parse::<i64>() {
2976 return Value::Int(n);
2977 }
2978 if let Ok(f) = raw.parse::<f64>() {
2979 return Value::Float(f);
2980 }
2981 // Recursive arith eval (matches getmathparam fallback at
2982 // Src/math.c:337). If that fails too, return 0 — C's
2983 // mathevall returns 0 with errflag set on parse failure.
2984 match crate::ported::math::mathevali(&raw) {
2985 Ok(n) => Value::Int(n),
2986 Err(_) => Value::Int(0),
2987 }
2988 });
2989
2990 // c:Src/options.c GLOB_SUBST + Src/cond.c:552 cond_match.
2991 // Pop pattern string; when GLOB_SUBST is OFF, escape every glob
2992 // metachar with `\` so the downstream StrMatch + patcompile
2993 // treat them as literals (matching C's tokenization-based
2994 // gate). When GLOB_SUBST is ON, pass through unchanged.
2995 // See BUILTIN_GLOB_SUBST_GUARD docs above for full rationale.
2996 vm.register_builtin(BUILTIN_GLOB_SUBST_GUARD, |vm, _argc| {
2997 let p = vm.pop().to_str();
2998 let glob_subst =
2999 crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
3000 if glob_subst {
3001 return Value::str(p);
3002 }
3003 let mut out = String::with_capacity(p.len() * 2);
3004 for c in p.chars() {
3005 match c {
3006 '*' | '?' | '[' | ']' | '(' | ')' | '|' | '<' | '>' | '#' | '^'
3007 | '~' | '\\' => {
3008 out.push('\\');
3009 out.push(c);
3010 }
3011 _ => out.push(c),
3012 }
3013 }
3014 Value::str(out)
3015 });
3016
3017 vm.register_builtin(BUILTIN_ARRAY_JOIN_STAR, |vm, _argc| {
3018 let name = vm.pop().to_str();
3019 let (joined, ifs_full, in_dq) = with_executor(|exec| {
3020 // c:Src/params.c — `"$*"` joins by IFS[0]. zsh
3021 // distinguishes IFS=unset (→ default `" "`) from
3022 // IFS="" (→ EMPTY separator → fields concatenate).
3023 // chars().next() collapsed both into the default, so
3024 // IFS="" was treated as IFS=" ".
3025 let ifs_full = exec
3026 .scalar("IFS")
3027 .unwrap_or_else(|| " \t\n".to_string());
3028 let sep = ifs_full
3029 .chars()
3030 .next()
3031 .map(|c| c.to_string())
3032 .unwrap_or_default();
3033 let in_dq = exec.in_dq_context > 0;
3034 let joined = if name == "@" || name == "*" || name == "argv" {
3035 exec.pparams().join(&sep)
3036 } else if let Some(assoc_map) = exec.assoc(&name) {
3037 // c:Src/params.c — assoc-splat values for
3038 // `"${h[@]}"` / `"${h[*]}"`. Bug #109 in
3039 // docs/BUGS.md.
3040 assoc_map.values().cloned().collect::<Vec<_>>().join(&sep)
3041 } else if let Some(arr) = exec.array(&name) {
3042 arr.join(&sep)
3043 } else {
3044 exec.get_variable(&name)
3045 };
3046 (joined, ifs_full, in_dq)
3047 });
3048 // c:Src/subst.c — UNQUOTED `${name[*]}` (or `$*`) goes
3049 // through the canonical "join via IFS[0], then word-split
3050 // via IFS" pipeline. The fast-path bypassed paramsubst
3051 // entirely so it never word-split, producing one joined
3052 // string instead of N argv entries. Bug #428.
3053 //
3054 // In QUOTED (`"${name[*]}"`) context, the result IS a
3055 // single scalar — return it as Str without splitting.
3056 if in_dq {
3057 return Value::str(joined);
3058 }
3059 if joined.is_empty() {
3060 return Value::Array(Vec::new());
3061 }
3062 // IFS word-split — every IFS char is a separator. Empty
3063 // resulting fields are dropped (the canonical
3064 // "remove empty unquoted words" pass from
3065 // Src/subst.c::prefork c:184-187).
3066 let parts: Vec<String> = joined
3067 .split(|c: char| ifs_full.contains(c))
3068 .filter(|s| !s.is_empty())
3069 .map(String::from)
3070 .collect();
3071 if parts.is_empty() {
3072 Value::Array(Vec::new())
3073 } else if parts.len() == 1 {
3074 Value::str(parts.into_iter().next().unwrap())
3075 } else {
3076 Value::Array(parts.into_iter().map(Value::str).collect())
3077 }
3078 });
3079
3080 vm.register_builtin(BUILTIN_ARRAY_ALL, |vm, _argc| {
3081 let name = vm.pop().to_str();
3082 with_executor(|exec| {
3083 // Special positional names — splice the positional list.
3084 if name == "@" || name == "*" || name == "argv" {
3085 return Value::Array(exec.pparams().iter().map(Value::str).collect());
3086 }
3087 // c:Src/Modules/parameter.c — funcstack/funcfiletrace/
3088 // funcsourcetrace/functrace are PM_ARRAY|PM_READONLY
3089 // specials backed by the canonical FUNCSTACK Vec.
3090 // `${funcstack[@]}` inside a function call should splat
3091 // the innermost-first names; without this branch the
3092 // runtime fell to the scalar fallback (get_variable
3093 // returns empty for these specials) and `[@]` came out
3094 // empty. Bug #276 in docs/BUGS.md. Mirrors the parallel
3095 // arrays_get handler at src/ported/subst.rs ~10685.
3096 // c:Src/Modules/datetime.c:256 — `epochtime` PM_ARRAY|
3097 // PM_READONLY backed by getcurrenttime(). Same parallel
3098 // arrangement as the FUNCSTACK-backed specials below.
3099 if name == "epochtime" {
3100 let arr = crate::ported::modules::datetime::getcurrenttime();
3101 return Value::Array(arr.into_iter().map(Value::str).collect());
3102 }
3103 if matches!(
3104 name.as_str(),
3105 "funcstack" | "funcfiletrace" | "funcsourcetrace" | "functrace"
3106 ) {
3107 if let Ok(f) = crate::ported::modules::parameter::FUNCSTACK.lock() {
3108 let vals: Vec<Value> = f
3109 .iter()
3110 .rev()
3111 .map(|fs| {
3112 let s = match name.as_str() {
3113 "funcstack" => fs.name.clone(),
3114 "funcfiletrace" => fs.filename.clone().unwrap_or_default(),
3115 // funcsourcetrace / functrace
3116 _ => format!("{}:{}", fs.name, fs.lineno),
3117 };
3118 Value::str(s)
3119 })
3120 .collect();
3121 return Value::Array(vals);
3122 }
3123 }
3124 // c:Src/params.c — `${assoc[@]}` enumerates VALUES (per
3125 // params.c:1696-1750 hashparam splat). Check assoc
3126 // storage BEFORE the scalar fallback so an associative
3127 // array named X resolves `${X[@]}` to the values, not
3128 // empty. Bug #109 in docs/BUGS.md: `${h[@]}` on an
3129 // assoc routed through BUILTIN_ARRAY_ALL, which only
3130 // consulted `exec.array(name)` (the indexed-array map)
3131 // — that lookup missed for assocs, fell through to
3132 // `get_variable("h")` (also empty for an assoc-only
3133 // name), and returned `Array(vec![])`. zsh's expected
3134 // behavior is to enumerate values.
3135 if let Some(assoc_map) = exec.assoc(&name) {
3136 return Value::Array(
3137 assoc_map.values().cloned().map(Value::str).collect(),
3138 );
3139 }
3140 match exec.array(&name) {
3141 Some(v) => Value::Array(v.iter().map(Value::str).collect()),
3142 None => {
3143 // Fall back to scalar lookup. zsh (unlike bash)
3144 // does NOT IFS-split a scalar variable in a for
3145 // list — `for w in $scalar` iterates ONCE with the
3146 // scalar value. Word-splitting requires either
3147 // sh_word_split option or explicit `${(s.,.)scalar}`.
3148 let val = exec.get_variable(&name);
3149 if val.is_empty() && !exec.has_scalar(&name) && env::var(&name).is_err() {
3150 Value::Array(vec![])
3151 } else if opt_state_get("shwordsplit").unwrap_or(false) {
3152 // bash-compat: under setopt sh_word_split, do
3153 // split scalars on IFS chars.
3154 let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
3155 let parts: Vec<Value> = val
3156 .split(|c: char| ifs.contains(c))
3157 .filter(|s| !s.is_empty())
3158 .map(Value::str)
3159 .collect();
3160 Value::Array(parts)
3161 } else {
3162 Value::Array(vec![Value::str(val)])
3163 }
3164 }
3165 }
3166 })
3167 });
3168
3169 // BUILTIN_ARRAY_FLATTEN(N): pops N values, flattens one level of Array
3170 // nesting, pushes the resulting Array AND its length as a separate Int.
3171 // The two-value return shape lets the caller (for-loop compile path)
3172 // SetSlot the length before SetSlot'ing the array, without re-deriving
3173 // the length from the array via a second builtin call.
3174 // `coproc [name] { body }` — bidirectional pipe to backgrounded body.
3175 // Stack discipline (top first): [name (str, "" for default), sub_idx (int)].
3176 // On success: parent's `executor.arrays[name]` becomes [write_fd, read_fd]
3177 // and Status(0) is returned. The caller writes to the child's stdin via
3178 // write_fd, reads its stdout via read_fd, and closes both when done.
3179 //
3180 // Bash's coproc convention is `${NAME[0]}` = read_fd, `${NAME[1]}` =
3181 // write_fd. We follow that: arrays[name] = [read_fd_str, write_fd_str].
3182 vm.register_builtin(BUILTIN_RUN_COPROC, |vm, _argc| {
3183 let sub_idx = vm.pop().to_int() as usize;
3184 let raw_name = vm.pop().to_str();
3185 let name = if raw_name.is_empty() {
3186 "COPROC".to_string()
3187 } else {
3188 raw_name
3189 };
3190 let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
3191 Some(c) => c,
3192 None => return Value::Status(1),
3193 };
3194
3195 // (parent_read ← child_stdout)
3196 let mut p2c = [0i32; 2]; // parent writes, child reads
3197 let mut c2p = [0i32; 2]; // child writes, parent reads
3198 if unsafe { libc::pipe(p2c.as_mut_ptr()) } < 0 {
3199 return Value::Status(1);
3200 }
3201 if unsafe { libc::pipe(c2p.as_mut_ptr()) } < 0 {
3202 unsafe {
3203 libc::close(p2c[0]);
3204 libc::close(p2c[1]);
3205 }
3206 return Value::Status(1);
3207 }
3208
3209 match unsafe { libc::fork() } {
3210 -1 => {
3211 unsafe {
3212 libc::close(p2c[0]);
3213 libc::close(p2c[1]);
3214 libc::close(c2p[0]);
3215 libc::close(c2p[1]);
3216 }
3217 Value::Status(1)
3218 }
3219 0 => {
3220 // Child: stdin from p2c[0], stdout to c2p[1]. Close all
3221 // unused fds. setsid so SIGINT to fg doesn't hit us.
3222 unsafe {
3223 libc::dup2(p2c[0], libc::STDIN_FILENO);
3224 libc::dup2(c2p[1], libc::STDOUT_FILENO);
3225 libc::close(p2c[0]);
3226 libc::close(p2c[1]);
3227 libc::close(c2p[0]);
3228 libc::close(c2p[1]);
3229 libc::setsid();
3230 }
3231 crate::fusevm_disasm::maybe_print_stdout("coproc:child", &chunk);
3232 let mut co_vm = fusevm::VM::new(chunk);
3233 register_builtins(&mut co_vm);
3234 let _ = co_vm.run();
3235 let _ = std::io::stdout().flush();
3236 let _ = std::io::stderr().flush();
3237 std::process::exit(co_vm.last_status);
3238 }
3239 pid => {
3240 // Parent: close child ends, store [read_fd, write_fd] in NAME.
3241 unsafe {
3242 libc::close(p2c[0]);
3243 libc::close(c2p[1]);
3244 }
3245 let read_fd = c2p[0];
3246 let write_fd = p2c[1];
3247 with_executor(|exec| {
3248 exec.unset_scalar(&name);
3249 exec.set_array(name, vec![read_fd.to_string(), write_fd.to_string()]);
3250 });
3251 // c:Src/exec.c — `coprocin`/`coprocout` are the
3252 // canonical globals that bin_read's `-p` arm
3253 // (Src/builtin.c:6510) and bin_print's `-p` arm
3254 // (Src/builtin.c:4827) read to find the
3255 // coprocess fds. The Rust port has the atomic
3256 // declarations at src/ported/modules/clone.rs:262
3257 // but the coproc-launch path never updated them,
3258 // so `read -p` / `print -p` always errored with
3259 // "-p: no coprocess" even when a coproc was
3260 // running. Bug #388 in docs/BUGS.md. Update them
3261 // here so the canonical builtins find the live
3262 // pipe.
3263 crate::ported::modules::clone::coprocin
3264 .store(read_fd, std::sync::atomic::Ordering::Relaxed);
3265 crate::ported::modules::clone::coprocout
3266 .store(write_fd, std::sync::atomic::Ordering::Relaxed);
3267 // c:Src/exec.c:2837 — `lastpid = (zlong) pid;`. zsh
3268 // sets the `$!` global to the coproc child's PID so
3269 // subsequent `$!` reads return it. The Rust port at
3270 // exec.rs:6773 mirrors this for regular background
3271 // jobs but the coproc launch path was missing the
3272 // assignment, leaving `$!` at 0 after `coproc cmd`.
3273 crate::ported::modules::clone::lastpid
3274 .store(pid, std::sync::atomic::Ordering::Relaxed);
3275 Value::Status(0)
3276 }
3277 }
3278 });
3279
3280 vm.register_builtin(BUILTIN_ARRAY_FLATTEN, |vm, argc| {
3281 let n = argc as usize;
3282 let start = vm.stack.len().saturating_sub(n);
3283 let raw: Vec<Value> = vm.stack.drain(start..).collect();
3284 let mut flat: Vec<Value> = Vec::with_capacity(raw.len());
3285 for v in raw {
3286 match v {
3287 Value::Array(items) => flat.extend(items),
3288 other => flat.push(other),
3289 }
3290 }
3291 let len = flat.len() as i64;
3292 // Push the array first; the Int(len) becomes the builtin's return
3293 // value (which CallBuiltin already pushes). Caller consumes in
3294 // reverse: SetSlot(len_slot) pops Int, SetSlot(arr_slot) pops Array.
3295 vm.push(Value::Array(flat));
3296 Value::Int(len)
3297 });
3298
3299 // Shell variable get/set — routes through executor.variables so nested
3300 // VMs (function calls) and tree-walker callers see the same storage.
3301 vm.register_builtin(BUILTIN_GET_VAR, |vm, argc| {
3302 let args = pop_args(vm, argc);
3303 let name = args.into_iter().next().unwrap_or_default();
3304 let live_status = vm.last_status;
3305 // Suppress sync when a deferred subshell exit just landed:
3306 // LASTVAL holds the correct deferred status, vm.last_status
3307 // is stale (post-subshell vm doesn't propagate status). See
3308 // SUBSHELL_EXIT_STATUS_PENDING TLS declaration for rationale.
3309 let suppress_sync = SUBSHELL_EXIT_STATUS_PENDING.with(|c| {
3310 let prev = c.get();
3311 c.set(false);
3312 prev
3313 });
3314 // `$@` and `$*` need splice semantics — return Value::Array of
3315 // positional params so for-loop's BUILTIN_ARRAY_FLATTEN spreads them
3316 // and pop_args splits them into argv slots. zsh's `"$@"` bslashquote-each-
3317 // word semantics matches: each pos-param becomes its own arg.
3318 // Same for arrays accessed by name (e.g. `$arr` in some contexts).
3319 let sync_status = |exec: &mut ShellExecutor| {
3320 if !suppress_sync {
3321 exec.set_last_status(live_status);
3322 }
3323 };
3324 if name == "@" || name == "*" {
3325 return with_executor(|exec| {
3326 sync_status(exec);
3327 Value::Array(exec.pparams().iter().map(Value::str).collect())
3328 });
3329 }
3330 // RC_EXPAND_PARAM: when the option is set and `name` refers to
3331 // an array, return Value::Array so the enclosing word's
3332 // BUILTIN_CONCAT_DISTRIBUTE distributes element-wise. Without
3333 // the option, arrays still join to a space-separated scalar
3334 // (zsh's default unquoted-array-as-scalar semantics).
3335 let rc_expand = with_executor(|exec| opt_state_get("rcexpandparam").unwrap_or(false));
3336 if rc_expand {
3337 let arr_val = with_executor(|exec| {
3338 sync_status(exec);
3339 exec.array(&name)
3340 });
3341 if let Some(arr) = arr_val {
3342 return Value::Array(arr.into_iter().map(Value::str).collect());
3343 }
3344 }
3345 // Magic-assoc fallback FIRST — `${aliases}` / `${functions}`
3346 // / `${commands}` / etc. should return the value list per
3347 // zsh's bare-assoc semantics. Without this, those names fell
3348 // through to `get_variable` which is empty (they live in
3349 // separate executor tables, not `assoc_arrays`). Return as
3350 // a Value::Array so `arr=(${aliases})` distributes into
3351 // multiple elements, matching zsh's array-context word
3352 // splitting for assoc-bare references.
3353 let magic_vals = with_executor(|exec| {
3354 sync_status(exec);
3355 // Canonical PARTAB dispatch (Src/Modules/parameter.c:2235-
3356 // 2298 + SPECIALPMDEFs in mapfile/terminfo/termcap/system/
3357 // zleparameter): PARTAB_ARRAY entries → whole-array getfn;
3358 // PARTAB entries → scan keys + per-key getpm/scanpm fn
3359 // pointers.
3360 let _ = exec;
3361 if let Some(values) = partab_array_get(&name) {
3362 Some(values)
3363 } else if let Some(keys) = partab_scan_keys(&name) {
3364 Some(
3365 keys.iter()
3366 .map(|k| partab_get(&name, k).unwrap_or_default())
3367 .collect::<Vec<_>>(),
3368 )
3369 } else {
3370 None
3371 }
3372 });
3373 if let Some(vals) = magic_vals {
3374 // Distinguish "name IS a magic-assoc with no entries"
3375 // (return Array(empty)) from "name is unknown — fall
3376 // through to get_variable".
3377 return Value::Array(vals.into_iter().map(Value::str).collect());
3378 }
3379 // Indexed-array path: return Value::Array so pop_args splats
3380 // each element into its own argv slot. Direct port of zsh's
3381 // unquoted `$arr` semantics — each element becomes a separate
3382 // word in command-arg position.
3383 //
3384 // DQ context exception: inside `"...$arr..."`, zsh joins with
3385 // the first char of $IFS (default space) so the DQ word stays
3386 // a single argv slot. Detect via in_dq_context (bumped by
3387 // BUILTIN_EXPAND_TEXT mode 1) and return the joined scalar.
3388 // Direct port of Src/subst.c:1759-1813 nojoin/sepjoin: in DQ
3389 // (qt=1) without explicit `(@)`, sepjoin runs and the result
3390 // is one word.
3391 let arr_assoc_data = with_executor(|exec| {
3392 sync_status(exec);
3393 let in_dq = exec.in_dq_context > 0;
3394 // KSH_ARRAYS: bare `$arr` returns ONLY arr[0] (zero-
3395 // based first-element-only semantics). Direct port of
3396 // Src/params.c getstrvalue's KSH_ARRAYS gate which
3397 // returns aval[0] instead of the whole array.
3398 let ksh_arrays = opt_state_get("ksharrays").unwrap_or(false);
3399 if let Some(arr) = exec.array(&name) {
3400 if ksh_arrays {
3401 return Some((vec![arr.first().cloned().unwrap_or_default()], in_dq));
3402 }
3403 return Some((arr.clone(), in_dq));
3404 }
3405 if let Some(map) = exec.assoc(&name) {
3406 let mut keys: Vec<&String> = map.keys().collect();
3407 keys.sort();
3408 let values: Vec<String> =
3409 keys.iter().filter_map(|k| map.get(*k).cloned()).collect();
3410 if ksh_arrays {
3411 return Some((vec![values.into_iter().next().unwrap_or_default()], in_dq));
3412 }
3413 return Some((values, in_dq));
3414 }
3415 None
3416 });
3417 if let Some((items, in_dq)) = arr_assoc_data {
3418 if in_dq {
3419 let sep = with_executor(|exec| {
3420 exec.get_variable("IFS")
3421 .chars()
3422 .next()
3423 .map(|c| c.to_string())
3424 .unwrap_or_else(|| " ".to_string())
3425 });
3426 return Value::str(items.join(&sep));
3427 }
3428 return Value::Array(items.into_iter().map(Value::str).collect());
3429 }
3430 let (val, in_dq, is_known) = with_executor(|exec| {
3431 sync_status(exec);
3432 let v = exec.get_variable(&name);
3433 // For nounset detection: a name is "known" when it has a
3434 // paramtab/array/assoc/env entry. Special chars ($?, $#,
3435 // $@, $*, $-, $$, $!, $_, $0) always count as known
3436 // regardless of value. Pure-digit positional params
3437 // count as known iff index <= $# (set -- has populated
3438 // that slot). c:Src/subst.c:1689 — NOUNSET fires on
3439 // unset positional param too: `set --; echo "$1"` with
3440 // nounset must diagnose.
3441 let is_special_single = name.len() == 1
3442 && matches!(
3443 name.chars().next().unwrap(),
3444 '?' | '#' | '@' | '*' | '-' | '$' | '!' | '_' | '0'
3445 );
3446 let is_pure_digit = !name.is_empty() && name.chars().all(|c| c.is_ascii_digit());
3447 let positional_known = if is_pure_digit {
3448 let idx: usize = name.parse().unwrap_or(0);
3449 if idx == 0 {
3450 true // $0 always set
3451 } else {
3452 idx <= exec.pparams().len()
3453 }
3454 } else {
3455 false
3456 };
3457 let known = !v.is_empty()
3458 || name.is_empty()
3459 || is_special_single
3460 || positional_known
3461 || crate::ported::params::paramtab()
3462 .read()
3463 .ok()
3464 .map(|t| t.contains_key(&name))
3465 .unwrap_or(false)
3466 || env::var(&name).is_ok();
3467 (v, exec.in_dq_context > 0, known)
3468 });
3469 // c:Src/subst.c:1689 — NO_UNSET / nounset: reading an unset
3470 // parameter fires "parameter not set" diagnostic and aborts
3471 // the substitution. Direct port of the noerrs gate at c:1689
3472 // (zerr + errflag). Matches `set -u` POSIX semantics.
3473 if !is_known && opt_state_get("nounset").unwrap_or(false) {
3474 crate::ported::utils::zerr(&format!("{}: parameter not set", name));
3475 crate::ported::utils::errflag.fetch_or(
3476 crate::ported::zsh_h::ERRFLAG_ERROR,
3477 std::sync::atomic::Ordering::Relaxed,
3478 );
3479 with_executor(|exec| exec.set_last_status(1));
3480 return Value::str("");
3481 }
3482 // Empty unquoted scalar → drop the arg (zsh "remove empty
3483 // unquoted words" rule). Returning empty Value::Array makes
3484 // pop_args contribute zero items. DQ context keeps the empty
3485 // string so "$a" stays a single empty arg. Direct port of
3486 // subst.c's elide-empty pass.
3487 if val.is_empty() && !in_dq {
3488 return Value::Array(Vec::new());
3489 }
3490 // c:Src/subst.c:1759 SH_WORD_SPLIT — when shwordsplit is set and
3491 // we're in unquoted command-arg position (not DQ), split scalar
3492 // value on IFS into multiple words. Matches BUILTIN_ARRAY_ALL's
3493 // shwordsplit arm (fusevm_bridge.rs:2200). Without this, bare
3494 // `$s` in `print $s` stayed a single arg even with the option
3495 // set, breaking POSIX-style scalar word-splitting.
3496 if !in_dq && opt_state_get("shwordsplit").unwrap_or(false) {
3497 let ifs =
3498 with_executor(|exec| exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string()));
3499 let parts: Vec<Value> = val
3500 .split(|c: char| ifs.contains(c))
3501 .filter(|s| !s.is_empty())
3502 .map(|s| Value::str(s.to_string()))
3503 .collect();
3504 if parts.is_empty() {
3505 return Value::Array(Vec::new());
3506 } else if parts.len() == 1 {
3507 return parts.into_iter().next().unwrap();
3508 } else {
3509 return Value::Array(parts);
3510 }
3511 }
3512 Value::str(val)
3513 });
3514
3515 // `name+=val` (no parens) — runtime dispatch:
3516 // - if `name` is in `arrays` → push `val` as new element
3517 // - if `name` is in `assoc_arrays` → refuse (zsh errors here)
3518 // - else → scalar concat (existing behavior)
3519 // Stack: [name, value].
3520 vm.register_builtin(BUILTIN_APPEND_SCALAR_OR_PUSH, |vm, argc| {
3521 let args = pop_args(vm, argc);
3522 let mut iter = args.into_iter();
3523 let name = iter.next().unwrap_or_default();
3524 let value = iter.next().unwrap_or_default();
3525 with_executor(|exec| {
3526 // Array form: `arr+=elem` pushes a single element.
3527 // Routes through canonical assignaparam(name, [value],
3528 // ASSPM_AUGMENT) — Src/params.c:3357 c:3402-3412 augment
3529 // path prepends prior scalar / appends to existing array.
3530 if exec.array(&name).is_some() {
3531 let _ = crate::ported::params::assignaparam(
3532 &name,
3533 vec![value.clone()],
3534 crate::ported::zsh_h::ASSPM_AUGMENT,
3535 );
3536 #[cfg(feature = "recorder")]
3537 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3538 let ctx = exec.recorder_ctx();
3539 let attrs = exec.recorder_attrs_for(&name);
3540 emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
3541 }
3542 return;
3543 }
3544 if exec.assoc(&name).is_some() {
3545 eprintln!("zshrs: {}: cannot use += on assoc without (key val)", name);
3546 return;
3547 }
3548 // Scalar / integer / float form: route through canonical
3549 // assignsparam(name, value, ASSPM_AUGMENT) which
3550 // dispatches PM_TYPE — PM_SCALAR concats, PM_INTEGER
3551 // arith-adds (c:2775-2778), PM_FLOAT float-adds.
3552 let _ = crate::ported::params::assignsparam(
3553 &name,
3554 &value,
3555 crate::ported::zsh_h::ASSPM_AUGMENT,
3556 );
3557 #[cfg(feature = "recorder")]
3558 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3559 let ctx = exec.recorder_ctx();
3560 let attrs = exec.recorder_attrs_for(&name);
3561 // Re-read the canonical value via get_variable for the
3562 // recorder bundle (assignsparam may have transformed it
3563 // through integer/float arithmetic).
3564 let final_val = exec.get_variable(&name);
3565 let lower = name.to_ascii_lowercase();
3566 if matches!(
3567 lower.as_str(),
3568 "path" | "fpath" | "manpath" | "module_path" | "cdpath"
3569 ) {
3570 emit_path_or_assign(&name, std::slice::from_ref(&final_val), attrs, true, &ctx);
3571 } else {
3572 crate::recorder::emit_assign_typed(&name, &final_val, attrs, ctx);
3573 }
3574 }
3575 });
3576 Value::Status(0)
3577 });
3578
3579 // BUILTIN_SET_VAR — `name=value` runtime scalar assignment.
3580 // PURE PASSTHRU: hand to canonical `setsparam` (C port of
3581 // `Src/params.c::setsparam`). That walks assignsparam →
3582 // assignstrvalue which already does:
3583 // - readonly rejection (zerr + errflag at c:2701)
3584 // - PM_INTEGER math evaluation (mathevali at c:3590)
3585 // - PM_EFLOAT / PM_FFLOAT float coercion (c:3608)
3586 // - PM_LOWER / PM_UPPER case fold (via setstrvalue)
3587 // - GSU special-param dispatch (homesetfn / ifssetfn / etc.)
3588 // - allexport env mirror via the PM_EXPORTED setfn
3589 //
3590 // Bridge-only concerns kept here:
3591 // - inline_env_stack (zsh `X=foo cmd` scoped env)
3592 // - recorder emission (PFA-SMR)
3593 // - vm.last_status propagation for `a=$(cmd)` exit-code chaining
3594 vm.register_builtin(BUILTIN_SET_VAR, |vm, argc| {
3595 // Snapshot the raw Values BEFORE pop_args's to_str
3596 // flattening — needed to distinguish Int (arith assignment,
3597 // integer-typed param) from Str (scalar assignment).
3598 let mut raw_values: Vec<fusevm::Value> = Vec::with_capacity(argc as usize);
3599 for _ in 0..argc {
3600 raw_values.push(vm.pop());
3601 }
3602 raw_values.reverse();
3603 let name = raw_values.first().map(|v| v.to_str()).unwrap_or_default();
3604 let value_raw = raw_values.get(1).cloned();
3605 let value = value_raw.as_ref().map(|v| v.to_str()).unwrap_or_default();
3606 // c:Src/params.c — when the bytecode hands us an Int value
3607 // (only the arith assignment paths emit this — `(( X = N ))`
3608 // is the canonical site), route through setiparam so the
3609 // param ends up PM_INTEGER + inherits the math layer's
3610 // `lastbase` for display formatting (`(( X = 16#ff ));
3611 // echo \$X` → `16#FF`). Scalar `X=val` and `$((expr))`
3612 // assignments still take the setsparam path below.
3613 let int_assign = matches!(value_raw, Some(fusevm::Value::Int(_)));
3614 let float_assign = matches!(value_raw, Some(fusevm::Value::Float(_)));
3615 with_executor(|exec| {
3616 // c:Src/params.c assignsparam — PM_READONLY rejection
3617 // BEFORE any env mutation. The inline-env-prefix path
3618 // (`X=2 env`) called env::set_var unconditionally before
3619 // the readonly check fired in setsparam, so the OS env
3620 // got X=2 even though the assignment errored. env then
3621 // inherited the polluted env from fork, leaking the
3622 // attempted override past the readonly guard. Mirror
3623 // C's order: readonly check → zerr → bail; only mutate
3624 // env when the assignment is admissible. Bug #551
3625 // (security-relevant).
3626 if exec.is_readonly_param(&name) {
3627 crate::ported::utils::zerr(&format!("read-only variable: {}", name));
3628 return;
3629 }
3630 // Inline-assignment frame tracking (`X=foo cmd` reverts on
3631 // command return).
3632 if !exec.inline_env_stack.is_empty() {
3633 let prev_var = crate::ported::params::getsparam(&name);
3634 let prev_env = env::var(&name).ok();
3635 exec.inline_env_stack
3636 .last_mut()
3637 .unwrap()
3638 .push((name.clone(), prev_var, prev_env));
3639 env::set_var(&name, &value);
3640 }
3641 // Canonical setsparam handles readonly, integer math, case
3642 // fold, GSU dispatch. For Int values (arith assigns) route
3643 // through setiparam so the param is PM_INTEGER + inherits
3644 // the math layer's lastbase for display formatting. For
3645 // Float (arith assigns producing MN_FLOAT) route through
3646 // setnparam so the param is PM_FFLOAT — `(( b = a * 2 ))`
3647 // with scalar `a="3.14"` should create b as typeset -F,
3648 // not a scalar holding "6.28".
3649 if int_assign {
3650 if let Some(fusevm::Value::Int(i)) = value_raw {
3651 crate::ported::params::setiparam(&name, i);
3652 } else {
3653 crate::ported::params::setsparam(&name, &value);
3654 }
3655 } else if float_assign {
3656 if let Some(fusevm::Value::Float(f)) = value_raw {
3657 // ArithCompiler returns Value::Float whenever any
3658 // operand came through Str (BUILTIN_GET_VAR yields
3659 // Value::Str even for integer-shaped scalars). To
3660 // avoid forcing every `(( b = a + 3 ))` to PM_FFLOAT
3661 // when `a="5"` (integer-shaped), detect integer-
3662 // valued floats and route through setiparam instead.
3663 // True floats (non-integral) reach setnparam →
3664 // PM_FFLOAT so `typeset -p b` shows `typeset -F …`.
3665 if f.fract() == 0.0 && f.is_finite() && f.abs() <= i64::MAX as f64 {
3666 crate::ported::params::setiparam(&name, f as i64);
3667 } else {
3668 let mnval = crate::ported::math::mnumber {
3669 l: 0,
3670 d: f,
3671 type_: crate::ported::math::MN_FLOAT,
3672 };
3673 crate::ported::params::setnparam(&name, mnval);
3674 }
3675 } else {
3676 crate::ported::params::setsparam(&name, &value);
3677 }
3678 } else {
3679 crate::ported::params::setsparam(&name, &value);
3680 }
3681 // PM_EXPORTED / allexport env mirror — read AFTER setsparam
3682 // so the flag bit reflects any GSU setfn side-effects.
3683 let allexport = opt_state_get("allexport").unwrap_or(false);
3684 let already_exported =
3685 (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
3686 if allexport || already_exported {
3687 env::set_var(&name, &value);
3688 }
3689 #[cfg(feature = "recorder")]
3690 if crate::recorder::is_enabled()
3691 && exec.local_scope_depth == 0
3692 && !matches!(
3693 name.as_str(),
3694 "PPID" | "LINENO" | "ZSH_ARGZERO" | "argv0" | "ARGC" | "?" | "_" | "RANDOM"
3695 )
3696 {
3697 let ctx = exec.recorder_ctx();
3698 let attrs = exec.recorder_attrs_for(&name);
3699 crate::recorder::emit_assign_typed(&name, &value, attrs, ctx);
3700 }
3701 });
3702 Value::Status(vm.last_status)
3703 });
3704
3705 // Pre-compiled function registration — used by compile_zsh.rs's
3706 // FuncDef path. Stack: [name, base64-bincode-of-Chunk]. We decode
3707 // the base64, deserialize the Chunk, and store directly in
3708 // executor.functions_compiled. Bypasses the ShellCommand JSON layer.
3709 // BUILTIN_VAR_EXISTS — `[[ -v name ]]` set-test.
3710 // PURE PASSTHRU: build `${+name}` and route through canonical
3711 // `subst::paramsubst` which returns "1" for set / "0" for unset
3712 // (C port of `Src/subst.c::paramsubst` plus-prefix arm).
3713 // paramsubst handles all the shapes the 48-line hand-roll did:
3714 // - bare scalar / array / assoc
3715 // - subscripted `a[N]` / `h[key]`
3716 // - positional params (any digit-only name)
3717 // - env-var fallback (`HOME` set via getsparam → lookup_special_var)
3718 vm.register_builtin(BUILTIN_VAR_EXISTS, |vm, _argc| {
3719 let name = vm.pop().to_str();
3720 let body = format!("${{+{}}}", name);
3721 let mut ret_flags: i32 = 0;
3722 let (_full, _pos, nodes) =
3723 crate::ported::subst::paramsubst(&body, 0, false, 0i32, &mut ret_flags);
3724 let result = nodes.into_iter().next().unwrap_or_default();
3725 Value::Bool(result == "1")
3726 });
3727
3728 // `time { compound; ... }` — runs the sub-chunk and prints elapsed
3729 // wall-clock time. zsh's full `time` also tracks user/system CPU via
3730 // getrusage on the *child*; we approximate via wall-time only since
3731 // the sub-chunk runs in-process (no fork). Output format matches
3732 // `time simple-cmd` (already implemented elsewhere via exectime).
3733 vm.register_builtin(BUILTIN_TIME_SUBLIST, |vm, argc| {
3734 let sub_idx = vm.pop().to_int() as usize;
3735 // c:Src/jobs.c:1028-1029 — `pn->text` arg to printtime. argc==2
3736 // means the compiler also pushed a desc string (bug #66 fix);
3737 // older callers with argc==1 push only sub_idx and we synthesize
3738 // an empty desc for backward compat with cached bytecode that
3739 // predates the desc-threading patch.
3740 let desc = if argc >= 2 {
3741 vm.pop().to_str().to_string()
3742 } else {
3743 String::new()
3744 };
3745 let chunk_opt = vm.chunk.sub_chunks.get(sub_idx).cloned();
3746 let Some(chunk) = chunk_opt else {
3747 return Value::Status(0);
3748 };
3749 // c:Src/jobs.c:1968 — `getrusage(RUSAGE_CHILDREN, &ti)` before
3750 // and after the timed sublist gives accurate per-stage user/sys
3751 // CPU. Wall-time-only approximation (0.7×/0.1× fudge factors)
3752 // produced bogus user/sys columns and ignored TIMEFMT. Bug #66
3753 // in docs/BUGS.md.
3754 let ru_before: libc::rusage = unsafe {
3755 let mut r: libc::rusage = std::mem::zeroed();
3756 libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
3757 r
3758 };
3759 let start = Instant::now();
3760 crate::fusevm_disasm::maybe_print_stdout("time_sublist", &chunk);
3761 let mut sub_vm = fusevm::VM::new(chunk);
3762 register_builtins(&mut sub_vm);
3763 let _ = sub_vm.run();
3764 let status = sub_vm.last_status;
3765 let elapsed = start.elapsed();
3766 let ru_after: libc::rusage = unsafe {
3767 let mut r: libc::rusage = std::mem::zeroed();
3768 libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
3769 r
3770 };
3771 // Delta children rusage = timed work's CPU.
3772 let mut delta = ru_after;
3773 let sub = |a: libc::timeval, b: libc::timeval| -> libc::timeval {
3774 let mut sec = a.tv_sec - b.tv_sec;
3775 let mut usec = a.tv_usec as i64 - b.tv_usec as i64;
3776 if usec < 0 {
3777 sec -= 1;
3778 usec += 1_000_000;
3779 }
3780 libc::timeval {
3781 tv_sec: sec,
3782 tv_usec: usec as libc::suseconds_t,
3783 }
3784 };
3785 delta.ru_utime = sub(ru_after.ru_utime, ru_before.ru_utime);
3786 delta.ru_stime = sub(ru_after.ru_stime, ru_before.ru_stime);
3787 let ti = crate::ported::zsh_h::timeinfo::from_rusage(&delta);
3788 // c:Src/jobs.c:808-809 — `s = getsparam("TIMEFMT"); s ||
3789 // DEFAULT_TIMEFMT`. Honor user-set TIMEFMT, fall back to the
3790 // canonical default.
3791 let fmt = crate::ported::params::getsparam("TIMEFMT")
3792 .unwrap_or_else(|| crate::ported::zsh_system_h::DEFAULT_TIMEFMT.to_string());
3793 // c:Src/jobs.c:768 `desc` arg — for the `time { sublist }` /
3794 // `time simple-cmd` keyword path, zsh passes the sublist's
3795 // source text (used by %J via printtime). The compiler now
3796 // threads the rendered source text through as the desc operand
3797 // (compile_zsh.rs Time arm, argc==2 form). Bug #66.
3798 let line = crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
3799 eprintln!("{}", line);
3800 Value::Status(status)
3801 });
3802
3803 // `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocator.
3804 // Stack: [path, varid, op_byte]. Opens path with the appropriate mode
3805 // and stores the resulting fd number in $varid as a string. We use
3806 // a high starting fd (10+) by allocating then dup'ing — matches zsh's
3807 // "fresh fd >= 10" promise so subsequent commands don't collide on
3808 // stdin/out/err.
3809 vm.register_builtin(BUILTIN_OPEN_NAMED_FD, |vm, _argc| {
3810 let op_byte = vm.pop().to_int() as u8;
3811 let varid = vm.pop().to_str();
3812 let path = vm.pop().to_str();
3813 let path_c = match CString::new(path.clone()) {
3814 Ok(c) => c,
3815 Err(_) => return Value::Status(1),
3816 };
3817 let flags = match op_byte {
3818 b if b == fusevm::op::redirect_op::READ => libc::O_RDONLY,
3819 b if b == fusevm::op::redirect_op::WRITE || b == fusevm::op::redirect_op::CLOBBER => {
3820 libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC
3821 }
3822 b if b == fusevm::op::redirect_op::APPEND => {
3823 libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND
3824 }
3825 b if b == fusevm::op::redirect_op::READ_WRITE => libc::O_RDWR | libc::O_CREAT,
3826 _ => return Value::Status(1),
3827 };
3828 let fd = unsafe { libc::open(path_c.as_ptr(), flags, 0o644) };
3829 if fd < 0 {
3830 return Value::Status(1);
3831 }
3832 // Re-dup to fd >= 10 so positional fds (0/1/2/etc.) stay free.
3833 let new_fd = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
3834 let final_fd = if new_fd >= 10 {
3835 unsafe { libc::close(fd) };
3836 new_fd
3837 } else {
3838 fd
3839 };
3840 with_executor(|exec| {
3841 exec.set_scalar(varid, final_fd.to_string());
3842 });
3843 Value::Status(0)
3844 });
3845
3846 // BUILTIN_SET_TRY_BLOCK_ERROR — capture the try-block's exit
3847 // status into `__zshrs_try_block_saved_status` (a scratch
3848 // scalar) so the always-arm can later restore it. Also set
3849 // `TRY_BLOCK_ERROR` per zsh semantics: it stays at -1 unless
3850 // the try-block fired an explicit error (errflag), per
3851 // c:Src/exec.c execlist's WC_TRYBLOCK arm.
3852 vm.register_builtin(BUILTIN_SET_TRY_BLOCK_ERROR, |vm, _argc| {
3853 use std::sync::atomic::Ordering;
3854 let vm_status = vm.last_status;
3855 let errored = (crate::ported::utils::errflag.load(Ordering::Relaxed)
3856 & crate::ported::zsh_h::ERRFLAG_ERROR)
3857 != 0;
3858 // c:Src/exec.c WC_TRYBLOCK — the always-arm runs with a
3859 // clean escape state. Snapshot RETFLAG / BREAKS / CONTFLAG /
3860 // EXIT_PENDING here and clear them; RESTORE_TRY_BLOCK_STATUS
3861 // re-applies them at always-arm exit so the propagation jump
3862 // emitted by compile_zsh fires correctly.
3863 let ret_save = crate::ported::builtin::RETFLAG.swap(0, Ordering::Relaxed);
3864 let brk_save = crate::ported::builtin::BREAKS.swap(0, Ordering::Relaxed);
3865 let cont_save = crate::ported::builtin::CONTFLAG.swap(0, Ordering::Relaxed);
3866 let exit_save = crate::ported::builtin::EXIT_PENDING.swap(0, Ordering::Relaxed);
3867 TRY_ESCAPE_SAVE.with(|s| {
3868 s.borrow_mut()
3869 .push((ret_save, brk_save, cont_save, exit_save));
3870 });
3871 with_executor(|exec| {
3872 exec.set_scalar(
3873 "__zshrs_try_block_saved_status".to_string(),
3874 vm_status.to_string(),
3875 );
3876 // c:Src/exec.c WC_TRYBLOCK — TRY_BLOCK_ERROR reflects
3877 // the errflag state at try-block exit. zsh leaves it
3878 // at -1 (sentinel) when the block completed normally,
3879 // and sets to last_status when errflag triggered the
3880 // unwind. The always-arm can reset it to 0 to
3881 // SWALLOW the error.
3882 if errored {
3883 exec.set_scalar("TRY_BLOCK_ERROR".to_string(), vm_status.to_string());
3884 // Clear errflag so always-arm runs cleanly.
3885 crate::ported::utils::errflag
3886 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
3887 } else {
3888 // c:Src/Modules/parameter.c — TRY_BLOCK_ERROR reads
3889 // as 0 inside an always-arm when no error fired
3890 // (per zsh's PM_INTEGER default-zero). The previous
3891 // port set -1 (the C internal "no try yet" sentinel)
3892 // which leaked to user-visible reads.
3893 exec.set_scalar("TRY_BLOCK_ERROR".to_string(), "0".to_string());
3894 }
3895 });
3896 Value::Status(0)
3897 });
3898
3899 // BUILTIN_BEGIN_INLINE_ENV / END_INLINE_ENV — wrap an
3900 // inline-assignment-prefixed command (`X=foo Y=bar cmd`):
3901 // BEGIN pushes a save frame; SET_VAR fires for each assign and
3902 // ALSO env::set_var's the value (visible to cmd's child); the
3903 // command runs; END pops the frame and restores both shell-var
3904 // and process-env state. Direct port of zsh's addvars() →
3905 // execute_simple → restore-after-exec contract.
3906 vm.register_builtin(BUILTIN_BEGIN_INLINE_ENV, |_vm, _argc| {
3907 with_executor(|exec| {
3908 exec.inline_env_stack.push(Vec::new());
3909 });
3910 Value::Status(0)
3911 });
3912 vm.register_builtin(BUILTIN_END_INLINE_ENV, |_vm, _argc| {
3913 with_executor(|exec| {
3914 if let Some(frame) = exec.inline_env_stack.pop() {
3915 for (name, prev_var, prev_env) in frame.into_iter().rev() {
3916 match prev_var {
3917 Some(v) => {
3918 exec.set_scalar(name.clone(), v);
3919 }
3920 None => {
3921 exec.unset_scalar(&name);
3922 }
3923 }
3924 match prev_env {
3925 Some(v) => env::set_var(&name, &v),
3926 None => env::remove_var(&name),
3927 }
3928 }
3929 }
3930 });
3931 Value::Status(0)
3932 });
3933
3934 // BUILTIN_RESTORE_TRY_BLOCK_STATUS — emitted at the end of an
3935 // `always` arm. Per zshmisc, the exit status of the entire
3936 // `{ try } always { finally }` construct is the try-list's
3937 // status, regardless of what happens in the always-list (the
3938 // exception is `return`/`exit` inside always, which short-
3939 // circuits and the cleanup is the only thing that runs). So
3940 // restore TRY_BLOCK_ERROR unconditionally — the always-list's
3941 // exit status is discarded for the construct.
3942 vm.register_builtin(BUILTIN_RESTORE_TRY_BLOCK_STATUS, |_vm, _argc| {
3943 use std::sync::atomic::Ordering;
3944 // c:Src/exec.c — the entire `{try} always {…}` construct's
3945 // exit status is the try-block's last status. Per zsh
3946 // semantics this carries through regardless of what the
3947 // always-arm did (including reads/writes of TRY_BLOCK_ERROR
3948 // — those affect later commands' visible value but don't
3949 // override the construct's exit). The "swallow" idiom in
3950 // C is gated on errflag state at always-arm exit, not on
3951 // TBE's literal value; full fidelity needs more state and
3952 // is deferred.
3953 let saved = with_executor(|exec| {
3954 exec.scalar("__zshrs_try_block_saved_status")
3955 .and_then(|s| s.parse::<i32>().ok())
3956 .unwrap_or(0)
3957 });
3958 // Re-apply the escape flags captured by SET_TRY_BLOCK_ERROR.
3959 // If the always-arm itself fired return/break/continue/exit,
3960 // its handler already overwrote the canonical atomics; let
3961 // those win — the always-arm's own escape always takes
3962 // priority over the try-block's deferred one.
3963 if let Some((ret, brk, cont, exit_p)) = TRY_ESCAPE_SAVE.with(|s| s.borrow_mut().pop()) {
3964 if crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) == 0 {
3965 crate::ported::builtin::RETFLAG.store(ret, Ordering::Relaxed);
3966 }
3967 if crate::ported::builtin::BREAKS.load(Ordering::Relaxed) == 0 {
3968 crate::ported::builtin::BREAKS.store(brk, Ordering::Relaxed);
3969 }
3970 if crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed) == 0 {
3971 crate::ported::builtin::CONTFLAG.store(cont, Ordering::Relaxed);
3972 }
3973 if crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) == 0 {
3974 crate::ported::builtin::EXIT_PENDING.store(exit_p, Ordering::Relaxed);
3975 }
3976 }
3977 Value::Status(saved)
3978 });
3979
3980 vm.register_builtin(BUILTIN_IS_TTY, |vm, _argc| {
3981 let fd_str = vm.pop().to_str();
3982 let fd: i32 = fd_str.trim().parse().unwrap_or(-1);
3983 let is_tty = if fd < 0 {
3984 false
3985 } else {
3986 unsafe { libc::isatty(fd) != 0 }
3987 };
3988 Value::Bool(is_tty)
3989 });
3990
3991 // Set $LINENO before executing the next statement. Direct
3992 // port of zsh's `lineno` global tracking from Src/input.c
3993 // (`if ((inbufflags & INP_LINENO) || !strin) && c == '\n')
3994 // lineno++;`). The compiler emits one of these before each
3995 // top-level pipe in `compile_sublist`, carrying the line
3996 // number captured by the parser at `ZshPipe.lineno`. Pops
3997 // [n], updates `$LINENO` in the variable table.
3998 vm.register_builtin(BUILTIN_SET_LINENO, |vm, _argc| {
3999 let n = vm.pop().to_int();
4000 // c:Src/exec.c:lineno = N — direct write to the param's
4001 // u_val. Cannot go through setsparam because LINENO carries
4002 // PM_READONLY (so `(t)LINENO` reads `integer-readonly-special`
4003 // per zsh); setsparam → assignstrvalue's PM_READONLY guard
4004 // would reject the internal write. C zsh handles this via the
4005 // PM_SPECIAL GSU vtable's setfn callback which bypasses the
4006 // generic readonly check; the Rust port writes the canonical
4007 // field directly instead.
4008 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
4009 if let Some(pm) = tab.get_mut("LINENO") {
4010 pm.u_val = n;
4011 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
4012 }
4013 }
4014 // Mirror to the file-static `lineno` (utils.c:121) that
4015 // zerrmsg reads at utils.c:301 for the `:N: msg` prefix.
4016 crate::ported::utils::set_lineno(n as i32);
4017 // DAP hook — checks breakpoints / step mode / pause-request
4018 // for the line we just landed on. O(1) no-op when DAP is off
4019 // (single atomic load on a OnceLock). Inside `--dap` mode
4020 // this is the call that blocks the executor on a Condvar
4021 // until the IDE sends `continue`. Mirrors strykelang's
4022 // `debugger.should_stop(line) → debugger.prompt(...)` flow.
4023 crate::extensions::dap::check_line(n as u32);
4024 Value::Status(0)
4025 });
4026
4027 // Direct port of Src/prompt.c:1623 cmdpush. Token is a `CS_*`
4028 // value (zsh.h:2775-2806) emitted by compile_zsh around each
4029 // compound command (if/while/[[…]]/((…))/$(…)) and consumed by
4030 // `%_` in PS4 / prompt expansion.
4031 vm.register_builtin(BUILTIN_CMD_PUSH, |vm, _argc| {
4032 let token = vm.pop().to_int() as u8;
4033 // Route through canonical cmdpush (Src/prompt.c:1623). The
4034 // prompt expander reads from the file-static `CMDSTACK` at
4035 // `prompt.rs:2006`, not `exec.cmd_stack` — without this,
4036 // `%_` in PS4 saw an empty stack during xtrace.
4037 if (token as i32) < crate::ported::zsh_h::CS_COUNT {
4038 crate::ported::prompt::cmdpush(token);
4039 }
4040 Value::Status(0)
4041 });
4042
4043 // Direct port of Src/prompt.c:1631 cmdpop.
4044 vm.register_builtin(BUILTIN_CMD_POP, |_vm, _argc| {
4045 crate::ported::prompt::cmdpop();
4046 Value::Status(0)
4047 });
4048
4049 vm.register_builtin(BUILTIN_OPTION_SET, |vm, _argc| {
4050 let name = vm.pop().to_str();
4051 // Direct port of `optison(char *name, char *s)` at Src/cond.c:502 — `[[ -o NAME ]]`
4052 // reads through the same `opts[]` array that `setopt NAME`
4053 // writes via `dosetopt`. Earlier code read a duplicate Executor
4054 // HashMap which never saw `bin_setopt`'s writes (those land in
4055 // `OPTS_LIVE` via `opt_state_set`). Routing through the canonical
4056 // C port restores the single-store invariant: one `opts[]`,
4057 // shared between setopt/unsetopt and `[[ -o ]]`.
4058 let r = crate::ported::cond::optison("test", &name); // c:cond.c:502
4059 match r {
4060 0 => Value::Bool(true), // c:cond.c:520 set
4061 1 => Value::Bool(false), // c:cond.c:518/520 unset
4062 _ => {
4063 // c:cond.c:514 — unknown option: zwarnnam emitted by
4064 // optison itself when POSIXBUILTINS is unset; mirror to
4065 // stderr here for parity with the earlier diagnostic.
4066 eprintln!("{}:1: no such option: {}", shname(), name);
4067 Value::Bool(false)
4068 }
4069 }
4070 });
4071 // Tri-state `-o` for compile_cond's direct status path. Returns
4072 // 0 / 1 / 3 as a Value::Int that compile_cond consumes via
4073 // Op::SetStatus. Mirrors zsh's `[[ -o invalid ]]` returning $?=3.
4074 vm.register_builtin(BUILTIN_OPTION_CHECK_TRISTATE, |vm, _argc| {
4075 let name = vm.pop().to_str();
4076 let r = crate::ported::cond::optison("test", &name); // c:cond.c:502
4077 // optison itself prints the diagnostic via zwarnnam when r=3
4078 // and POSIXBUILTINS is unset (the canonical path). Don't
4079 // double-emit here. r is already 0/1/3.
4080 Value::Int(r as i64)
4081 });
4082
4083 // BUILTIN_PARAM_FILTER — `${var:#pat}` / `${var:|name}` etc.
4084 // PURE PASSTHRU: rebuild `${name:#pat}` and route to paramsubst.
4085 vm.register_builtin(BUILTIN_PARAM_FILTER, |vm, _argc| {
4086 let pattern = vm.pop().to_str();
4087 let name = vm.pop().to_str();
4088 let body = format!("${{{}:#{}}}", name, pattern);
4089 paramsubst_to_value(&body)
4090 });
4091
4092 // `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()`
4093 // — subscripted-array assign with array RHS. Stack pushed by
4094 // compile_assign as: [elem0, elem1, …, elemN-1, name, key].
4095 vm.register_builtin(BUILTIN_SET_SUBSCRIPT_RANGE, |vm, argc| {
4096 let n = argc as usize;
4097 let mut popped: Vec<Value> = Vec::with_capacity(n);
4098 for _ in 0..n {
4099 popped.push(vm.pop());
4100 }
4101 popped.reverse();
4102 if popped.len() < 2 {
4103 return Value::Status(1);
4104 }
4105 let key = popped.pop().unwrap().to_str();
4106 let name = popped.pop().unwrap().to_str();
4107 let mut values: Vec<String> = Vec::new();
4108 for v in popped {
4109 match v {
4110 Value::Array(items) => {
4111 for it in items {
4112 values.push(it.to_str());
4113 }
4114 }
4115 other => values.push(other.to_str()),
4116 }
4117 }
4118 with_executor(|exec| {
4119 // Parse subscript: slice `lo,hi` or single index `i`.
4120 // setarrvalue (Src/params.c:2895) expects 1-based start/
4121 // end inclusive where start==end means replace one
4122 // element. Negative bounds translate to len+n+1 (1-based).
4123 //
4124 // c:Src/params.c — the END side accepts 0 as a valid value
4125 // that signals "insert BEFORE start position" (the canonical
4126 // `a[N,N-1]=val` prepend / mid-insert idiom). Bug #275 in
4127 // docs/BUGS.md: the previous Rust port clamped end up to 1,
4128 // collapsing `a[1,0]=(X Y)` into `a[1,1]=(X Y)` which
4129 // OVERWRITES position 1 instead of prepending. Provide two
4130 // translators — start_translate clamps to 1 (1-based);
4131 // end_translate keeps 0 intact so the splice in
4132 // setarrvalue (start_idx=0..end_idx=0) inserts at the front.
4133 // Bug #589: for scalars (no array), use the scalar's char
4134 // count as `len` so negative-index translation (`a[2,-1]`)
4135 // computes against the actual string length, not 0.
4136 let len = exec
4137 .array(&name)
4138 .map(|a| a.len() as i64)
4139 .or_else(|| {
4140 crate::ported::params::paramtab()
4141 .read()
4142 .ok()
4143 .and_then(|t| {
4144 t.get(&name).and_then(|pm| {
4145 if crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
4146 == crate::ported::zsh_h::PM_SCALAR
4147 {
4148 pm.u_str
4149 .as_ref()
4150 .map(|s| s.chars().count() as i64)
4151 } else {
4152 None
4153 }
4154 })
4155 })
4156 })
4157 .unwrap_or(0);
4158 let start_translate = |raw: i64| -> i32 {
4159 if raw < 0 {
4160 (len + raw + 1).max(1) as i32
4161 } else {
4162 raw.max(1) as i32
4163 }
4164 };
4165 let end_translate = |raw: i64| -> i32 {
4166 if raw < 0 {
4167 (len + raw + 1).max(0) as i32
4168 } else {
4169 raw.max(0) as i32
4170 }
4171 };
4172 // c:Src/params.c — KSH_ARRAYS option flips array subscripts
4173 // from 1-based to 0-based. setarrvalue expects 1-based
4174 // inclusive bounds, so under KSH_ARRAYS we shift positive
4175 // inputs by +1 before translation. Negative bounds left
4176 // alone (count from end). Sibling of #610/#611/#612.
4177 // Bug #613.
4178 let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
4179 let ksh_shift = |raw: i64| -> i64 {
4180 if ksh_arrays && raw >= 0 { raw + 1 } else { raw }
4181 };
4182 let (start, end) = if let Some((s_str, e_str)) = key.split_once(',') {
4183 let s = ksh_shift(s_str.trim().parse::<i64>().unwrap_or(0));
4184 let e = ksh_shift(e_str.trim().parse::<i64>().unwrap_or(0));
4185 (start_translate(s), end_translate(e))
4186 } else {
4187 let i = ksh_shift(key.trim().parse::<i64>().unwrap_or(0));
4188 if i == 0 {
4189 return;
4190 }
4191 let n = start_translate(i);
4192 (n, n)
4193 };
4194 // Route through canonical setarrvalue (Src/params.c:2895).
4195 // It handles PM_READONLY rejection, PM_HASHED slice-error,
4196 // PM_ARRAY splice + bounds clamp + padding (c:2980+).
4197 let taken = match crate::ported::params::paramtab().write() {
4198 Ok(mut tab) => tab.remove(&name),
4199 Err(_) => None,
4200 };
4201 // c:Src/params.c:2748+ — PM_SCALAR with subscript range
4202 // SPLICES the value into the scalar's char string. Bug
4203 // #589: zshrs's slice handler always called setarrvalue,
4204 // erroring "attempt to assign array value to non-array"
4205 // for `a=hello; a[2,3]=XYZ`. Detect PM_SCALAR and route
4206 // through assignstrvalue (which does scalar splice via
4207 // the PM_SCALAR arm at params.rs:3709-3789).
4208 let is_scalar = taken.as_ref().map_or(false, |pm| {
4209 crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
4210 == crate::ported::zsh_h::PM_SCALAR
4211 });
4212 let mut v = crate::ported::zsh_h::value {
4213 pm: taken,
4214 arr: Vec::new(),
4215 scanflags: 0,
4216 valflags: 0,
4217 start,
4218 end,
4219 };
4220 if is_scalar {
4221 // Scalar splice — concat values, route through
4222 // assignstrvalue which dispatches by PM_TYPE.
4223 // start_translate returns 1-based positions; assignstrvalue's
4224 // PM_SCALAR arm at params.rs:3735+ expects 0-based start
4225 // (chars before start are kept) and 0-based end-exclusive
4226 // (chars from end are kept). Convert: start-=1.
4227 if v.start > 0 {
4228 v.start -= 1;
4229 }
4230 let val: String = values.join("");
4231 crate::ported::params::assignstrvalue(Some(&mut v), Some(val), 0);
4232 } else {
4233 crate::ported::params::setarrvalue(&mut v, values);
4234 }
4235 // Write the mutated Param back to paramtab — setarrvalue
4236 // mutated v.pm in-place; the prior `tab.remove(&name)` at
4237 // the top of this handler took ownership, so we re-insert
4238 // here. setarrvalue + this re-insert IS the canonical
4239 // store (Src/params.c:2895). No further mirror needed.
4240 if let Some(pm) = v.pm {
4241 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
4242 tab.insert(name, pm);
4243 }
4244 }
4245 });
4246 Value::Status(0)
4247 });
4248
4249 // BUILTIN_CONCAT_SPLICE — word-segment concat with first/last
4250 // sticking (default zsh splice semantics for `${arr[@]}`, `$@`).
4251 vm.register_builtin(BUILTIN_CONCAT_SPLICE, |vm, _argc| {
4252 let rhs = vm.pop();
4253 let lhs = vm.pop();
4254 match (lhs, rhs) {
4255 (Value::Array(mut la), Value::Array(ra)) => {
4256 if la.is_empty() {
4257 return Value::Array(ra);
4258 }
4259 if ra.is_empty() {
4260 return Value::Array(la);
4261 }
4262 // Last of la merges with first of ra; rest unchanged.
4263 let last_l = la.pop().unwrap();
4264 let mut ra_iter = ra.into_iter();
4265 let first_r = ra_iter.next().unwrap();
4266 let l_s = last_l.as_str_cow();
4267 let r_s = first_r.as_str_cow();
4268 let mut merged = String::with_capacity(l_s.len() + r_s.len());
4269 merged.push_str(&l_s);
4270 merged.push_str(&r_s);
4271 la.push(Value::str(merged));
4272 la.extend(ra_iter);
4273 Value::Array(la)
4274 }
4275 (Value::Array(mut la), rhs_scalar) => {
4276 // c:Src/subst.c paramsubst splice — empty array on
4277 // either side preserves the empty (zero words),
4278 // doesn't collapse into a single-empty-string scalar.
4279 // Bug #120 in docs/BUGS.md: empty array slice
4280 // concatenated with empty literal returned
4281 // Value::str("") which surfaced as one empty arg
4282 // instead of zero args.
4283 let rhs_s = rhs_scalar.as_str_cow();
4284 if la.is_empty() {
4285 if rhs_s.is_empty() {
4286 return Value::Array(Vec::new());
4287 }
4288 return Value::str(rhs_s.to_string());
4289 }
4290 let last = la.pop().unwrap();
4291 let l_s = last.as_str_cow();
4292 let mut s = String::with_capacity(l_s.len() + rhs_s.len());
4293 s.push_str(&l_s);
4294 s.push_str(&rhs_s);
4295 la.push(Value::str(s));
4296 Value::Array(la)
4297 }
4298 (lhs_scalar, Value::Array(mut ra)) => {
4299 let lhs_s = lhs_scalar.as_str_cow();
4300 if ra.is_empty() {
4301 // Empty-array RHS — preserve emptiness when the
4302 // LHS is also empty (no prefix to attach). Bug
4303 // #120 in docs/BUGS.md.
4304 if lhs_s.is_empty() {
4305 return Value::Array(Vec::new());
4306 }
4307 return Value::str(lhs_s.to_string());
4308 }
4309 let first = ra.remove(0);
4310 let r_s = first.as_str_cow();
4311 let mut s = String::with_capacity(lhs_s.len() + r_s.len());
4312 s.push_str(&lhs_s);
4313 s.push_str(&r_s);
4314 let mut out = Vec::with_capacity(ra.len() + 1);
4315 out.push(Value::str(s));
4316 out.extend(ra);
4317 Value::Array(out)
4318 }
4319 (lhs_s, rhs_s) => {
4320 let l = lhs_s.as_str_cow();
4321 let r = rhs_s.as_str_cow();
4322 let mut s = String::with_capacity(l.len() + r.len());
4323 s.push_str(&l);
4324 s.push_str(&r);
4325 Value::str(s)
4326 }
4327 }
4328 });
4329
4330 // BUILTIN_CONCAT_DISTRIBUTE — word-segment concat. With
4331 // rcexpandparam (zsh option), distributes element-wise (cartesian
4332 // product). Default mode: joins arrays with IFS first char to a
4333 // single scalar before concat, matching zsh's default unquoted
4334 // and DQ semantics. Direct port of Src/subst.c sepjoin path
4335 // (line ~1813) which gates element-vs-join on the rc_expand_param
4336 // option, defaulting to join.
4337 // BUILTIN_CONCAT_DISTRIBUTE_FORCED — same shape as
4338 // CONCAT_DISTRIBUTE, but always cartesian-distributes when one
4339 // side is Array. Used for compile-time-detected explicit
4340 // distribution forms (`${^arr}` etc.) where the source flag
4341 // overrides the rcexpandparam option default.
4342 vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE_FORCED, |vm, _argc| {
4343 let rhs = vm.pop();
4344 let lhs = vm.pop();
4345 match (lhs, rhs) {
4346 (Value::Array(la), Value::Array(ra)) => {
4347 if ra.is_empty() {
4348 return Value::Array(la);
4349 }
4350 if la.is_empty() {
4351 return Value::Array(ra);
4352 }
4353 let mut out = Vec::with_capacity(la.len() * ra.len());
4354 for a in &la {
4355 let a_s = a.as_str_cow();
4356 for b in &ra {
4357 let b_s = b.as_str_cow();
4358 let mut s = String::with_capacity(a_s.len() + b_s.len());
4359 s.push_str(&a_s);
4360 s.push_str(&b_s);
4361 out.push(Value::str(s));
4362 }
4363 }
4364 Value::Array(out)
4365 }
4366 (Value::Array(la), rhs_scalar) => {
4367 let r = rhs_scalar.as_str_cow();
4368 let out: Vec<Value> = la
4369 .into_iter()
4370 .map(|a| {
4371 let a_s = a.as_str_cow();
4372 let mut s = String::with_capacity(a_s.len() + r.len());
4373 s.push_str(&a_s);
4374 s.push_str(&r);
4375 Value::str(s)
4376 })
4377 .collect();
4378 Value::Array(out)
4379 }
4380 (lhs_scalar, Value::Array(ra)) => {
4381 let l = lhs_scalar.as_str_cow();
4382 let out: Vec<Value> = ra
4383 .into_iter()
4384 .map(|b| {
4385 let b_s = b.as_str_cow();
4386 let mut s = String::with_capacity(l.len() + b_s.len());
4387 s.push_str(&l);
4388 s.push_str(&b_s);
4389 Value::str(s)
4390 })
4391 .collect();
4392 Value::Array(out)
4393 }
4394 (lhs_s, rhs_s) => {
4395 let l = lhs_s.as_str_cow();
4396 let r = rhs_s.as_str_cow();
4397 let mut s = String::with_capacity(l.len() + r.len());
4398 s.push_str(&l);
4399 s.push_str(&r);
4400 Value::str(s)
4401 }
4402 }
4403 });
4404
4405 vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE, |vm, argc| {
4406 let rhs = vm.pop();
4407 let lhs = vm.pop();
4408 // c:Src/options.c — RC_EXPAND_PARAM applies to UNQUOTED
4409 // expansions only; inside DQ `"$foo${arr}bar"` joins via
4410 // $IFS[0] regardless of the option. The compiler emits
4411 // CallBuiltin(BUILTIN_CONCAT_DISTRIBUTE, 1) when the parent
4412 // word is DQ-wrapped (compile_zsh.rs parent_is_dq); the
4413 // default UNQUOTED path emits argc=2 (lhs + rhs). Treat
4414 // argc==1 as "force rc_expand off." Bug #246 in docs/BUGS.md.
4415 let dq_suppress = argc == 1;
4416 let rc_expand = !dq_suppress
4417 && with_executor(|exec| opt_state_get("rcexpandparam").unwrap_or(false));
4418 let ifs_first = || -> String {
4419 with_executor(|exec| {
4420 exec.get_variable("IFS")
4421 .chars()
4422 .next()
4423 .map(|c| c.to_string())
4424 .unwrap_or_else(|| " ".to_string())
4425 })
4426 };
4427 // Helper: join an Array to scalar via IFS-first.
4428 let join_arr = |arr: Vec<Value>| -> String {
4429 let sep = ifs_first();
4430 arr.iter()
4431 .map(|v| v.as_str_cow().into_owned())
4432 .collect::<Vec<_>>()
4433 .join(&sep)
4434 };
4435 if !rc_expand {
4436 // Default: join any Array side to scalar, then concat.
4437 let l = match lhs {
4438 Value::Array(a) => join_arr(a),
4439 other => other.as_str_cow().into_owned(),
4440 };
4441 let r = match rhs {
4442 Value::Array(a) => join_arr(a),
4443 other => other.as_str_cow().into_owned(),
4444 };
4445 let mut s = String::with_capacity(l.len() + r.len());
4446 s.push_str(&l);
4447 s.push_str(&r);
4448 return Value::str(s);
4449 }
4450 match (lhs, rhs) {
4451 (Value::Array(la), Value::Array(ra)) => {
4452 // Cartesian product: [a + b for a in la for b in ra].
4453 let mut out = Vec::with_capacity(la.len() * ra.len().max(1));
4454 if ra.is_empty() {
4455 return Value::Array(la);
4456 }
4457 if la.is_empty() {
4458 return Value::Array(ra);
4459 }
4460 for a in &la {
4461 let a_s = a.as_str_cow();
4462 for b in &ra {
4463 let b_s = b.as_str_cow();
4464 let mut s = String::with_capacity(a_s.len() + b_s.len());
4465 s.push_str(&a_s);
4466 s.push_str(&b_s);
4467 out.push(Value::str(s));
4468 }
4469 }
4470 Value::Array(out)
4471 }
4472 (Value::Array(la), rhs_scalar) => {
4473 let r = rhs_scalar.as_str_cow();
4474 let out: Vec<Value> = la
4475 .into_iter()
4476 .map(|a| {
4477 let a_s = a.as_str_cow();
4478 let mut s = String::with_capacity(a_s.len() + r.len());
4479 s.push_str(&a_s);
4480 s.push_str(&r);
4481 Value::str(s)
4482 })
4483 .collect();
4484 Value::Array(out)
4485 }
4486 (lhs_scalar, Value::Array(ra)) => {
4487 let l = lhs_scalar.as_str_cow();
4488 let out: Vec<Value> = ra
4489 .into_iter()
4490 .map(|b| {
4491 let b_s = b.as_str_cow();
4492 let mut s = String::with_capacity(l.len() + b_s.len());
4493 s.push_str(&l);
4494 s.push_str(&b_s);
4495 Value::str(s)
4496 })
4497 .collect();
4498 Value::Array(out)
4499 }
4500 (lhs_s, rhs_s) => {
4501 // Fast path: both scalar → identical to Op::Concat.
4502 let l = lhs_s.as_str_cow();
4503 let r = rhs_s.as_str_cow();
4504 let mut s = String::with_capacity(l.len() + r.len());
4505 s.push_str(&l);
4506 s.push_str(&r);
4507 Value::str(s)
4508 }
4509 }
4510 });
4511
4512 // `[[ a -ef b ]]` — same-inode test. Resolves both paths via fs::metadata
4513 // (follows symlinks the way zsh's -ef does) and compares (dev, inode).
4514 // Returns false on any I/O error (path missing, permission denied, etc.).
4515 vm.register_builtin(BUILTIN_SAME_FILE, |vm, _argc| {
4516 let b = vm.pop().to_str();
4517 let a = vm.pop().to_str();
4518 let same = match (fs::metadata(&a), fs::metadata(&b)) {
4519 (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
4520 _ => false,
4521 };
4522 Value::Bool(same)
4523 });
4524
4525 // `[[ -c path ]]` — character device.
4526 vm.register_builtin(BUILTIN_IS_CHARDEV, |vm, _argc| {
4527 let path = vm.pop().to_str();
4528 let result = fs::metadata(&path)
4529 .map(|m| m.file_type().is_char_device())
4530 .unwrap_or(false);
4531 Value::Bool(result)
4532 });
4533 // `[[ -b path ]]` — block device.
4534 vm.register_builtin(BUILTIN_IS_BLOCKDEV, |vm, _argc| {
4535 let path = vm.pop().to_str();
4536 let result = fs::metadata(&path)
4537 .map(|m| m.file_type().is_block_device())
4538 .unwrap_or(false);
4539 Value::Bool(result)
4540 });
4541 // `[[ -p path ]]` — FIFO (named pipe).
4542 vm.register_builtin(BUILTIN_IS_FIFO, |vm, _argc| {
4543 let path = vm.pop().to_str();
4544 let result = fs::metadata(&path)
4545 .map(|m| m.file_type().is_fifo())
4546 .unwrap_or(false);
4547 Value::Bool(result)
4548 });
4549 // `[[ -S path ]]` — socket.
4550 vm.register_builtin(BUILTIN_IS_SOCKET, |vm, _argc| {
4551 let path = vm.pop().to_str();
4552 let result = fs::symlink_metadata(&path)
4553 .map(|m| m.file_type().is_socket())
4554 .unwrap_or(false);
4555 Value::Bool(result)
4556 });
4557
4558 // `[[ -k path ]]` / `-u` / `-g` — sticky / setuid / setgid bit.
4559 vm.register_builtin(BUILTIN_HAS_STICKY, |vm, _argc| {
4560 let path = vm.pop().to_str();
4561 let result = fs::metadata(&path)
4562 .map(|m| m.permissions().mode() & libc::S_ISVTX as u32 != 0)
4563 .unwrap_or(false);
4564 Value::Bool(result)
4565 });
4566 vm.register_builtin(BUILTIN_HAS_SETUID, |vm, _argc| {
4567 let path = vm.pop().to_str();
4568 let result = fs::metadata(&path)
4569 .map(|m| m.permissions().mode() & libc::S_ISUID as u32 != 0)
4570 .unwrap_or(false);
4571 Value::Bool(result)
4572 });
4573 vm.register_builtin(BUILTIN_HAS_SETGID, |vm, _argc| {
4574 let path = vm.pop().to_str();
4575 let result = fs::metadata(&path)
4576 .map(|m| m.permissions().mode() & libc::S_ISGID as u32 != 0)
4577 .unwrap_or(false);
4578 Value::Bool(result)
4579 });
4580 vm.register_builtin(BUILTIN_OWNED_BY_USER, |vm, _argc| {
4581 let path = vm.pop().to_str();
4582 let euid = unsafe { libc::geteuid() };
4583 let result = fs::metadata(&path)
4584 .map(|m| m.uid() == euid)
4585 .unwrap_or(false);
4586 Value::Bool(result)
4587 });
4588 vm.register_builtin(BUILTIN_OWNED_BY_GROUP, |vm, _argc| {
4589 let path = vm.pop().to_str();
4590 let egid = unsafe { libc::getegid() };
4591 let result = fs::metadata(&path)
4592 .map(|m| m.gid() == egid)
4593 .unwrap_or(false);
4594 Value::Bool(result)
4595 });
4596
4597 // `[[ -N path ]]` — file's access time is NOT newer than its
4598 // modification time (zsh man: "true if file exists and its
4599 // access time is not newer than its modification time"). Used
4600 // by zsh's mailbox-watching code. The semantic is `atime <=
4601 // mtime` (equivalent to `mtime >= atime`) — equal counts as
4602 // true, which a strict `mtime > atime` check missed for newly
4603 // created files where both stamps are identical.
4604 vm.register_builtin(BUILTIN_FILE_MODIFIED_SINCE_ACCESS, |vm, _argc| {
4605 let path = vm.pop().to_str();
4606 let result = fs::metadata(&path)
4607 .map(|m| m.atime() <= m.mtime())
4608 .unwrap_or(false);
4609 Value::Bool(result)
4610 });
4611
4612 // `[[ a -nt b ]]` — true if `a`'s mtime is strictly later than `b`'s.
4613 // BOTH files must exist; if either is missing the result is false.
4614 // (Earlier behavior was bash's "missing == infinitely-old"; zsh
4615 // strictly requires both files to exist.)
4616 vm.register_builtin(BUILTIN_FILE_NEWER, |vm, _argc| {
4617 let b = vm.pop().to_str();
4618 let a = vm.pop().to_str();
4619 // Use SystemTime modified() for nanosecond precision —
4620 // MetadataExt::mtime() returns seconds only, so two files
4621 // touched within the same second compared equal even when
4622 // 500ms apart. zsh tracks ns and uses `>=` for ties (touching
4623 // a then b in quick succession should still report b newer).
4624 let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
4625 let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
4626 let result = match (ta, tb) {
4627 (Some(ta), Some(tb)) => ta > tb,
4628 _ => false,
4629 };
4630 Value::Bool(result)
4631 });
4632
4633 // `[[ a -ot b ]]` — mirror of -nt. Same both-must-exist contract.
4634 vm.register_builtin(BUILTIN_FILE_OLDER, |vm, _argc| {
4635 let b = vm.pop().to_str();
4636 let a = vm.pop().to_str();
4637 let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
4638 let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
4639 let result = match (ta, tb) {
4640 (Some(ta), Some(tb)) => ta < tb,
4641 _ => false,
4642 };
4643 Value::Bool(result)
4644 });
4645
4646 // `set -e` / `setopt errexit` post-command check. Compiler emits
4647 // this after each top-level command's SetStatus (skipped inside
4648 // conditionals/pipelines/&&||/`!`). If errexit is on AND the last
4649 // command exited non-zero AND it's not a `return` from a function,
4650 // exit the shell with that status.
4651 // `set -x` / `setopt xtrace` — print each command before it runs.
4652 // The compiler emits this BEFORE the actual builtin/external call
4653 // with the command's literal text as a single string arg. We
4654 // print to stderr if xtrace is on. Honors `$PS4` (default `+ `).
4655 //
4656 // ── XTRACE flow control ────────────────────────────────────────
4657 // Mirror of C zsh's `doneps4` flag in execcmd_exec (Src/exec.c).
4658 // When an assignment trace fires (XTRACE_ASSIGN), it emits PS4
4659 // and sets this flag so the subsequent XTRACE_ARGS skips its own
4660 // PS4 emission — the assignment + command end up on the SAME
4661 // line: `<PS4>a=1 echo hello\n`. XTRACE_ARGS / XTRACE_NEWLINE
4662 // reset the flag after emitting the trailing `\n`.
4663 vm.register_builtin(BUILTIN_XTRACE_IS_ON, |_vm, _argc| {
4664 // Push live xtrace state. Caller pairs this with JumpIfFalse
4665 // to skip the trace-string-building block when xtrace is off,
4666 // avoiding side-effectful operand re-evaluation. Bug #159 in
4667 // docs/BUGS.md.
4668 let on = with_executor(|_| opt_state_get("xtrace").unwrap_or(false));
4669 Value::Int(if on { 1 } else { 0 })
4670 });
4671
4672 vm.register_builtin(BUILTIN_XTRACE_LINE, |vm, _argc| {
4673 let cmd_text = vm.pop().to_str();
4674 // Sync exec.last_status with the live vm.last_status BEFORE
4675 // the next command runs. Direct port of the zsh exec.c
4676 // contract — `$?` reads the exit status of the *most recent*
4677 // command. XTRACE_LINE is emitted by the compiler BEFORE
4678 // every simple command, so it's the natural sync point.
4679 let live = vm.last_status;
4680 with_executor(|exec| {
4681 exec.set_last_status(live);
4682 });
4683 // C zsh emits xtrace for `(( … ))` / `[[ … ]]` / `case` /
4684 // `if/while/until/for/repeat` head expressions via
4685 // `printprompt4(); fprintf(xtrerr, "%s\n", expr)` at
4686 // Src/exec.c:5240 (math), c:5286 (cond), c:4117 (for), etc.
4687 // The compiler emits BUILTIN_XTRACE_LINE only at those
4688 // construct boundaries (compile_arith / compile_cond /
4689 // compile_if / compile_while / compile_for / compile_case);
4690 // simple commands route to BUILTIN_XTRACE_ARGS instead. So
4691 // this handler always emits when xtrace is on — no prefix-
4692 // string heuristic.
4693 let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
4694 if on {
4695 let already = XTRACE_DONE_PS4.with(|f| f.get());
4696 if !already {
4697 printprompt4();
4698 }
4699 eprintln!("{}", cmd_text);
4700 XTRACE_DONE_PS4.with(|f| f.set(false));
4701 }
4702 Value::Status(0)
4703 });
4704
4705 // Like XTRACE_LINE but reads the top `argc - 1` values from the
4706 // VM stack WITHOUT consuming them (peek), then pops a prefix
4707 // string at the top. Joins prefix + peeked args with spaces using
4708 // zsh's quotedzputs-equivalent quoting. Direct port of
4709 // Src/exec.c:2055-2066 — emit AFTER expansion, with each arg
4710 // shell-quoted, so `for i in a b; echo for $i` traces as
4711 // `echo for a` / `echo for b`, not `echo for $i`.
4712 //
4713 // Stack contract on entry: [arg1, arg2, ..., argN, prefix].
4714 // Pops prefix; peeks argN..arg1 below. argc = N + 1.
4715 vm.register_builtin(BUILTIN_XTRACE_ARGS, |vm, argc| {
4716 let prefix = vm.pop().to_str();
4717 let live = vm.last_status;
4718 with_executor(|exec| {
4719 exec.set_last_status(live);
4720 });
4721 let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
4722 if on {
4723 let n_args = argc.saturating_sub(1) as usize;
4724 let len = vm.stack.len();
4725 // c:Src/exec.c:2055 — argv is the POST-expansion word
4726 // list, so an arg that expanded to multiple words splats
4727 // into multiple trace tokens AND an arg that expanded to
4728 // zero words (empty unquoted `${UNSET}`) emits nothing.
4729 // pop_args (line 6243) already does this splat for the
4730 // real handler; mirror the same Array → splat / empty →
4731 // drop logic here so xtrace renders `echo ${UNSET}` as
4732 // `echo` (zsh) instead of `echo ''` (the previous
4733 // single-arg stringify path returned "" and then
4734 // quotedzputs wrapped it in `''`).
4735 let arg_strs: Vec<String> = if n_args > 0 && len >= n_args {
4736 let mut out = Vec::new();
4737 for v in &vm.stack[len - n_args..] {
4738 match v {
4739 Value::Array(items) => {
4740 for item in items {
4741 out.push(quotedzputs(&item.to_str()));
4742 }
4743 }
4744 other => out.push(quotedzputs(&other.to_str())),
4745 }
4746 }
4747 out
4748 } else {
4749 Vec::new()
4750 };
4751 // Builtins dispatch through `execbuiltin` (Src/builtin.c:442)
4752 // which emits its own PS4 + name + args xtrace. To avoid
4753 // double-emission, skip our emission here when the first
4754 // arg is a known builtin with a registered HandlerFunc —
4755 // those go through execbuiltin and will trace themselves.
4756 // Externals + builtins-not-yet-routed-through-execbuiltin
4757 // keep our emission as a stand-in.
4758 let goes_through_execbuiltin = crate::ported::builtin::BUILTINS
4759 .iter()
4760 .any(|b| b.node.nam == prefix && b.handlerfunc.is_some());
4761 if !goes_through_execbuiltin {
4762 let line = if arg_strs.is_empty() {
4763 prefix
4764 } else {
4765 format!("{} {}", prefix, arg_strs.join(" "))
4766 };
4767 // Mirrors Src/exec.c:2055 xtrace emission. C does:
4768 // if (!doneps4) printprompt4();
4769 // ... emit args + spaces ...
4770 // fputc('\n', xtrerr); fflush(xtrerr);
4771 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
4772 if !already_ps4 {
4773 printprompt4();
4774 }
4775 eprintln!("{}", line);
4776 }
4777 XTRACE_DONE_PS4.with(|f| f.set(false));
4778 }
4779 Value::Status(0)
4780 });
4781
4782 // BUILTIN_XTRACE_ASSIGN — direct port of the per-assignment
4783 // trace block at Src/exec.c:2517-2582. C body excerpt:
4784 // xtr = isset(XTRACE);
4785 // if (xtr) { printprompt4(); doneps4 = 1; }
4786 // while (assign) {
4787 // if (xtr) fprintf(xtrerr, "%s+=" or "%s=", name);
4788 // ... eval value into `val` ...
4789 // if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
4790 // ...
4791 // }
4792 //
4793 // Stack on entry: [..., name, value]. PEEKS both (they're left
4794 // on stack for SET_VAR to pop). Emits `name=<quoted-val> ` with
4795 // no newline; trailing `\n` comes from XTRACE_ARGS (cmd path)
4796 // or XTRACE_NEWLINE (assignment-only path).
4797 vm.register_builtin(BUILTIN_XTRACE_ASSIGN, |vm, _argc| {
4798 let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
4799 if on {
4800 // PEEK [..., name, value] — argc==2 by contract.
4801 let len = vm.stack.len();
4802 if len >= 2 {
4803 let name = vm.stack[len - 2].to_str();
4804 let value = vm.stack[len - 1].to_str();
4805 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
4806 if !already_ps4 {
4807 printprompt4();
4808 XTRACE_DONE_PS4.with(|f| f.set(true));
4809 }
4810 // C: `fprintf(xtrerr, "%s=", name)` then `quotedzputs
4811 // (val); fputc(' ', xtrerr);`. Emit no newline.
4812 eprint!("{}={} ", name, quotedzputs(&value));
4813 }
4814 }
4815 Value::Status(0)
4816 });
4817
4818 // BUILTIN_XTRACE_NEWLINE — emit trailing `\n` + flush iff a
4819 // prior XTRACE_ASSIGN this line already emitted PS4. Mirrors
4820 // C's `fputc('\n', xtrerr); fflush(xtrerr);` at exec.c:3398
4821 // (the assignment-only path through execcmd_exec).
4822 vm.register_builtin(BUILTIN_XTRACE_NEWLINE, |_vm, _argc| {
4823 let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
4824 if on {
4825 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
4826 if already_ps4 {
4827 eprintln!();
4828 XTRACE_DONE_PS4.with(|f| f.set(false));
4829 }
4830 }
4831 Value::Status(0)
4832 });
4833
4834 // c:Src/exec.c WC_TRYBLOCK — post-always re-jump probes. Each
4835 // returns 1 + consumes the atomic when the corresponding
4836 // escape flag is set; the try-block compile pairs each with
4837 // a JumpIfFalse + Jump → outer scope's return / break /
4838 // continue patches.
4839 vm.register_builtin(BUILTIN_RETFLAG_CHECK, |_vm, _argc| {
4840 use std::sync::atomic::Ordering;
4841 let r = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
4842 if r != 0 {
4843 // Don't clear here — doshfunc owns the clear at c:6047
4844 // when the function unwinds. Leaving it set propagates
4845 // through nested `eval`/`source` callers correctly.
4846 Value::Int(1)
4847 } else {
4848 Value::Int(0)
4849 }
4850 });
4851 vm.register_builtin(BUILTIN_BREAKS_CHECK, |_vm, _argc| {
4852 use std::sync::atomic::Ordering;
4853 let b = crate::ported::builtin::BREAKS.load(Ordering::Relaxed);
4854 let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
4855 // `break` sets BREAKS but NOT CONTFLAG; `continue` sets both.
4856 // Filter out the continue path here so the two checks are
4857 // mutually exclusive.
4858 if b != 0 && c == 0 {
4859 // Consume BREAKS so the outer loop's break_patches
4860 // landing doesn't double-decrement.
4861 crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
4862 Value::Int(1)
4863 } else {
4864 Value::Int(0)
4865 }
4866 });
4867 vm.register_builtin(BUILTIN_CONTFLAG_CHECK, |_vm, _argc| {
4868 use std::sync::atomic::Ordering;
4869 let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
4870 if c != 0 {
4871 crate::ported::builtin::CONTFLAG.store(0, Ordering::Relaxed);
4872 crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
4873 Value::Int(1)
4874 } else {
4875 Value::Int(0)
4876 }
4877 });
4878 vm.register_builtin(BUILTIN_NOEXEC_CHECK, |_vm, _argc| {
4879 // c:Src/exec.c:1390 — `set -n` / `noexec` option: parse but
4880 // don't execute. Returns Int(1) when noexec is set so the
4881 // emit-side JumpIfTrue skips the statement body.
4882 if opt_state_get("noexec").unwrap_or(false) {
4883 Value::Int(1)
4884 } else {
4885 Value::Int(0)
4886 }
4887 });
4888 vm.register_builtin(BUILTIN_DONETRAP_RESET, |_vm, _argc| {
4889 // c:Src/exec.c:1455 — `donetrap = 0;` at sublist start.
4890 // Reset before each top-level statement so the next
4891 // sublist's ERREXIT_CHECK fires the ZERR trap on its FIRST
4892 // non-zero command. Carries the "already fired" state
4893 // across function-call returns within the SAME outer
4894 // sublist (per C semantics — donetrap is process-global).
4895 // Bug #303 in docs/BUGS.md.
4896 crate::ported::exec::DONETRAP.store(0, std::sync::atomic::Ordering::Relaxed);
4897 Value::Status(0)
4898 });
4899
4900 // `[[ -z X ]]` / `[[ -n X ]]` — pop one Value, route through
4901 // canonical `src/ported/cond.rs::evalcond` so the actual
4902 // empty/non-empty test reuses the C-port at `cond.rs:270-271`
4903 // (`'n' => !arg.is_empty()`, `'z' => arg.is_empty()`).
4904 //
4905 // The Array→args conversion lives at the bridge because cond.rs
4906 // expects `&[&str]` (C `cond_str` signature equivalent). For
4907 // `"${arr[@]}"` in DQ context the splice yields `Value::Array`
4908 // — an empty array still expands to one implicit empty word
4909 // (per zsh's "${arr[@]}" splat preserving at least one slot
4910 // in cond context), so:
4911 // - Array(0) → ["-z", ""] → evalcond → 0 (true)
4912 // - Array(1) → ["-z", word] → evalcond → 0/1
4913 // - Array(2+) → ["-z", w1, w2, ...] → evalcond → 2 (parse
4914 // error: too many ops)
4915 // → coerced to false
4916 // - Str(s) → ["-z", s] → evalcond → 0/1
4917 //
4918 // Bug #185 in docs/BUGS.md.
4919 fn run_cond_str_empty(v: Value, op: &str) -> Value {
4920 let words: Vec<String> = match v {
4921 Value::Array(arr) => arr.into_iter().map(|x| x.to_str()).collect(),
4922 Value::Str(s) => vec![s.to_string()],
4923 other => vec![other.to_str()],
4924 };
4925 let mut args: Vec<&str> = vec![op];
4926 if words.is_empty() {
4927 args.push("");
4928 } else {
4929 args.extend(words.iter().map(|s| s.as_str()));
4930 }
4931 let opts: std::collections::HashMap<String, bool> =
4932 std::collections::HashMap::new();
4933 let vars: std::collections::HashMap<String, String> =
4934 std::collections::HashMap::new();
4935 // c:Src/cond.c:62-66 — `evalcond` returns 0=true, 1=false,
4936 // 2=syntax-error. Coerce error to false (observable behavior
4937 // in zsh: `[[ -z a b ]]` errors and the test as a whole
4938 // returns non-zero).
4939 // `[[ ]]` dispatch — C's `evalcond(state, NULL)` calling convention.
4940 // `None` for from_test → mathevali integer-compare coercion path.
4941 let ret = crate::ported::cond::evalcond(&args, &opts, &vars, false, None);
4942 Value::Int(if ret == 0 { 1 } else { 0 })
4943 }
4944 vm.register_builtin(BUILTIN_COND_STR_EMPTY, |vm, _argc| {
4945 let v = vm.pop();
4946 run_cond_str_empty(v, "-z")
4947 });
4948 vm.register_builtin(BUILTIN_COND_STR_NONEMPTY, |vm, _argc| {
4949 let v = vm.pop();
4950 run_cond_str_empty(v, "-n")
4951 });
4952
4953 // `exec N<<<"str"` — herestring redirect to explicit fd, applied
4954 // permanently. Direct port of `Src/exec.c:4655 getherestr` +
4955 // `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)` at c:3766-
4956 // 3780 for the nullexec=1 bare-exec-redir path. Bug #205 in
4957 // docs/BUGS.md.
4958 vm.register_builtin(BUILTIN_EXEC_HERESTR_FD, |vm, _argc| {
4959 let fd = vm.pop().to_int() as i32;
4960 let content = vm.pop().to_str();
4961 // c:4671-4672 — append `\n` for "real" herestrings (not
4962 // heredoc-derived). zshrs's bare-exec path only fires for
4963 // the `<<<` syntax (REDIR_HERESTR), so always append.
4964 let body = format!("{}\n", content);
4965 // c:4673-4679 — gettempfile → write_loop → close → reopen
4966 // read-only → unlink. Rust equivalent via tempfile crate or
4967 // explicit O_TMPFILE; use mkstemp + unlink-immediately to
4968 // mirror C exactly.
4969 use std::ffi::CString;
4970 let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
4971 let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
4972 if write_fd < 0 {
4973 crate::ported::utils::zwarn(&format!(
4974 "can't create temp file for here document: {}",
4975 std::io::Error::last_os_error()
4976 ));
4977 return Value::Status(1);
4978 }
4979 // c:4675 — write_loop(fd, t, len)
4980 let bytes = body.as_bytes();
4981 let mut off = 0;
4982 while off < bytes.len() {
4983 let n = unsafe {
4984 libc::write(
4985 write_fd,
4986 bytes[off..].as_ptr() as *const libc::c_void,
4987 bytes.len() - off,
4988 )
4989 };
4990 if n <= 0 {
4991 unsafe { libc::close(write_fd) };
4992 return Value::Status(1);
4993 }
4994 off += n as usize;
4995 }
4996 unsafe { libc::close(write_fd) }; // c:4676
4997 // Path null-terminated by mkstemp; reopen for reading.
4998 let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
4999 // c:4678 — unlink immediately so the file disappears on
5000 // close, leaving only the fd reference.
5001 unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
5002 if read_fd < 0 {
5003 return Value::Status(1);
5004 }
5005 // c:3779 addfd → dup2 to target fd, close intermediate.
5006 let r = unsafe { libc::dup2(read_fd, fd) };
5007 unsafe { libc::close(read_fd) };
5008 if r < 0 {
5009 return Value::Status(1);
5010 }
5011 Value::Status(0)
5012 });
5013 // c:Src/exec.c:2418 + addfd splice — MULTIOS fan-out. Stack
5014 // layout pushed by compile_zsh's coalescing pass:
5015 // [target_1, op_byte_1, target_2, op_byte_2, …, target_N,
5016 // op_byte_N, fd]
5017 // argc = 2N + 1. Pops, opens every target, sets up a pipe +
5018 // splitter thread that reads pipe → writes every chunk to
5019 // every opened target, dup2's pipe-write-end onto fd. The
5020 // splitter is closed + joined by host_redirect_scope_end.
5021 // Bug #36 in docs/BUGS.md.
5022 vm.register_builtin(BUILTIN_MULTIOS_REDIRECT, |vm, argc| {
5023 if argc < 3 || argc % 2 == 0 {
5024 // Bad shape — bail.
5025 return Value::Status(1);
5026 }
5027 // Pop fd first (top of stack).
5028 let fd = vm.pop().to_int() as i32;
5029 // Then pop (op, target) pairs in reverse compile order.
5030 let n_targets = ((argc - 1) / 2) as usize;
5031 let mut pairs: Vec<(u8, String)> = Vec::with_capacity(n_targets);
5032 for _ in 0..n_targets {
5033 let op_byte = vm.pop().to_int() as u8;
5034 let target = vm.pop().to_str();
5035 pairs.push((op_byte, target));
5036 }
5037 // Restore compile order (target_1 first).
5038 pairs.reverse();
5039
5040 // Open every target per its op_byte.
5041 let mut target_fds: Vec<i32> = Vec::with_capacity(pairs.len());
5042 for (op_byte, target) in &pairs {
5043 let open_result = match *op_byte {
5044 r::WRITE | r::CLOBBER => fs::OpenOptions::new()
5045 .write(true)
5046 .create(true)
5047 .truncate(true)
5048 .open(target),
5049 r::APPEND => fs::OpenOptions::new()
5050 .write(true)
5051 .create(true)
5052 .append(true)
5053 .open(target),
5054 _ => fs::OpenOptions::new()
5055 .write(true)
5056 .create(true)
5057 .truncate(true)
5058 .open(target),
5059 };
5060 match open_result {
5061 Ok(file) => target_fds.push(file.into_raw_fd()),
5062 Err(e) => {
5063 let msg = match e.kind() {
5064 std::io::ErrorKind::PermissionDenied => "permission denied",
5065 std::io::ErrorKind::NotFound => "no such file or directory",
5066 std::io::ErrorKind::IsADirectory => "is a directory",
5067 _ => "redirect failed",
5068 };
5069 eprintln!("{}:1: {}: {}", shname(), msg, target);
5070 // Close already-opened fds to avoid leaks.
5071 for prev in &target_fds {
5072 unsafe {
5073 libc::close(*prev);
5074 }
5075 }
5076 with_executor(|exec| {
5077 exec.redirect_failed = true;
5078 });
5079 return Value::Status(1);
5080 }
5081 }
5082 }
5083
5084 // Save current fd state for scope-end restoration.
5085 let saved = unsafe { libc::dup(fd) };
5086 if saved >= 0 {
5087 with_executor(|exec| {
5088 if let Some(top) = exec.redirect_scope_stack.last_mut() {
5089 top.push((fd, saved));
5090 } else {
5091 unsafe { libc::close(saved) };
5092 }
5093 });
5094 }
5095
5096 // Create the splitter pipe.
5097 let (read_end, write_end) = match os_pipe::pipe() {
5098 Ok(p) => p,
5099 Err(_) => {
5100 for f in &target_fds {
5101 unsafe {
5102 libc::close(*f);
5103 }
5104 }
5105 return Value::Status(1);
5106 }
5107 };
5108 let pipe_write_raw = AsRawFd::as_raw_fd(&write_end);
5109 // Spawn the splitter thread: read pipe → write every chunk
5110 // to every target fd. Each write inside the thread uses
5111 // libc::write directly on the raw fd (no Rust File ownership
5112 // so the splitter can close after EOF without racing main).
5113 let target_fds_for_thread = target_fds.clone();
5114 let handle = std::thread::spawn(move || {
5115 let mut r = read_end;
5116 let mut buf = [0u8; 8192];
5117 loop {
5118 match std::io::Read::read(&mut r, &mut buf) {
5119 Ok(0) => break,
5120 Ok(n) => {
5121 for &tfd in &target_fds_for_thread {
5122 let mut off = 0;
5123 while off < n {
5124 let w = unsafe {
5125 libc::write(
5126 tfd,
5127 buf[off..n].as_ptr() as *const libc::c_void,
5128 n - off,
5129 )
5130 };
5131 if w <= 0 {
5132 break;
5133 }
5134 off += w as usize;
5135 }
5136 }
5137 }
5138 Err(_) => break,
5139 }
5140 }
5141 // Close every target so file contents flush.
5142 for tfd in target_fds_for_thread {
5143 unsafe {
5144 libc::close(tfd);
5145 }
5146 }
5147 });
5148
5149 // Dup the pipe write-end onto the target fd; close the
5150 // original write_end so EOF arrives when host_redirect_scope_end
5151 // closes our tracked pipe_write_fd.
5152 let write_dup = unsafe { libc::dup(pipe_write_raw) };
5153 drop(write_end);
5154 if write_dup < 0 {
5155 return Value::Status(1);
5156 }
5157 unsafe {
5158 libc::dup2(write_dup, fd);
5159 libc::close(write_dup);
5160 }
5161 // Track the running splitter so scope-end can drain + join.
5162 // The "write_fd" we store is the user-visible fd (e.g. 1).
5163 // Closing that fd at scope-end isn't quite right; we need a
5164 // way to send EOF. Solution: track the write_dup we just
5165 // closed; instead keep a second dup for the close-on-end.
5166 let close_on_end = unsafe { libc::dup(fd) };
5167 with_executor(|exec| {
5168 if let Some(top) = exec.multios_scope_stack.last_mut() {
5169 top.push((close_on_end, handle));
5170 } else {
5171 // No scope — leak the dup; thread will keep running
5172 // until process exit. Should not happen because
5173 // host_redirect_scope_begin pushed a frame.
5174 unsafe { libc::close(close_on_end) };
5175 }
5176 });
5177 Value::Status(0)
5178 });
5179 // c:Src/exec.c:2418 input-arm — MULTIOS read fan-in. Stack
5180 // layout pushed by compile_zsh:
5181 // [source_1, source_2, …, source_N, fd]
5182 // argc = N + 1. Opens every source, sets up a pipe + producer
5183 // thread that reads each source in order and writes to the
5184 // pipe write-end, then closes its write-end so the consumer
5185 // gets EOF. dup2 the pipe read-end onto fd. Bug #36 input
5186 // side in docs/BUGS.md.
5187 vm.register_builtin(BUILTIN_MULTIOS_READ, |vm, argc| {
5188 if argc < 2 {
5189 return Value::Status(1);
5190 }
5191 let fd = vm.pop().to_int() as i32;
5192 let n_sources = (argc - 1) as usize;
5193 let mut sources: Vec<String> = Vec::with_capacity(n_sources);
5194 for _ in 0..n_sources {
5195 sources.push(vm.pop().to_str());
5196 }
5197 sources.reverse();
5198
5199 // Open every source.
5200 let mut source_fds: Vec<i32> = Vec::with_capacity(sources.len());
5201 for path in &sources {
5202 match fs::File::open(path) {
5203 Ok(f) => source_fds.push(f.into_raw_fd()),
5204 Err(e) => {
5205 let msg = match e.kind() {
5206 std::io::ErrorKind::PermissionDenied => "permission denied",
5207 std::io::ErrorKind::NotFound => "no such file or directory",
5208 _ => "open failed",
5209 };
5210 eprintln!("{}:1: {}: {}", shname(), msg, path);
5211 for prev in &source_fds {
5212 unsafe {
5213 libc::close(*prev);
5214 }
5215 }
5216 with_executor(|exec| {
5217 exec.redirect_failed = true;
5218 });
5219 return Value::Status(1);
5220 }
5221 }
5222 }
5223
5224 // Save current fd state for scope-end restoration.
5225 let saved = unsafe { libc::dup(fd) };
5226 if saved >= 0 {
5227 with_executor(|exec| {
5228 if let Some(top) = exec.redirect_scope_stack.last_mut() {
5229 top.push((fd, saved));
5230 } else {
5231 unsafe { libc::close(saved) };
5232 }
5233 });
5234 }
5235
5236 // Create the concatenator pipe.
5237 let (read_end, write_end) = match os_pipe::pipe() {
5238 Ok(p) => p,
5239 Err(_) => {
5240 for f in &source_fds {
5241 unsafe {
5242 libc::close(*f);
5243 }
5244 }
5245 return Value::Status(1);
5246 }
5247 };
5248 // dup the pipe read-end onto fd before spawning the
5249 // producer; close the original read_end so the consumer
5250 // (reading via fd) is the sole reference until scope-end.
5251 let read_dup = unsafe { libc::dup(AsRawFd::as_raw_fd(&read_end)) };
5252 drop(read_end);
5253 if read_dup < 0 {
5254 for f in &source_fds {
5255 unsafe {
5256 libc::close(*f);
5257 }
5258 }
5259 return Value::Status(1);
5260 }
5261 unsafe {
5262 libc::dup2(read_dup, fd);
5263 libc::close(read_dup);
5264 }
5265 // Spawn the producer.
5266 let source_fds_for_thread = source_fds.clone();
5267 let handle = std::thread::spawn(move || {
5268 let mut w = write_end;
5269 let mut buf = [0u8; 8192];
5270 for sfd in source_fds_for_thread {
5271 loop {
5272 let n = unsafe {
5273 libc::read(
5274 sfd,
5275 buf.as_mut_ptr() as *mut libc::c_void,
5276 buf.len(),
5277 )
5278 };
5279 if n <= 0 {
5280 break;
5281 }
5282 let n = n as usize;
5283 if std::io::Write::write_all(&mut w, &buf[..n]).is_err() {
5284 break;
5285 }
5286 }
5287 unsafe {
5288 libc::close(sfd);
5289 }
5290 }
5291 // Closing w (the write_end) at scope drop signals EOF
5292 // to the consumer.
5293 });
5294 with_executor(|exec| {
5295 // Track using a closed-write sentinel — the producer
5296 // owns write_end so we just need to join. Use -1 fd
5297 // marker meaning "no fd to close".
5298 if let Some(top) = exec.multios_scope_stack.last_mut() {
5299 top.push((-1, handle));
5300 } else {
5301 let _ = handle.join();
5302 }
5303 });
5304 Value::Status(0)
5305 });
5306 // c:Src/exec.c — block-level redirect-failure gate. When a
5307 // compound command (`{ … } < file`, `( … ) > file`, etc.) has a
5308 // failing redirect (e.g. `< /nonexistent`), zsh skips the entire
5309 // body AND sets lastval to 1. The simple-command path's
5310 // redirect_failed check (line 215-221 above) only catches the
5311 // failure when a builtin dispatches and is consumed by that
5312 // single builtin call — so a multi-statement block kept running
5313 // its remaining statements after the redir error. Emit-side at
5314 // compile_zsh.rs::compile_command's Redirected arm pairs this
5315 // with a JumpIfTrue → WithRedirectsEnd to abandon the body.
5316 vm.register_builtin(BUILTIN_REDIRECT_FAILED_CHECK, |vm, _argc| {
5317 let failed = with_executor(|exec| {
5318 let f = exec.redirect_failed;
5319 exec.redirect_failed = false;
5320 f
5321 });
5322 if failed {
5323 vm.last_status = 1;
5324 Value::Int(1)
5325 } else {
5326 Value::Int(0)
5327 }
5328 });
5329 // c:Src/exec.c — drop-in replacement for fusevm's Op::Exec used by
5330 // the dynamic-first-word path (`$cmd`, `$(cmd)`, glob-named cmds).
5331 // fusevm's Op::Exec returns Value::Status(0) when post-expansion
5332 // argv is empty (vm.rs:1722) — that clobbers \$? for the
5333 // `\$(exit 1); echo \$?` case where the cmd-subst left
5334 // last_status = 1 but the empty expansion gets exec'd to 0.
5335 // Mirror C zsh: when the word list is empty after expansion,
5336 // \$? becomes whatever the inner cmd-subst's last_status is
5337 // (preserved here by returning Value::Status(last_status)).
5338 vm.register_builtin(BUILTIN_EXEC_DYNAMIC, |vm, argc| {
5339 let raw = pop_args(vm, argc);
5340 // Flatten Array entries into argv slots (matches fusevm
5341 // Op::Exec's flatten at vm.rs:1660-1665) so `${arr[@]}` /
5342 // splice expansions produce one argv slot per element.
5343 let args: Vec<String> = raw.into_iter().collect();
5344 // c:Src/subst.c paramsubst — when `${var:?msg}` or
5345 // `${var?msg}` set errflag, the expansion may produce empty
5346 // argv[0] which would fall into the EACCES/permission-denied
5347 // path below, masking the real paramsubst diagnostic with a
5348 // spurious "permission denied:" line and rc=126. Honour
5349 // errflag so the simple command ends with the paramsubst
5350 // error as the sole diagnostic, rc=1. Bug #86.
5351 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
5352 & crate::ported::zsh_h::ERRFLAG_ERROR)
5353 != 0
5354 {
5355 return Value::Status(1);
5356 }
5357 if args.is_empty() {
5358 // c:Src/exec.c — empty argv preserves prior \$?. The
5359 // cmd-subst inside the word already set last_status; just
5360 // round-trip it back through SetStatus.
5361 return Value::Status(vm.last_status);
5362 }
5363 if args[0].is_empty() {
5364 // Explicit empty command word — exec returns EACCES.
5365 let script_name =
5366 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
5367 let lineno: u64 = with_executor(|exec| {
5368 exec.scalar("LINENO")
5369 .and_then(|s| s.parse::<u64>().ok())
5370 .unwrap_or(1)
5371 });
5372 eprintln!("{}:{}: permission denied: ", script_name, lineno);
5373 return Value::Status(126);
5374 }
5375 // c:Src/exec.c:2900 execcmd_exec — canonical simple-command
5376 // dispatcher. Runs precmd-modifier walk (c:3013-3091), then
5377 // dispatches to execbuiltin (c:4233) / runshfunc (c:3431+) /
5378 // execute (c:4314) per the resolved head. zshrs's bytecode VM
5379 // expanded the args before reaching here; we feed them in via
5380 // eparams.args and let execcmd_exec do the rest exactly as C
5381 // does for static heads. Without this, `c=builtin; $c source X`
5382 // skipped the precmd walk and emitted "command not found:
5383 // builtin".
5384 let mut state = crate::ported::zsh_h::estate {
5385 prog: Box::<crate::ported::zsh_h::eprog>::default(),
5386 pc: 0,
5387 strs: None,
5388 strs_offset: 0,
5389 };
5390 let mut eparams = crate::ported::zsh_h::execcmd_params {
5391 args: Some(args),
5392 redir: None,
5393 beg: 0,
5394 varspc: None,
5395 assignspc: None,
5396 typ: crate::ported::zsh_h::WC_SIMPLE as i32,
5397 postassigns: 0,
5398 htok: 0,
5399 };
5400 // input/output=0 → no pipe redirection (use shell stdio
5401 // directly); `output != 0` at c:2988 forks immediately. last1=1
5402 // marks this as the last/only command (no further pipe stages).
5403 crate::ported::exec::execcmd_exec(
5404 &mut state,
5405 &mut eparams,
5406 0, // input (c:2989)
5407 0, // output (c:2988)
5408 crate::ported::zsh_h::Z_SYNC as i32, // how
5409 1, // last1=1 last/only
5410 -1, // close_if_forked
5411 );
5412 let status = crate::ported::builtin::LASTVAL
5413 .load(std::sync::atomic::Ordering::Relaxed);
5414 let mut synth = crate::ported::zsh_h::job::default();
5415 crate::ported::jobs::waitonejob(&mut synth);
5416 Value::Status(status)
5417 });
5418 // c:Src/exec.c:3340-3364 — `< file` / `> file` with no command
5419 // word. Resolves NULLCMD/READNULLCMD at runtime then routes
5420 // through host_exec_external. Redirects are already applied by
5421 // the surrounding WithRedirectsBegin scope.
5422 vm.register_builtin(BUILTIN_NULLCMD_EXEC, |vm, argc| {
5423 let args = pop_args(vm, argc);
5424 let is_single_read = args
5425 .first()
5426 .map(|s| s != "0" && !s.is_empty())
5427 .unwrap_or(false);
5428 // c:Src/exec.c — when the surrounding redir-open failed
5429 // (e.g. `< /nonexistent`), zerr already printed the diag
5430 // and set redirect_failed. Don't invoke NULLCMD — return
5431 // status 1 like the wordcode path does.
5432 let redir_failed = with_executor(|exec| {
5433 let f = exec.redirect_failed;
5434 exec.redirect_failed = false;
5435 f
5436 });
5437 if redir_failed {
5438 crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
5439 return Value::Status(1);
5440 }
5441 let nullcmd = crate::ported::params::getsparam("NULLCMD");
5442 let nc_str = nullcmd.as_deref().unwrap_or("");
5443 let nc_empty = nc_str.is_empty();
5444 // c:3340-3344 — CSHNULLCMD or no NULLCMD set → diagnostic.
5445 if nc_empty || crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLCMD) {
5446 let script_name =
5447 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
5448 let lineno: u64 = with_executor(|exec| {
5449 exec.scalar("LINENO")
5450 .and_then(|s| s.parse::<u64>().ok())
5451 .unwrap_or(1)
5452 });
5453 eprintln!("{}:{}: redirection with no command", script_name, lineno);
5454 return Value::Status(1);
5455 }
5456 // c:3350 — SHNULLCMD → run `:`.
5457 let cmd: String = if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHNULLCMD) {
5458 ":".to_string()
5459 } else if is_single_read {
5460 // c:3354-3359 — single REDIR_READ + READNULLCMD set → readnullcmd.
5461 let rnc = crate::ported::params::getsparam("READNULLCMD");
5462 let rnc_str = rnc.as_deref().unwrap_or("");
5463 if !rnc_str.is_empty() {
5464 rnc_str.to_string()
5465 } else {
5466 nc_str.to_string() // c:3360-3363 fallback
5467 }
5468 } else {
5469 nc_str.to_string() // c:3360-3363
5470 };
5471 let status = with_executor(|exec| exec.host_exec_external(&[cmd]));
5472 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
5473 Value::Status(status)
5474 });
5475 // c:Src/exec.c:3342 — `zerr("redirection with no command")`.
5476 // Bare prefix-keyword (`builtin`, `command`, `exec`, `noglob`,
5477 // `nocorrect`) with a redirect but no command word. Emits the
5478 // canonical diagnostic via zerr (which sets errflag) and
5479 // returns Status(1). Bug #534.
5480 vm.register_builtin(BUILTIN_REDIR_NO_CMD, |_vm, _argc| {
5481 crate::ported::utils::zerr("redirection with no command");
5482 Value::Status(1)
5483 });
5484 vm.register_builtin(BUILTIN_DEBUG_TRAP, |vm, _argc| {
5485 // c:Src/signals.c:1245 dotrap(SIGDEBUG) — fires the DEBUG
5486 // trap body once per statement. The body sees the parent
5487 // shell's $? (LASTVAL). Guard against re-entry: commands
5488 // inside the DEBUG trap body would otherwise trigger
5489 // DEBUG_TRAP recursively → stack overflow. zsh guards via
5490 // its in_trap counter; we mirror with a thread-local Cell.
5491 //
5492 // c:Src/exec.c::trapcmd — before dotrap, the C source sets
5493 // `ZSH_DEBUG_CMD` to the about-to-run command text via
5494 // `dupstring(text)`. The trap body reads the parameter;
5495 // C unsets it after the trap returns. compile_list emits
5496 // the rendered statement text as the single arg here so the
5497 // shell-visible parameter reflects the command. Bug #263 in
5498 // docs/BUGS.md.
5499 let cmd_text = vm.pop().to_str();
5500 DEBUG_TRAP_REENTRY.with(|c| {
5501 if c.get() {
5502 return Value::Status(0);
5503 }
5504 // c:Src/exec.c:1423 — `if (sigtrapped[SIGDEBUG] &&
5505 // isset(DEBUGBEFORECMD) && !intrap)`. Bug #573: without
5506 // this gate, every sublist boundary called
5507 // setsparam("ZSH_DEBUG_CMD", ...) even when no DEBUG trap
5508 // was set, polluting the param table and (under
5509 // WARN_CREATE_GLOBAL) emitting a spurious
5510 // `scalar parameter ZSH_DEBUG_CMD created globally`
5511 // warning at every function call.
5512 //
5513 // Two trap registries exist (per signals.rs:1481-1511 dotrap):
5514 // - settrap path → sigtrapped[SIGDEBUG] bits set
5515 // - bin_trap path → traps_table["DEBUG"] populated, sigtrapped untouched
5516 // Mirror the dotrap dispatch decision: skip only when BOTH
5517 // are absent.
5518 let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
5519 let debug_trapped = crate::ported::signals::sigtrapped
5520 .lock()
5521 .map(|v| v.get(sig_debug).copied().unwrap_or(0))
5522 .unwrap_or(0);
5523 let debug_in_table = crate::ported::builtin::traps_table()
5524 .lock()
5525 .map(|t| t.contains_key("DEBUG"))
5526 .unwrap_or(false);
5527 if debug_trapped == 0 && !debug_in_table {
5528 return Value::Status(0);
5529 }
5530 c.set(true);
5531 // c:Src/exec.c — set ZSH_DEBUG_CMD scalar (PM_READONLY
5532 // is NOT set on ZSH_DEBUG_CMD, so the canonical
5533 // setsparam path is fine here — no direct paramtab
5534 // mutation needed).
5535 crate::ported::params::setsparam("ZSH_DEBUG_CMD", &cmd_text);
5536 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGDEBUG);
5537 // c:Src/exec.c::trapcmd — `unsetparam("ZSH_DEBUG_CMD")`
5538 // after the trap returns. Mirror that.
5539 crate::ported::params::unsetparam("ZSH_DEBUG_CMD");
5540 c.set(false);
5541 Value::Status(0)
5542 })
5543 });
5544
5545 vm.register_builtin(BUILTIN_ERREXIT_CHECK, |vm, _argc| {
5546 // Returns Value::Int(1) when the caller should jump to the
5547 // current scope's return-patch landing (subshell-end / func-
5548 // end / chunk-end). Returns Value::Int(0) otherwise. Emit
5549 // side at `emit_errexit_check` pairs this with a JumpIfTrue
5550 // → return_patches pattern so the caller can short-circuit.
5551 //
5552 // Four triggers:
5553 // 1. RETFLAG set by a nested `return` / `exit` (eval,
5554 // sourced file, called function). Unwind THIS scope so
5555 // the flag propagates outward until something clears it.
5556 // 2. EXIT_PENDING set (mostly subshell-context exits). Same
5557 // propagation logic.
5558 // 3. `set -e` + nonzero status — the classic errexit path.
5559 // 4. errflag set in non-interactive mode — readonly
5560 // reassign, bad redirect, parse error mid-expansion etc.
5561 // Aborts the script (c:Src/init.c loop()).
5562 use std::sync::atomic::Ordering;
5563 let retflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
5564 let exit_pending = crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed);
5565 if retflag != 0 || exit_pending != 0 {
5566 return Value::Int(1);
5567 }
5568 let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
5569 & crate::ported::zsh_h::ERRFLAG_ERROR)
5570 != 0;
5571 if errflag_set && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE) {
5572 // Clear errflag so the abort doesn't keep re-triggering;
5573 // the script-end last_status gives the caller the
5574 // failing status. Update BOTH executor's last_status
5575 // (LASTVAL) AND the VM's last_status so run_chunk's
5576 // post-script sync sees the failing value.
5577 crate::ported::utils::errflag
5578 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
5579 with_executor(|exec| exec.set_last_status(1));
5580 vm.last_status = 1;
5581 // c:Src/init.c loop() — a non-interactive errflag-fired
5582 // abort propagates to the SHELL, not just the current
5583 // function/sourced file. Inside a function, the local
5584 // BUILTIN_ERREXIT_CHECK unwinds the function scope; but
5585 // the caller's next ERREXIT_CHECK only sees errflag if we
5586 // didn't clear it — and we did (above). Set EXIT_PENDING
5587 // so the outer ERREXIT_CHECK at script-level takes the
5588 // EXIT_PENDING arm and aborts. Bug #74 in docs/BUGS.md:
5589 // `f() { local -r x=5; x=10; }; f; echo after` printed
5590 // `after` because errflag-clear above let the script-level
5591 // check see a clean state.
5592 crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
5593 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
5594 return Value::Int(1);
5595 }
5596 let last = vm.last_status;
5597 if last == 0 {
5598 return Value::Int(0);
5599 }
5600 // c:Src/exec.c:1598 `if (!this_noerrexit && !donetrap &&
5601 // !this_donetrap)` — gate the ZERR trap fire on DONETRAP so
5602 // an inner sublist (e.g. `false` inside a function) that
5603 // already fired ZERR doesn't fire it AGAIN at the outer
5604 // sublist's post-command check (after the function
5605 // returned non-zero). Bug #303 in docs/BUGS.md. DONETRAP
5606 // is reset at top-level statement boundaries via
5607 // BUILTIN_DONETRAP_RESET (compile_list emit at
5608 // compile_zsh.rs).
5609 let already_done =
5610 crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
5611 if !already_done {
5612 // c:Src/signals.c:1245 dotrap(SIGZERR) — canonical ZERR
5613 // trap dispatch. Fires whenever a command exits
5614 // non-zero.
5615 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR);
5616 // c:1602 — `donetrap = 1;` after firing.
5617 crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed);
5618 }
5619 // c:Src/exec.c:1605-1610 — compute errreturn / errexit.
5620 // errreturn = ERRRETURN && (INTERACTIVE || locallevel || sourcelevel)
5621 // && !(noerrexit & NOERREXIT_RETURN)
5622 // errexit = (ERREXIT || (ERRRETURN && !errreturn))
5623 // && !(noerrexit & NOERREXIT_EXIT)
5624 let no_err = crate::ported::exec::noerrexit.load(Ordering::Relaxed);
5625 let locallvl = crate::ported::params::locallevel.load(Ordering::Relaxed);
5626 let sourcelvl = crate::ported::init::sourcelevel.load(Ordering::Relaxed);
5627 let errreturn_opt = isset(crate::ported::zsh_h::ERRRETURN);
5628 let in_unwindable_scope = isset(crate::ported::zsh_h::INTERACTIVE)
5629 || locallvl != 0
5630 || sourcelvl != 0;
5631 let errreturn = errreturn_opt
5632 && in_unwindable_scope
5633 && (no_err & crate::ported::zsh_h::NOERREXIT_RETURN) == 0;
5634 if errreturn {
5635 // c:1620-1623 — `retflag = 1; breaks = loops;` — unwind to
5636 // function boundary without exiting the shell.
5637 crate::ported::builtin::RETFLAG.store(1, Ordering::Relaxed);
5638 let loops = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
5639 crate::ported::builtin::BREAKS.store(loops, Ordering::Relaxed);
5640 return Value::Int(1);
5641 }
5642 let (errexit_on, in_subshell) = with_executor(|exec| {
5643 let on_canonical = isset(ERREXIT)
5644 || (errreturn_opt && !errreturn); // c:1608-1609
5645 let on_legacy = opt_state_get("errexit").unwrap_or(false);
5646 (
5647 (on_canonical || on_legacy)
5648 && (no_err & crate::ported::zsh_h::NOERREXIT_EXIT) == 0,
5649 !exec.subshell_snapshots.is_empty(),
5650 )
5651 });
5652 if !errexit_on {
5653 return Value::Int(0);
5654 }
5655 // c:Src/exec.c — `set -e` fires shell exit, NOT a function-only
5656 // unwind. When LOCAL_OPTIONS restores the option mid-fn, the
5657 // restoration would otherwise mask the trigger and let the
5658 // outer scope continue. Setting EXIT_PENDING + EXIT_VAL here
5659 // (for ALL scope kinds, not just subshells) makes the fn-exit
5660 // path propagate to the shell-exit boundary at c:6135-6155.
5661 crate::ported::builtin::EXIT_VAL.store(last, Ordering::Relaxed);
5662 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
5663 let _ = in_subshell;
5664 // Function scope and top-level scope both branch to their
5665 // respective return_patches; top-level lands at chunk-end,
5666 // so execute_script returns `last` as the script's exit
5667 // status (same observable behavior as a process::exit).
5668 Value::Int(1)
5669 });
5670
5671 // `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
5672 // Pops [name, op_byte, rhs] (rhs popped first). Returns the modified
5673 // value as Value::Str. Handles unset/empty distinction (`:-` etc.
5674 // treat empty same as unset, matching POSIX).
5675 // BUILTIN_PARAM_DEFAULT_FAMILY — `${var-x}` / `${var:-x}` / `${var=x}` /
5676 // `${var:=x}` / `${var?x}` / `${var:?x}` / `${var+x}` / `${var:+x}`.
5677 // PURE PASSTHRU: pop name + op + rhs, reconstruct the canonical
5678 // brace expression, hand to `subst::paramsubst` (C port of
5679 // `Src/subst.c::paramsubst`). All "missing vs empty" gating,
5680 // nounset suppression, default-evaluation, and elide-empty-words
5681 // semantics live inside paramsubst.
5682 vm.register_builtin(BUILTIN_PARAM_DEFAULT_FAMILY, |vm, _argc| {
5683 let rhs = vm.pop().to_str();
5684 let op = vm.pop().to_int() as u8;
5685 let name = vm.pop().to_str();
5686 // op=8 is the `${+name}` set-test prefix form (distinct from the
5687 // `${name+rhs}` substitute-if-set suffix form which is op=7).
5688 // Per compile_zsh.rs::parse_param_modifier: the `+` is emitted as
5689 // a leading sigil and `rhs` is empty.
5690 let body = if op == 8 {
5691 format!("${{+{}}}", name)
5692 } else {
5693 let op_str = match op {
5694 0 => ":-",
5695 1 => ":=",
5696 2 => ":?",
5697 3 => ":+",
5698 4 => "-",
5699 5 => "=",
5700 6 => "?",
5701 7 => "+",
5702 _ => "-",
5703 };
5704 format!("${{{}{}{}}}", name, op_str, rhs)
5705 };
5706 paramsubst_to_value(&body)
5707 });
5708
5709 // `${var:offset[:length]}` — substring. Pops [name, offset, length].
5710 // length == -1 means "rest of string". Negative offset counts from end.
5711 // BUILTIN_PARAM_SUBSTRING — `${var:offset:length}` literal-int form.
5712 // PURE PASSTHRU: reconstruct `${name:offset:length}` and route
5713 // through `subst::paramsubst`. Length sentinel `i64::MIN` =
5714 // "no length given" (omit the `:length` portion).
5715 //
5716 // c:Src/subst.c:1571,3781 — `${name:-N}` is the colon-default
5717 // operator, NOT a substring with negative offset. zsh's lexical
5718 // rule disambiguates via a literal space: `${name: -N}` (space
5719 // before `-`) is the substring form. The reconstructed body MUST
5720 // preserve that space when offset < 0; otherwise paramsubst's
5721 // `:-` dispatch fires on the synthesized `${name:-N}` body and
5722 // returns N as the unset-default instead of slicing the last N
5723 // chars. Length-form `${name:-N:M}` has the same trap.
5724 vm.register_builtin(BUILTIN_PARAM_SUBSTRING, |vm, _argc| {
5725 let length = vm.pop().to_int();
5726 let offset = vm.pop().to_int();
5727 let name = vm.pop().to_str();
5728 let off_sep = if offset < 0 { " " } else { "" };
5729 let body = if length == i64::MIN {
5730 format!("${{{}:{}{}}}", name, off_sep, offset)
5731 } else {
5732 format!("${{{}:{}{}:{}}}", name, off_sep, offset, length)
5733 };
5734 paramsubst_to_value(&body)
5735 });
5736
5737 // BUILTIN_PARAM_SUBSTRING_EXPR — `${var:offset_expr[:length_expr]}` form.
5738 // PURE PASSTHRU: rebuild `${name:offset:length}` using the
5739 // expression text verbatim (paramsubst's offset/length
5740 // parser evaluates arith / param refs itself).
5741 //
5742 // c:Src/subst.c:1571,3781 — same `:-` disambiguation trap as
5743 // BUILTIN_PARAM_SUBSTRING. The expression text may itself start
5744 // with `-` (e.g. `${VAR:$((-1))}` arith resolves at the body-
5745 // assembly layer in some upstream paths, leaving `-1` in
5746 // off_expr). Insert a leading space when off_expr starts with
5747 // `-` so paramsubst's check_colon_subscript (subst.c:1571)
5748 // accepts the operand as a math expression instead of the
5749 // `:-` operator catching it.
5750 vm.register_builtin(BUILTIN_PARAM_SUBSTRING_EXPR, |vm, _argc| {
5751 let has_len = vm.pop().to_int() != 0;
5752 let len_expr = vm.pop().to_str();
5753 let off_expr = vm.pop().to_str();
5754 let name = vm.pop().to_str();
5755 let off_sep = if off_expr.starts_with('-') { " " } else { "" };
5756 let body = if has_len {
5757 format!("${{{}:{}{}:{}}}", name, off_sep, off_expr, len_expr)
5758 } else {
5759 format!("${{{}:{}{}}}", name, off_sep, off_expr)
5760 };
5761 paramsubst_to_value(&body)
5762 });
5763
5764 // `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}`
5765 // Pops [name, pattern, op_byte]. op: 0=`#` short-prefix, 1=`##` long,
5766 // 2=`%` short-suffix, 3=`%%` long. Glob-pattern matching via the
5767 // existing glob_match_static helper.
5768 // BUILTIN_PARAM_STRIP — `${var#pat}` / `${var##pat}` / `${var%pat}` /
5769 // `${var%%pat}`. PURE PASSTHRU: reconstruct the brace expression
5770 // and route through `subst::paramsubst`. (M)/(S) flags arrive
5771 // through SUB_FLAGS (already inside paramsubst's scope), so we
5772 // just clear the bridge-side cached read.
5773 vm.register_builtin(BUILTIN_PARAM_STRIP, |vm, _argc| {
5774 let _dq_flag = vm.pop().to_int() != 0;
5775 let op = vm.pop().to_int() as u8;
5776 let pattern = vm.pop().to_str();
5777 let name = vm.pop().to_str();
5778 let op_str = match op {
5779 0 => "#",
5780 1 => "##",
5781 2 => "%",
5782 3 => "%%",
5783 _ => "#",
5784 };
5785 let body = format!("${{{}{}{}}}", name, op_str, pattern);
5786 paramsubst_to_value(&body)
5787 });
5788
5789 // `$((expr))` — pops [expr_string], evaluates via MathEval which
5790 // honors integer-vs-float distinction (zsh-compatible). Returns
5791 // the result as Value::Str so it can be Concat'd into surrounding
5792 // word context.
5793 vm.register_builtin(BUILTIN_ARITH_EVAL, |vm, _argc| {
5794 // Pure path: evaluate expr, return string. errflag may be
5795 // set by arithsubst on math error; the caller decides
5796 // whether to clear it. For `(( ... ))` (math command) the
5797 // compile_arith path clears via BUILTIN_ARITH_CMD_FINISH;
5798 // for `$((... ))` (substitution inside another command)
5799 // errflag stays set so the surrounding command aborts —
5800 // matches c:Src/math.c "math errors propagate as errflag
5801 // through the containing word expansion".
5802 let expr = vm.pop().to_str();
5803 let result = crate::ported::subst::arithsubst(&expr, "", "");
5804 let _ = vm; // silence unused warning when no math error path mutates
5805 Value::str(result)
5806 });
5807
5808 // After-call hook used by compile_arith's `(( ... ))` path: when
5809 // arithsubst set errflag (math error), clear it and signal
5810 // status=2 in vm.last_status — matches zsh's c:exec.c arith-
5811 // failure: the math command exits 2 and the script continues.
5812 vm.register_builtin(BUILTIN_ARITH_CMD_FINISH, |vm, _argc| {
5813 use std::sync::atomic::Ordering;
5814 let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
5815 let err = live & crate::ported::zsh_h::ERRFLAG_ERROR;
5816 let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
5817 if err != 0 {
5818 // c:Src/subst.c:3344 — when `${var:?msg}` fires, errflag
5819 // is OR'd with ERRFLAG_HARD to signal a script-abort
5820 // error (vs a recoverable math error like `$((1/0))`).
5821 // Clear only the ERRFLAG_ERROR bit; preserve
5822 // ERRFLAG_HARD so the next ERREXIT_CHECK aborts the
5823 // script. Bug #193 in docs/BUGS.md.
5824 if hard != 0 {
5825 // Keep ERRFLAG_HARD AND ERRFLAG_ERROR set so the
5826 // script-abort gate downstream still fires.
5827 vm.last_status = 2;
5828 Value::Status(2)
5829 } else {
5830 crate::ported::utils::errflag
5831 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
5832 vm.last_status = 2;
5833 Value::Status(2)
5834 }
5835 } else {
5836 Value::Status(vm.last_status)
5837 }
5838 });
5839
5840 // `$(cmd)` — pops [cmd_string], routes through
5841 // run_command_substitution which performs an in-process pipe-capture.
5842 // Avoids the Op::CmdSubst sub-chunk word-emit bug
5843 // (`printf "a\nb"` produced "anb" via that path). Returns trimmed
5844 // output (trailing newlines stripped per POSIX cmd-sub semantics).
5845 vm.register_builtin(BUILTIN_CMD_SUBST_TEXT, |vm, _argc| {
5846 let cmd = vm.pop().to_str();
5847 // Inherit live $? into the inner shell so cmd-subst sees the
5848 // parent's most recent exit. Same rationale as the mode-3
5849 // backtick path above.
5850 let live_status = vm.last_status;
5851 let result = with_executor(|exec| {
5852 exec.set_last_status(live_status);
5853 exec.run_command_substitution(&cmd)
5854 });
5855 // Mirror run_command_substitution's exec.last_status side
5856 // effect into the VM's live counter so a containing
5857 // assignment's BUILTIN_SET_VAR — which reads vm.last_status
5858 // — sees the cmd-subst's exit. Without this, `a=$(false);
5859 // echo $?` reads stale 0 (vm.last_status was zeroed by
5860 // compile_assign's prelude SetStatus, and run_cmd_subst only
5861 // updated exec.last_status). Pull the value back through
5862 // exec since it owns the canonical post-subst record.
5863 let cs_status = with_executor(|exec| exec.last_status());
5864 vm.last_status = cs_status;
5865 Value::str(result)
5866 });
5867
5868 // Text-based word expansion. Pops [preserved_text, mode_byte].
5869 // mode_byte:
5870 // 0 = Default — expand_string + xpandbraces + expand_glob
5871 // 1 = DoubleQuoted — strip outer `"…"`, expand_string only
5872 // (no brace, no glob — DQ semantics)
5873 // 2 = SingleQuoted — strip outer `'…'`, no expansion
5874 // (kept for symmetry; Snull early-return covers most SQ)
5875 // 3 = AltBackquote — strip backticks, run as cmd-sub
5876 // Single result → Value::str; multi → Value::Array.
5877 vm.register_builtin(BUILTIN_EXPAND_TEXT, |vm, _argc| {
5878 let mode = vm.pop().to_int() as u8;
5879 let text = vm.pop().to_str();
5880 // Sync vm.last_status → exec.last_status so cmd-subst (mode 3)
5881 // and any nested $? reads inside singsub see the live `$?`
5882 // from the most recent VM op. Without this, cmd-subst inside
5883 // arg-eval saw a stale exec.last_status that was zeroed at
5884 // the start of the current statement. Direct port of zsh's
5885 // pre-cmdsubst lastval propagation per Src/exec.c:4770.
5886 let live_status = vm.last_status;
5887 with_executor(|exec| exec.set_last_status(live_status));
5888 let result_value = with_executor(|exec| match mode {
5889 // Mode 1 = DoubleQuoted (argument context).
5890 // Mode 5 = DoubleQuoted in scalar-assignment context.
5891 // Both share the same DQ unescape pre-processing; mode 5
5892 // additionally bumps `in_scalar_assign` so subst_port's
5893 // paramsubst sees ssub=true and suppresses split flags
5894 // `(f)` / `(s:STR:)` / `(0)` per Src/subst.c:1759 +
5895 // Src/exec.c::addvars line 2546 (the PREFORK_SINGLE bit
5896 // C zsh sets when prefork-ing the assignment RHS).
5897 1 | 5 => {
5898 // DoubleQuoted: strip outer `"…"` if present. In DQ
5899 // context, `\` escapes the DQ-special chars `$`, `` ` ``,
5900 // `"`, `\`. zsh's expand_string expects the lexer's
5901 // `\0X` literal-marker for an already-escaped char, so
5902 // we pre-process: `\$` → `\0$`, `\\` → `\0\`, etc. Then
5903 // expand_string handles the rest.
5904 let inner = if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
5905 &text[1..text.len() - 1]
5906 } else {
5907 text.as_str()
5908 };
5909 // The lexer's dquote_parse (Src/lex.c) already tokenized
5910 // DQ contents: `$` → Qstring (\u{8c}), `\$`/`\\`/`\"`/
5911 // `` \` `` → Bnull (\u{9f}) + literal. Stringsubst /
5912 // multsub recognize these markers natively. We pass
5913 // `inner` through verbatim — no re-tokenization needed.
5914 let prepped: String = inner.to_string();
5915 // Tell parameter-flag application that we're inside
5916 // double quotes — array-only flags ((o), (O), (n),
5917 // (i), (M), (u)) must be no-ops here per zsh.
5918 exec.in_dq_context += 1;
5919 if mode == 5 {
5920 exec.in_scalar_assign += 1;
5921 }
5922 // Mode 1 = argv DQ word; mode 5 = scalar-assign RHS.
5923 // In C zsh, the corresponding prefork-on-list paths
5924 // are: argv → `prefork(argv_list, 0)` returns multi-
5925 // word LinkList (Src/exec.c::execcmd), assignment →
5926 // `prefork(rhs_list, PREFORK_SINGLE|PREFORK_ASSIGN)`
5927 // returns single-word (Src/exec.c::addvars line
5928 // 2546). zshrs's `multsub` (Src/subst.c:544) is the
5929 // multi-result variant; `singsub` (Src/subst.c:514)
5930 // asserts ≤1 node. Mode 5 keeps singsub; mode 1
5931 // switches to multsub so `"${(@)arr}"`/`"$@"`/
5932 // `"${arr[@]}"` in argv context emit multiple words
5933 // as the C path would.
5934 let result_value = if mode == 5 {
5935 let out = crate::ported::subst::singsub(&prepped);
5936 Value::str(out)
5937 } else {
5938 let (_first, nodes, _ms_ws, _ret) = crate::ported::subst::multsub(&prepped, 0);
5939 // c:Src/subst.c:655 — multsub returns Vec::new()
5940 // for zero-word results (quoted array splat that
5941 // resolved to empty array). Surface as
5942 // Value::Array(vec![]) so the downstream array
5943 // assignment / argv flattening sees ZERO args.
5944 // Previous Rust port returned Value::str("") which
5945 // surfaced as ONE empty arg. Bug #120 in
5946 // docs/BUGS.md.
5947 if nodes.is_empty() {
5948 Value::Array(Vec::new())
5949 } else if nodes.len() == 1 {
5950 Value::str(nodes.into_iter().next().unwrap())
5951 } else {
5952 Value::Array(nodes.into_iter().map(Value::str).collect())
5953 }
5954 };
5955 if mode == 5 {
5956 exec.in_scalar_assign -= 1;
5957 }
5958 exec.in_dq_context -= 1;
5959 result_value
5960 }
5961 2 => {
5962 // SingleQuoted: pure literal, strip outer `'…'`.
5963 let inner = if text.len() >= 2 && text.starts_with('\'') && text.ends_with('\'') {
5964 &text[1..text.len() - 1]
5965 } else {
5966 text.as_str()
5967 };
5968 Value::str(inner.to_string())
5969 }
5970 3 => {
5971 // Backquote command sub: strip outer backticks.
5972 // Word-split the result on IFS when the surrounding
5973 // word is unquoted — zsh: `print -l \`echo a b c\``
5974 // emits one arg per word. The $(…) path applies the
5975 // same split via BUILTIN_WORD_SPLIT after capture; do
5976 // the equivalent here for the `…` form.
5977 let inner = if text.len() >= 2 && text.starts_with('`') && text.ends_with('`') {
5978 &text[1..text.len() - 1]
5979 } else {
5980 text.as_str()
5981 };
5982 // Apply the live VM status before running the inner
5983 // shell so the inherited $? matches zsh's lastval
5984 // propagation.
5985 exec.set_last_status(live_status);
5986 let captured = exec.run_command_substitution(inner);
5987 let trimmed = captured.trim_end_matches('\n');
5988 if exec.in_dq_context > 0 {
5989 Value::str(trimmed.to_string())
5990 } else {
5991 let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
5992 let parts: Vec<Value> = trimmed
5993 .split(|c: char| ifs.contains(c))
5994 .filter(|s| !s.is_empty())
5995 .map(|s| Value::str(s.to_string()))
5996 .collect();
5997 if parts.is_empty() {
5998 Value::str(String::new())
5999 } else if parts.len() == 1 {
6000 parts.into_iter().next().unwrap()
6001 } else {
6002 Value::Array(parts)
6003 }
6004 }
6005 }
6006 4 => {
6007 // HeredocBody: expand variables / command-subst / arith
6008 // but NOT glob or brace. Heredoc lines like `[42]` must
6009 // pass through verbatim — running them through the
6010 // default pipeline triggers NOMATCH on the literal.
6011 Value::str(crate::ported::subst::singsub(&text))
6012 }
6013 _ => {
6014 // Default (unquoted): the lexer's gettokstr already
6015 // tokenized backslash-escapes (`\$` → Bnull+$, etc).
6016 // Pass `text` through verbatim — multsub/stringsubst
6017 // recognize the markers natively. No bridge-side
6018 // re-tokenization needed.
6019 //
6020 // Mode 6 = unquoted RHS in scalar-assign context.
6021 // Pass PREFORK_ASSIGN so prefork's filesub colon-walk
6022 // fires per c:Src/exec.c:2546.
6023 let prepped: String = text.clone();
6024 if std::env::var("ZSHRS_TRACE_DEFP").is_ok() {
6025 eprintln!(
6026 "[TRACE_DEFP] text={:?} prepped={:?} mode={}",
6027 text, prepped, mode
6028 );
6029 }
6030 let pf_flags = if mode == 6 {
6031 crate::ported::zsh_h::PREFORK_ASSIGN
6032 } else {
6033 0
6034 };
6035 // c:Src/subst.c:544+ — `multsub(&prepped, 0)` is the
6036 // unquoted-argv equivalent of zsh's `prefork(list,
6037 // 0, NULL)` for a single-element list. Returns the
6038 // post-expansion node list (Vec<String>) so array-
6039 // shape results (e.g. `${a:e}`, `${a[@]}`,
6040 // `${(s::)str}`) splat into multiple argv words.
6041 // singsub() collapses to one string and discards the
6042 // splat — parity bug #28 (whole-array modifier).
6043 let (_first, nodes, _ms_ws, _ret) =
6044 crate::ported::subst::multsub(&prepped, pf_flags);
6045 if std::env::var("ZSHRS_TRACE_MULTSUB").is_ok() {
6046 eprintln!("[TRACE_MULTSUB] prepped={:?} nodes={:?}", prepped, nodes);
6047 }
6048 // c:Src/subst.c:166 — xpandbraces runs AFTER prefork's
6049 // substitution pass and BEFORE untokenize/glob. Per
6050 // word, scan for Inbrace TOKEN and expand. Words that
6051 // don't contain Inbrace TOKEN pass through unchanged.
6052 // Brace expansion is done here (inside the bridge
6053 // default arm) instead of via a post-EXPAND_TEXT
6054 // BRACE_EXPAND emit because untokenize (line below)
6055 // strips TOKEN bytes, after which the strict-TOKEN
6056 // xpandbraces gate would no longer match.
6057 let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
6058 // c:Src/options.c — `no_brace_expand` (negated
6059 // `braceexpand`) gates brace expansion entirely.
6060 // When off, `{a,b}` stays literal.
6061 let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
6062 let pre_brace: Vec<String> = if nodes.is_empty() {
6063 vec![String::new()]
6064 } else {
6065 nodes
6066 };
6067 let brace_expanded: Vec<String> = pre_brace
6068 .into_iter()
6069 .flat_map(|w| {
6070 if brace_expand && w.contains('\u{8f}') {
6071 crate::ported::glob::xpandbraces(&w, brace_ccl)
6072 } else {
6073 vec![w]
6074 }
6075 })
6076 .collect();
6077 // zsh stores the option as `glob` (default ON);
6078 // `setopt noglob` writes `glob=false`. Honor either
6079 // form so the dispatcher behaves the same as zsh.
6080 let noglob = opt_state_get("noglob").unwrap_or(false)
6081 || opt_state_get("GLOB").map(|v| !v).unwrap_or(false)
6082 || !opt_state_get("glob").unwrap_or(true);
6083 let parts: Vec<String> = brace_expanded
6084 .into_iter()
6085 .flat_map(|s| {
6086 // The lexer leaves glob metacharacters in their
6087 // META-encoded form: `*` → `\u{87}`, `?` →
6088 // `\u{86}`, `[` → `\u{91}`, etc. expand_string
6089 // doesn't untokenize them, so the literal-char
6090 // checks below (`s.contains('*')`) would miss
6091 // every real glob and skip expand_glob — that
6092 // bug let `echo *.toml` print the literal
6093 // `*.toml` because the META `\u{87}` never
6094 // matched the literal `*`. Untokenize once so
6095 // the metacharacter checks see the canonical
6096 // form. zsh's pattern.c expects `*` etc. as
6097 // bare chars at the glob layer.
6098 // c:Src/pattern.c:4306 haswilds — token-only
6099 // gate matching C verbatim. C's haswilds checks
6100 // ONLY the META-TOKEN codes (Inpar `\u{88}`,
6101 // Bar `\u{89}`, Star `\u{87}`, Inbrack `\u{91}`,
6102 // Inang `\u{94}`, Quest `\u{86}`, Pound `\u{84}`
6103 // /EXTENDEDGLOB, Hat `\u{8a}`/EXTENDEDGLOB),
6104 // never their literal ASCII counterparts. The
6105 // lexer tokenizes source-level `[abc]` →
6106 // Inbrack/Outbrack, source-level `*.toml` → Star
6107 // — those reach haswilds with tokens and fire.
6108 // Bare literal `[`/`*`/`?` from `$'...'` decode,
6109 // `:-` default values, or variable expansion
6110 // never get shtokenize'd (C subst.c:3231 sets
6111 // globsubst=0 in the `:-` arm) so haswilds
6112 // skips them. Bug #625: `${${X}:-$'\e[hi]'}`
6113 // returned bare literal `[hi]` from the nested
6114 // paramsubst; the previous post-untokenize
6115 // haswilds saw literal `[` and globbed → NOMATCH
6116 // fired. The TOKEN-only check has to happen
6117 // PRE-untokenize so source-level Star tokens
6118 // (`*.toml`) survive while substituted bare
6119 // glob chars stay literal.
6120 let is_glob_pre = if noglob {
6121 false
6122 } else {
6123 use crate::ported::zsh_h::{
6124 isset, Bar, Bang, EXTENDEDGLOB, Hat, Inang, Inbrack, Inpar,
6125 KSHGLOB, Outbrack, Pound, Quest, SHGLOB, Star,
6126 };
6127 // c:Src/pattern.c:4310-4312 — single-byte
6128 // bare Inbrack/Outbrack is legal pattern.
6129 let bytes = s.as_bytes();
6130 let len = bytes.len();
6131 let single_bracket = len == 1
6132 && (bytes[0] == Inbrack as u8 || bytes[0] == Outbrack as u8);
6133 // c:4317-4318 — `%?foo` job-ref skip.
6134 let skip_pos_1 = len >= 2
6135 && bytes[0] == b'%'
6136 && bytes[1] == Quest as u8;
6137 let mut found = false;
6138 if !single_bracket {
6139 let disp = crate::ported::pattern::zpc_disables
6140 .lock()
6141 .unwrap();
6142 for i in 0..len {
6143 if skip_pos_1 && i == 1 {
6144 continue;
6145 }
6146 let b = bytes[i];
6147 // c:4326-4335 Inpar — KSHGLOB
6148 // prev-char gating mirrors C.
6149 if b == Inpar as u8 {
6150 let prev = if i > 0 { bytes[i - 1] } else { 0 };
6151 if (!isset(SHGLOB)
6152 && disp[crate::ported::zsh_h::ZPC_INPAR as usize] == 0)
6153 || (i > 0
6154 && isset(KSHGLOB)
6155 && ((prev == Quest as u8
6156 && disp[crate::ported::zsh_h::ZPC_KSH_QUEST as usize] == 0)
6157 || (prev == Star as u8
6158 && disp[crate::ported::zsh_h::ZPC_KSH_STAR as usize] == 0)
6159 || (prev == b'+'
6160 && disp[crate::ported::zsh_h::ZPC_KSH_PLUS as usize] == 0)
6161 || (prev == Bang as u8
6162 && disp[crate::ported::zsh_h::ZPC_KSH_BANG as usize] == 0)
6163 || (prev == b'!'
6164 && disp[crate::ported::zsh_h::ZPC_KSH_BANG2 as usize] == 0)
6165 || (prev == b'@'
6166 && disp[crate::ported::zsh_h::ZPC_KSH_AT as usize] == 0)))
6167 {
6168 found = true;
6169 break;
6170 }
6171 } else if b == Bar as u8 {
6172 if disp[crate::ported::zsh_h::ZPC_BAR as usize] == 0 {
6173 found = true;
6174 break;
6175 }
6176 } else if b == Star as u8 {
6177 if disp[crate::ported::zsh_h::ZPC_STAR as usize] == 0 {
6178 found = true;
6179 break;
6180 }
6181 } else if b == Inbrack as u8 {
6182 if disp[crate::ported::zsh_h::ZPC_INBRACK as usize] == 0 {
6183 found = true;
6184 break;
6185 }
6186 } else if b == Inang as u8 {
6187 if disp[crate::ported::zsh_h::ZPC_INANG as usize] == 0 {
6188 found = true;
6189 break;
6190 }
6191 } else if b == Quest as u8 {
6192 if disp[crate::ported::zsh_h::ZPC_QUEST as usize] == 0 {
6193 found = true;
6194 break;
6195 }
6196 } else if b == Pound as u8 {
6197 if isset(EXTENDEDGLOB)
6198 && disp[crate::ported::zsh_h::ZPC_HASH as usize] == 0
6199 {
6200 found = true;
6201 break;
6202 }
6203 } else if b == Hat as u8 {
6204 if isset(EXTENDEDGLOB)
6205 && disp[crate::ported::zsh_h::ZPC_HAT as usize] == 0
6206 {
6207 found = true;
6208 break;
6209 }
6210 }
6211 }
6212 }
6213 found
6214 };
6215 let s = crate::lex::untokenize(&s);
6216 // Skip glob expansion for assignment-shaped
6217 // words (`NAME=value`). zsh doesn't expand the
6218 // RHS of an assignment as a path glob unless
6219 // `setopt globassign` is set, and feeding such
6220 // words through expand_glob makes NOMATCH
6221 // (default ON) fire spuriously on
6222 // `integer i=2*3+1`, `path=*.rs`, etc.
6223 let is_assignment_shape = {
6224 let bytes = s.as_bytes();
6225 let mut i = 0;
6226 if !bytes.is_empty()
6227 && (bytes[0] == b'_' || bytes[0].is_ascii_alphabetic())
6228 {
6229 i += 1;
6230 while i < bytes.len()
6231 && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric())
6232 {
6233 i += 1;
6234 }
6235 i < bytes.len() && bytes[i] == b'='
6236 } else {
6237 false
6238 }
6239 };
6240 // Glob-trigger decision: pre-untokenize
6241 // haswilds_tokens_only result (computed above
6242 // before the untokenize that collapses META
6243 // tokens to their ASCII forms). The TOKEN-only
6244 // gate matches C `Src/pattern.c:4306-4376`
6245 // exactly — only Inbrack/Star/Quest/Inpar/Bar/
6246 // Inang/Pound/Hat token codes count as wild,
6247 // not their literal ASCII counterparts. Source-
6248 // level `*.toml` carries Star token so globs;
6249 // `$'…'`-decoded `[abc]` carries bare `[` so
6250 // stays literal. Bug #625.
6251 if is_glob_pre && !is_assignment_shape {
6252 exec.expand_glob(&s)
6253 } else {
6254 vec![s]
6255 }
6256 })
6257 .collect();
6258 if parts.len() == 1 {
6259 let only = parts.into_iter().next().unwrap_or_default();
6260 // Empty unquoted expansion → drop the arg entirely
6261 // (zsh "remove empty unquoted words" rule). Returning
6262 // an empty Value::Array makes pop_args contribute zero
6263 // items. Direct port of subst.c's empty-elide pass at
6264 // the end of multsub which removes empty linknodes
6265 // from unquoted contexts. Quoted DQ/SQ paths (modes
6266 // 1/2/5) take separate arms above and always emit
6267 // Value::Str so the empty arg survives.
6268 if only.is_empty() {
6269 Value::Array(Vec::new())
6270 } else {
6271 Value::str(only)
6272 }
6273 } else {
6274 Value::Array(parts.into_iter().map(Value::str).collect())
6275 }
6276 }
6277 });
6278 // Pull any inner cmd-subst (`` `cmd` `` via mode 3 or via
6279 // mode 0/6 multsub → getoutput, `$(cmd)` via the default
6280 // arm's multsub path, nested `$()`s reached through
6281 // stringsubst) back into vm.last_status so a containing
6282 // assignment's BUILTIN_SET_VAR — which reads vm.last_status —
6283 // sees the cmd-subst's exit. Without this, backtick
6284 // assignments (`a=\`false\`; echo $?`) reported 0 because the
6285 // ported LASTVAL update never reached the VM-side counter.
6286 let cs_status = with_executor(|exec| exec.last_status());
6287 vm.last_status = cs_status;
6288 result_value
6289 });
6290
6291 // `${#name}` — pops [name]. Returns the value's element count for
6292 // arrays (indexed and assoc) or character length for scalars.
6293 // BUILTIN_PARAM_LENGTH — `${#name}`. PURE PASSTHRU.
6294 vm.register_builtin(BUILTIN_PARAM_LENGTH, |vm, _argc| {
6295 let name = vm.pop().to_str();
6296 // PARAM_LENGTH's empty-result semantics differ from
6297 // paramsubst_to_value: 0 nodes → "0" (numeric length), not
6298 // empty array. paramsubst on `${#X}` always returns at least
6299 // one node in practice (the length string); the empty case
6300 // is defensive.
6301 let mut ret_flags: i32 = 0;
6302 let (_full, _pos, nodes) = crate::ported::subst::paramsubst(
6303 &format!("${{#{}}}", name),
6304 0,
6305 false,
6306 0i32,
6307 &mut ret_flags,
6308 );
6309 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
6310 with_executor(|exec| exec.set_last_status(1));
6311 }
6312 if nodes.is_empty() {
6313 Value::str("0")
6314 } else {
6315 nodes_to_value(nodes)
6316 }
6317 });
6318
6319 // `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
6320 // `${var/%pat/repl}` — Pops [name, pattern, replacement, op_byte].
6321 // op: 0=first, 1=all, 2=anchor-prefix (`/#`), 3=anchor-suffix (`/%`).
6322 // BUILTIN_PARAM_REPLACE — `${var/pat/repl}` / `${var//pat/repl}` /
6323 // `${var/#pat/repl}` / `${var/%pat/repl}`. PURE PASSTHRU.
6324 vm.register_builtin(BUILTIN_PARAM_REPLACE, |vm, _argc| {
6325 let _dq_flag = vm.pop().to_int() != 0;
6326 let op = vm.pop().to_int() as u8;
6327 let repl = vm.pop().to_str();
6328 let pattern = vm.pop().to_str();
6329 let name = vm.pop().to_str();
6330 // op encoding: 0 = first `/`, 1 = all `//`, 2 = anchor-prefix
6331 // `/#`, 3 = anchor-suffix `/%`. The brace form distinguishes
6332 // first-vs-all by single vs doubled slash, and anchored by
6333 // a `#` or `%` immediately after the slash(es).
6334 let body = match op {
6335 0 => format!("${{{}/{}/{}}}", name, pattern, repl),
6336 1 => format!("${{{}//{}/{}}}", name, pattern, repl),
6337 2 => format!("${{{}/#{}/{}}}", name, pattern, repl),
6338 3 => format!("${{{}/%{}/{}}}", name, pattern, repl),
6339 _ => format!("${{{}/{}/{}}}", name, pattern, repl),
6340 };
6341 paramsubst_to_value(&body)
6342 });
6343
6344 vm.register_builtin(BUILTIN_REGISTER_COMPILED_FN, |vm, argc| {
6345 let args = pop_args(vm, argc);
6346 let mut iter = args.into_iter();
6347 let name = iter.next().unwrap_or_default();
6348 let body_b64 = iter.next().unwrap_or_default();
6349 let body_source = iter.next().unwrap_or_default();
6350 let line_base_str = iter.next().unwrap_or_default();
6351 let line_base: i64 = line_base_str.parse().unwrap_or(0);
6352 let bytes = base64_decode(&body_b64);
6353 let status = match bincode::deserialize::<fusevm::Chunk>(&bytes) {
6354 Ok(chunk) => with_executor(|exec| {
6355 // c:Src/exec.c:5383 — `shf->filename =
6356 // ztrdup(scriptfilename);` — the function's
6357 // definition-file is read from the canonical
6358 // file-scope `scriptfilename` global at compile
6359 // time, NOT from a per-executor struct field.
6360 // exec.scriptfilename is seeded once at
6361 // bins/zshrs.rs:1717 to the bin basename ("zsh")
6362 // and never updates on source/dot, so reading from
6363 // it left every user function's def_file as "zsh".
6364 // Route through scriptfilename_get() so source /
6365 // dot's set_scriptfilename calls propagate.
6366 let def_file = crate::ported::utils::scriptfilename_get()
6367 .or_else(|| exec.scriptfilename.clone());
6368 if !body_source.is_empty() {
6369 exec.function_source
6370 .insert(name.clone(), body_source.clone());
6371 }
6372 exec.function_line_base.insert(name.clone(), line_base);
6373 exec.function_def_file.insert(name.clone(), def_file);
6374 // PFA-SMR aspect: every `name() {}` / `function name { }`
6375 // funnels through here at compile time. Emit one record
6376 // with the function name + raw body source.
6377 #[cfg(feature = "recorder")]
6378 if crate::recorder::is_enabled() {
6379 let ctx = exec.recorder_ctx();
6380 let body = if body_source.is_empty() {
6381 None
6382 } else {
6383 Some(body_source.as_str())
6384 };
6385 crate::recorder::emit_function(&name, body, ctx);
6386 }
6387 // Mirror into canonical shfunctab so scanfunctions /
6388 // ${(k)functions} / functions builtin see user defs.
6389 // C: exec.c:funcdef → shfunctab->addnode(ztrdup(name),shf).
6390 if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
6391 let mut shf = crate::ported::hashtable::shfunc_with_body(&name, &body_source);
6392 // c:Src/exec.c:5409 — `shf->lineno = lineno;`. Use
6393 // the same max(1, line_base) clamp as the synth_shf
6394 // in vm_helper::dispatch_function_call. Bug #396.
6395 shf.lineno = std::cmp::max(1, line_base);
6396 tab.add(shf);
6397 }
6398 // c:Src/exec.c:5460-5475 — `TRAP<SIG>() { ... }` is the
6399 // function-named trap install. zsh detects the `TRAP`
6400 // prefix at func-def time and calls
6401 // `settrap(signum, NULL, ZSIG_FUNC)` so the next
6402 // dispatch of that signal routes to the named shfunc.
6403 // Bug #157 in docs/BUGS.md — fusevm_bridge's funcdef
6404 // opcode skipped this dispatch entirely, so TRAPEXIT /
6405 // TRAPUSR1 / TRAPZERR / TRAPDEBUG never fired.
6406 if name.len() > 4 && name.starts_with("TRAP") {
6407 if let Some(sn) = crate::ported::jobs::getsigidx(&name[4..]) {
6408 let _ = crate::ported::signals::settrap(
6409 sn,
6410 None,
6411 crate::ported::zsh_h::ZSIG_FUNC as i32,
6412 );
6413 }
6414 }
6415 exec.functions_compiled.insert(name, chunk);
6416 0
6417 }),
6418 Err(_) => 1,
6419 };
6420 Value::Status(status)
6421 });
6422
6423 // Wire the ShellHost so direct shell ops (Op::Glob, Op::TildeExpand,
6424 // Op::ExpandParam, Op::CmdSubst, Op::CallFunction, etc.) route through
6425 // ZshrsHost back into the executor.
6426 vm.set_shell_host(Box::new(ZshrsHost));
6427}
6428
6429impl ZshrsHost {
6430 /// True iff `c` can be a `(j:…:)` / `(s:…:)` delimiter — non-alphanumeric,
6431 /// non-underscore. Restricting to punctuation avoids `(jL)` consuming `L`
6432 /// as a delim instead of as the next flag.
6433 fn is_zsh_flag_delim(c: char) -> bool {
6434 !c.is_ascii_alphanumeric() && c != '_'
6435 }
6436}
6437
6438/// Run `body` through `crate::ported::subst::paramsubst` and convert
6439/// the resulting node list into a fusevm `Value`. Centralises the
6440/// pattern duplicated across ~10 BUILTIN_* handlers:
6441/// - build a `${...}` body string from opcode operands
6442/// - paramsubst the body
6443/// - propagate errflag to `exec.last_status`
6444/// - delegate the LinkList → Value conversion to `nodes_to_value`
6445///
6446/// **Extension** — Rust-only helper. No direct C analog because C
6447/// zsh uses LinkList everywhere; the conversion happens at the
6448/// boundary back into the VM's stack.
6449fn paramsubst_to_value(body: &str) -> Value {
6450 // c:Src/subst.c:1625 paramsubst's `qt` flag is the C signal that
6451 // the current expansion is inside `"…"`. The fast-path bridges
6452 // (BUILTIN_PARAM_*, BUILTIN_BRIDGE_BRACE_ARRAY) used to hardcode
6453 // qt=false, which silently broke DQ-only semantics inside
6454 // `${arr:^other}` / `${arr:^^other}` (Src/subst.c:3456-3520).
6455 // The executor's `in_dq_context` counter is bumped by EXPAND_TEXT
6456 // mode 1 / mode 5 before the bridge fires, so reading it here
6457 // propagates the DQ flag without changing every bridge call site.
6458 let qt = with_executor(|exec| exec.in_dq_context > 0);
6459 let mut ret_flags: i32 = 0;
6460 let (_full, _pos, nodes) = crate::ported::subst::paramsubst(body, 0, qt, 0i32, &mut ret_flags);
6461 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
6462 with_executor(|exec| exec.set_last_status(1));
6463 }
6464 nodes_to_value(nodes)
6465}
6466
6467/// Wrap a `Vec<String>` (e.g. paramsubst nodes, multsub parts,
6468/// xpandbraces output) into a fusevm `Value`: 0 → empty Array, 1 →
6469/// Str, >1 → Array. Same unwrap idiom every handler that calls a
6470/// canonical Vec-returning fn does.
6471fn nodes_to_value(nodes: Vec<String>) -> Value {
6472 // c:Src/glob.c:3649 remnulargs — strip the Nularg (`\u{a1}`)
6473 // sentinel and other INULL bytes that paramsubst's splat block
6474 // emits for empty array elements (so prefork's empty-node-delete
6475 // pass doesn't drop them). Downstream consumers (cond `-z`/`-n`,
6476 // command args, etc.) must see the post-remnulargs strings. Bug
6477 // #185 in docs/BUGS.md: `[[ -z "${b[@]}" ]]` for b=("") returned
6478 // false because the leftover `\u{a1}` had StringLen=1.
6479 let stripped: Vec<String> = nodes
6480 .into_iter()
6481 .map(|mut s| {
6482 crate::ported::glob::remnulargs(&mut s);
6483 s
6484 })
6485 .collect();
6486 if stripped.is_empty() {
6487 Value::Array(Vec::new())
6488 } else if stripped.len() == 1 {
6489 let only = stripped.into_iter().next().unwrap();
6490 // c:Src/subst.c:183-186 — `else if (!(flags & PREFORK_SINGLE)
6491 // && !(*ret_flags & PREFORK_KEY_VALUE) && !keep)
6492 // uremnode(list, node);`
6493 // C zsh's prefork removes empty linknodes from the result
6494 // list when in non-SINGLE (argv-context) mode. The ported
6495 // prefork at subst.rs:388-396 honors the same delete-empty
6496 // pass, but some paramsubst paths land here with a single-
6497 // empty-string Vec instead of an empty Vec (paramsubst's
6498 // slice / substring / parameter-flag branches allocate a
6499 // result before checking emptiness). Mirror the prefork
6500 // drop at this layer: single-empty under !in_dq_context
6501 // collapses to Value::Array(empty), and pop_args (line 6243)
6502 // splats the empty Array → zero argv words. DQ context
6503 // (in_dq_context > 0) keeps the empty string so
6504 // `echo "${UNSET}"` still produces an empty arg per zsh's
6505 // quoting rules (c:Src/subst.c:1650-1656 isarr comment).
6506 if only.is_empty() {
6507 let in_dq = with_executor(|exec| exec.in_dq_context > 0);
6508 if !in_dq {
6509 return Value::Array(Vec::new());
6510 }
6511 }
6512 Value::str(only)
6513 } else {
6514 Value::Array(stripped.into_iter().map(Value::str).collect())
6515 }
6516}
6517
6518fn pop_args(vm: &mut fusevm::VM, argc: u8) -> Vec<String> {
6519 let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
6520 for _ in 0..argc {
6521 popped.push(vm.pop());
6522 }
6523 popped.reverse();
6524 let mut args: Vec<String> = Vec::with_capacity(popped.len());
6525 for v in popped {
6526 match v {
6527 Value::Array(items) => {
6528 for item in items {
6529 args.push(item.to_str());
6530 }
6531 }
6532 other => args.push(other.to_str()),
6533 }
6534 }
6535 // `expand_glob` set the glob-failed cell when a no-match glob
6536 // triggered nomatch (c:Src/glob.c:1877). Signal the failure via
6537 // last_status + the per-command glob_failed cell; the dispatcher
6538 // (`host_exec_external`) consumes + clears it and returns status 1
6539 // without running the command body.
6540 if with_executor(|exec| exec.current_command_glob_failed.get()) {
6541 with_executor(|exec| exec.set_last_status(1));
6542 }
6543 // `$_` tracks the last argument of the PREVIOUSLY executed
6544 // command (zsh / bash convention). Promote the deferred value
6545 // into `$_` BEFORE this command runs (so `echo $_` reads the
6546 // prior command's last arg) then stash THIS command's last arg
6547 // for the next dispatch.
6548 let new_last = args.last().cloned();
6549 with_executor(|exec| {
6550 if let Some(prev) = exec.pending_underscore.take() {
6551 exec.set_scalar("_".to_string(), prev);
6552 }
6553 if let Some(last) = new_last {
6554 exec.pending_underscore = Some(last);
6555 }
6556 });
6557 args
6558}
6559
6560/// zsh dispatch order is alias → function → builtin → external. The
6561/// compiler emits direct CallBuiltin ops for known builtin names for
6562/// perf, which silently skips a user function that shadows the same
6563/// name (e.g. `echo() { ... }; echo hi` would run the C builtin
6564/// without this check). Returns Some(status) when the call is routed
6565/// to the user function; the builtin handler should fall through to
6566/// its native impl when None.
6567/// Fork+exec a system binary by name. Used by `reg_overridable!` as
6568/// the fall-through path when `[builtins].coreutils_shadows = off`
6569/// (the default) — runs the canonical `/bin/X` instead of zshrs's
6570/// in-process shadow so old scripts hit zero behavioral divergence.
6571///
6572/// Inherits stdin/stdout/stderr from the parent so pipelines work
6573/// transparently. Resolves the binary via PATH; mirrors what zsh's
6574/// own external-command dispatch would do. Returns the child's exit
6575/// status (or 127 if PATH lookup fails — the standard "command not
6576/// found" code).
6577fn exec_system_command(name: &str, args: &[String]) -> i32 {
6578 let status = std::process::Command::new(name)
6579 .args(args)
6580 .stdin(std::process::Stdio::inherit())
6581 .stdout(std::process::Stdio::inherit())
6582 .stderr(std::process::Stdio::inherit())
6583 .status();
6584 match status {
6585 Ok(s) => s.code().unwrap_or(if s.success() { 0 } else { 1 }),
6586 Err(e) => {
6587 eprintln!("zshrs: {}: {}", name, e);
6588 127
6589 }
6590 }
6591}
6592
6593fn try_user_fn_override(name: &str, args: &[String]) -> Option<i32> {
6594 let has_fn = with_executor(|exec| {
6595 exec.functions_compiled.contains_key(name) || exec.function_exists(name)
6596 });
6597 if !has_fn {
6598 return None;
6599 }
6600 Some(with_executor(|exec| {
6601 exec.dispatch_function_call(name, args).unwrap_or(127)
6602 }))
6603}
6604
6605/// Builtin ID for `${name}` reads — routes through canonical
6606/// `getsparam` (Src/params.c:3076) via paramtab + env walk so nested
6607/// VMs (function calls) see the same storage.
6608pub const BUILTIN_GET_VAR: u16 = 283;
6609
6610/// Builtin ID for `name=value` assignments — pops [name, value] and
6611/// routes through canonical `setsparam` (Src/params.c:3350).
6612pub const BUILTIN_SET_VAR: u16 = 284;
6613
6614/// Builtin ID for pipeline execution. Pops N sub-chunk indices from the stack;
6615/// each index points into `vm.chunk.sub_chunks` (compiled stage bodies). Forks
6616/// N children, wires stdin/stdout between them via pipes, runs each stage's
6617/// bytecode on a fresh VM in its child, parent waits for all and pushes the
6618/// last stage's exit status. This is bytecode-native pipeline execution —
6619/// no tree-walker delegation.
6620pub const BUILTIN_RUN_PIPELINE: u16 = 285;
6621
6622/// Builtin ID for `Array → String` joining. Pops one value: if it's an Array,
6623/// joins its string-coerced elements with a single space; otherwise passes
6624/// through. Used after `Op::Glob` to convert the pattern's matched paths into
6625/// the single argv-token form the bytecode word model expects (no per-word
6626/// splitting yet — that's a future phase).
6627pub const BUILTIN_ARRAY_JOIN: u16 = 286;
6628
6629/// Builtin ID for `cmd &` background execution. IDs 287/288/289 are reserved
6630/// for the planned array work in Phase G1 (SET_ARRAY/SET_ASSOC/ARRAY_INDEX),
6631/// so this lands at 290. Pops one sub-chunk index; forks; child detaches
6632/// (`setsid`), runs the sub-chunk on a fresh VM, exits with last_status; parent
6633/// returns Status(0) immediately. Job-table registration (so `jobs`/`fg`/`wait`
6634/// can see the pid) is deferred to Phase G6 — fire-and-forget for now.
6635pub const BUILTIN_RUN_BG: u16 = 290;
6636
6637/// Indexed-array assignment: `arr=(a b c)`. Compile_simple emits N element
6638/// pushes followed by name push, then `CallBuiltin(BUILTIN_SET_ARRAY, N+1)`.
6639/// The handler pops args (last popped = name in our pushing order) and stores
6640/// `Vec<String>` into `executor.arrays`. Tree-walker callers see the same
6641/// storage. Any prior scalar binding in `executor.variables` for `name` is
6642/// removed so `${name}` (scalar context) consistently reflects the array's
6643/// first element via `get_variable`.
6644pub const BUILTIN_SET_ARRAY: u16 = 287;
6645
6646/// Single-key set on an associative array: `foo[key]=val`. Stack (top-down):
6647/// [name, key, value]. Stores `value` into `executor.assoc_arrays[name][key]`,
6648/// creating the outer entry if missing. compile_simple detects `var[...]=...`
6649/// in assignments and emits this builtin.
6650pub const BUILTIN_SET_ASSOC: u16 = 288;
6651
6652/// `${arr[idx]}` — single-element array index. Pops two args:
6653/// stack: [name, idx_str]
6654/// Returns the indexed element as Value::str. Indexing semantics: zsh is
6655/// 1-based by default; bash is 0-based. We follow zsh.
6656/// Special idx values: `@` and `*` return the whole array as Value::Array
6657/// (which fuses correctly via the Op::Exec splice for argv splice).
6658pub const BUILTIN_ARRAY_INDEX: u16 = 289;
6659
6660/// `${#arr[@]}` and `${#arr}` (when arr is an array name) — array length.
6661/// Pops one arg: name. Returns Value::str of len.
6662
6663/// `${arr[@]}` — splice all elements as a Value::Array. Pops one arg: name.
6664/// The Array gets flattened by Op::Exec/ExecBg/CallFunction into argv.
6665pub const BUILTIN_ARRAY_ALL: u16 = 292;
6666
6667/// Flatten one level of Value::Array nesting. Pops N values; for each, if it's
6668/// a Value::Array, its elements are appended directly; otherwise the value is
6669/// appended as-is. Pushes a single Value::Array of the flattened result. Used
6670/// by the for-loop word-list compile path: when a word like `${arr[@]}`
6671/// produces a nested Array, this lets `for i in ${arr[@]}` iterate over the
6672/// inner elements rather than the outer single-element array.
6673pub const BUILTIN_ARRAY_FLATTEN: u16 = 293;
6674
6675/// `coproc [name] { body }` — bidirectional pipe to async child. Pops a name
6676/// (optional, "" for default) and a sub-chunk index. Creates two pipes, forks,
6677/// child redirects its fd 0/1 to the inner ends and runs the body, parent
6678/// stores [write_fd, read_fd] into the named array (default `COPROC`). Caller
6679/// closes the fds and `wait`s when done. Job-table integration deferred to
6680/// Phase G6 alongside the bg `&` work.
6681pub const BUILTIN_RUN_COPROC: u16 = 294;
6682
6683/// `arr+=(d e f)` — append N elements to an existing indexed array. Compile
6684/// emits N element pushes + name push, then `CallBuiltin(295, N+1)`. Handler
6685/// drains args (last popped = name), extends `executor.arrays[name]` (creates
6686/// the entry if missing). Mirrors zsh's `+=` semantics for indexed arrays.
6687pub const BUILTIN_APPEND_ARRAY: u16 = 295;
6688
6689/// `select var in words; do body; done` — interactive numbered-menu loop.
6690/// Compile emits N word pushes + var-name push + sub-chunk index push, then
6691/// `CallBuiltin(296, N+2)`. Handler prints `1) word1\n2) word2\n...` to
6692/// stderr, prints `$PROMPT3` (default `?# `) to stderr, reads a line from
6693/// stdin. On EOF returns 0. On a valid 1-based number, sets `var` to the
6694/// chosen word, runs the sub-chunk, then redisplays the menu and loops. On
6695/// invalid input redraws the menu without running the body. `break` from
6696/// inside the body exits the loop (handled by the body's own bytecode).
6697pub const BUILTIN_RUN_SELECT: u16 = 296;
6698
6699/// `m[k]+=value` — append onto an existing assoc-array value (string concat).
6700/// If the key doesn't exist, behaves like SET_ASSOC. Stack: [name, key, value].
6701
6702/// `break` from inside a body that runs on a sub-VM (select, future
6703/// loop-via-builtin constructs). Writes the canonical
6704/// `crate::ported::builtin::BREAKS` atomic (port of `Src/loop.c:46
6705/// breaks`). Outer-loop builtins drain BREAKS/CONTFLAG after each
6706/// body run, matching the loop.c:529-534 drain pattern.
6707pub const BUILTIN_SET_BREAK: u16 = 299;
6708
6709/// `continue` from inside a sub-VM body. Sets CONTFLAG=1 + bumps
6710/// BREAKS, matching `bin_break`'s WC_CONTINUE arm at Src/builtin.c
6711/// c:5836 `contflag = 1; FALLTHROUGH; breaks++;`.
6712pub const BUILTIN_SET_CONTINUE: u16 = 300;
6713
6714/// Brace expansion: `{a,b,c}` → 3 values, `{1..5}` → 5 values, `{01..05}` →
6715/// zero-padded numerics, `{a..e}` → letter range. Pops one string, returns
6716/// Value::Array of expansions (empty array → original string preserved).
6717pub const BUILTIN_BRACE_EXPAND: u16 = 301;
6718
6719/// Glob qualifier filter: `*(qualifier)` filters glob results by predicate.
6720/// Pops [pattern, qualifier_string]. Returns Value::Array of matching paths.
6721
6722/// Re-export the regex_match host method as a builtin so `[[ s =~ pat ]]`
6723/// works even when fusevm's Op::RegexMatch isn't routed (compat fallback).
6724
6725/// Word-split a string on IFS (default: whitespace). Pops one string,
6726/// returns Value::Array of fields. Used in array-literal context where
6727/// `arr=($(cmd))` should expand cmd's stdout into multiple elements.
6728pub const BUILTIN_WORD_SPLIT: u16 = 304;
6729
6730/// Register a pre-compiled fusevm chunk as a function. Stack: [name,
6731/// base64-bincode-of-Chunk]. Used by compile_zsh's compile_funcdef to
6732/// register functions parsed via parse_init+parse without going through the
6733/// ShellCommand JSON serialization path.
6734pub const BUILTIN_REGISTER_COMPILED_FN: u16 = 305;
6735/// `BUILTIN_VAR_EXISTS` constant.
6736pub const BUILTIN_VAR_EXISTS: u16 = 306;
6737/// Native param-modifier builtins. Each takes a fixed argv shape and
6738/// returns the modified value as Value::Str.
6739///
6740/// `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
6741/// — pop [name, op_byte, rhs]. op_byte: 0=`:-`, 1=`:=`, 2=`:?`, 3=`:+`.
6742pub const BUILTIN_PARAM_DEFAULT_FAMILY: u16 = 307;
6743/// `${var:offset[:length]}` — pop [name, offset, length] (length=-1 means
6744/// "rest of value"; negative offset counts from end).
6745pub const BUILTIN_PARAM_SUBSTRING: u16 = 308;
6746/// `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}` — pop
6747/// [name, pattern, op_byte]. op_byte: 0=`#`, 1=`##`, 2=`%`, 3=`%%`.
6748pub const BUILTIN_PARAM_STRIP: u16 = 309;
6749/// `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
6750/// `${var/%pat/repl}` — pop [name, pattern, replacement, op_byte].
6751/// op_byte: 0=first, 1=all, 2=anchor-prefix, 3=anchor-suffix.
6752pub const BUILTIN_PARAM_REPLACE: u16 = 310;
6753/// `${#name}` — character length of a scalar value, or element count
6754/// of an indexed/assoc array. Pops \[name\], returns count as Value::Str.
6755pub const BUILTIN_PARAM_LENGTH: u16 = 311;
6756/// `$((expr))` arithmetic substitution. Pops \[expr_string\], evaluates
6757/// via the executor's MathEval (integer-aware), returns result as
6758/// Value::Str. Bypasses ArithCompiler's float-only Op::Div path so
6759/// `$((10/3))` returns "3" not "3.333...".
6760pub const BUILTIN_ARITH_EVAL: u16 = 312;
6761/// `(( ... ))` math command post-eval status hook. Pops nothing,
6762/// pushes Value::Status. If errflag is set (math error in the
6763/// preceding BUILTIN_ARITH_EVAL call), clears it and emits status=2
6764/// matching c:Src/math.c arith-failure semantics. Otherwise emits
6765/// the current vm.last_status. Used by compile_arith's `(( ... ))`
6766/// path so the math command swallows errors without halting the
6767/// script — `$((... ))` substitutions skip this hook so their
6768/// errflag propagates up to the containing command.
6769pub const BUILTIN_ARITH_CMD_FINISH: u16 = 527;
6770/// `$(cmd)` command substitution. Pops \[cmd_string\], runs through
6771/// `run_command_substitution` which compiles via parse_init+parse + ZshCompiler
6772/// and captures stdout via an in-process pipe. Returns trimmed output
6773/// as Value::Str. Avoids the sub-chunk word-emit quoting bug in the
6774/// raw Op::CmdSubst path.
6775pub const BUILTIN_CMD_SUBST_TEXT: u16 = 313;
6776/// Text-based word expansion. Pops \[preserved_text\]: the word with
6777/// quotes preserved (Dnull→`"`, Snull→`'`, Bnull→`\`), runs
6778/// `expand_string` (variable + cmd-sub + arith) then `xpandbraces`
6779/// then `expand_glob`. Returns Value::str (single match) or
6780/// Value::Array (multi-match brace/glob).
6781pub const BUILTIN_EXPAND_TEXT: u16 = 314;
6782
6783/// `[[ a -ef b ]]` — same-inode test. Stack: [a, b]. Pushes Bool true iff
6784/// both paths resolve to the same `(dev, inode)` pair (zsh + bash semantics).
6785pub const BUILTIN_SAME_FILE: u16 = 315;
6786
6787/// `[[ a -nt b ]]` — file `a` newer than file `b` (mtime strict).
6788/// Stack: [path_a, path_b]. Pushes Bool. zsh-compatible "missing"
6789/// rules: if both exist, compare mtime; if only `a` exists → true;
6790/// otherwise false.
6791pub const BUILTIN_FILE_NEWER: u16 = 324;
6792
6793/// `[[ a -ot b ]]` — mirror of `-nt`. If both exist, compare mtime;
6794/// if only `b` exists → true; otherwise false.
6795pub const BUILTIN_FILE_OLDER: u16 = 325;
6796
6797/// `[[ -k path ]]` — sticky bit (S_ISVTX) set on path.
6798pub const BUILTIN_HAS_STICKY: u16 = 326;
6799/// `[[ -u path ]]` — setuid bit (S_ISUID).
6800pub const BUILTIN_HAS_SETUID: u16 = 327;
6801/// `[[ -g path ]]` — setgid bit (S_ISGID).
6802pub const BUILTIN_HAS_SETGID: u16 = 328;
6803/// `[[ -O path ]]` — owned by effective UID.
6804pub const BUILTIN_OWNED_BY_USER: u16 = 329;
6805/// `[[ -G path ]]` — owned by effective GID.
6806pub const BUILTIN_OWNED_BY_GROUP: u16 = 330;
6807/// `[[ -N path ]]` — file modified since last accessed (atime <= mtime).
6808pub const BUILTIN_FILE_MODIFIED_SINCE_ACCESS: u16 = 341;
6809
6810/// `name+=val` (no parens) — runtime-dispatched append.
6811/// If name is an indexed array → push val as element.
6812/// If name is an assoc array → error (zsh requires `(k v)` form).
6813/// Else → scalar concat (existing SET_VAR behavior).
6814pub const BUILTIN_APPEND_SCALAR_OR_PUSH: u16 = 331;
6815
6816/// `[[ -c path ]]` — character device.
6817pub const BUILTIN_IS_CHARDEV: u16 = 332;
6818/// `[[ -b path ]]` — block device.
6819pub const BUILTIN_IS_BLOCKDEV: u16 = 333;
6820/// `[[ -p path ]]` — FIFO / named pipe.
6821pub const BUILTIN_IS_FIFO: u16 = 334;
6822/// `[[ -S path ]]` — socket.
6823pub const BUILTIN_IS_SOCKET: u16 = 335;
6824/// `BUILTIN_ERREXIT_CHECK` constant.
6825pub const BUILTIN_ERREXIT_CHECK: u16 = 336;
6826/// Post-`always`-arm checks for the canonical RETFLAG / BREAKS /
6827/// CONTFLAG atomics that mark try-block escapes. Each returns
6828/// Value::Int(1) when the corresponding atomic is set (and consumes
6829/// it so the next escape doesn't re-fire) and Value::Int(0) otherwise.
6830/// Paired with JumpIfFalse + Jump to outer return_patches /
6831/// break_patches / continue_patches by compile_zsh's `Try` arm.
6832pub const BUILTIN_RETFLAG_CHECK: u16 = 600;
6833/// `BUILTIN_BREAKS_CHECK` constant.
6834pub const BUILTIN_BREAKS_CHECK: u16 = 601;
6835/// `BUILTIN_CONTFLAG_CHECK` constant.
6836pub const BUILTIN_CONTFLAG_CHECK: u16 = 602;
6837/// Fire the DEBUG trap (SIGDEBUG) before each statement.
6838/// c:Src/exec.c:1357-1500 DEBUGBEFORECMD — when a "DEBUG" entry is
6839/// installed via `trap '...' DEBUG`, run the body just before the
6840/// next command. Cheap when no DEBUG trap is set (one hashmap lookup
6841/// returns None and we early-out).
6842pub const BUILTIN_DEBUG_TRAP: u16 = 603;
6843/// `set -n` / `set -o noexec` — parse but don't execute. Returns
6844/// Value::Int(1) when the noexec option is set so the caller's
6845/// JumpIfTrue skips the statement body. c:Src/exec.c:1390 main loop
6846/// check.
6847pub const BUILTIN_NOEXEC_CHECK: u16 = 604;
6848/// Block-level redirect-failure gate. Reads exec.redirect_failed
6849/// (set by host.redirect when a redirect open fails); returns
6850/// Value::Int(1) AND clears the flag if set, else 0. Emit-side at
6851/// compile_zsh.rs::compile_command's Redirected arm pairs with a
6852/// JumpIfTrue → WithRedirectsEnd to abandon the body. Without this,
6853/// a multi-statement block after a failed redir kept running every
6854/// statement after the first (the first builtin consumed the flag,
6855/// subsequent statements ran unimpeded).
6856pub const BUILTIN_REDIRECT_FAILED_CHECK: u16 = 605;
6857/// Drop-in replacement for fusevm's Op::Exec for the dynamic-first-
6858/// word path (`$cmd`, `$(cmd)`, `~/bin/foo`). Returns
6859/// Value::Status(vm.last_status) when post-expansion argv is empty
6860/// (preserves the inner cmd-subst's exit), Value::Status(126) with
6861/// "permission denied" when `argv[0]` is empty, otherwise routes
6862/// through executor.host_exec_external like Op::Exec did.
6863pub const BUILTIN_EXEC_DYNAMIC: u16 = 606;
6864/// `< file` / `> file` with no command word (NULLCMD path).
6865/// Resolves NULLCMD (default "cat") / READNULLCMD (default "more")
6866/// at runtime per Src/exec.c:3340-3364 and exec's it through
6867/// host_exec_external. Argc is 1: the int (0 or 1) on the stack
6868/// indicates whether this is a single REDIR_READ redirect
6869/// (selects READNULLCMD when set + non-empty).
6870pub const BUILTIN_NULLCMD_EXEC: u16 = 607;
6871/// `.` (dot) — alias of source/bin_dot but dispatches with the
6872/// literal name "." so the diagnostic prefix matches zsh's
6873/// (`zsh:.:1: …` vs source's `zsh:source:1: …`).
6874/// c:Src/builtin.c:9308 — `BUILTIN(".", BINF_PSPECIAL, bin_dot, …)`.
6875pub const BUILTIN_DOT: u16 = 608;
6876/// `logout` — fusevm maps this to BUILTIN_EXIT alongside `exit`/`bye`,
6877/// which drops the name and dispatches with BIN_EXIT funcid. zsh's
6878/// `logout` outside a login shell must emit "not login shell" + exit 1,
6879/// which only fires when bin_break sees BIN_LOGOUT funcid. Dedicated
6880/// opcode dispatches via BUILTINS table by literal name "logout".
6881pub const BUILTIN_LOGOUT: u16 = 610;
6882/// `BUILTIN_PARAM_SUBSTRING_EXPR` constant.
6883pub const BUILTIN_PARAM_SUBSTRING_EXPR: u16 = 337;
6884/// `BUILTIN_XTRACE_LINE` constant.
6885pub const BUILTIN_XTRACE_LINE: u16 = 338;
6886/// `BUILTIN_ARRAY_JOIN_STAR` constant.
6887pub const BUILTIN_ARRAY_JOIN_STAR: u16 = 339;
6888/// `BUILTIN_SET_RAW_OPT` constant.
6889pub const BUILTIN_SET_RAW_OPT: u16 = 340;
6890
6891/// `time { compound; ... }` — wall-clock-time the sub-chunk and print
6892/// elapsed seconds. Stack: [sub_chunk_idx as Int]. Runs the sub-chunk
6893/// on the current VM (so positional/local state is shared) and prints
6894/// the timing summary to stderr in zsh's format. Pushes Status.
6895pub const BUILTIN_TIME_SUBLIST: u16 = 316;
6896
6897/// `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocation.
6898/// Stack: [path, varid, op_byte]. Opens `path` per `op_byte`, gets the
6899/// new fd (≥10 in zsh; we use libc::open with O_CLOEXEC bit cleared so
6900/// the inherited fd survives Command::new spawns), stores the fd number
6901/// as a string in `$varid`. Pushes Status (0 success, 1 error).
6902pub const BUILTIN_OPEN_NAMED_FD: u16 = 317;
6903
6904/// Word-segment concat that does cartesian-product distribution over
6905/// arrays. Stack: [lhs, rhs]. Used for RC_EXPAND_PARAM `${arr}` and
6906/// explicit-distribute forms (`${^arr}`, `${(@)…}`).
6907///
6908/// - both scalar: `Value::str(a + b)` (fast path, identical to Op::Concat)
6909/// - lhs Array, rhs scalar: `Value::Array([a + rhs for a in lhs])`
6910/// - lhs scalar, rhs Array: `Value::Array([lhs + b for b in rhs])`
6911/// - both Array: cartesian product `[a + b for a in lhs for b in rhs]`
6912pub const BUILTIN_CONCAT_DISTRIBUTE: u16 = 318;
6913
6914/// Forced-distribute concat — like `BUILTIN_CONCAT_DISTRIBUTE` but
6915/// always distributes cartesian regardless of the `rcexpandparam`
6916/// option. Emitted by the segments fast-path when an
6917/// `is_distribute_expansion` segment is present (`${^arr}`,
6918/// `${(@)arr}`, `${(s.…)arr}` etc.) per zsh: the source-level
6919/// distribution flag overrides the option default.
6920/// Direct port of Src/subst.c:1875 `case Hat: nojoin = 1` and the
6921/// `rcexpandparam` test bypass for the explicit-distribute flags.
6922pub const BUILTIN_CONCAT_DISTRIBUTE_FORCED: u16 = 522;
6923
6924/// Capture current `last_status` into the `TRY_BLOCK_ERROR` variable.
6925/// Emitted between the try block and the always block of `{ … } always
6926/// { … }` so the finally arm can read $TRY_BLOCK_ERROR.
6927pub const BUILTIN_SET_TRY_BLOCK_ERROR: u16 = 320;
6928/// `BUILTIN_RESTORE_TRY_BLOCK_STATUS` constant.
6929pub const BUILTIN_RESTORE_TRY_BLOCK_STATUS: u16 = 432;
6930/// `BUILTIN_BEGIN_INLINE_ENV` constant.
6931pub const BUILTIN_BEGIN_INLINE_ENV: u16 = 433;
6932/// `BUILTIN_END_INLINE_ENV` constant.
6933pub const BUILTIN_END_INLINE_ENV: u16 = 434;
6934
6935/// `[[ -o option ]]` — shell-option-set test. Stack: \[option_name\].
6936/// Normalizes the name (strip underscores, lowercase) and reads
6937/// `exec.options`. Pushes Bool.
6938pub const BUILTIN_OPTION_SET: u16 = 321;
6939/// Tri-state `[[ -o NAME ]]` — same lookup as BUILTIN_OPTION_SET
6940/// but returns a Value::Int (0=set, 1=unset, 3=invalid-name). The
6941/// 3-state code matches zsh's `[[ -o invalid ]]` exit (Src/cond.c
6942/// :502 `optison()`). Used by compile_cond's `-o` arm to skip the
6943/// generic bool→status conversion and preserve the invalid-name
6944/// signal in `$?`.
6945pub const BUILTIN_OPTION_CHECK_TRISTATE: u16 = 609;
6946
6947/// `${var:#pattern}` — array filter: remove elements matching `pattern`.
6948/// Stack: [name, pattern]. For scalar `var`, returns empty if it matches
6949/// the pattern, else the value. For array `var`, returns Array of
6950/// non-matching elements.
6951pub const BUILTIN_PARAM_FILTER: u16 = 322;
6952
6953/// `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()` —
6954/// subscripted-array assign with array-literal RHS. Stack:
6955/// [...elements, name, key]. Empty elements + single-int key `a[i]=()`
6956/// removes that element. Comma-key `a[i,j]=(...)` splices.
6957pub const BUILTIN_SET_SUBSCRIPT_RANGE: u16 = 323;
6958
6959/// `[[ -X file ]]` for unknown unary test op `-X`. Stack: \[op_name\].
6960/// Emits zsh's `unknown condition: -X` diagnostic to stderr and
6961/// pushes Bool(false). Without this, unknown conditions silently
6962/// returned false matching neither zsh's error format nor the
6963/// expected status code (zsh returns 2 for parse error).
6964
6965/// `[[ -t fd ]]` — fd-is-a-tty check. Stack: \[fd_string\].
6966/// Routes through libc::isatty. Pushes Bool.
6967pub const BUILTIN_IS_TTY: u16 = 325;
6968
6969/// Update `$LINENO` to track the source line of the next statement.
6970/// Stack: \[n\] (the line number from `ZshPipe.lineno`). Direct port
6971/// of zsh's `lineno` global tracking (Src/input.c:330) — the
6972/// compiler emits one of these per top-level pipe so `$LINENO`
6973/// reflects the source position at runtime. ID 342 picked because
6974/// the previous `326` collided with `BUILTIN_HAS_STICKY` (the file
6975/// has several other duplicate IDs — 325 has two as well — but
6976/// fixing those is out of scope for this port).
6977pub const BUILTIN_SET_LINENO: u16 = 342;
6978
6979/// Pop a scalar from the VM stack, run expand_glob on it, push the
6980/// result as Value::Array. Used by the segment-concat compile path
6981/// when var refs concatenate with glob meta literals (`$D/*`,
6982/// `${prefix}*`, etc.) — those skip the bridge's pathname-expansion
6983/// pass and would otherwise leak the glob meta to argv as a literal.
6984pub const BUILTIN_GLOB_EXPAND: u16 = 343;
6985
6986/// Push a `CmdState` token onto the command-context stack. Direct
6987/// port of zsh's `cmdpush(int cmdtok)` (Src/prompt.c:1623). The
6988/// stack is consulted by `%_` in PS4/prompt expansion to produce
6989/// the cumulative control-flow-context labels (`if`, `then`,
6990/// `cmdand`, `cmdor`, `cmdsubst`, …) that `zsh -x` xtrace shows
6991/// in the trace prefix. Compile_zsh emits push/pop pairs around
6992/// each compound command (if/while/[[…]]/((…))/$(…) etc.).
6993/// Token is a `CmdState as u8`.
6994pub const BUILTIN_CMD_PUSH: u16 = 344;
6995
6996/// Pop the top of the command-context stack. Direct port of zsh's
6997/// `cmdpop(void)` (Src/prompt.c:1631).
6998pub const BUILTIN_CMD_POP: u16 = 345;
6999
7000/// Emit an xtrace line built from the top `argc` values on the VM
7001/// stack, peeked WITHOUT consuming. Used to trace simple commands
7002/// AFTER expansion, so `echo for $i` shows as `echo for a` / `echo
7003/// for b`. Direct port of Src/exec.c:2055-2066.
7004pub const BUILTIN_XTRACE_ARGS: u16 = 346;
7005
7006/// Trace one assignment: emits `name=<quoted-value> ` (no newline)
7007/// to xtrerr if XTRACE is on. Coalesces with subsequent
7008/// XTRACE_ASSIGN / XTRACE_ARGS calls onto the SAME line via the
7009/// `XTRACE_DONE_PS4` flag so `a=1 b=2 echo $a $b` produces:
7010/// `<PS4>a=1 b=2 echo 1 2\n`
7011/// matching C zsh's `execcmd_exec` body (Src/exec.c:2517-2582):
7012/// xtr = isset(XTRACE);
7013/// if (xtr) { printprompt4(); doneps4 = 1; }
7014/// while (assign) {
7015/// if (xtr) fprintf(xtrerr, "%s=", name);
7016/// ... eval value ...
7017/// if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
7018/// }
7019///
7020/// Stack contract on entry: [..., name, value]. Both peeked, NOT
7021/// consumed (the matching SET_VAR call pops them after). argc = 2.
7022pub const BUILTIN_XTRACE_ASSIGN: u16 = 525;
7023
7024/// Emit a trailing `\n` + flush iff XTRACE is on AND PS4 was
7025/// emitted by an earlier XTRACE_ASSIGN this line. Used at the end
7026/// of compile_simple's assignment-only path so the trace line gets
7027/// terminated. Mirrors C's exec.c:3397-3399 (the assign-only return
7028/// path through execcmd_exec which does `fputc('\n', xtrerr);
7029/// fflush(xtrerr)`).
7030///
7031/// Stack: untouched. argc = 0.
7032pub const BUILTIN_XTRACE_NEWLINE: u16 = 526;
7033
7034/// Push the live `xtrace` opt-state as `Value::Int(1)` (on) or
7035/// `Value::Int(0)` (off). Used by `compile_cond` to gate the
7036/// trace-string-building block on xtrace state at runtime — without
7037/// this the trace path's `compile_word_str` on each operand re-
7038/// evaluates side-effectful expressions (`$((i++))`) once for the
7039/// trace string and once for the actual condition, doubling the
7040/// effective increment. Bug #159 in docs/BUGS.md.
7041///
7042/// Stack: pushes Int(0|1). argc = 0.
7043pub const BUILTIN_XTRACE_IS_ON: u16 = 611;
7044
7045/// Reset the `DONETRAP` flag at the start of each top-level statement
7046/// (sublist boundary). Mirrors C `Src/exec.c:1455` — `donetrap = 0`.
7047/// Stack: untouched. argc = 0. Bug #303 in docs/BUGS.md.
7048pub const BUILTIN_DONETRAP_RESET: u16 = 612;
7049
7050/// `[[ -z X ]]` / `[[ -n X ]]` operand-empty test that honours zsh's
7051/// array-splice semantics. C zsh evaluates `[[ -z X ]]` per
7052/// `Src/cond.c:347` (case 'z'): `s` is the SCALAR operand passed
7053/// through `cond_str`'s singsub. For `"${arr[@]}"` zsh expands per
7054/// `Src/subst.c:multsub` which yields each element as its own word
7055/// list node; cond.c then sees the joined-or-single-element form.
7056///
7057/// The compile-side `-z` shortcut at `compile_zsh.rs:5371` used
7058/// `Op::StringLen` which calls `Value::len` — for `Value::Array`
7059/// that returns ARRAY LENGTH, not string length. `b=("")` produced
7060/// `Value::Array([""])` → `len = 1` → `-z` returned false.
7061///
7062/// This builtin pops one `Value` and pushes `1` (empty) or `0`
7063/// (non-empty) per the cond context:
7064/// - `Value::Str(s)` → s.is_empty()
7065/// - `Value::Array([])` → true (zero words → vacuous-empty)
7066/// - `Value::Array([s])` → s.is_empty() (single-word case)
7067/// - `Value::Array([_; n>=2])` → false (multiple non-empty
7068/// words; zsh would raise "unknown condition" but the
7069/// observable test result is non-empty/false)
7070///
7071/// Companion to BUILTIN_COND_STR_NONEMPTY (#185 in docs/BUGS.md).
7072pub const BUILTIN_COND_STR_EMPTY: u16 = 613;
7073
7074/// `[[ -n X ]]` operand-non-empty test (logical complement of
7075/// BUILTIN_COND_STR_EMPTY).
7076pub const BUILTIN_COND_STR_NONEMPTY: u16 = 614;
7077
7078/// `exec N<<<"str"` — herestring redirect to explicit fd, applied
7079/// permanently to the shell (no scope restoration). Pops `[content,
7080/// fd]` from the stack; creates a temp file, writes
7081/// `content + "\n"`, reopens read-only, dup2's to `fd`, unlinks the
7082/// temp path so it disappears on close. Mirrors C `Src/exec.c:4655
7083/// getherestr` + `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)`
7084/// at c:3766-3780 for the bare-exec-redir code path (nullexec=1).
7085/// Bug #205 in docs/BUGS.md.
7086///
7087/// Stack: pushes `Value::Status(0)` on success, `Status(1)` on
7088/// failure. argc = 2.
7089pub const BUILTIN_EXEC_HERESTR_FD: u16 = 615;
7090
7091/// MULTIOS write/append fan-out for `cmd > a > b` / `cmd > a >> b`
7092/// style redirects (Bug #36 in docs/BUGS.md). zsh's MULTIOS option
7093/// (Src/exec.c:2418 `mfds[fd1]` check + addfd splice) creates a
7094/// pipe at fd1, spawns an internal "tee" process that copies
7095/// stdin → every collected target file. Without this, only the
7096/// LAST redirect target survives because each dup2 overwrites the
7097/// previous binding.
7098///
7099/// Stack layout (pushed by compile_zsh's compile_redirs coalescing
7100/// pass): `[target_1, op_byte_1, target_2, op_byte_2, …, target_N,
7101/// op_byte_N, fd]`. Pops 2N+1 elements; `argc = 2*N + 1`.
7102///
7103/// Runtime:
7104/// 1. Open all targets per their op_byte (WRITE truncate /
7105/// APPEND).
7106/// 2. Save `dup(fd)` onto the active redirect_scope_stack so
7107/// `host_redirect_scope_end` restores the original fd.
7108/// 3. Create a pipe; spawn a thread that reads from the pipe
7109/// read-end and writes every chunk to every opened target.
7110/// 4. dup2 the pipe write-end onto `fd` so the command's writes
7111/// go through the splitter.
7112/// 5. Track `(pipe_write_fd, JoinHandle)` so scope-end can close
7113/// the pipe (draining the thread) and join before restoring.
7114pub const BUILTIN_MULTIOS_REDIRECT: u16 = 617;
7115
7116/// MULTIOS input-side concatenation for `cmd < a < b` shapes
7117/// (Bug #36 input arm). C zsh's `Src/exec.c:2418` mfds dispatch
7118/// also covers the read direction — when multiple `<` redirects
7119/// target the same fd, mfds[fd] grows and addfd splices a
7120/// concatenating cat into the pipe.
7121///
7122/// Stack layout: `[source_1, source_2, …, source_N, fd]`. Pops
7123/// N + 1 elements (argc = N + 1). All sources are file paths; the
7124/// op_byte is implicitly READ.
7125///
7126/// Runtime:
7127/// 1. Open every source file for reading.
7128/// 2. Save `dup(fd)` onto the redirect_scope_stack.
7129/// 3. Create a pipe; spawn a thread that reads each source in
7130/// order and writes every chunk to the pipe write-end. Close
7131/// write-end when done so the consumer sees EOF.
7132/// 4. dup2 the pipe read-end onto `fd`.
7133/// 5. Track the JoinHandle so scope-end joins (no fd-close needed
7134/// here — the producer thread closes its own pipe write-end
7135/// on exit).
7136pub const BUILTIN_MULTIOS_READ: u16 = 618;
7137
7138/// `redirection with no command` parse-time error for bare
7139/// `builtin 2>&1` / `command < file` / `exec >&-` precmd-keyword
7140/// shapes with a redirect but no following command. Direct port
7141/// of `Src/exec.c:3342 zerr("redirection with no command")`.
7142/// argc=0; pushes Value::Status(1).
7143pub const BUILTIN_REDIR_NO_CMD: u16 = 616;
7144
7145/// GLOB_SUBST guard for `[[ x == $pat ]]` pattern RHS coming from
7146/// parameter / command substitution. C-zsh's `[[ == ]]` semantics
7147/// (Src/options.c GLOB_SUBST default OFF + Src/cond.c:552
7148/// `cond_match` + Src/pattern.c patcompile tokenization-based
7149/// meta detection) treat chars from substitution as LITERAL
7150/// unless GLOB_SUBST is on. The Rust patcompile accepts both
7151/// tokenized and raw-ASCII meta chars, losing the distinction,
7152/// so `pat="h*"; [[ hello == $pat ]]` matched in zshrs but not
7153/// in zsh. Bug #116 in docs/BUGS.md.
7154///
7155/// Compile-time signal: emitted by `compile_cond_expr` ONLY when
7156/// the RHS contains `$` or backtick. Runtime checks the live
7157/// option state. If GLOB_SUBST is OFF, the popped string has
7158/// its glob metachars escaped with `\` so the downstream StrMatch
7159/// → patcompile treats them as literals. If GLOB_SUBST is ON,
7160/// the value passes through unchanged so `setopt glob_subst`
7161/// restores zsh's pattern-on-expansion behavior.
7162///
7163/// Stack: pops one string, pushes the (possibly escaped) result.
7164/// argc = 1.
7165pub const BUILTIN_GLOB_SUBST_GUARD: u16 = 528;
7166
7167/// Coerce a string parameter value to a math number (Int or Float)
7168/// for arithmetic-context reads, mirroring C-zsh's `getmathparam`
7169/// (Src/math.c:337). When the variable holds a string like "hello"
7170/// that isn't numeric, C falls back to recursively evaluating the
7171/// raw string as an arith expression; if that fails too, returns 0.
7172///
7173/// Used by the ArithCompiler pre-load path so `(( y = x ))` with
7174/// `x="hello"` reads `x` as integer 0, then assigns y as integer 0
7175/// — matching zsh's behaviour. The previous Rust port used
7176/// BUILTIN_GET_VAR which returned the raw string "hello"; the
7177/// ArithCompiler stored it verbatim in y's slot, and the post-sync
7178/// BUILTIN_SET_VAR wrote y="hello" as scalar instead of y=0 as
7179/// integer. Bug #118 in docs/BUGS.md.
7180///
7181/// Stack: pops `name` (string), pushes coerced numeric Value.
7182/// argc = 1.
7183pub const BUILTIN_GET_MATH_VAR: u16 = 529;
7184
7185/// GLOB_SUBST runtime gate for words containing parameter / command
7186/// substitution. C-zsh's `prefork` (Src/subst.c) runs `shtokenize`
7187/// on the substituted value when `GLOB_SUBST` is set, making the
7188/// substituted chars eligible for filename generation. With the
7189/// option off, substituted chars stay literal.
7190///
7191/// The Rust port's compile_zsh emits `compile_word_str` for words
7192/// like `/tmp/X/$pat`, which returns the post-expansion string but
7193/// never runs glob expansion (no path here triggers
7194/// BUILTIN_GLOB_EXPAND). Bug #119 in docs/BUGS.md: with `setopt
7195/// glob_subst`, `for f in /tmp/X/$pat` (pat="*.txt") never matched
7196/// `*.txt` files.
7197///
7198/// This opcode wraps the substitution result and dispatches at
7199/// runtime: when GLOB_SUBST is OFF, return unchanged; when ON,
7200/// pass the value through `expand_glob` so glob metas become
7201/// active. Emitted by `compile_for_words` (and similar sites)
7202/// after WORD_SPLIT for words with unquoted expansion.
7203///
7204/// Stack: pops a Value (Str or Array of Str), pushes the glob-
7205/// expanded result (still Str or Array depending on input shape).
7206/// argc = 1.
7207pub const BUILTIN_GLOB_SUBST_EXPAND: u16 = 530;
7208/// `BUILTIN_ASSOC_HAS_KEY` constant — `${(k)assoc[name]}` key-existence
7209/// query. Returns the key text on hit, empty string on miss. Bug #145.
7210pub const BUILTIN_ASSOC_HAS_KEY: u16 = 531;
7211/// `BUILTIN_ARRAY_DROP_EMPTY` constant — filter empty elements from
7212/// an Array on the stack. Used by `for x in $@` / `for x in $*`
7213/// unquoted forms. Bug #166.
7214pub const BUILTIN_ARRAY_DROP_EMPTY: u16 = 532;
7215/// `BUILTIN_QUOTEDZPUTS` constant — run top-of-stack value through
7216/// `crate::ported::utils::quotedzputs` and push the quoted result.
7217/// Used by the cond xtrace path so non-printable bytes (e.g.
7218/// `$'\C-[OP'` expanded ESC+OP) get re-wrapped in `$'…'` form for
7219/// the trace prefix line, matching zsh's `Src/exec.c` cond trace
7220/// which calls `quotedzputs(operand, xtrerr)` on each side. Bug
7221/// surfaced when `[[ -n $'\C-[OP' ]]` traced as `[[ -n OP ]]`
7222/// (raw bytes leaked through the terminal) vs zsh's
7223/// `[[ -n $'\C-[OP' ]]` source-form preservation.
7224pub const BUILTIN_QUOTEDZPUTS: u16 = 533;
7225/// `BUILTIN_QUOTE_TOKENIZED_OUTPUT` — port of
7226/// `crate::ported::exec::quote_tokenized_output` (Src/exec.c:2114)
7227/// applied to top-of-stack scalar. Used by cond xtrace for the RHS
7228/// of pattern-context comparisons (`=` / `==` / `!=`) where C zsh
7229/// emits the SOURCE form: untokenize lexer tokens (Star → `*`,
7230/// Inpar → `(`, …) and backslash-escape special chars, but
7231/// preserve literal ASCII unchanged. Distinct from quotedzputs
7232/// which wraps the whole string in `'…'` / `$'…'` based on
7233/// non-printability — that's wrong for `[[ x = a* ]]` which must
7234/// render as `[[ x = a* ]]`, not `'a*'`.
7235pub const BUILTIN_QUOTE_TOKENIZED_OUTPUT: u16 = 534;
7236
7237/// Bridge into subst_port::substitute_brace_array for nested forms
7238/// that need to PRESERVE array shape across the expand_string
7239/// boundary. Stack: `[content_string]`. Returns Value::Array of the
7240/// per-element words. Used by the compile path for
7241/// `${(@)<nested>...##pat}` shapes — the standard substitute_brace
7242/// returns String which collapses array→scalar; this builtin
7243/// preserves the multi-word output via paramsubst's third return
7244/// (`nodes` vec, the C source's `aval` thread).
7245pub const BUILTIN_BRIDGE_BRACE_ARRAY: u16 = 347;
7246
7247/// Word-segment concat with FIRST/LAST sticking. Stack: [lhs, rhs].
7248/// Used for default unquoted splice forms (`${arr[@]}`, `$@`, `$*`)
7249/// where prefix sticks to first element only and suffix to last only.
7250///
7251/// Distribution table:
7252/// - both scalar: `Value::str(a + b)` (fast path)
7253/// - lhs scalar, rhs Array(b₀..bₙ): `Value::Array([lhs+b₀, b₁, …, bₙ])`
7254/// - lhs Array(a₀..aₙ), rhs scalar: `Value::Array([a₀, …, aₙ₋₁, aₙ+rhs])`
7255/// - both Array: `Value::Array([a₀, …, aₙ₋₁, aₙ+b₀, b₁, …, bₙ])`
7256/// (last of lhs merges with first of rhs; the rest stay separate)
7257///
7258/// This is the default zsh semantics for `print -l X${arr[@]}Y` →
7259/// "Xa", "b", "cY" — three distinct args, surrounding text only on ends.
7260pub const BUILTIN_CONCAT_SPLICE: u16 = 319;
7261
7262/// `${(flags)name}` — zsh parameter expansion flags. Stack: [name, flags].
7263/// Flags applied left-to-right. Supported subset (high-value, used by zpwr):
7264///
7265/// `L` — lowercase the value (scalar; or each element if array)
7266/// `U` — uppercase
7267/// `j:sep:` — join array with `sep` (delim is the char after `j`)
7268/// `s:sep:` — split scalar on `sep` (returns Value::Array)
7269/// `f` — split on newlines (shorthand for `s.\n.`)
7270/// `o` — sort array ascending
7271/// `O` — sort array descending
7272/// `P` — indirect: read name's value as another var name, return that's value
7273/// `@` — keep as array (returns Value::Array — useful before `j` etc.)
7274/// `k` — keys of assoc array
7275/// `v` — values of assoc array
7276/// `#` — word count (array length as scalar)
7277///
7278/// Flags can stack: `(jL)` joins then lowercases; `(s.,.U)` splits on `,`
7279/// then uppercases each element. The long-tail flags (`q`, `qq`, `qqq` for
7280/// quoting, `A` for assoc, `%` for prompt expansion, `e`/`g` for re-eval,
7281/// `n`/`p` for numeric, `t` for type, etc.) are deferred — they hit the
7282/// runtime fallback via the catch-all expansion path.
7283pub const BUILTIN_PARAM_FLAG: u16 = 297;
7284
7285/// `ShellHost` implementation that delegates to the current `ShellExecutor`
7286/// via the `with_executor` thread-local.
7287///
7288/// Construct fresh on each VM run (it carries no state itself). The VM
7289/// dispatches host method calls during `vm.run()`, and `with_executor`
7290/// resolves to the executor pointer set by `ExecutorContext::enter`.
7291/// fusevm-host implementation tying bytecode ops to the
7292/// shell executor.
7293/// zshrs-original — no C counterpart. C zsh has no bytecode VM
7294/// to host; everything runs through `execlist()`/`execpline()`
7295/// directly (Src/exec.c lines 1349/1668).
7296pub struct ZshrsHost;
7297
7298impl fusevm::ShellHost for ZshrsHost {
7299 fn glob(&mut self, pattern: &str, _recursive: bool) -> Vec<String> {
7300 with_executor(|exec| exec.expand_glob(pattern))
7301 }
7302
7303 fn tilde_expand(&mut self, s: &str) -> String {
7304 with_executor(|exec| s.to_string())
7305 }
7306
7307 fn brace_expand(&mut self, s: &str) -> Vec<String> {
7308 // Direct call to the canonical brace expander
7309 // (Src/glob.c::xpandbraces port at glob.rs:1678). Was
7310 // routing through singsub which uses PREFORK_SINGLE — that
7311 // flag explicitly suppresses brace expansion in subst.c:166,
7312 // so `print X{1,2,3}Y` returned the literal string.
7313 //
7314 // brace_ccl: respect the BRACE_CCL option which the bracket-
7315 // class form `{a-z}` requires. Pull from executor options.
7316 let brace_ccl = with_executor(|exec| opt_state_get("braceccl").unwrap_or(false));
7317 crate::ported::glob::xpandbraces(s, brace_ccl)
7318 }
7319
7320 fn str_match(&mut self, s: &str, pattern: &str) -> bool {
7321 // Shell glob match — `*`, `?`, `[...]`, alternation. Used by `[[ x = pat ]]`,
7322 // `case` arms, and any other point that compares against a glob pattern.
7323 glob_match_static(s, pattern)
7324 }
7325
7326 fn expand_param(&mut self, name: &str, _modifier: u8, _args: &[Value]) -> Value {
7327 // Sole funnel: route through `getsparam` matching C zsh's
7328 // `getsparam(name)` → `getvalue` → `getstrvalue` →
7329 // `Param.gsu->getfn` dispatch (Src/params.c:3076 / 2335).
7330 //
7331 // The lookup chain (GSU dispatch + variables + env + array-
7332 // join) lives in `params::getsparam`; subst.rs and this
7333 // bridge both call into it so the logic is in exactly one
7334 // place — mirroring C's "every read goes through getsparam"
7335 // architecture. fuseVM bytecode triggers this bridge when
7336 // the VM hits a PARAM opcode, equivalent to C's wordcode VM
7337 // resolving a parameter read during `exec.c` execution.
7338 //
7339 // Modifier handling: the `_modifier` / `_args` parameters
7340 // are populated by the bytecode compiler but applied by
7341 // separate VM opcodes (LENGTH/STRIP/SUBST/etc.) downstream
7342 // of this fetch — matching C's split between getsparam
7343 // (value fetch) and paramsubst's modifier-walk loop. This
7344 // bridge is the value-fetch step only.
7345 let val_str = crate::ported::params::getsparam(name).unwrap_or_default();
7346 Value::str(val_str)
7347 }
7348
7349 fn process_sub_in(&mut self, sub: &fusevm::Chunk) -> String {
7350 // c:Src/exec.c::getproc — `<(cmd)` uses pipe + fork + the
7351 // `/dev/fd/N` filesystem entry (where N is the read end of
7352 // the pipe held open in the parent). Consumer opens
7353 // `/dev/fd/N`, reads the cmd's stdout through the pipe.
7354 // Both macOS and Linux expose `/dev/fd` for held-open file
7355 // descriptors. Previous Rust port captured stdout into
7356 // `/tmp/zshrs_psub_*` tempfiles synchronously — works for
7357 // `diff <(a) <(b)` style readers that scan once but diverges
7358 // from zsh's observable path string and breaks any consumer
7359 // that introspects the path or expects a non-seekable pipe.
7360 let mut fds: [libc::c_int; 2] = [-1, -1];
7361 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
7362 // Pipe creation failed — fall back to tempfile so we at
7363 // least return SOMETHING.
7364 let fifo_path = format!(
7365 "/tmp/zshrs_psub_fallback_{}_{}",
7366 std::process::id(),
7367 with_executor(|e| {
7368 let n = e.process_sub_counter;
7369 e.process_sub_counter += 1;
7370 n
7371 })
7372 );
7373 let _ = fs::remove_file(&fifo_path);
7374 return fifo_path;
7375 }
7376 let (read_end, write_end) = (fds[0], fds[1]);
7377 let sub_for_child = sub.clone();
7378 match unsafe { libc::fork() } {
7379 -1 => {
7380 unsafe {
7381 libc::close(read_end);
7382 libc::close(write_end);
7383 }
7384 return String::from("/dev/null");
7385 }
7386 0 => {
7387 // Child: close read end, dup write end to stdout,
7388 // run the sub-chunk, exit. The exit closes the
7389 // write end automatically, so the parent's reader
7390 // gets EOF when the cmd finishes.
7391 unsafe {
7392 libc::close(read_end);
7393 libc::dup2(write_end, libc::STDOUT_FILENO);
7394 libc::close(write_end);
7395 }
7396 crate::fusevm_disasm::maybe_print_stdout("process_subst_in", &sub_for_child);
7397 let mut vm = fusevm::VM::new(sub_for_child);
7398 register_builtins(&mut vm);
7399 vm.set_shell_host(Box::new(ZshrsHost));
7400 let _ = vm.run();
7401 let _ = std::io::stdout().flush();
7402 unsafe { libc::_exit(0) };
7403 }
7404 _ => {
7405 // Parent: close write end, keep read end open under
7406 // the same fd value so `/dev/fd/N` resolves to the
7407 // pipe's read side. NOTE: FD_CLOEXEC must STAY clear
7408 // — consumers like `cat <(cmd)` and `diff <(a) <(b)`
7409 // discover the fd via exec inheritance, so closing
7410 // on exec defeats the whole point. C zsh's getproc
7411 // (Src/exec.c:5045+) leaves the fd open across exec.
7412 unsafe {
7413 libc::close(write_end);
7414 }
7415 }
7416 }
7417 format!("/dev/fd/{}", read_end)
7418 }
7419
7420 fn process_sub_out(&mut self, sub: &fusevm::Chunk) -> String {
7421 // `>(cmd)` — consumer reads stdin from a FIFO that the parent
7422 // writes to. Create a real named pipe, fork a child that
7423 // dup2s the read end onto stdin and runs the sub-chunk; return
7424 // the FIFO path to the parent so it writes there.
7425 let fifo_path = format!(
7426 "/tmp/zshrs_psub_out_{}_{}",
7427 std::process::id(),
7428 with_executor(|e| {
7429 let n = e.process_sub_counter;
7430 e.process_sub_counter += 1;
7431 n
7432 })
7433 );
7434 let _ = fs::remove_file(&fifo_path);
7435 let cpath = match CString::new(fifo_path.clone()) {
7436 Ok(c) => c,
7437 Err(_) => return fifo_path,
7438 };
7439 if unsafe { libc::mkfifo(cpath.as_ptr(), 0o600) } != 0 {
7440 // Fall back to plain file if mkfifo fails.
7441 let _ = fs::write(&fifo_path, "");
7442 return fifo_path;
7443 }
7444 let sub = sub.clone();
7445 let fifo_for_child = fifo_path.clone();
7446 match unsafe { libc::fork() } {
7447 -1 => {
7448 let _ = fs::remove_file(&fifo_path);
7449 }
7450 0 => {
7451 // Child: open FIFO for read, dup onto stdin, run sub-chunk, exit.
7452 if let Ok(f) = fs::OpenOptions::new().read(true).open(&fifo_for_child) {
7453 let fd = f.as_raw_fd();
7454 unsafe {
7455 libc::dup2(fd, libc::STDIN_FILENO);
7456 }
7457 }
7458 crate::fusevm_disasm::maybe_print_stdout("process_subst_out:child", &sub);
7459 let mut vm = fusevm::VM::new(sub);
7460 register_builtins(&mut vm);
7461 vm.set_shell_host(Box::new(ZshrsHost));
7462 let _ = vm.run();
7463 unsafe { libc::_exit(0) };
7464 }
7465 _ => {
7466 // Parent — return path; child handles cleanup of the FIFO
7467 // once stdin EOFs. (The path may leak if the parent never
7468 // writes; acceptable for common `>(cmd)` idioms.)
7469 }
7470 }
7471 fifo_path
7472 }
7473
7474 fn subshell_begin(&mut self) {
7475 with_executor(|exec| {
7476 // libc::umask returns the previous mask AND sets the new
7477 // one; call with current value to read without changing.
7478 let cur_umask = unsafe {
7479 let m = libc::umask(0o022);
7480 libc::umask(m);
7481 m as u32
7482 };
7483 // Snapshot paramtab + hashed-storage too (step 1 of the
7484 // store unification mirrors writes there; restoring only
7485 // the HashMaps leaks subshell-scoped writes to the parent
7486 // via paramtab readers like `paramsubst → vars_get`).
7487 let paramtab_snap = crate::ported::params::paramtab()
7488 .read()
7489 .ok()
7490 .map(|t| t.clone())
7491 .unwrap_or_default();
7492 let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
7493 .lock()
7494 .ok()
7495 .map(|m| m.clone())
7496 .unwrap_or_default();
7497 exec.subshell_snapshots.push(SubshellSnapshot {
7498 paramtab: paramtab_snap,
7499 paramtab_hashed_storage: paramtab_hashed_snap,
7500 positional_params: exec.pparams(),
7501 env_vars: env::vars().collect(),
7502 // Save the LOGICAL pwd ($PWD env), not `current_dir()`'s
7503 // symlink-resolved path. zsh's subshell isolation per
7504 // Src/exec.c at the `entersubsh` path treats `pwd` (the
7505 // shell-tracked logical PWD) as the carrier — see
7506 // `Src/builtin.c:1239-1242` where cd writes the logical
7507 // dest into `pwd`. Falling back to current_dir() only
7508 // when PWD is unset matches `setupvals` at
7509 // `Src/init.c:1100+`.
7510 cwd: env::var("PWD")
7511 .ok()
7512 .map(PathBuf::from)
7513 .or_else(|| env::current_dir().ok()),
7514 umask: cur_umask,
7515 // Snapshot canonical `traps_table` — bin_trap writes
7516 // there (`Src/builtin.c`).
7517 traps: crate::ported::builtin::traps_table()
7518 .lock()
7519 .map(|t| t.clone())
7520 .unwrap_or_default(),
7521 // Snapshot option store so `(set -e)` /
7522 // `(setopt extendedglob)` don't leak to parent.
7523 opts: crate::ported::options::opt_state_snapshot(),
7524 // c:Src/exec.c — fork() copies the alias table to
7525 // the subshell. `(alias x=y)` inside the subshell
7526 // dies with the child; the parent doesn't see x.
7527 // Snapshot here so subshell_end can restore.
7528 // Bug #209 in docs/BUGS.md.
7529 aliases: crate::ported::hashtable::aliastab_lock()
7530 .read()
7531 .ok()
7532 .map(|t| {
7533 t.iter()
7534 .map(|(k, v)| (k.clone(), v.text.clone()))
7535 .collect()
7536 })
7537 .unwrap_or_default(),
7538 // c:Src/exec.c::entersubsh — same fork-copy
7539 // semantics for shfunctab. `(f() { ... })` defined
7540 // inside the subshell dies with the child; parent's
7541 // `type f` reports "not found". Bug #208 in
7542 // docs/BUGS.md.
7543 shfuncs: crate::ported::hashtable::shfunctab_lock()
7544 .read()
7545 .ok()
7546 .map(|t| t.snapshot())
7547 .unwrap_or_default(),
7548 functions_compiled: exec.functions_compiled.clone(),
7549 function_source: exec.function_source.clone(),
7550 // c:Src/exec.c::entersubsh — subshell forks its own
7551 // modulestab. A `(zmodload zsh/X)` inside the
7552 // subshell flips MOD_INIT_B on the CHILD's
7553 // modulestab; when the child exits the change
7554 // dies with it. zshrs's in-process subshell would
7555 // otherwise leak the load to the parent.
7556 // Bug #210 in docs/BUGS.md. Snapshot just the
7557 // (name → flags) pairs since the only mutating
7558 // field is the flags bitmask (MOD_INIT_B for
7559 // loaded, MOD_UNLOAD for unloaded).
7560 modules: crate::ported::module::MODULESTAB
7561 .lock()
7562 .ok()
7563 .map(|t| {
7564 t.modules
7565 .iter()
7566 .map(|(k, v)| (k.clone(), v.node.flags))
7567 .collect()
7568 })
7569 .unwrap_or_default(),
7570 // c:Src/exec.c::entersubsh — fork-copy semantics for
7571 // THINGYTAB (ZLE widget registry). A subshell `zle -N`
7572 // / `zle -D` mutation dies with the child in C zsh;
7573 // mirror via in-process snapshot. Bug #453.
7574 thingytab: crate::ported::zle::zle_thingy::thingytab()
7575 .lock()
7576 .ok()
7577 .map(|t| t.clone())
7578 .unwrap_or_default(),
7579 // c:Src/exec.c::entersubsh — same fork-copy for the
7580 // KEYMAPNAMTAB (named keymap registry). `bindkey -N km`
7581 // / `bindkey -D km` inside a subshell dies with the
7582 // child. Bug #454.
7583 keymapnamtab: crate::ported::zle::zle_keymap::keymapnamtab()
7584 .lock()
7585 .ok()
7586 .map(|t| t.clone())
7587 .unwrap_or_default(),
7588 });
7589 // Subshell starts with EXIT trap cleared so the parent's
7590 // EXIT handler doesn't fire when the subshell ends. zsh:
7591 // each subshell has its own trap context. Other signals
7592 // are inherited (well, parent's are still in place — but
7593 // a trap set INSIDE the subshell shouldn't leak out).
7594 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
7595 t.remove("EXIT");
7596 }
7597 let level = exec
7598 .scalar("ZSH_SUBSHELL")
7599 .and_then(|s| s.parse::<i32>().ok())
7600 .unwrap_or(0);
7601 // c:Src/exec.c — ZSH_SUBSHELL carries PM_READONLY (declared
7602 // in params.rs special_params); setsparam would be rejected
7603 // by assignstrvalue's PM_READONLY guard. Write u_val
7604 // directly — same bypass pattern as BUILTIN_SET_LINENO at
7605 // line 2784. C zsh's PM_SPECIAL GSU vtable handles this
7606 // implicitly via the setfn callback.
7607 let new_level = (level + 1) as i64;
7608 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
7609 if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
7610 pm.u_val = new_level;
7611 pm.u_str = Some(new_level.to_string());
7612 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
7613 }
7614 }
7615 });
7616 // Bump SUBSHELL_DEPTH so zexit defers process::exit (see
7617 // SUBSHELL_DEPTH declaration in src/ported/builtin.rs for
7618 // rationale).
7619 crate::ported::builtin::SUBSHELL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7620 // c:Src/exec.c::entersubsh — C zsh's subshell is a forked
7621 // child process: signals sent to the parent (via `kill $$`
7622 // inside the subshell, where `$$` is the parent's pid)
7623 // never reach the child's signal handlers. zshrs's
7624 // in-process subshell shares the process pid with the
7625 // parent, so without queueing the subshell's trap handler
7626 // fires for signals that zsh would deliver only to the
7627 // parent. Queue signals across the subshell body so the
7628 // parent's restored trap table sees them after
7629 // subshell_end's unqueue drain. Bug #450.
7630 crate::ported::signals_h::queue_signals();
7631 }
7632
7633 fn subshell_end(&mut self) -> Option<i32> {
7634 // Fire subshell's EXIT trap BEFORE restoring parent state so
7635 // the trap body sees the subshell's vars and exit status. zsh
7636 // forks for `(...)` so the trap runs in the child process,
7637 // before exit. We mirror by running it here, just before the
7638 // pop+restore. REMOVE the trap before firing so the inner
7639 // execute_script doesn't fire it again at its own end.
7640 let exit_trap_body = crate::ported::builtin::traps_table()
7641 .lock()
7642 .ok()
7643 .and_then(|mut t| t.remove("EXIT"));
7644 if let Some(body) = exit_trap_body {
7645 // Execute the trap body. Errors during trap execution
7646 // don't bubble — zsh ignores trap-body errors.
7647 with_executor(|exec| {
7648 let _ = exec.execute_script(&body);
7649 });
7650 }
7651 with_executor(|exec| {
7652 if let Some(snap) = exec.subshell_snapshots.pop() {
7653 // Restore paramtab + hashed storage so subshell-scoped
7654 // writes via setsparam/setaparam/sethparam don't leak
7655 // to the parent via paramtab readers.
7656 if let Some(tab) = crate::ported::params::paramtab()
7657 .write()
7658 .ok()
7659 .as_deref_mut()
7660 {
7661 *tab = snap.paramtab;
7662 }
7663 if let Some(m) = crate::ported::params::paramtab_hashed_storage()
7664 .lock()
7665 .ok()
7666 .as_deref_mut()
7667 {
7668 *m = snap.paramtab_hashed_storage;
7669 }
7670 exec.set_pparams(snap.positional_params);
7671 // Restore the OS env to its pre-subshell state.
7672 // Removes any `export` writes the subshell made, and
7673 // restores any vars the subshell unset. Without this
7674 // `(export y=sub)` would leak `y` to the parent shell.
7675 let current: HashMap<String, String> = env::vars().collect();
7676 for k in current.keys() {
7677 if !snap.env_vars.contains_key(k) {
7678 env::remove_var(k);
7679 }
7680 }
7681 for (k, v) in &snap.env_vars {
7682 if current.get(k) != Some(v) {
7683 env::set_var(k, v);
7684 }
7685 }
7686 if let Some(cwd) = snap.cwd {
7687 let _ = env::set_current_dir(&cwd);
7688 // Resync $PWD env so a parent `pwd` doesn't read
7689 // the cwd the subshell `cd`'d into.
7690 env::set_var("PWD", &cwd);
7691 }
7692 // Restore umask. zsh's `(umask 077)` doesn't leak to
7693 // parent because the subshell forks; we run in-process
7694 // so we manually reset.
7695 unsafe {
7696 libc::umask(snap.umask as libc::mode_t);
7697 }
7698 // Restore parent's traps (the subshell's own traps die
7699 // with it). zsh: `(trap "X" USR1)` doesn't leak the
7700 // USR1 trap out of the subshell. Write back to the
7701 // canonical `traps_table` (bin_trap writes there).
7702 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
7703 *t = snap.traps;
7704 }
7705 // Restore parent's option store so `(set -e)` /
7706 // `(setopt extendedglob)` don't leak. zsh forks
7707 // subshells so child option changes die with the
7708 // child; we run in-process and must restore.
7709 crate::ported::options::opt_state_restore(snap.opts);
7710 // c:Src/exec.c — fork() means alias mutations in a
7711 // subshell die with the child. Restore parent's
7712 // alias table from snapshot. Clear current entries
7713 // then re-add parent's. Bug #209 in docs/BUGS.md.
7714 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
7715 tab.clear();
7716 for (name, text) in snap.aliases {
7717 tab.add(crate::ported::zsh_h::alias {
7718 node: crate::ported::zsh_h::hashnode {
7719 next: None,
7720 nam: name,
7721 flags: 0,
7722 },
7723 text,
7724 inuse: 0,
7725 });
7726 }
7727 }
7728 // c:Src/exec.c::entersubsh — same fork-copy
7729 // semantics for shfunctab. Restore parent's function
7730 // table from snapshot so `(f() { ... })` definitions
7731 // inside the subshell don't leak to the parent.
7732 // Bug #208 in docs/BUGS.md.
7733 if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
7734 tab.restore(snap.shfuncs);
7735 }
7736 // Restore the runtime dispatch tables (compiled chunks
7737 // + source). Without these, a subshell-defined
7738 // override leaves its bytecode in place even after
7739 // shfunctab is restored — `g` after the subshell would
7740 // still run the override.
7741 exec.functions_compiled = snap.functions_compiled;
7742 exec.function_source = snap.function_source;
7743 // c:Src/exec.c::entersubsh — restore parent's
7744 // modulestab so a subshell `(zmodload zsh/X)` doesn't
7745 // leak to the parent. Bug #210 in docs/BUGS.md.
7746 // Restore via per-module flag write since the
7747 // snapshot is `(name → flags)` only.
7748 if let Ok(mut t) = crate::ported::module::MODULESTAB.lock() {
7749 for (name, saved_flags) in &snap.modules {
7750 if let Some(m) = t.modules.get_mut(name) {
7751 m.node.flags = *saved_flags;
7752 }
7753 }
7754 }
7755 // c:Src/exec.c::entersubsh — restore parent's THINGYTAB
7756 // so a subshell's `zle -N w f` / `zle -D w` doesn't
7757 // affect the parent's widget registry. Bug #453.
7758 if let Ok(mut t) = crate::ported::zle::zle_thingy::thingytab().lock() {
7759 *t = snap.thingytab;
7760 }
7761 // Same for KEYMAPNAMTAB. Bug #454.
7762 if let Ok(mut t) = crate::ported::zle::zle_keymap::keymapnamtab().lock() {
7763 *t = snap.keymapnamtab;
7764 }
7765 }
7766 });
7767 // Decrement SUBSHELL_DEPTH. If a deferred subshell exit
7768 // landed inside (EXIT_PENDING set with depth > 0), promote
7769 // the deferred status into the subshell's exit status now
7770 // that we're at the boundary, then clear so the parent
7771 // continues. Matches C zsh's "subshell-exit-via-fork"
7772 // boundary where the child's process::exit(N) becomes
7773 // $WAITSTATUS / $? in the parent.
7774 crate::ported::builtin::SUBSHELL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
7775 // c:Src/exec.c — drain the signal queue against the now-
7776 // restored parent trap table. Pairs with the
7777 // queue_signals() call at the end of subshell_begin.
7778 // Any `kill $$` from inside the subshell is processed
7779 // here against OUTER's trap, matching C zsh's
7780 // signal-delivery-to-parent semantics. Bug #450.
7781 crate::ported::signals_h::unqueue_signals();
7782 let exit_pending =
7783 crate::ported::builtin::EXIT_PENDING.load(std::sync::atomic::Ordering::Relaxed);
7784 if exit_pending != 0 {
7785 // c:Src/builtin.c — `exit N` masks N to 8 bits because
7786 // POSIX _exit takes the low byte as status. `(exit 256)`
7787 // and `(exit 0)` are indistinguishable to the parent;
7788 // `(exit 257)` exits with 1. Without the mask zshrs's
7789 // in-process subshell propagated the full i32 (256) into
7790 // the parent's $?, diverging from zsh.
7791 let raw = crate::ported::builtin::EXIT_VAL.load(std::sync::atomic::Ordering::Relaxed);
7792 let val = raw & 0xFF;
7793 with_executor(|exec| exec.set_last_status(val));
7794 crate::ported::builtin::EXIT_PENDING.store(0, std::sync::atomic::Ordering::Relaxed);
7795 crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
7796 crate::ported::builtin::BREAKS.store(0, std::sync::atomic::Ordering::Relaxed);
7797 // Set the post-subshell-exit guard. The next GET_VAR
7798 // sync_status path consults this to skip its
7799 // vm.last_status→LASTVAL sync (which would overwrite the
7800 // deferred-exit status we just set with stale vm state
7801 // since SubshellEnd doesn't propagate status into the
7802 // VM). Cleared as soon as the next sync_status sees it.
7803 SUBSHELL_EXIT_STATUS_PENDING.with(|c| c.set(true));
7804 // Return the deferred-exit status so the VM updates its
7805 // own `last_status`. Otherwise run_chunk's post-script
7806 // `set_last_status(vm.last_status)` would clobber LASTVAL
7807 // back to the stale pre-subshell value.
7808 return Some(val);
7809 }
7810 None
7811 }
7812
7813 fn redirect(&mut self, fd: u8, op: u8, target: &str) {
7814 // Apply a redirection at the OS level for the next command/builtin.
7815 // The host tracks saved fds in a per-executor stack so a future
7816 // `with_redirects_end` can restore. For now, this is a thin wrapper
7817 // that performs the dup2; pairing with explicit save/restore is
7818 // delivered by `with_redirects_begin/end`.
7819 with_executor(|exec| exec.host_apply_redirect(fd, op, target));
7820 }
7821
7822 fn with_redirects_begin(&mut self, count: u8) {
7823 with_executor(|exec| exec.host_redirect_scope_begin(count));
7824 }
7825
7826 fn regex_match(&mut self, s: &str, regex: &str) -> bool {
7827 // c:Src/Modules/regex.c:54 `zcond_regex_match` — POSIX ERE
7828 // matching + populate `$MATCH` / `$MBEGIN` / `$MEND` /
7829 // `$match[]` / `$mbegin[]` / `$mend[]` (or `$BASH_REMATCH`
7830 // under BASHREMATCH). Direct delegation to the canonical
7831 // port at src/ported/modules/regex.rs:58.
7832 //
7833 // The bridge passthru path delivers TOKEN-form bytes here
7834 // (Inbrack \u{91}, Outbrack \u{92}, Star \u{87}, Quest
7835 // \u{86}, etc.) since the lexer tokenizes regex meta chars
7836 // inside `[[ ]]`. The host regex engine expects ASCII, so
7837 // untokenize the pattern (and subject, for safety) once at
7838 // this boundary. zsh C reaches its POSIX-ERE engine through
7839 // the same untokenize path inside zcond_regex_match.
7840 let s_clean = crate::lex::untokenize(s);
7841 let regex_clean = crate::lex::untokenize(regex);
7842 crate::ported::modules::regex::zcond_regex_match(
7843 &[s_clean.as_str(), regex_clean.as_str()],
7844 crate::ported::modules::regex::ZREGEX_EXTENDED,
7845 ) != 0
7846 }
7847
7848 fn with_redirects_end(&mut self) {
7849 with_executor(|exec| exec.host_redirect_scope_end());
7850 // c:Src/exec.c:5172 — if any redirect in this scope failed
7851 // (noclobber-blocked, ENOENT for read, etc.), the command's
7852 // exit status is forced to 1 regardless of what the (still-
7853 // executed) command's own exit was. C zsh prevents the
7854 // command from running at all when a redirect fails; the
7855 // Rust port still runs it (sinking output to /dev/null in
7856 // the noclobber arm at host_apply_redirect:5481) and then
7857 // overrides $? here. Same observable effect for the common
7858 // pattern `echo x > existing-file` under noclobber.
7859 let failed = with_executor(|exec| {
7860 let f = exec.redirect_failed;
7861 exec.redirect_failed = false;
7862 f
7863 });
7864 if failed {
7865 with_executor(|exec| exec.set_last_status(1));
7866 }
7867 }
7868
7869 fn heredoc(&mut self, content: &str) {
7870 // C `Src/exec.c:4641` — `parsestr(&buf)` runs parameter +
7871 // command substitution on the heredoc body. The lexer's
7872 // quoted-delimiter detection (`<<'EOF'`) routes through the
7873 // `Op::HereDoc` path in `compile_zsh.rs` which short-circuits
7874 // before reaching here; unquoted forms route through the
7875 // BUILTIN_EXPAND_TEXT mode-4 emit path that calls singsub.
7876 // This handler covers the verbatim/quoted case.
7877 with_executor(|exec| exec.host_set_pending_stdin(content.to_string()));
7878 }
7879
7880 fn herestring(&mut self, content: &str) {
7881 // Shell semantics: herestring appends a newline. `<<<` body
7882 // substitution (`Src/exec.c:4655 getherestr` calls
7883 // `quotesubst` + `untokenize`) lands here verbatim; the
7884 // upstream compiler routes through `Op::HereString` after
7885 // BUILTIN_EXPAND_TEXT for the substitution pass, so callers
7886 // of `host.herestring` see the already-expanded form.
7887 let mut s = content.to_string();
7888 s.push('\n');
7889 with_executor(|exec| exec.host_set_pending_stdin(s));
7890 }
7891
7892 fn exec(&mut self, args: Vec<String>) -> i32 {
7893 // c:Src/subst.c paramsubst — when `${var:?msg}` or `${var?msg}`
7894 // triggered the "parameter null or not set" error, errflag
7895 // is raised and zsh aborts the simple command without
7896 // attempting exec. The expansion may have produced empty
7897 // argv[0] which falls into the c:?/permission-denied path
7898 // below, masking the real diagnostic with a spurious
7899 // "permission denied:" line and rc=126 instead of rc=1.
7900 // Honour errflag here so the script ends with the
7901 // paramsubst error as the sole diagnostic. Bug #86.
7902 //
7903 // c:Src/exec.c — C's execlist loop clears ERRFLAG_ERROR
7904 // between sublists when the error came from a NOMATCH-style
7905 // command failure (glob no-match, etc.) so subsequent
7906 // sublists run. zshrs's vm dispatch handles this at the
7907 // post-command-boundary HERE: if THIS command has its
7908 // `current_command_glob_failed` cell set (meaning the glob
7909 // NOMATCH happened during this command's argv prep), surface
7910 // status 1 and clear BOTH the cell AND ERRFLAG_ERROR so the
7911 // NEXT exec call sees a clean state. The errflag from
7912 // genuine script-fatal errors (parse, redirect, paramsubst
7913 // `${:?msg}`) does NOT come paired with glob_failed, so
7914 // those still short-circuit + propagate.
7915 let glob_failed = with_executor(|exec| {
7916 let f = exec.current_command_glob_failed.get();
7917 exec.current_command_glob_failed.set(false);
7918 f
7919 });
7920 if glob_failed {
7921 crate::ported::utils::errflag.fetch_and(
7922 !crate::ported::zsh_h::ERRFLAG_ERROR,
7923 std::sync::atomic::Ordering::Relaxed,
7924 );
7925 with_executor(|exec| exec.set_last_status(1));
7926 return 1;
7927 }
7928 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
7929 & crate::ported::zsh_h::ERRFLAG_ERROR)
7930 != 0
7931 {
7932 return 1;
7933 }
7934 // c:Src/exec.c — two distinct empty-command cases:
7935 //
7936 // 1. args=[""] — an explicit empty-string command word
7937 // (`""`, `"\$unset"`, `\$'\$x'`). zsh attempts exec(2)
7938 // on the empty path → EACCES → "permission denied", \$?
7939 // = 126.
7940 //
7941 // 2. args=[] — the WORD LIST is empty (unquoted \$(\$cmd)
7942 // that produced empty, or an unquoted unset \$var that
7943 // elided). zsh: no exec is attempted; \$? becomes the
7944 // last cmd-subst's exit status (the inner sub-VM
7945 // already set last_status), and the line completes
7946 // silently. Critically NOT 126.
7947 if args.is_empty() {
7948 // c:Src/exec.c — empty word list passes through to a
7949 // no-op; preserve whatever the inner cmd-subst's exit
7950 // is. Return last_status so the caller's SetStatus
7951 // round-trips correctly.
7952 return with_executor(|exec| exec.last_status());
7953 }
7954 if args[0].is_empty() {
7955 let script_name =
7956 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
7957 let lineno: u64 = with_executor(|exec| {
7958 exec.scalar("LINENO")
7959 .and_then(|s| s.parse::<u64>().ok())
7960 .unwrap_or(1)
7961 });
7962 eprintln!("{}:{}: permission denied: ", script_name, lineno);
7963 return 126;
7964 }
7965 // c:Src/exec.c — when any redirect in the current scope
7966 // failed (e.g. noclobber blocked a `>` overwrite), zsh
7967 // refuses to execute the command and exits with status 1.
7968 // The Rust port still applied the command (writing to the
7969 // /dev/null sink installed by host_apply_redirect's
7970 // noclobber arm), but the success status overwrote the
7971 // intended `1`. Short-circuit here so the exec returns 1
7972 // without running the body.
7973 let redir_failed = with_executor(|exec| {
7974 let f = exec.redirect_failed;
7975 exec.redirect_failed = false;
7976 f
7977 });
7978 if redir_failed {
7979 return 1;
7980 }
7981 // Track `$_` as the last argument of the last command (zsh /
7982 // bash convention). Empty arglists leave it untouched.
7983 if let Some(last) = args.last() {
7984 with_executor(|exec| {
7985 exec.set_scalar("_".to_string(), last.clone());
7986 });
7987 }
7988 // Route external command spawning through `executor.execute_external`
7989 // so intercepts (AOP before/after/around), command_hash lookups,
7990 // pre/postexec hooks, and zsh-specific fork-then-exec all apply.
7991 // Without this override, fusevm's default `host.exec` calls
7992 // `Command::new` directly, bypassing zshrs's dispatch logic.
7993 let status = with_executor(|exec| exec.host_exec_external(&args));
7994 // c:Src/jobs.c:1748 waitonejob (no-procs else-branch). zshrs's
7995 // exec model routes external commands through host_exec_external
7996 // (which already waitpid'd in-line); the canonical waitonejob
7997 // expects a Job to derive lastval, but here we already know
7998 // it. Synthesize a procs-less job so waitonejob's no-procs
7999 // branch fires the `pipestats[0]=lastval; numpipestats=1;`
8000 // update via the canonical port.
8001 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
8002 let mut synth = crate::ported::zsh_h::job::default();
8003 crate::ported::jobs::waitonejob(&mut synth);
8004 status
8005 }
8006
8007 fn cmd_subst(&mut self, sub: &fusevm::Chunk) -> String {
8008 // Run the sub-chunk on a nested VM with the same host wired up,
8009 // capturing stdout. The current executor remains active via the
8010 // thread-local — the nested VM uses CallBuiltin to dispatch shell
8011 // ops back through `with_executor`.
8012 let (read_end, write_end) = match os_pipe::pipe() {
8013 Ok(p) => p,
8014 Err(_) => return String::new(),
8015 };
8016 let saved_stdout = unsafe { libc::dup(libc::STDOUT_FILENO) };
8017 if saved_stdout < 0 {
8018 return String::new();
8019 }
8020 let saved_stderr = unsafe { libc::dup(libc::STDERR_FILENO) };
8021 let write_fd = AsRawFd::as_raw_fd(&write_end);
8022 unsafe {
8023 libc::dup2(write_fd, libc::STDOUT_FILENO);
8024 }
8025 drop(write_end);
8026
8027 // c:Bug #56 — publish the saved outer fds so a trap firing
8028 // during the nested VM run can route its body output to the
8029 // PARENT's stdout instead of the cmdsub's pipe-bound fd 1.
8030 // zsh's forked cmdsub gets this for free (trap runs in the
8031 // parent process whose fd 1 is untouched). zshrs's
8032 // in-process cmdsub needs this thread-local stack so the
8033 // trap dispatcher can find the right destination fd.
8034 CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().push((saved_stdout, saved_stderr)));
8035
8036 crate::fusevm_disasm::maybe_print_stdout("host.cmd_subst", sub);
8037 let mut vm = fusevm::VM::new(sub.clone());
8038 register_builtins(&mut vm);
8039 vm.set_shell_host(Box::new(ZshrsHost));
8040 let _ = vm.run();
8041 let cmd_status = vm.last_status;
8042
8043 CMDSUBST_OUTER_FDS.with(|s| {
8044 s.borrow_mut().pop();
8045 });
8046
8047 unsafe {
8048 libc::dup2(saved_stdout, libc::STDOUT_FILENO);
8049 libc::close(saved_stdout);
8050 if saved_stderr >= 0 {
8051 libc::close(saved_stderr);
8052 }
8053 }
8054
8055 // Inner cmd's status not propagated for the same reason as
8056 // run_command_substitution — see GAPS.md.
8057 let _ = cmd_status;
8058
8059 let mut buf = String::new();
8060 let mut reader = read_end;
8061 let _ = reader.read_to_string(&mut buf);
8062 // Strip trailing newlines (POSIX command substitution semantics)
8063 while buf.ends_with('\n') {
8064 buf.pop();
8065 }
8066 buf
8067 }
8068
8069 fn call_function(&mut self, name: &str, args: Vec<String>) -> Option<i32> {
8070 // c:Src/exec.c — when the command word is empty (e.g. `""`
8071 // or `"$nonexistent"`), zsh attempts the exec(2) which
8072 // returns EACCES ("permission denied") and exits 126. The
8073 // Rust port silently treated empty as a no-op (status 0).
8074 // Match zsh by emitting the diagnostic and returning 126.
8075 if name.is_empty() {
8076 let script_name =
8077 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
8078 let lineno: u64 = with_executor(|exec| {
8079 exec.scalar("LINENO")
8080 .and_then(|s| s.parse::<u64>().ok())
8081 .unwrap_or(1)
8082 });
8083 eprintln!("{}:{}: permission denied: ", script_name, lineno);
8084 with_executor(|exec| exec.set_last_status(126));
8085 return Some(126);
8086 }
8087 // c:Src/exec.c — redirect failure in this scope means the
8088 // command should NOT run. The Host::exec path already has
8089 // this gate (at fn exec above); call_function takes external
8090 // commands like `cat <&3` through a different code path, so
8091 // gate here too. Without this, bad-fd redirects produced
8092 // the diagnostic but the external command still ran, so $?
8093 // came out from the command's natural exit instead of the
8094 // forced 1.
8095 let redir_failed = with_executor(|exec| {
8096 let f = exec.redirect_failed;
8097 exec.redirect_failed = false;
8098 f
8099 });
8100 if redir_failed {
8101 with_executor(|exec| exec.set_last_status(1));
8102 return Some(1);
8103 }
8104 // zsh-bundled rename helpers + zcalc: short-circuit BEFORE the
8105 // function/autoload lookup so the autoloaded zsh source (which
8106 // can hang zshrs's parser on zsh-specific syntax) never runs.
8107 // Native Rust impls live in builtin_zmv / builtin_zcalc.
8108 match name {
8109 "zmv" => {
8110 return Some(crate::extensions::ext_builtins::zmv(&args, "mv"));
8111 }
8112 "zcp" => {
8113 return Some(crate::extensions::ext_builtins::zmv(&args, "cp"));
8114 }
8115 "zln" => {
8116 return Some(crate::extensions::ext_builtins::zmv(&args, "ln"));
8117 }
8118 "zcalc" => {
8119 return Some(crate::extensions::ext_builtins::zcalc(&args));
8120 }
8121 // ztest framework (src/extensions/ztest.rs — port of
8122 // ../strykelang's unit-test framework). All zassert_*/
8123 // ztest_* names route through the single try_dispatch
8124 // helper so adding/removing assertions only touches
8125 // ztest.rs.
8126 n if crate::extensions::ztest::try_dispatch_known(n) => {
8127 let status = with_executor(|exec| {
8128 crate::extensions::ztest::try_dispatch(exec, n, &args).unwrap_or(1)
8129 });
8130 return Some(status);
8131 }
8132 // Daemon-managed z* builtins — thin IPC wrappers. Short-circuit BEFORE
8133 // the function-lookup path so a missing daemon doesn't fall through to
8134 // "command not found". The name list is owned by the daemon crate
8135 // (zshrs_daemon::builtins::ZSHRS_BUILTIN_NAMES); routing through
8136 // try_dispatch keeps this site zero-touch as new z* builtins land.
8137 n if crate::daemon::builtins::is_zshrs_builtin(n) => {
8138 let argv: Vec<String> = std::iter::once(name.to_string()).chain(args).collect();
8139 return Some(crate::daemon::builtins::try_dispatch(n, &argv).unwrap_or(1));
8140 }
8141 _ => {}
8142 }
8143
8144 // c:Src/exec.c:3050-3068 — module-provided builtins (registered
8145 // via each module's `bintab` and folded into the canonical
8146 // `builtintab` by `createbuiltintable`) must dispatch BEFORE
8147 // PATH lookup. fusevm's `shell_builtins::builtin_id` doesn't
8148 // know about per-module entries like `log`
8149 // (Src/Modules/watch.c:693) — they reach call_function as
8150 // CallFunction ops. Consult the merged builtintab here so
8151 // `log` runs the canonical `bin_log` instead of falling
8152 // through to `/usr/bin/log` on macOS. Bug #72 in docs/BUGS.md.
8153 //
8154 // User-defined functions still take precedence over builtins
8155 // (zsh's `alias → function → builtin → external` resolution
8156 // order, c:Src/exec.c:3038-3068). Check `functions_compiled`
8157 // first so a user `log() { ... }` shadows the module bin_log.
8158 // c:Src/exec.c — shfunctab->getnode (the DISABLED-filtering
8159 // accessor) returns NULL for entries flipped to DISABLED via
8160 // `disable -f NAME`. functions_compiled holds the body
8161 // independently of the DISABLED flag, so check shfunctab first
8162 // and mask the lookup when the entry is disabled. Bug #221
8163 // in docs/BUGS.md.
8164 let user_fn_disabled = crate::ported::hashtable::shfunctab_lock()
8165 .read()
8166 .ok()
8167 .and_then(|t| {
8168 let entry = t.get_including_disabled(name)?;
8169 Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
8170 })
8171 .unwrap_or(false);
8172 let has_user_fn = !user_fn_disabled
8173 && with_executor(|exec| exec.functions_compiled.contains_key(name));
8174 if !has_user_fn {
8175 // c:Src/exec.c:3056 — `builtintab->getnode(builtintab,
8176 // cmdarg)` returns NULL for DISABLED entries, falling
8177 // execcmd through to PATH lookup. Mirror by gating the
8178 // bn_in_tab match on the BUILTINS_DISABLED set. Bug #106
8179 // in docs/BUGS.md.
8180 let disabled = crate::ported::builtin::BUILTINS_DISABLED
8181 .lock()
8182 .map(|s| s.contains(name))
8183 .unwrap_or(false);
8184 let bn_in_tab = !disabled
8185 && crate::ported::builtin::createbuiltintable().contains_key(name);
8186 if bn_in_tab {
8187 return Some(dispatch_builtin_raw(name, args));
8188 }
8189 }
8190
8191 // c:Src/lex.c — alias expansion is a LEXER-TIME pass, not a
8192 // run-time lookup. zsh parses the whole `-c` argument (or
8193 // script) before executing, so aliases defined in the same
8194 // parse unit don't apply to commands parsed earlier. Only at
8195 // an INTERACTIVE prompt does each line parse separately with
8196 // the latest aliastab visible.
8197 //
8198 // Gate the run-time alias-rewrite path on `interactive` so
8199 // `alias hi='echo hello'; hi` in `-c` mode falls through to
8200 // "command not found" (matching zsh) while interactive REPL
8201 // input still re-parses with the live aliastab.
8202 let interactive = crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE);
8203 let already_expanding = if interactive {
8204 crate::ported::hashtable::aliastab_lock()
8205 .read()
8206 .ok()
8207 .and_then(|tab| tab.get(name).map(|a| a.inuse != 0))
8208 .unwrap_or(false)
8209 } else {
8210 true // suppress lookup entirely in non-interactive mode
8211 };
8212 let alias_body = if already_expanding {
8213 None
8214 } else {
8215 with_executor(|exec| exec.alias(name))
8216 };
8217 if let Some(body) = alias_body {
8218 let combined = if args.is_empty() {
8219 body
8220 } else {
8221 let quoted: Vec<String> = args
8222 .iter()
8223 .map(|a| {
8224 let escaped = a.replace('\'', "'\\''");
8225 format!("'{}'", escaped)
8226 })
8227 .collect();
8228 format!("{} {}", body, quoted.join(" "))
8229 };
8230 // Bump inuse → run → clear, matching C's lexer behavior.
8231 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
8232 if let Some(a) = tab.get_mut(name) {
8233 a.inuse += 1;
8234 }
8235 }
8236 let status = with_executor(|exec| exec.execute_script(&combined).unwrap_or(1));
8237 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
8238 if let Some(a) = tab.get_mut(name) {
8239 a.inuse = (a.inuse - 1).max(0);
8240 }
8241 }
8242 return Some(status);
8243 }
8244
8245 // $_ pre-body bump and pending-underscore tracking are
8246 // ZshrsHost-only concerns (prompt rendering). Apply BEFORE
8247 // delegating to dispatch_function_call so the body sees the
8248 // bumped value.
8249 //
8250 // c:Src/exec.c:3491 — `setunderscore((args && nonempty(args))
8251 // ? ((char *) getdata(lastnode(args))) : "")`. C sets $_ to
8252 // the LAST node of the WHOLE args list (which includes argv[0]
8253 // == the function name). So for a no-arg `f`, $_ becomes "f"
8254 // inside the function body. The Rust port at the CallFunction
8255 // op-handler receives `args` WITHOUT the command name
8256 // (compile_zsh.rs:1571 only pushes simple.words[1..]). The
8257 // last() fallback `|| fn_name.clone()` already covers the
8258 // no-arg case, but `exec.set_scalar("_", ...)` writes paramtab
8259 // — the canonical `$_` read goes through `underscoregetfn`
8260 // (params.rs:7836) which reads the `zunderscore` Mutex.
8261 // setsparam("_") doesn't update that mutex, so the body's
8262 // `${_}` returned empty. Bug #279 in docs/BUGS.md. Mirror the
8263 // C `setunderscore` by writing via `set_zunderscore` directly.
8264 let fn_name = name.to_string();
8265 with_executor(|exec| {
8266 let dollar_underscore = args.last().cloned().unwrap_or_else(|| fn_name.clone());
8267 exec.set_scalar("_".to_string(), dollar_underscore.clone());
8268 crate::ported::params::set_zunderscore(std::slice::from_ref(&dollar_underscore));
8269 exec.pending_underscore = Some(dollar_underscore);
8270 });
8271
8272 // Delegate the actual function dispatch to the canonical
8273 // `dispatch_function_call` (which itself wraps the canonical
8274 // `doshfunc` port from `Src/exec.c:5823`). Single doshfunc
8275 // call-site keeps scope-mgmt invariants in one place.
8276 let status = with_executor(|exec| exec.dispatch_function_call(&fn_name, &args));
8277
8278 // $_ post-body — last call-arg or function name. Mirrors the
8279 // C `setunderscore` invocation after the body returns.
8280 with_executor(|exec| {
8281 let last_call_arg = args.last().cloned().unwrap_or_else(|| fn_name.clone());
8282 exec.set_scalar("_".to_string(), last_call_arg.clone());
8283 exec.pending_underscore = Some(last_call_arg);
8284 });
8285
8286 status
8287 }
8288}
8289
8290// ───────────────────────────────────────────────────────────────────────────
8291// Host-routed shell ops: ShellExecutor methods invoked by ZshrsHost from the
8292// fusevm VM. Not a port of Src/exec.c (see file-level docs above) — they're
8293// the bridge between fusevm opcodes and ShellExecutor state.
8294// ───────────────────────────────────────────────────────────────────────────
8295impl ShellExecutor {
8296 // ─── Host-routed shell ops (called by ZshrsHost from fusevm) ────────────
8297
8298 /// Apply a single redirection. The current scope's saved-fd vec gets a
8299 /// dup of the original fd so it can be restored by `host_redirect_scope_end`.
8300 /// `op_byte` matches `fusevm::op::redirect_op::*`.
8301 /// Apply a file-open result to a redirect fd; on error, emit
8302 /// zsh-format diagnostic, set redirect_failed, sink fd to /dev/null.
8303 /// Shared between WRITE/APPEND/READ/CLOBBER arms in
8304 /// host_apply_redirect to keep the error-handling identical.
8305 fn redir_open_or_fail(
8306 fd: i32,
8307 result: std::io::Result<fs::File>,
8308 target: &str,
8309 redirect_failed: &mut bool,
8310 ) -> bool {
8311 match result {
8312 Ok(file) => {
8313 let new_fd = file.into_raw_fd();
8314 unsafe {
8315 libc::dup2(new_fd, fd);
8316 libc::close(new_fd);
8317 }
8318 true
8319 }
8320 Err(e) => {
8321 let msg = match e.kind() {
8322 std::io::ErrorKind::PermissionDenied => "permission denied",
8323 std::io::ErrorKind::NotFound => "no such file or directory",
8324 std::io::ErrorKind::IsADirectory => "is a directory",
8325 _ => "redirect failed",
8326 };
8327 eprintln!("{}:1: {}: {}", shname(), msg, target);
8328 *redirect_failed = true;
8329 if let Ok(devnull) = fs::OpenOptions::new()
8330 .read(true)
8331 .write(true)
8332 .open("/dev/null")
8333 {
8334 let new_fd = devnull.into_raw_fd();
8335 unsafe {
8336 libc::dup2(new_fd, fd);
8337 libc::close(new_fd);
8338 }
8339 }
8340 false
8341 }
8342 }
8343 }
8344 /// `host_apply_redirect` — see implementation.
8345 pub fn host_apply_redirect(&mut self, fd: u8, op_byte: u8, target: &str) {
8346 // `&>` / `&>>` always target both fd 1 and fd 2 regardless of the
8347 // fd byte the parser supplied (the lexer's tokfd clamp makes the
8348 // raw value unreliable for these forms).
8349 let fd: i32 = if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
8350 1
8351 } else {
8352 fd as i32
8353 };
8354 // c:Src/exec.c — for DUP_READ / DUP_WRITE forms (<&N / >&N),
8355 // validate the source fd is open BEFORE the save-and-dup
8356 // dance below. The save's `dup(fd)` reclaims the lowest free
8357 // fd, which on closed-fd reuse would let dup2(src=N, …)
8358 // succeed against the freshly-claimed slot — masking the
8359 // user's "bad file descriptor" error. Check src_fd first.
8360 if matches!(op_byte, r::DUP_READ | r::DUP_WRITE) {
8361 let n_check = target.trim_start_matches('&');
8362 if n_check != "-" {
8363 if let Ok(src_fd) = n_check.parse::<i32>() {
8364 if unsafe { libc::fcntl(src_fd, libc::F_GETFD) } == -1 {
8365 eprintln!("{}:1: {}: bad file descriptor", shname(), src_fd);
8366 self.set_last_status(1);
8367 self.redirect_failed = true;
8368 return;
8369 }
8370 }
8371 }
8372 }
8373 let saved = unsafe { libc::dup(fd) };
8374 if saved >= 0 {
8375 if let Some(top) = self.redirect_scope_stack.last_mut() {
8376 top.push((fd, saved));
8377 } else {
8378 // No scope — leave saved fd open and let the next scope
8379 // reclaim it. (Caller without a scope leaks the dup; this
8380 // matches `WithRedirects` parser construction always wrapping.)
8381 unsafe { libc::close(saved) };
8382 }
8383 }
8384 // For `&>` / `&>>` also save fd 2 so the scope restores it after
8385 // the body. Otherwise stderr stays redirected past the command.
8386 if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
8387 let saved2 = unsafe { libc::dup(2) };
8388 if saved2 >= 0 {
8389 if let Some(top) = self.redirect_scope_stack.last_mut() {
8390 top.push((2, saved2));
8391 } else {
8392 unsafe { libc::close(saved2) };
8393 }
8394 }
8395 }
8396 match op_byte {
8397 r::WRITE => {
8398 // Honor `setopt noclobber`: refuse to overwrite an
8399 // existing regular file unless `>!` / `>|` (CLOBBER).
8400 // zsh internally stores the inverted-name `clobber`
8401 // (default ON); `setopt noclobber` writes
8402 // `clobber=false`. Honor both keys.
8403 //
8404 // c:Src/exec.c:2241-2245 clobber_open recover path:
8405 // after O_EXCL fails, reopen and `if (!S_ISREG(...))
8406 // return fd;` — non-regular targets (char/block-
8407 // special, FIFO, socket) bypass the noclobber check.
8408 // Bug #30 in docs/BUGS.md: this bridge-side check did
8409 // a bare `Path::exists()` and treated `/dev/null` as
8410 // a protected file, breaking `setopt no_clobber; echo
8411 // hi > /dev/null` and every `2> /dev/null` idiom.
8412 // Add a regular-file stat gate that matches the C
8413 // semantic. The canonical clobber_open at
8414 // src/ported/exec.rs:2123 already handles this; the
8415 // bridge duplicates a stripped-down version here and
8416 // must mirror the same check.
8417 let noclobber = opt_state_get("noclobber").unwrap_or(false)
8418 || !opt_state_get("clobber").unwrap_or(true);
8419 let target_is_regular_file = std::fs::metadata(target)
8420 .map(|m| m.file_type().is_file())
8421 .unwrap_or(false);
8422 if noclobber && target_is_regular_file {
8423 eprintln!("{}:1: file exists: {}", shname(), target);
8424 self.set_last_status(1);
8425 // c:Src/exec.c — set redirect_failed so the scope-end
8426 // hook (`with_redirects_end` in this file) forces
8427 // $? to 1 regardless of the still-running command's
8428 // own exit. Without this the next command (e.g.
8429 // `echo x` writing to /dev/null below) succeeds
8430 // and overwrites the redirect-failure status,
8431 // making noclobber unobservable from $?.
8432 self.redirect_failed = true;
8433 // Sink the upcoming command's stdout to /dev/null
8434 // so we don't leak its output to the terminal.
8435 // zsh skips the command entirely; we approximate by
8436 // discarding the output (the redirect target was
8437 // the user's chosen sink, but with noclobber the
8438 // file is protected — discarding matches the
8439 // user's intent better than printing to terminal).
8440 if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
8441 let new_fd = file.into_raw_fd();
8442 unsafe {
8443 libc::dup2(new_fd, fd);
8444 libc::close(new_fd);
8445 }
8446 }
8447 return;
8448 }
8449 if !Self::redir_open_or_fail(
8450 fd,
8451 fs::File::create(target),
8452 target,
8453 &mut self.redirect_failed,
8454 ) {
8455 self.set_last_status(1);
8456 }
8457 }
8458 r::CLOBBER => {
8459 if !Self::redir_open_or_fail(
8460 fd,
8461 fs::File::create(target),
8462 target,
8463 &mut self.redirect_failed,
8464 ) {
8465 self.set_last_status(1);
8466 }
8467 }
8468 r::APPEND => {
8469 // c:Src/exec.c:3924-3927 — `>>` honors NO_CLOBBER+!APPENDCREATE
8470 // by opening O_APPEND|O_WRONLY WITHOUT O_CREAT, so missing
8471 // files yield ENOENT. zsh source:
8472 // if (!isset(CLOBBER) && !isset(APPENDCREATE) &&
8473 // !IS_CLOBBER_REDIR(fn->type))
8474 // mode = O_WRONLY|O_APPEND|O_NOCTTY;
8475 // else mode = O_WRONLY|O_APPEND|O_CREAT|O_NOCTTY;
8476 // (IS_CLOBBER_REDIR — `>>!`/`>>|` — is currently flattened
8477 // to plain APPEND at compile time in
8478 // src/extensions/compile_zsh.rs:1654-1655, so the bang/pipe
8479 // forms can't be distinguished here yet.)
8480 let noclobber = opt_state_get("noclobber").unwrap_or(false)
8481 || !opt_state_get("clobber").unwrap_or(true);
8482 let append_create = opt_state_get("appendcreate").unwrap_or(false)
8483 || opt_state_get("append_create").unwrap_or(false);
8484 let open_result = if noclobber && !append_create {
8485 fs::OpenOptions::new().append(true).open(target) // no create
8486 } else {
8487 fs::OpenOptions::new()
8488 .create(true)
8489 .append(true)
8490 .open(target)
8491 };
8492 if !Self::redir_open_or_fail(
8493 fd,
8494 open_result,
8495 target,
8496 &mut self.redirect_failed,
8497 ) {
8498 self.set_last_status(1);
8499 }
8500 }
8501 r::READ => {
8502 if !Self::redir_open_or_fail(
8503 fd,
8504 fs::File::open(target),
8505 target,
8506 &mut self.redirect_failed,
8507 ) {
8508 self.set_last_status(1);
8509 }
8510 }
8511 r::READ_WRITE => {
8512 if let Ok(file) = fs::OpenOptions::new()
8513 .create(true)
8514 .truncate(false) // <> opens existing-or-new without truncating
8515 .read(true)
8516 .write(true)
8517 .open(target)
8518 {
8519 let new_fd = file.into_raw_fd();
8520 unsafe {
8521 libc::dup2(new_fd, fd);
8522 libc::close(new_fd);
8523 }
8524 }
8525 }
8526 r::DUP_READ | r::DUP_WRITE => {
8527 // Target is a numeric fd reference like `&3`. The parser
8528 // strips the `&` prefix before we get here in some paths,
8529 // others retain it — accept both. Also support `-` for
8530 // close-fd (`<&-` / `>&-`) per POSIX. The src_fd
8531 // validity check ran above before the save-and-dup.
8532 let n = target.trim_start_matches('&');
8533 if n == "-" {
8534 unsafe { libc::close(fd) };
8535 } else if n == "p" {
8536 // c:Src/exec.c — `<&p` / `>&p` route through the
8537 // coprocin / coprocout globals. zsh's `coproc CMD`
8538 // launch publishes those fds; the canonical
8539 // bin_print / bin_read `-p` arms already consume
8540 // them. The DUP redirect form is the third
8541 // consumer: it must dup the coproc fd onto the
8542 // target slot so the next command's stdin/stdout
8543 // is wired to the running coprocess. Bug #388.
8544 let coproc_fd = if op_byte == r::DUP_READ {
8545 crate::ported::modules::clone::coprocin
8546 .load(std::sync::atomic::Ordering::Relaxed)
8547 } else {
8548 crate::ported::modules::clone::coprocout
8549 .load(std::sync::atomic::Ordering::Relaxed)
8550 };
8551 if coproc_fd < 0 {
8552 eprintln!("{}:1: no coprocess", shname());
8553 self.set_last_status(1);
8554 self.redirect_failed = true;
8555 } else {
8556 unsafe {
8557 libc::dup2(coproc_fd, fd);
8558 }
8559 }
8560 } else if let Ok(src_fd) = n.parse::<i32>() {
8561 unsafe { libc::dup2(src_fd, fd) };
8562 } else {
8563 tracing::warn!(target = %target, "DUP redir: target not parseable as fd");
8564 }
8565 }
8566 r::WRITE_BOTH => {
8567 if let Ok(file) = fs::File::create(target) {
8568 let new_fd = file.into_raw_fd();
8569 unsafe {
8570 libc::dup2(new_fd, 1);
8571 libc::dup2(new_fd, 2);
8572 libc::close(new_fd);
8573 }
8574 }
8575 }
8576 r::APPEND_BOTH => {
8577 if let Ok(file) = fs::OpenOptions::new()
8578 .create(true)
8579 .append(true)
8580 .open(target)
8581 {
8582 let new_fd = file.into_raw_fd();
8583 unsafe {
8584 libc::dup2(new_fd, 1);
8585 libc::dup2(new_fd, 2);
8586 libc::close(new_fd);
8587 }
8588 }
8589 }
8590 _ => {}
8591 }
8592 }
8593
8594 /// Push a fresh redirect scope. `_count` is informational — the actual
8595 /// saved fds are appended by host_apply_redirect into the top scope.
8596 pub fn host_redirect_scope_begin(&mut self, _count: u8) {
8597 self.redirect_scope_stack.push(Vec::new());
8598 self.multios_scope_stack.push(Vec::new());
8599 }
8600
8601 /// Pop the top redirect scope, restoring saved fds.
8602 pub fn host_redirect_scope_end(&mut self) {
8603 // c:Src/exec.c — restore saved fds FIRST so the multios
8604 // pipe-write end is released from `fd`, then close our
8605 // tracked close_on_end (the last surviving writer dup), then
8606 // join the splitter thread. If we closed close_on_end before
8607 // restoring saved, `fd` would still hold a pipe writer and
8608 // the thread would block forever waiting for EOF.
8609 if let Some(saved) = self.redirect_scope_stack.pop() {
8610 for (fd, saved_fd) in saved.into_iter().rev() {
8611 unsafe {
8612 libc::dup2(saved_fd, fd);
8613 libc::close(saved_fd);
8614 }
8615 }
8616 }
8617 if let Some(scope) = self.multios_scope_stack.pop() {
8618 for (write_fd, handle) in scope {
8619 if write_fd >= 0 {
8620 unsafe {
8621 libc::close(write_fd);
8622 }
8623 }
8624 let _ = handle.join();
8625 }
8626 }
8627 }
8628
8629 /// Set up `content` as stdin (fd 0) for the next command via a real pipe.
8630 /// Used by `Op::HereDoc(idx)` and `Op::HereString`.
8631 ///
8632 /// The pattern: dup2 the read end of a fresh pipe onto fd 0, save the
8633 /// original fd 0 into the active redirect scope so `WithRedirectsEnd`
8634 /// restores it, and spawn a thread that writes `content` to the write end
8635 /// and closes it (so the consumer sees EOF after the body). A thread is
8636 /// needed because writing could block on a finite pipe buffer.
8637 pub fn host_set_pending_stdin(&mut self, content: String) {
8638 let (read_end, write_end) = match os_pipe::pipe() {
8639 Ok(p) => p,
8640 Err(_) => return,
8641 };
8642 let saved = unsafe { libc::dup(libc::STDIN_FILENO) };
8643 if saved >= 0 {
8644 if let Some(top) = self.redirect_scope_stack.last_mut() {
8645 top.push((libc::STDIN_FILENO, saved));
8646 } else {
8647 unsafe { libc::close(saved) };
8648 }
8649 }
8650 let read_fd = AsRawFd::as_raw_fd(&read_end);
8651 unsafe { libc::dup2(read_fd, libc::STDIN_FILENO) };
8652 drop(read_end);
8653 std::thread::spawn(move || {
8654 let mut w = write_end;
8655 let _ = w.write_all(content.as_bytes());
8656 });
8657 }
8658
8659 /// Spawn an external command using zshrs's full dispatch logic
8660 /// (intercepts, command_hash, redirect handling). Used by
8661 /// `ZshrsHost::exec` so the bytecode VM's `Op::Exec` and
8662 /// `Op::CallFunction` external fallback get the same semantics as
8663 /// the tree-walker's `execute_external` rather than a plain
8664 /// `Command::new` shortcut. Returns the exit status.
8665 pub fn host_exec_external(&mut self, args: &[String]) -> i32 {
8666 // If a glob expansion in this command's argv triggered the
8667 // nomatch error path, suppress the actual exec and return
8668 // status 1 — mirrors zsh's command-aborted-on-glob-error
8669 // behaviour. The flag is reset BEFORE returning so the next
8670 // command starts clean.
8671 //
8672 // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH sets
8673 // ERRFLAG_ERROR but C's execlist clears the bit per-sublist
8674 // so subsequent commands run. Symmetric with the builtin
8675 // dispatcher's clear at fusevm_bridge.rs:299 — clear it here
8676 // too at the external-command post-command-boundary.
8677 if self.current_command_glob_failed.get() {
8678 self.current_command_glob_failed.set(false);
8679 crate::ported::utils::errflag.fetch_and(
8680 !crate::ported::zsh_h::ERRFLAG_ERROR,
8681 std::sync::atomic::Ordering::Relaxed,
8682 );
8683 self.set_last_status(1);
8684 return 1;
8685 }
8686 let Some((cmd, rest)) = args.split_first() else {
8687 return 0;
8688 };
8689 // Empty command name (e.g. result of an empty `$(false)`
8690 // command-sub being the only word) — zsh: no command runs,
8691 // exit status preserved from prior step. Was hitting the
8692 // "command not found: " path with empty name.
8693 if cmd.is_empty() && rest.is_empty() {
8694 return self.last_status();
8695 }
8696 let rest_vec: Vec<String> = rest.to_vec();
8697 // Update `$_` with the just-arriving argv so the next command
8698 // reads `_=<last_arg>`. Mirrors C zsh's writeback in
8699 // `execcmd_exec` (Src/exec.c). Per `args.last()` semantics,
8700 // when invoked as `cmd a b c`, `$_` becomes "c" — for a bare
8701 // command with no args, `$_` becomes the command name itself.
8702 crate::ported::params::set_zunderscore(args);
8703
8704 // Builtins not in fusevm's name→id table fall through to
8705 // host.exec. Catch them here before the OS-level exec attempts
8706 // to spawn a non-existent binary.
8707 match cmd.as_str() {
8708 "sched" => return dispatch_builtin("sched", rest_vec.clone()),
8709 "echotc" => return dispatch_builtin("echotc", rest_vec.clone()),
8710 "echoti" => return dispatch_builtin("echoti", rest_vec.clone()),
8711 "zpty" => return dispatch_builtin("zpty", rest_vec.clone()),
8712 "ztcp" => return dispatch_builtin("ztcp", rest_vec.clone()),
8713 "zsocket" => {
8714 // c:Src/Modules/socket.c:276 BUILTIN spec — BUILTINS["zsocket"]
8715 // optstr "ad:ltv" parsed by execbuiltin.
8716 return dispatch_builtin("zsocket", rest_vec.clone());
8717 }
8718 "private" => {
8719 // c:Src/Modules/param_private.c:217 — bin_private via
8720 // BUILTINS["private"].
8721 return dispatch_builtin("private", rest_vec.clone());
8722 }
8723 "zformat" => return dispatch_builtin("zformat", rest_vec.clone()),
8724 "zregexparse" => return dispatch_builtin("zregexparse", rest_vec.clone()),
8725 // `unalias`/`unhash`/`unfunction` share `bin_unhash` but
8726 // each carries its own funcid (BIN_UNALIAS / BIN_UNHASH /
8727 // BIN_UNFUNCTION) — dispatch_builtin handles the BUILTINS
8728 // lookup + funcid propagation via execbuiltin.
8729 "unalias" | "unhash" | "unfunction" => {
8730 return dispatch_builtin(cmd.as_str(), rest_vec.clone());
8731 }
8732 // zsh-bundled rename helpers — implemented natively in
8733 // Rust so `autoload -U zmv` works without shipping the
8734 // function source. (Without this, the autoload path hangs.)
8735 "zmv" => return crate::extensions::ext_builtins::zmv(&rest_vec, "mv"),
8736 "zcp" => return crate::extensions::ext_builtins::zmv(&rest_vec, "cp"),
8737 "zln" => return crate::extensions::ext_builtins::zmv(&rest_vec, "ln"),
8738 "zcalc" => return crate::extensions::ext_builtins::zcalc(&rest_vec),
8739 "zselect" => {
8740 // Route through canonical dispatch_builtin which goes
8741 // via execbuiltin → BUILTINS["zselect"] (zselect.c:272).
8742 return dispatch_builtin("zselect", rest_vec.clone());
8743 }
8744 "cap" => return dispatch_builtin("cap", rest_vec.clone()),
8745 "getcap" => return dispatch_builtin("getcap", rest_vec.clone()),
8746 "setcap" => return dispatch_builtin("setcap", rest_vec.clone()),
8747 "yes" => return self.builtin_yes(&rest_vec),
8748 "nl" => return self.builtin_nl(&rest_vec),
8749 "env" => return self.builtin_env(&rest_vec),
8750 "printenv" => return self.builtin_printenv(&rest_vec),
8751 "tty" => return self.builtin_tty(&rest_vec),
8752 // c:Src/Modules/files.c:806 — BUILTINS["chgrp"] with
8753 // BIN_CHGRP funcid + "hRs" optstr.
8754 "chgrp" => return dispatch_builtin("chgrp", rest_vec.clone()),
8755 "nproc" => return self.builtin_nproc(&rest_vec),
8756 "expr" => return self.builtin_expr(&rest_vec),
8757 "sha256sum" => return self.builtin_sha256sum(&rest_vec),
8758 "base64" => return self.builtin_base64(&rest_vec),
8759 "tac" => return self.builtin_tac(&rest_vec),
8760 "expand" => return self.builtin_expand(&rest_vec),
8761 "unexpand" => return self.builtin_unexpand(&rest_vec),
8762 "paste" => return self.builtin_paste(&rest_vec),
8763 "fold" => return self.builtin_fold(&rest_vec),
8764 "shuf" => return self.builtin_shuf(&rest_vec),
8765 "comm" => return self.builtin_comm(&rest_vec),
8766 "cksum" => return self.builtin_cksum(&rest_vec),
8767 "factor" => return self.builtin_factor(&rest_vec),
8768 "tsort" => return self.builtin_tsort(&rest_vec),
8769 "sum" => return self.builtin_sum(&rest_vec),
8770 "mkfifo" => return self.builtin_mkfifo(&rest_vec),
8771 "link" => return self.builtin_link(&rest_vec),
8772 "unlink" => return self.builtin_unlink(&rest_vec),
8773 "dircolors" => return self.builtin_dircolors(&rest_vec),
8774 "groups" => return self.builtin_groups(&rest_vec),
8775 "arch" => return self.builtin_arch(&rest_vec),
8776 "nice" => return self.builtin_nice(&rest_vec),
8777 "logname" => return self.builtin_logname(&rest_vec),
8778 "tput" => return self.builtin_tput(&rest_vec),
8779 "users" => return self.builtin_users(&rest_vec),
8780 // "sync" => return self.bin_sync(&rest_vec),
8781 "zbuild" => return self.builtin_zbuild(&rest_vec),
8782 // `zf_*` aliases from `zsh/files` (Src/Modules/files.c
8783 // BUILTIN table at line 816-824). The C source binds
8784 // both unprefixed (`chmod`) and prefixed (`zf_chmod`)
8785 // names to the SAME `bin_chmod` etc. handlers — the
8786 // prefixed forms exist so a script can portably reach
8787 // the builtin even when a function or alias has shadowed
8788 // the bare name. Each arm routes through the canonical
8789 // zf_* aliases route through canonical BUILTINS entries
8790 // (files.c:816-824) — execbuiltin parses each fn's optstr
8791 // automatically.
8792 "mkdir" | "zf_mkdir" | "zf_rm" | "zf_rmdir" | "zf_chmod" | "zf_chown" | "zf_chgrp"
8793 | "zf_ln" | "zf_mv" | "zf_sync" => {
8794 return dispatch_builtin(cmd.as_str(), rest_vec.clone());
8795 }
8796 // `zstat` — port of zsh/stat module (Src/Modules/stat.c
8797 // BUILTIN("zstat", …)). Returns file metadata as
8798 // `field value` pairs / an assoc / a plus-separated
8799 // list depending on flags. zsh ALSO registers `stat`
8800 // bound to the same handler, but that name conflicts
8801 // with the system `stat(1)` binary (every script that
8802 // calls `stat -f '%Lp' …` would break). zsh resolves
8803 // this through opt-in `zmodload`; zshrs's modules are
8804 // statically linked so we keep `stat` routing to the
8805 // external command and only intercept the unambiguous
8806 // `zstat` name.
8807 "zstat" => {
8808 // Canonical bin_stat per stat.c:638 via BUILTINS["zstat"].
8809 return dispatch_builtin("zstat", rest_vec.clone());
8810 }
8811 _ => {}
8812 }
8813
8814 // AOP intercepts: when an `intercept :before/:around/:after foo` block
8815 // is registered, dynamic-command-name dispatch must consult it before
8816 // spawning. Without this, `cmd=ls; $cmd` bypasses every intercept that
8817 // a literal `ls` would trigger. The full_cmd string mirrors what the
8818 // tree-walker era passed (cmd + args joined by space) so existing
8819 // pattern matchers continue to work.
8820 if !self.intercepts.is_empty() {
8821 let full_cmd = if rest_vec.is_empty() {
8822 cmd.clone()
8823 } else {
8824 format!("{} {}", cmd, rest_vec.join(" "))
8825 };
8826 if let Some(intercept_result) = self.run_intercepts(cmd, &full_cmd, &rest_vec) {
8827 return intercept_result.unwrap_or(127);
8828 }
8829 }
8830
8831 // User-defined function lookup before OS-level exec. zsh's
8832 // dynamic-command-name dispatch (`cmd=hook1; $cmd`) checks
8833 // the function table FIRST — without this, `$f` for a
8834 // function-name `f` was always falling through to
8835 // `execute_external` and erroring "command not found".
8836 // Plugin code uses this pattern constantly:
8837 // for f in "${precmd_functions[@]}"; do "$f"; done
8838 if self.function_exists(cmd) {
8839 if let Some(status) = self.dispatch_function_call(cmd, &rest_vec) {
8840 return status;
8841 }
8842 }
8843
8844 self.execute_external(cmd, &rest_vec, &[]).unwrap_or(127)
8845 }
8846}