zsh/fusevm_bridge.rs
1//! fusevm bytecode-VM bridge for ShellExecutor.
2//!
3//! ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4//! !!! LAST-RESORT FILE — NOT FOR NEW LOGIC !!!
5//!
6//! This file is a **bridge**, not a port. It exists ONLY because zshrs uses
7//! a fusevm bytecode VM where C zsh uses its own wordcode walker (Src/exec.c
8//! `execlist`). Every line here is plumbing that hooks fusevm opcodes onto
9//! the canonical ports in `src/ported/`.
10//!
11//! **Before adding code to this file, STOP and ask:**
12//!
13//! 1. Is this logic that already lives in `src/ported/`?
14//! → Call the canonical fn. Don't reinline.
15//!
16//! 2. Is this logic that SHOULD live in `src/ported/` but isn't ported yet?
17//! → Port it. Add it to `src/ported/<file>.rs` with a `c:` citation.
18//! Then call the canonical fn from here.
19//!
20//! 3. Is this purely fusevm/bytecode plumbing (Op decode, Value conversion,
21//! VM-stack manipulation, thread-local executor pointer, etc.)?
22//! → OK to put it here. Cite the closest C analog in the comment.
23//!
24//! **NEVER:** reinvent paramsubst/expansion/glob/typeset/redirect logic here.
25//! Those have canonical ports in `src/ported/subst.rs`, `src/ported/glob.rs`,
26//! `src/ported/builtin.rs`, etc. The bridge should be SHRINKING over time,
27//! not growing.
28//!
29//! See also: memory `feedback_no_shortcuts_in_porting` (port C bodies
30//! faithfully, no structural shells), `feedback_no_exec_script_from_ported`
31//! (the inverse direction — src/ported must not call back into the bridge).
32//! ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
33//!
34//! **Extension** — has no Src/exec.c counterpart. C zsh's `Src/exec.c::execlist`
35//! (and related routines) implement the native **wordcode VM** that executes
36//! compiler output from `parse.c`. zshrs compiles the parsed AST to fusevm
37//! bytecode and runs it on a stack VM; this
38//! file holds the bridge between fusevm's `ShellHost` trait and our
39//! `ShellExecutor` state, the thread-local executor pointer, all
40//! `BUILTIN_*` opcode constants, and the giant `register_builtins`
41//! handler table that wires zsh builtins onto fusevm CallBuiltin
42//! opcodes.
43
44#![allow(unused_imports)]
45
46use indexmap::IndexMap;
47use std::collections::{HashMap, HashSet};
48use std::env;
49use std::path::PathBuf;
50
51use crate::exec_jobs::JobState;
52use crate::intercepts::Intercept;
53use crate::ported::vm_helper::*;
54use std::io::Write;
55
56// ═══════════════════════════════════════════════════════════════════════════
57// Thread-local executor context for VM builtin dispatch
58// ═══════════════════════════════════════════════════════════════════════════
59
60use crate::ported::options::opt_state_get;
61use crate::ported::zsh_h::{isset, options, ERREXIT, MAX_OPS};
62use fusevm::op::redirect_op as r;
63use fusevm::shell_builtins::*;
64use fusevm::Value;
65use std::cell::{Cell, RefCell};
66use std::cmp::Ordering;
67use std::ffi::CString;
68use std::fs;
69use std::io::BufRead;
70use std::io::Read;
71use std::io::Write as _;
72use std::os::unix::fs::FileTypeExt;
73use std::os::unix::fs::MetadataExt;
74use std::os::unix::fs::PermissionsExt;
75use std::os::unix::io::AsRawFd;
76use std::os::unix::io::IntoRawFd;
77use std::time::Instant;
78use std::time::{SystemTime, UNIX_EPOCH};
79
80thread_local! {
81 /// Mirror of C zsh's `doneps4` local in execcmd_exec
82 /// (Src/exec.c:2517+). Tracks whether PS4 has been emitted
83 /// for the current xtrace line so a coalesced sequence of
84 /// XTRACE_ASSIGN + XTRACE_ARGS produces ONE line:
85 /// `<PS4>a=1 b=2 echo 1 2\n`
86 /// instead of three. Reset to false by XTRACE_ARGS /
87 /// XTRACE_NEWLINE after emitting the trailing `\n`.
88 static XTRACE_DONE_PS4: Cell<bool> = const { Cell::new(false) };
89
90 /// Port of C's `FILE *xtrerr` xtrace stream (Src/exec.c:81). C builds
91 /// each trace line in this stdio buffer — `printprompt4` does
92 /// `fprintf(xtrerr, …)`, then args via `fputs`/`fputc` — and
93 /// `fflush(xtrerr)` writes the WHOLE line to stderr in one syscall
94 /// (makecline c:2122-2123, addvars c:2588, condition c:1372). That
95 /// single flush is exactly why a forked pipeline stage's trace line
96 /// reaches the shared stderr fd atomically and never interleaves with
97 /// a concurrent stage. zshrs previously emitted PS4 and the command
98 /// text as separate `eprint!` writes, which raced under load. Model
99 /// the FILE buffer as this thread-local String (a forked child owns
100 /// its own copy); `xtrerr_fputs` appends, `xtrerr_flush` does the
101 /// single write.
102 static XTRERR: RefCell<String> = const { RefCell::new(String::new()) };
103
104 /// Stack of (RETFLAG, BREAKS, CONTFLAG, EXIT_PENDING) tuples saved
105 /// at try-block exit so the always-arm body can run cleanly even
106 /// when the try-block fired `return` / `break` / `continue` /
107 /// `exit`. Restored right before the post-always re-jump so the
108 /// escape resumes propagation past the construct.
109 /// c:Src/exec.c WC_TRYBLOCK — zsh's wordcode walker handles this
110 /// inline; the zshrs port lifts it into a paired SET / RESTORE
111 /// pair around the always-arm.
112 /// Tuple: `(retflag, breaks, contflag, exit_pending, try_errflag,
113 /// try_interrupt)`. The last two are c:Src/loop.c:762-763's
114 /// `save_try_errflag` / `save_try_interrupt` — the ENCLOSING try
115 /// block's values, restored at c:778-779 so nested
116 /// `{…} always {…}` constructs don't leak `$TRY_BLOCK_ERROR`
117 /// outward.
118 static TRY_ESCAPE_SAVE: RefCell<Vec<(i32, i32, i32, i32, i64, i64)>> =
119 const { RefCell::new(Vec::new()) };
120 /// Re-entry guard for BUILTIN_DEBUG_TRAP. While the DEBUG trap
121 /// body is running, the per-statement DEBUG_TRAP dispatch in the
122 /// trap body must NOT re-fire (otherwise infinite recursion +
123 /// stack overflow). zsh's in_trap counter at Src/signals.c
124 /// serves the same purpose.
125 static DEBUG_TRAP_REENTRY: Cell<bool> = const { Cell::new(false) };
126 /// Stack of (saved_stdout, saved_stderr) tuples pushed by
127 /// `cmd_subst` around its nested-VM run. RUST-ONLY: zsh forks
128 /// each cmdsub so trap output during the cmdsub naturally
129 /// lands on the PARENT's stdout. zshrs's in-process cmdsub
130 /// dups fd 1 → pipe, so a trap firing during cmdsub would
131 /// emit into the captured value. Traps consult this stack
132 /// to route their body output to the topmost saved_stdout
133 /// instead of the cmdsub's fd 1. Bug #56 in docs/BUGS.md.
134 pub static CMDSUBST_OUTER_FDS: RefCell<Vec<(i32, i32)>> =
135 const { RefCell::new(Vec::new()) };
136 /// c:Src/exec.c:5025 getproc (PATH_DEV_FD branch) — the parent
137 /// keeps the `>(cmd)` pipe WRITE end open under `/dev/fd/N`,
138 /// parks it in the job's filelist (`fdtable[fd] =
139 /// FDT_PROC_SUBST; addfilelist(NULL, fd)`), and deletefilelist
140 /// closes it when the consuming job finishes — that close is
141 /// what lets the `>(cmd)` child's reader see EOF. zshrs runs
142 /// commands in-process, so the equivalent is: record
143 /// `(scope_depth, fd)` here and drain after the consuming
144 /// command (external exec or builtin dispatch) completes.
145 static PSUB_PENDING_FDS: RefCell<Vec<(usize, i32)>> = const { RefCell::new(Vec::new()) };
146 /// `(scope_depth, path)` for `=(cmd)` temp files, unlinked at the same
147 /// job-end boundary as the pending fds (c:Src/jobs.c deletefilelist).
148 static PSUB_PENDING_FILES: RefCell<Vec<(usize, String)>> = const { RefCell::new(Vec::new()) };
149 /// Scope depth for PSUB_PENDING_FDS tagging. Incremented around
150 /// nested execution contexts (cmd-subst bodies, shell-function
151 /// bodies) so a command running INSIDE the nested context only
152 /// drains its own `>(cmd)` fds, never the enclosing command's
153 /// (e.g. `tee >(wc) $(print x)` — print must not close tee's
154 /// fd). Mirrors C's per-job filelist ownership.
155 static PSUB_SCOPE_DEPTH: Cell<usize> = const { Cell::new(0) };
156 /// Forked `<(cmd)`/`>(cmd)` child pids awaiting reap. Drained
157 /// non-blockingly (WNOHANG) by note_psub_child so proc-sub children
158 /// don't accumulate as zombies across a shell session.
159 static PSUB_CHILDREN: RefCell<Vec<i32>> = const { RefCell::new(Vec::new()) };
160}
161
162/// Record a proc-sub child pid and best-effort reap any already-exited
163/// proc-sub children (WNOHANG). Non-blocking: still-running children
164/// stay parked and get reaped on a later call.
165pub(crate) fn note_psub_child(pid: i32) {
166 if pid <= 0 {
167 return;
168 }
169 PSUB_CHILDREN.with(|v| {
170 let mut v = v.borrow_mut();
171 v.push(pid);
172 v.retain(|&p| {
173 let mut status = 0;
174 // WNOHANG: reap if exited, else keep parked.
175 let r = unsafe { libc::waitpid(p, &mut status, libc::WNOHANG) };
176 // r == p → reaped; r == 0 → still running (keep); r < 0 →
177 // already gone/not ours (drop).
178 r == 0
179 });
180 });
181}
182
183/// Port of `fputs(s, xtrerr)` / `fprintf(xtrerr, "%s", s)` (Src/exec.c):
184/// append `s` to the buffered xtrace line. Nothing reaches stderr until
185/// [`xtrerr_flush`] (the port of `fflush(xtrerr)`) writes the line whole.
186pub(crate) fn xtrerr_fputs(s: &str) {
187 XTRERR.with(|b| b.borrow_mut().push_str(s));
188}
189
190/// Port of `fflush(xtrerr)` (Src/exec.c:1373/2123/2596): write the
191/// buffered xtrace line to stderr in ONE `write` and clear the buffer, so
192/// the line lands on the shared fd atomically (no interleaving across
193/// concurrent pipeline stages).
194pub(crate) fn xtrerr_flush() {
195 XTRERR.with(|b| {
196 let mut buf = b.borrow_mut();
197 if !buf.is_empty() {
198 use std::io::Write;
199 let _ = std::io::stderr().write_all(buf.as_bytes());
200 buf.clear();
201 }
202 });
203}
204
205/// RAII guard bumping the psub scope depth — see PSUB_SCOPE_DEPTH.
206pub(crate) struct PsubScope;
207
208impl PsubScope {
209 pub(crate) fn enter() -> Self {
210 PSUB_SCOPE_DEPTH.with(|d| d.set(d.get() + 1));
211 PsubScope
212 }
213}
214
215impl Drop for PsubScope {
216 fn drop(&mut self) {
217 PSUB_SCOPE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
218 }
219}
220
221/// RAII guard bumping `$ZSH_SUBSHELL` for the duration of an
222/// in-process command substitution.
223///
224/// c:Src/exec.c:1161 — entersubsh() does `zsh_subshell++;` and zsh's
225/// cmdsub FORKS, so the increment dies with the child and the parent's
226/// value is untouched. zshrs runs cmdsubs in-process on a nested VM,
227/// so the visible param must be bumped on entry and restored on exit.
228/// Writes paramtab u_val directly because ZSH_SUBSHELL is PM_READONLY
229/// (same bypass pattern as the subshell-builtin bump below); also
230/// mirrors into ported::exec::zsh_subshell so exec.c:4376-style
231/// `forked | zsh_subshell` reads agree.
232pub(crate) struct CmdSubstSubshellBump {
233 saved_val: i64,
234 saved_str: Option<String>,
235}
236
237impl CmdSubstSubshellBump {
238 pub(crate) fn enter() -> Self {
239 let mut saved_val = 0i64;
240 let mut saved_str = None;
241 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
242 if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
243 saved_val = pm.u_val;
244 saved_str = pm.u_str.clone();
245 pm.u_val = saved_val + 1;
246 pm.u_str = Some((saved_val + 1).to_string());
247 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
248 }
249 }
250 crate::ported::exec::zsh_subshell.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
251 CmdSubstSubshellBump {
252 saved_val,
253 saved_str,
254 }
255 }
256}
257
258impl Drop for CmdSubstSubshellBump {
259 fn drop(&mut self) {
260 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
261 if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
262 pm.u_val = self.saved_val;
263 pm.u_str = self.saved_str.take();
264 }
265 }
266 crate::ported::exec::zsh_subshell.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
267 }
268}
269
270/// Port of deletefilelist() from Src/jobs.c (the `>(cmd)` fd arm):
271/// closes every pending proc-subst write end created at or inside
272/// the current scope depth, exactly when C deletes the consuming
273/// job's filelist (getproc parks the fd there via
274/// `addfilelist(NULL, fd)`, Src/exec.c:5025+).
275fn close_pending_psub_fds() {
276 let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
277 PSUB_PENDING_FDS.with(|v| {
278 v.borrow_mut().retain(|&(d, fd)| {
279 if d >= depth {
280 unsafe { libc::close(fd) };
281 false
282 } else {
283 true
284 }
285 });
286 });
287 // c:Src/jobs.c deletefilelist — `=(cmd)` temp files are unlinked at the
288 // same job-end boundary. `f==(print x); [[ -f $f ]]` is false after the
289 // command line ends.
290 PSUB_PENDING_FILES.with(|v| {
291 v.borrow_mut().retain(|(d, path)| {
292 if *d >= depth {
293 let _ = fs::remove_file(path);
294 false
295 } else {
296 true
297 }
298 });
299 });
300}
301
302/// RAII drain guard — instantiated at the top of the consuming-
303/// command paths (dispatch_builtin, ZshrsHost::exec) so the pending
304/// `>(cmd)` write ends close on every exit path once the command
305/// finished, exactly when C's job filelist would be deleted.
306struct PsubFdGuard;
307
308impl Drop for PsubFdGuard {
309 fn drop(&mut self) {
310 close_pending_psub_fds();
311 }
312}
313
314/// Peek the outermost cmdsub-saved (stdout, stderr) fds, if any.
315/// Returns None when no cmdsub is currently capturing. Used by the
316/// trap dispatcher in `src/ported/signals.rs::dotrap` to route trap
317/// body output to the parent's real stdout (matching zsh's forked
318/// cmdsub behaviour) instead of the cmdsub's pipe-bound fd 1.
319/// Bug #56 in docs/BUGS.md.
320/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
321///
322/// C keeps a shell function in exactly ONE place — the `Shfunc` node in
323/// `shfunctab` — so `dosavetrap` copies a `TRAP<SIG>` function by
324/// duplicating that node (`Src/signals.c:638-656`) and `endtrapscope`
325/// puts it back with a single `shfunctab->addnode` (c:929-931).
326///
327/// zshrs splits one function across FIVE stores: the `shfunc` node in
328/// `shfunctab` (metadata + body text) plus the executor's
329/// `functions_compiled` (the fusevm `Chunk` that actually runs),
330/// `function_source`, `function_line_base` and `function_def_file`.
331/// Snapshotting only the hashtable node therefore restores the outer
332/// function's *metadata* while the inner function's *bytecode* keeps
333/// running. `FuncSnapshot` captures all five so the C-level "copy the
334/// node" / "add the node back" steps are faithful in behaviour.
335#[derive(Default)]
336pub struct FuncSnapshot {
337 pub shf: Option<crate::ported::zsh_h::shfunc>,
338 pub chunk: Option<fusevm::Chunk>,
339 pub source: Option<String>,
340 pub line_base: Option<i64>,
341 pub def_file: Option<Option<String>>,
342}
343
344impl FuncSnapshot {
345 /// True when `name` had no definition at snapshot time — restoring
346 /// such a snapshot deletes the function everywhere.
347 pub fn is_empty(&self) -> bool {
348 self.shf.is_none() && self.chunk.is_none() && self.source.is_none()
349 }
350}
351
352/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
353/// Capture every store that holds `name`'s definition. See
354/// [`FuncSnapshot`].
355pub fn snapshot_function(name: &str) -> FuncSnapshot {
356 let shf = crate::ported::hashtable::shfunctab_lock()
357 .read()
358 .ok()
359 .and_then(|t| t.get(name).cloned());
360 let (chunk, source, line_base, def_file) = try_with_executor(|exec| {
361 (
362 exec.functions_compiled.get(name).cloned(),
363 exec.function_source.get(name).cloned(),
364 exec.function_line_base.get(name).copied(),
365 exec.function_def_file.get(name).cloned(),
366 )
367 })
368 .unwrap_or((None, None, None, None));
369 FuncSnapshot {
370 shf,
371 chunk,
372 source,
373 line_base,
374 def_file,
375 }
376}
377
378/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
379/// Put a [`FuncSnapshot`] back under `name`. An empty snapshot (the
380/// function did not exist when it was taken) removes it from every
381/// store, mirroring C's `removehashnode(shfunctab, …)`.
382pub fn restore_function(name: &str, snap: FuncSnapshot) {
383 if let Ok(mut t) = crate::ported::hashtable::shfunctab_lock().write() {
384 match snap.shf {
385 Some(shf) => {
386 t.add(shf);
387 }
388 None => {
389 t.remove(name);
390 }
391 }
392 }
393 let _ = try_with_executor(|exec| {
394 match snap.chunk {
395 Some(c) => {
396 exec.functions_compiled.insert(name.to_string(), c);
397 }
398 None => {
399 exec.functions_compiled.remove(name);
400 }
401 }
402 match snap.source {
403 Some(s) => {
404 exec.function_source.insert(name.to_string(), s);
405 }
406 None => {
407 exec.function_source.remove(name);
408 }
409 }
410 match snap.line_base {
411 Some(n) => {
412 exec.function_line_base.insert(name.to_string(), n);
413 }
414 None => {
415 exec.function_line_base.remove(name);
416 }
417 }
418 match snap.def_file {
419 Some(f) => {
420 exec.function_def_file.insert(name.to_string(), f);
421 }
422 None => {
423 exec.function_def_file.remove(name);
424 }
425 }
426 });
427}
428
429pub fn cmdsubst_outer_stdout() -> Option<i32> {
430 CMDSUBST_OUTER_FDS.with(|s| s.borrow().last().map(|(o, _)| *o))
431}
432
433thread_local! {
434 /// The pipeline fds a stage still has to install onto 0/1, as
435 /// `(input, output)` with -1 meaning "leave this fd alone".
436 ///
437 /// c:Src/exec.c:3720-3724 — the pipe's read/write ends are the
438 /// FIRST entries of the command's multio table:
439 /// /* Add pipeline input/output to mnodes */
440 /// if (input) addfd(forked, save, mfds, 0, input, 0, NULL);
441 /// if (output) addfd(forked, save, mfds, 1, output, 1, NULL);
442 /// and that runs AFTER prefork (c:3304) + globlist (c:3702) have
443 /// expanded the command's argument words. So a `$(...)` inside a
444 /// stage's ARGS reads the shell's original fd 0, not the pipe:
445 /// `print -rl -- c a b | print -r -- "[$(cat)]"` prints `[]`.
446 /// (The stage's own fork at c:3000 happens before the expansion,
447 /// which is why `${x::=v}` in a non-last stage doesn't survive —
448 /// but the fds are still installed after it.)
449 ///
450 /// zshrs's [`BUILTIN_RUN_PIPELINE`] forks per stage, so it parks
451 /// the stage's fds here instead of dup2'ing them itself, and the
452 /// compiled stage chunk installs them via
453 /// [`BUILTIN_PIPE_FDS_INSTALL`] at the C-faithful point: after the
454 /// arg-word ops, before the redirect scope
455 /// (compile_zsh.rs::emit_stage_fds_install). A compound stage
456 /// (`{ … }`, `( … )`, a function) installs at chunk entry — its
457 /// body legitimately reads the pipe.
458 static PENDING_STAGE_FDS: std::cell::Cell<(i32, i32)> = const { std::cell::Cell::new((-1, -1)) };
459}
460
461/// Park the current stage's `(input, output)` pipe fds for the stage
462/// chunk's `BUILTIN_PIPE_FDS_INSTALL` to pick up. Returns the previous
463/// value so a nested pipeline (`print -- "$(a | b)" | c`) can restore
464/// the outer stage's still-uninstalled fds when it finishes.
465fn stage_fds_park(input: i32, output: i32) -> (i32, i32) {
466 PENDING_STAGE_FDS.with(|c| c.replace((input, output)))
467}
468
469/// Take (and clear) the parked stage fds.
470fn stage_fds_take() -> (i32, i32) {
471 PENDING_STAGE_FDS.with(|c| c.replace((-1, -1)))
472}
473
474// Thread-local pointer to the current ShellExecutor.
475// Set before VM execution, cleared after. Used by builtin handlers.
476thread_local! {
477 static CURRENT_EXECUTOR: RefCell<Option<*mut ShellExecutor>> = const { RefCell::new(None) };
478 /// The installed session executor, registered by
479 /// `exec::install_session_executor`. Lets [`with_session_context`]
480 /// establish a VM execution context for STARTUP work that runs
481 /// before the loop's first `execode` enters one (rc-file sourcing in
482 /// `run_init_scripts`, c:1914). Mirrors exec.rs's own
483 /// `SESSION_EXECUTOR`; kept here so the context helper lives next to
484 /// `ExecutorContext`/`CURRENT_EXECUTOR`.
485 static SESSION_EXECUTOR_PTR: std::cell::Cell<Option<*mut ShellExecutor>> = const { std::cell::Cell::new(None) };
486 /// GLOB_ASSIGN eligibility carrier. Set true by BUILTIN_MARK_GLOB_ELIGIBLE
487 /// (emitted by the compiler ONLY when a scalar-assignment RHS carries an
488 /// UNQUOTED glob token — Star/Quest/Inbrack), read+cleared by the next
489 /// BUILTIN_SET_VAR. Matches C zsh's `GLOB_ASSIGN` (Src/exec.c:2554): only
490 /// a literal unquoted glob pattern in the wordcode is globbed; values from
491 /// `$param` / `$(cmd)` / quoted strings are NOT (verified against zsh).
492 /// The runtime SET_VAR value arrives untokenized (the compiler DQ-wraps to
493 /// suppress compile-time globbing), so quoting can no longer be recovered
494 /// from the value bytes — this flag carries the compile-time decision.
495 static SET_VAR_GLOB_ELIGIBLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
496}
497
498/// Register the session executor pointer (called from
499/// `install_session_executor`). See [`with_session_context`].
500pub fn register_session_executor(exec: &mut ShellExecutor) {
501 SESSION_EXECUTOR_PTR.with(|c| c.set(Some(exec as *mut ShellExecutor)));
502}
503
504/// Run `f` with the registered session executor established as
505/// `CURRENT_EXECUTOR`, so code reaching the live executor via
506/// `try_with_executor` works even when no per-command `execode` context
507/// is active yet.
508///
509/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
510/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
511/// first `execode`. Without an active context those sourced bodies
512/// `try_with_executor` → `None` → no-op, so the shell silently ignored
513/// the user's dotfiles. The scope is entered once around the startup
514/// sourcing window and dropped before the loop begins — deliberately NOT
515/// Run `f` with the registered session executor established as
516/// `CURRENT_EXECUTOR`, so code reaching the live executor via
517/// `try_with_executor` works even when no per-command `execode` context
518/// is active yet.
519///
520/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
521/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
522/// first `execode`. Without an active context those sourced bodies
523/// `try_with_executor` → `None` → no-op, so the shell silently ignored
524/// the user's dotfiles. The scope is entered once around the startup
525/// sourcing window and dropped before the loop begins — deliberately NOT
526/// a global fallback inside `execute_script_zsh_pipeline`, which would
527/// re-enter the executor on nested command substitution and block on
528/// input.
529pub fn with_session_context<R>(f: impl FnOnce() -> R) -> R {
530 let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
531 match ptr {
532 // SAFETY: set by install_session_executor to an executor that
533 // outlives the single-threaded interactive session.
534 Some(ptr) => {
535 let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
536 f()
537 }
538 None => f(),
539 }
540}
541
542/// Merge finished background-compinit results into shell state, callable
543/// from any site (active VM context first, session executor otherwise —
544/// the ZLE completion path runs OUTSIDE a VM frame, where
545/// `try_with_executor` alone is None and $_comps stayed empty forever).
546pub fn drain_compinit_bg_hook() {
547 if try_with_executor(|exec| exec.drain_compinit_bg()).is_some() {
548 return;
549 }
550 let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
551 if let Some(ptr) = ptr {
552 // SAFETY: per with_session_context.
553 let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
554 unsafe { (*ptr).drain_compinit_bg() }
555 }
556}
557
558/// RAII guard that sets/clears the thread-local executor pointer.
559///
560/// Idempotent: calling `enter` when a context is already active is a no-op
561/// for the entry side, and the guard's drop only clears the thread-local if
562/// *this* call was the one that set it. Nested `execute_command` invocations
563/// (e.g. from inside a builtin handler) reuse the outer pointer instead of
564/// stomping it.
565pub(crate) struct ExecutorContext {
566 we_set_it: bool,
567}
568
569impl ExecutorContext {
570 pub(crate) fn enter(executor: &mut ShellExecutor) -> Self {
571 let we_set_it = CURRENT_EXECUTOR.with(|cell| {
572 let mut slot = cell.borrow_mut();
573 if slot.is_some() {
574 false
575 } else {
576 *slot = Some(executor as *mut ShellExecutor);
577 true
578 }
579 });
580 ExecutorContext { we_set_it }
581 }
582}
583
584impl Drop for ExecutorContext {
585 fn drop(&mut self) {
586 if self.we_set_it {
587 CURRENT_EXECUTOR.with(|cell| {
588 *cell.borrow_mut() = None;
589 });
590 }
591 }
592}
593
594/// Access the current executor from a builtin handler.
595/// # Safety
596/// Only call this from within a VM execution context (after ExecutorContext::enter).
597#[inline]
598pub(crate) fn with_executor<F, R>(f: F) -> R
599where
600 F: FnOnce(&mut ShellExecutor) -> R,
601{
602 CURRENT_EXECUTOR.with(|cell| {
603 let ptr = cell
604 .borrow()
605 .expect("with_executor called outside VM context");
606 // SAFETY: The pointer is valid for the duration of VM execution,
607 // and we're single-threaded within the executor.
608 let executor = unsafe { &mut *ptr };
609 f(executor)
610 })
611}
612
613/// Non-panicking variant of [`with_executor`]: runs `f` against the
614/// current executor and returns `Some(result)`, or `None` when no
615/// executor is in scope (`CURRENT_EXECUTOR` unset — e.g. unit tests /
616/// compsys contexts with no fusevm bridge running).
617///
618/// This is the primitive the `crate::ported::exec` accessor wrappers
619/// (array/assoc/dispatch_function_call/execute_script/...) use to
620/// reach the live executor while preserving the exact "no executor →
621/// fall back to the direct param table / default value" semantics that
622/// the deleted `exec_hooks` OnceLock layer encoded via its
623/// "is-the-hook-installed?" check. `CURRENT_EXECUTOR` being set is the
624/// faithful equivalent of "the bridge installed the hooks".
625#[inline]
626pub(crate) fn try_with_executor<F, R>(f: F) -> Option<R>
627where
628 F: FnOnce(&mut ShellExecutor) -> R,
629{
630 CURRENT_EXECUTOR.with(|cell| {
631 let ptr = (*cell.borrow())?;
632 // SAFETY: same contract as with_executor — the pointer is valid
633 // for the duration of VM execution and access is single-threaded.
634 let executor = unsafe { &mut *ptr };
635 Some(f(executor))
636 })
637}
638
639/// Look up a canonical builtin by name in `BUILTINS` and dispatch
640/// via `execbuiltin` (Src/builtin.c:250). NO shadow check — calls the
641/// builtin even if a user function with the same name exists. Used by
642/// the `builtin foo` prefix opcode (which explicitly bypasses function
643/// lookup per zsh semantics) and by internal call sites where shadowing
644/// is unwanted. For zsh's normal name-resolution order (function shadows
645/// builtin), use `dispatch_builtin` instead.
646/// Shell-identifier prefix for diagnostic lines. Reads the canonical
647/// scriptname (`zsh` in `--zsh` parity mode, `zshrs` otherwise) so a
648/// single helper replaces hardcoded `"zshrs:"` literals across the
649/// file's eprintln paths.
650fn shname() -> String {
651 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string())
652}
653
654/// c:Src/subst.c:505-507 + Src/exec.c:3378-3380 — per-command
655/// CSH_NULL_GLOB outcome check. During this command's word expansion
656/// `expand_glob` accumulated `badcshglob |= 1` per failed glob and
657/// `|= 2` per successful one (Src/glob.c:1871-1875). Exactly 1 —
658/// failures and no successes — is the csh-style error: `no match`,
659/// command skipped, status 1. Any other value (0 = no globs, 2/3 =
660/// at least one matched) is silent. Always resets the counter for
661/// the next command (C resets at prefork entry, subst.rs:1307).
662/// Returns true when the error fired; callers mirror their
663/// glob_failed handling (builtins leave ERRFLAG_ERROR set so the
664/// script aborts, externals clear it so the next sublist runs —
665/// verified against zsh 5.9.1).
666/// Restore the user's GLOB_SUBST after a `${~spec}` carrier flip
667/// (see subst::TILDE_GLOBSUBST_CARRIER). Runs at the same
668/// command-dispatch boundaries that consume glob_failed /
669/// badcshglob — by then every glob op of the current word pipeline
670/// has read the carrier.
671pub(crate) fn consume_tilde_globsubst_carrier() {
672 crate::ported::subst::TILDE_GLOBSUBST_CARRIER.with(|c| {
673 if let Some(saved) = c.take() {
674 crate::ported::options::opt_state_set("globsubst", saved);
675 }
676 });
677}
678
679/// Pop `argc` stack slots for a whole-array assignment: the LAST popped
680/// (deepest pushed) is the param name, the rest are the values in stack
681/// order, with any `Value::Array` flattened to its elements. Mirrors the
682/// Flatten an array-assignment RHS value into scalar strings, descending
683/// through nested `Value::Array`s. zsh arrays are always flat, so recursion
684/// only collapses the wrapper layers the compiler introduces — in particular
685/// the single `Value::Array` built by `Op::MakeArray` for `arr=(...)` literals
686/// (used to dodge `CallBuiltin`'s u8 argc cap), whose own elements may
687/// themselves be arrays from an unquoted `$other_array` expansion. A
688/// one-level flatten would stringify those inner arrays into a single element.
689fn flatten_array_value(v: Value, out: &mut Vec<String>) {
690 match v {
691 Value::Array(items) => {
692 for it in items.iter() {
693 flatten_array_value(it.clone(), out);
694 }
695 }
696 other => out.push(other.to_str()),
697 }
698}
699
700/// pop/flatten prologue of BUILTIN_SET_ARRAY / BUILTIN_APPEND_ARRAY.
701fn pop_array_args_with_name(vm: &mut fusevm::VM, argc: u8) -> (String, Vec<String>) {
702 let n = argc as usize;
703 let mut popped: Vec<Value> = Vec::with_capacity(n);
704 for _ in 0..n {
705 popped.push(vm.pop());
706 }
707 popped.reverse();
708 let name = popped.pop().map(|v| v.to_str()).unwrap_or_default();
709 let mut values: Vec<String> = Vec::new();
710 for v in popped {
711 flatten_array_value(v, &mut values);
712 }
713 (name, values)
714}
715
716fn consume_badcshglob() -> bool {
717 let v = crate::ported::glob::BADCSHGLOB.swap(0, std::sync::atomic::Ordering::Relaxed);
718 if v == 1 {
719 crate::ported::utils::zerr("no match"); // c:Src/subst.c:507
720 true
721 } else {
722 false
723 }
724}
725
726/// Map a builtin name to the zsh module that owns it, IFF zsh does
727/// not auto-load that builtin on first use. Used by
728/// `dispatch_builtin_raw` to gate `--zsh` mode dispatch behind
729/// `zmodload`, mirroring `zsh -fc <name>` returning 127 for these
730/// names without an explicit module load.
731///
732/// Returns `Some(module_name)` if `name` belongs to a non-auto-load
733/// module per the per-module `Src/Modules/<x>.c` `bintab[]` plus
734/// the auto-load flag set at module-build time. `None` for core
735/// builtins and for auto-loaded module builtins (sched, log, echotc,
736/// echoti, zformat, zparseopts, zregexparse, zstyle, strftime,
737/// private, vared, zle, bindkey, comp*) which work without zmodload.
738fn module_bound_builtin_module(name: &str) -> Option<&'static str> {
739 match name {
740 "zftp" => Some("zsh/zftp"),
741 "zsocket" => Some("zsh/net/socket"),
742 "ztcp" => Some("zsh/net/tcp"),
743 "zstat" => Some("zsh/stat"),
744 "zselect" => Some("zsh/zselect"),
745 "zpty" => Some("zsh/zpty"),
746 "zprof" => Some("zsh/zprof"),
747 "zsystem" | "syserror" => Some("zsh/system"),
748 "clone" => Some("zsh/clone"),
749 "zcurses" => Some("zsh/curses"),
750 "ztie" | "zuntie" | "zgdbmpath" => Some("zsh/db/gdbm"),
751 "pcre_compile" | "pcre_match" | "pcre_study" => Some("zsh/pcre"),
752 "example" => Some("zsh/example"),
753 "cap" | "getcap" | "setcap" => Some("zsh/cap"),
754 "zgetattr" | "zsetattr" | "zdelattr" | "zlistattr" => Some("zsh/attr"),
755 // c:Src/Modules/datetime.c — `strftime` is registered via
756 // partab[] when zsh/datetime loads. Verified by
757 // `zsh -fc 'strftime -s s %Y 0'` → 127 "command not found".
758 "strftime" => Some("zsh/datetime"),
759 _ => None,
760 }
761}
762
763/// Dispatch a zshrs-ORIGINAL builtin by NAME, argv-style. These are
764/// registered as fusevm opcodes in [`register_builtins`] (async, doctor,
765/// peach, …), so a *literal* name compiles to `CallBuiltin` and runs. But
766/// they are absent from the static `BUILTINS` port table and the merged
767/// `builtintab`, so when the command name is resolved only at run time —
768/// `$var` indirection, `builtin NAME` — the ported command-resolution path
769/// never finds them and reports "command not found" / "no such builtin",
770/// even though `whence` (correctly) calls them builtins. The
771/// `register_builtins` closures use the VM only to pop args and then call an
772/// executor method, so the identical dispatch works here from any parent-side
773/// resolver that has an executor — no VM re-entry (which would alias the
774/// running `&mut VM`). Returns `None` for a name that is not one of them, so
775/// the caller falls through to external lookup.
776///
777/// !!! Keep in sync with the matching `vm.register_builtin(...)` closures in
778/// `register_builtins`: both must route a name to the same executor method.
779pub(crate) fn try_run_registered_builtin(name: &str, argv: &[String]) -> Option<i32> {
780 let s = match name {
781 "async" => with_executor(|e| e.builtin_async(argv)),
782 "await" => with_executor(|e| e.builtin_await(argv)),
783 "barrier" => with_executor(|e| e.builtin_barrier(argv)),
784 "peach" => with_executor(|e| e.builtin_peach(argv)),
785 "pmap" => with_executor(|e| e.builtin_pmap(argv)),
786 "pgrep" => with_executor(|e| e.builtin_pgrep(argv)),
787 "intercept" => with_executor(|e| e.builtin_intercept(argv)),
788 "intercept_proceed" => with_executor(|e| e.builtin_intercept_proceed(argv)),
789 "doctor" => with_executor(|e| e.builtin_doctor(argv)),
790 "dbview" => with_executor(|e| e.builtin_dbview(argv)),
791 "profile" => with_executor(|e| e.builtin_profile(argv)),
792 "provenance" => with_executor(|e| e.builtin_provenance(argv)),
793 "caller" => with_executor(|e| e.builtin_caller(argv)),
794 "help" => with_executor(|e| e.builtin_help(argv)),
795 "cdreplay" => with_executor(|e| e.builtin_cdreplay(argv)),
796 "zsleep" => crate::extensions::ext_builtins::zsleep(argv),
797 // Host-registered native commands (`extensions/native_cmds.rs`): the
798 // fat binary's sibling runtimes — `git` (zvcs), `arb` (arblang),
799 // `stryke` (strykelang) in the zshrs-native build. Unknown here in the
800 // thin shell, where the table is empty and this arm falls through to
801 // `None` exactly as before.
802 //
803 // Reached from the two places that ask "is this a builtin?": the
804 // pre-PATH arm of the ZshrsHost dispatch (after functions and after
805 // builtintab, so a user `git()` still shadows it) and the forced
806 // `builtin NAME` precommand. `command git` consults neither, so the
807 // escape hatch to the `git` on PATH is untouched.
808 //
809 // The registry's contract is full argv — argv[0] is the command name
810 // as invoked, which zvcs needs for its `git-<verb>` dashed form and
811 // for its `zvcs: <command>: <reason>` diagnostics — while every arm
812 // above takes the operands alone, so the name is spliced back on here.
813 n => {
814 if !crate::native_cmds::is_enabled(n) {
815 return None;
816 }
817 let full: Vec<String> = std::iter::once(n.to_string())
818 .chain(argv.iter().cloned())
819 .collect();
820 return crate::native_cmds::dispatch(n, &full);
821 }
822 };
823 Some(s)
824}
825
826pub(crate) fn dispatch_builtin_raw(name: &str, args: Vec<String>) -> i32 {
827 // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
828 // Native p10k engine intercept (src/extensions/p10k): sourcing
829 // powerlevel10k.zsh-theme activates the Rust segment engine
830 // instead of executing the ~13k-line zsh theme. The user's
831 // `.p10k.zsh` CONFIG is NOT intercepted — it sources normally so
832 // its POWERLEVEL9K_* typesets land in the paramtab, which the
833 // engine reads live at every render. Placed here (the chokepoint
834 // every builtin route funnels through) so `source`, `.`, and
835 // `builtin source` all hit it.
836 if matches!(name, "source" | ".") {
837 if let Some(status) = crate::p10k::maybe_intercept_theme_source(&args) {
838 // Register a `p10k` stub function so `${+functions[p10k]}`
839 // guards in .zshrc templates stay truthy. The body forwards
840 // to the bridge-intercepted `zshrs-p10k-api` name so
841 // `p10k segment` (custom-segment protocol) and the other
842 // API subcommands reach the native engine (p10k_api).
843 try_with_executor(|exec| {
844 let _ = exec.execute_script("function p10k() { zshrs-p10k-api \"$@\" }");
845 });
846 return status;
847 }
848 }
849 // Native p10k API dispatch — the `p10k` stub function forwards
850 // here (see the theme intercept above). Must run before the
851 // generic builtintab lookup: the name is not a real builtin.
852 if let Some(status) = crate::p10k::maybe_intercept_command(name, &args) {
853 return status;
854 }
855 // c:Src/exec.c:2700-2717 — `private` is an autoloaded builtin in
856 // zsh (autofeature b:private of zsh/param/private): first use
857 // runs ensurefeature → require_module → load_module → boot_,
858 // marking the module MOD_INIT_B (what `zmodload -e` reads) and
859 // installing the wrap_private FuncWrap (param_private.c:712).
860 // doshfunc gates the wrapper dispatch on that load state, so
861 // this require_module is what activates private scoping. The
862 // raw dispatcher is the chokepoint every builtin route funnels
863 // through; require_module is idempotent after the first call
864 // (needs_load checks MOD_INIT_B).
865 if name == "private" {
866 if let Ok(mut tab) = crate::ported::module::MODULESTAB.lock() {
867 let _ = crate::ported::module::require_module(
868 &mut tab,
869 "zsh/param/private",
870 None,
871 0,
872 false,
873 );
874 // c:2710 ensurefeature
875 }
876 }
877 // c:Src/Modules/param_private.c:682-685 setup_ — loading
878 // zsh/param/private REPLACES the `local` builtintab node's
879 // handlerfunc + optstr with bin_private's ("Even more horrible
880 // hack"), so once the module is loaded `local` IS bin_private: it
881 // accepts the -P/-Pa private-scope flags, and without -P delegates
882 // to bin_typeset, which already treats `local` and `private`
883 // identically (is_locallike, builtin.rs:3666). Replicate the swap by
884 // routing `local` through the `private` node only after the module
885 // is loaded — before then, `local -P` still errors "bad option: -P"
886 // exactly like stock zsh. The `private` node carries the augmented
887 // optstr (with P) that the `local` node lacks.
888 if name == "local"
889 && crate::ported::module::MODULESTAB
890 .lock()
891 .map(|t| t.is_bound("zsh/param/private"))
892 .unwrap_or(false)
893 {
894 // c:Src/Modules/param_private.c:683-685 — the swap copies EXACTLY
895 // two fields:
896 // ((Builtin)hn)->handlerfunc = bintab[0].handlerfunc;
897 // ((Builtin)hn)->optstr = bintab[0].optstr;
898 // `defopts` is NOT copied, so the `local` node keeps its own
899 // (NULL) defaults while `private`'s node keeps `"P"`. That is
900 // what makes `local x` delegate straight to bin_typeset
901 // (c:225-229 `if (!OPT_ISSET(ops, 'P'))`) while `private x`
902 // opens a private scope. Dispatching `local` through the
903 // `private` NODE inherited defopts="P", so every `local NAME`
904 // ran the private-promotion path: `() { local h=scalar;
905 // private -A h }` reported "can't change type of private param"
906 // where zsh reports "can't change scope of existing param"
907 // (V10private.ztst:13), and `local` silently made privates.
908 // zshrs's builtintab maps to `&'static builtin` rows in an
909 // immutable static, so mirror C's field swap on a private
910 // static copy of the `local` node instead of mutating the table.
911 static LOCAL_AS_PRIVATE: std::sync::LazyLock<crate::ported::zsh_h::builtin> =
912 std::sync::LazyLock::new(|| crate::ported::zsh_h::builtin {
913 // c:683 `save_local = *(Builtin)hn;` — start from the
914 // real `local` row so name/flags/minargs/maxargs/funcid/
915 // defopts all stay `local`'s.
916 node: crate::ported::zsh_h::hashnode {
917 next: None,
918 nam: "local".to_string(),
919 flags: (crate::ported::zsh_h::BINF_PLUSOPTS
920 | crate::ported::zsh_h::BINF_MAGICEQUALS
921 | crate::ported::zsh_h::BINF_PSPECIAL
922 | crate::ported::zsh_h::BINF_ASSIGN) as i32,
923 },
924 // c:684 — handlerfunc from bintab[0] (bin_private).
925 handlerfunc: Some(
926 crate::ported::modules::param_private::bin_private
927 as crate::ported::zsh_h::HandlerFunc,
928 ),
929 minargs: 0,
930 maxargs: -1,
931 funcid: 0,
932 // c:685 — optstr from bintab[0] (private's, which adds `P`).
933 optstr: Some("AE:%F:%HL:%PR:%TUZ:%ahi:%lnmrtux".to_string()),
934 // c:683-685 — NOT copied: `local` keeps its own defaults.
935 defopts: None,
936 });
937 let bn_ptr = &*LOCAL_AS_PRIVATE as *const _ as *mut _;
938 return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
939 }
940 // c:Bugs #475/#504/#555 — bash-only builtins (`mapfile`,
941 // `readarray`, `compopt`) should emit "command not found" in
942 // `--zsh` mode matching zsh's external-command-lookup miss.
943 // The per-opcode closures for caller/help/complete/compgen
944 // already gate via IS_ZSH_MODE at their registration sites;
945 // names without dedicated opcodes (compopt/mapfile/readarray)
946 // route through this generic builtintab lookup and need the
947 // gate here.
948 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
949 && matches!(name, "compopt" | "mapfile" | "readarray")
950 {
951 eprintln!("zsh:1: command not found: {}", name);
952 let _ = args;
953 return 127;
954 }
955 // c:Src/exec.c:2700-2724 resolvebuiltin — autoloaded-builtin stub
956 // (registered by `zmodload -ab MOD NAME`, Src/module.c:426
957 // add_autobin) fires on first use: ensurefeature loads the owning
958 // module, then dispatch proceeds against the real builtin. Must
959 // run BEFORE the module-bound 127 gate below — `zmodload -ab
960 // zsh/zselect zselect; zselect` previously died there with
961 // `command not found` because the gate only checked is_loaded,
962 // never the autoload ledger.
963 if let Some(rc) = crate::ported::module::resolvebuiltin(name) {
964 if rc != 0 {
965 // Load failed or feature undefined — diagnostics already
966 // printed (load_module zwarn / resolvebuiltin zerr).
967 // C's execbuiltin head returns 1 (Src/builtin.c:264-267).
968 return 1;
969 }
970 // Module loaded — fall through; the is_loaded gates below now
971 // pass and the normal dispatch chain runs the real builtin.
972 }
973 // c:Src/Modules/<mod>.c boot_/setup_ chain — module-bound builtins
974 // (zftp, zsocket, ztcp, zstat, etc.) are only registered into
975 // `builtintab` when their module is loaded via `zmodload`. In
976 // zsh `-fc` (the parity test harness's invocation), the modules
977 // are NOT pre-loaded, so each name reports "command not found"
978 // with exit 127. zshrs intentionally pre-loads all module bintabs
979 // in `createbuiltintable` (builtin.rs:131-152) for the default
980 // mode so users can call these without `zmodload`; that auto-load
981 // diverges from zsh's gate behavior. Match zsh's stance only when
982 // the user explicitly asked for parity via `--zsh`.
983 //
984 // The list is the union of builtins from modules that zsh does
985 // NOT auto-load (verified via `zsh -fc <name>` returning 127):
986 // zsh/zftp → zftp
987 // zsh/net/socket → zsocket
988 // zsh/net/tcp → ztcp
989 // zsh/stat → zstat (NOT `stat`; that name resolves to
990 // /bin/stat on PATH per zsh's setup)
991 // zsh/zselect → zselect
992 // zsh/zpty → zpty
993 // zsh/zprof → zprof
994 // zsh/system → zsystem, syserror
995 // zsh/clone → clone
996 // zsh/curses → zcurses
997 // zsh/db/gdbm → ztie, zuntie, zgdbmpath
998 // zsh/pcre → pcre_compile, pcre_match, pcre_study
999 // zsh/example → example
1000 // zsh/cap → cap, getcap, setcap
1001 // zsh/attr → zgetattr, zsetattr, zdelattr, zlistattr
1002 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
1003 && module_bound_builtin_module(name)
1004 .map(|m| {
1005 !crate::ported::module::MODULESTAB
1006 .lock()
1007 .map(|t| t.is_loaded(m))
1008 .unwrap_or(false)
1009 })
1010 .unwrap_or(false)
1011 {
1012 eprintln!("zsh:1: command not found: {}", name);
1013 let _ = args;
1014 return 127;
1015 }
1016 // c:Src/Modules/files.c:806-824 — zsh/files registers `chmod`,
1017 // `chown`, `chgrp`, `ln`, `mkdir`, `mv`, `rm`, `rmdir`, `sync`
1018 // (plus their `zf_*` aliases) into builtintab on module load.
1019 // Without an explicit `zmodload zsh/files`, zsh resolves the
1020 // names through PATH lookup — `zsh -fc 'chmod +x f'` runs
1021 // `/bin/chmod`, whose argv-parser accepts symbolic modes like
1022 // `+x` that bin_chmod's octal-only parser rejects with
1023 // "invalid mode `+x'". The shadow-aware wrapper at
1024 // `dispatch_builtin` (line 438) already has this gate, but the
1025 // direct `dispatch_builtin_raw` path used by fusevm's
1026 // CallBuiltin opcode bypasses it. Mirror the gate here so the
1027 // low-level dispatch matches C's PATH-fall-through behavior.
1028 // The gate is NOT emulation-mode dependent. C has no `zsh/files`
1029 // builtins in `builtintab` until the module is loaded, in ANY mode, so a
1030 // bare `rm`/`mv`/`chmod` falls through to PATH — `chmod +x FILE` runs
1031 // /bin/chmod and succeeds. Conditioning this on `IS_ZSH_MODE` meant the
1032 // native binary answered `chmod +x` from `bin_chmod`'s octal-only parser
1033 // ("invalid mode `+x'"), and `rm -s` / `mv -s` from the module's argument
1034 // parser instead of the system tool's. `dispatch_builtin` at :1087 already
1035 // gates unconditionally; this low-level `CallBuiltin` path did not.
1036 if module_gated_files_builtin(name)
1037 && !crate::ported::module::MODULESTAB
1038 .lock()
1039 .map(|t| t.is_loaded("zsh/files"))
1040 .unwrap_or(false)
1041 {
1042 // PATH lookup uses the LITERAL name: bare `mkdir` finds
1043 // /bin/mkdir; a `zf_*` alias finds nothing and exits 127 —
1044 // matching zsh -fc `zf_mkdir d` → "command not found:
1045 // zf_mkdir" (the aliases exist ONLY in the loaded module's
1046 // builtintab, Src/Modules/files.c:816-824; PATH has no
1047 // /bin/zf_rm). The previous zf_-strip silently ran the
1048 // system binary instead.
1049 let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1050 return status;
1051 }
1052 // c:Src/Modules/stat.c:637-638 — zsh/stat registers BOTH `stat`
1053 // and `zstat`. `zstat` is in the module_bound 127-gate above (no
1054 // /usr/bin/zstat exists), but the bare `stat` name must FALL
1055 // THROUGH to PATH when zsh/stat isn't loaded — zsh -fc
1056 // 'stat -f %Lp f' runs /usr/bin/stat, while bin_stat's parser
1057 // rejects stat(1) flags ("bad option: -c"). Same fall-through
1058 // shape as the zsh/files gate above.
1059 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
1060 && name == "stat"
1061 && !crate::ported::module::MODULESTAB
1062 .lock()
1063 .map(|t| t.is_loaded("zsh/stat"))
1064 .unwrap_or(false)
1065 {
1066 let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1067 return status;
1068 }
1069 // c:Src/exec.c:3050-3068 — builtin lookup hits `builtintab` (the
1070 // merged table containing module-provided builtins). The previous
1071 // port walked only the core `BUILTINS` slice, so per-module
1072 // entries like `log` (Src/Modules/watch.c:693 `BUILTIN("log", …,
1073 // bin_log, …)`) were registered into builtintab via
1074 // createbuiltintable but never reached at dispatch — `log` fell
1075 // through to PATH and ran `/usr/bin/log`. Bug #72 in docs/BUGS.md.
1076 let tab = crate::ported::builtin::createbuiltintable();
1077 if let Some(bn_static) = tab.get(name) {
1078 let bn_ptr = *bn_static as *const _ as *mut _;
1079 return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
1080 }
1081 1
1082}
1083
1084/// Shadow-aware dispatch matching zsh's name-resolution order:
1085/// alias → reserved word → **function (shadows builtin)** → builtin →
1086/// external. All `BUILTIN_X` opcode handlers route through here so a
1087/// user-defined `cd () { … }` (or `r`, `fc`, `which`, … anything in
1088/// fusevm's name→opcode map) takes precedence over the C builtin —
1089/// matching `Src/exec.c:execcmd_exec`'s dispatch at c:3050-3068.
1090/// Without this, compile-time builtin resolution silently ignored
1091/// user wrappers (e.g. ZPWR's `cd () { builtin cd "$@"; … }`).
1092/// True for builtins that are bound by zsh/files's boot_/setup_
1093/// chain (Src/Modules/files.c:806-824). These are the bare-name
1094/// `mkdir`/`rm`/`mv`/`ln`/`chmod`/`chown`/`chgrp`/`sync`/`rmdir`
1095/// AND their `zf_*` aliases at c:816-824. Without explicit
1096/// `zmodload zsh/files`, the names fall through to PATH lookup
1097/// (zsh's `type rm` reports `/bin/rm`). Bug #28.
1098fn module_gated_files_builtin(name: &str) -> bool {
1099 matches!(
1100 name,
1101 "mkdir"
1102 | "rmdir"
1103 | "rm"
1104 | "mv"
1105 | "ln"
1106 | "chmod"
1107 | "chown"
1108 | "chgrp"
1109 | "sync"
1110 | "zf_mkdir"
1111 | "zf_rmdir"
1112 | "zf_rm"
1113 | "zf_mv"
1114 | "zf_ln"
1115 | "zf_chmod"
1116 | "zf_chown"
1117 | "zf_chgrp"
1118 | "zf_sync"
1119 )
1120}
1121
1122pub(crate) fn dispatch_builtin(name: &str, args: Vec<String>) -> i32 {
1123 // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close any
1124 // `>(cmd)` write ends owned by this command once it finishes
1125 // (drops on every return path below).
1126 let _psub_fds = PsubFdGuard;
1127 // c:Src/exec.c — when any redirect in the current scope failed
1128 // (e.g. noclobber blocked a `>` overwrite), zsh refuses to
1129 // execute the command and exits with status 1. The Rust port
1130 // still applied the command (writing to the /dev/null sink
1131 // installed by host_apply_redirect's noclobber arm) so the
1132 // success status overwrote the intended 1. Short-circuit here
1133 // for builtins (the external-exec equivalent lives in
1134 // ZshrsHost::exec).
1135 let redir_failed = with_executor(|exec| {
1136 let f = exec.redirect_failed;
1137 exec.redirect_failed = false;
1138 f
1139 });
1140 if redir_failed {
1141 // c:Src/exec.c:4367-4386 — POSIX special-builtin escalation:
1142 // a failed redirect on a PSPECIAL builtin (set, readonly,
1143 // typeset, ...) under POSIX_BUILTINS is FATAL in a
1144 // non-interactive shell (`exit(1)` at c:4383). The `command`
1145 // prefix resets this (BINF_COMMAND, c:4369) — that path
1146 // dispatches through bin_command, not here.
1147 if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
1148 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
1149 && builtin_is_pspecial(name)
1150 {
1151 use std::sync::atomic::Ordering;
1152 crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
1153 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
1154 }
1155 return 1;
1156 }
1157 // c:Src/glob.c:1876-1880 NOMATCH path — when expand_glob() failed
1158 // on a no-match glob, zsh aborts the simple command after zerr()
1159 // printed "no matches found". In C, this works because zerr()
1160 // sets ERRFLAG_ERROR (Src/utils.c) and execcmd_exec()
1161 // (Src/exec.c:3050+) checks errflag before invoking the builtin
1162 // table. Rust's builtin dispatch doesn't sit on the same errflag
1163 // gate, so we explicitly consume the per-command glob-fail cell
1164 // and short-circuit with status 1. Mirrors the external-path
1165 // guard at host_exec_external (line 5167). Without this:
1166 // `echo /never/*` would print empty (silently rolled back to ""
1167 // by the empty glob expansion). Parity bug #13.
1168 consume_tilde_globsubst_carrier();
1169 let glob_failed = with_executor(|exec| {
1170 let f = exec.current_command_glob_failed.get();
1171 exec.current_command_glob_failed.set(false); // c:1879 cleanup
1172 f
1173 });
1174 if glob_failed {
1175 // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH zerr sets
1176 // ERRFLAG_ERROR (via utils.c:184). For a BUILTIN command the
1177 // expansion runs IN the shell process, so errflag stays set
1178 // and the rest of the input aborts (zsh -fc 'echo /nope_*;
1179 // echo after' prints nothing after the error — verified
1180 // against zsh 5.9). The continue-after-nomatch behaviour
1181 // belongs ONLY to externals: C forks BEFORE expansion there,
1182 // so the child's zerr can't touch the parent's errflag (zsh
1183 // -fc 'ls /nope_*; echo after' prints `after`) — that path's
1184 // clear lives in fn exec / execute_external. Leave
1185 // ERRFLAG_ERROR set here; BUILTIN_ERREXIT_CHECK trigger 4
1186 // aborts the remaining script at the next command boundary.
1187 return 1; // c:1880 — command aborted, status 1
1188 }
1189 // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the NOMATCH
1190 // gate above: all of this command's globs failed silently (words
1191 // dropped, badcshglob accumulated 1s and no 2s) → `no match`,
1192 // skip the builtin, status 1. Like the NOMATCH path, ERRFLAG
1193 // from zerr stays set for builtins so the rest of the script
1194 // aborts (zsh -fc 'setopt cshnullglob; print *nope* x; print
1195 // after' prints only the error — verified zsh 5.9.1).
1196 if consume_badcshglob() {
1197 // c:Src/exec.c:3380 — `lastval = 1;` so the shell's final
1198 // exit status reflects the aborted command.
1199 with_executor(|exec| exec.set_last_status(1));
1200 return 1;
1201 }
1202 // c:Src/exec.c:4162-4295 — assignment-builtin (BINF_ASSIGN family:
1203 // typeset / declare / local / export / readonly / integer / float /
1204 // private) whose `name=value` postassign arg raised errflag while
1205 // its RHS was preforked (PREFORK_ASSIGN, c:4239-4245) — the classic
1206 // case is a math error in `typeset -F fv=$((1/0))`. The postassign
1207 // loop `break`s on errflag (c:4243) and then `if (!errflag)
1208 // execbuiltin(...)` (c:4287) SKIPS the builtin entirely, so `lastval`
1209 // is left UNCHANGED from before the command (0 fresh, 1 after
1210 // `false`). This differs from a PLAIN assignment `x=$((1/0))`, which
1211 // goes through execsimple c:1375 `lv = errflag ? errflag : cmdoutval`
1212 // → 1, and from a NON-assign builtin `print $((1/0))`, whose main
1213 // args-prefork errflag lands on c:3760 `lastval = 1`. Only the
1214 // assignment-BUILTIN postassign path preserves the prior status.
1215 // Mirror it here: the fusevm reg_passthru dispatch still calls us
1216 // with errflag set (unlike C's pre-invoke gate), so consume that
1217 // state and return the prior LASTVAL instead of running the builtin.
1218 {
1219 use std::sync::atomic::Ordering;
1220 let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
1221 let ef = live & crate::ported::zsh_h::ERRFLAG_ERROR;
1222 let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
1223 // Only the SOFT recoverable error (math failure like `$((1/0))`,
1224 // ERRFLAG_ERROR without ERRFLAG_HARD) preserves the prior status
1225 // per c:4287. A HARD error (`${var?msg}`, which c:Src/subst.c
1226 // OR's ERRFLAG_HARD onto errflag) is a script-abort that yields
1227 // status 1 regardless of the prior status — leave that to the
1228 // normal dispatch/abort path below (which returns 1 and keeps
1229 // ERRFLAG_HARD set for the downstream errexit gate).
1230 if ef != 0 && hard == 0 && builtin_is_assign_family(name) {
1231 // c:4287 — execbuiltin skipped; lastval unchanged.
1232 return crate::ported::builtin::LASTVAL.load(Ordering::Relaxed);
1233 }
1234 }
1235 if let Some(status) = try_user_fn_override(name, &args) {
1236 // c:Src/jobs.c:1748 waitonejob — canonical single-command
1237 // pipestats update via the no-procs else-branch.
1238 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1239 let mut synth = crate::ported::zsh_h::job::default();
1240 crate::ported::jobs::waitonejob(&mut synth);
1241 return status;
1242 }
1243 // c:Src/builtin.c:587 + Src/exec.c:3056 — a builtin disabled via
1244 // `disable <name>` has its `DISABLED` flag set in `builtintab`;
1245 // `builtintab->getnode` (the DISABLED-filtering accessor) returns
1246 // NULL for it at lookup time, so execcmd_exec falls through to
1247 // PATH lookup and runs the external. The Rust port stores the
1248 // disabled set in `BUILTINS_DISABLED`; the previous dispatcher
1249 // only checked the immutable `createbuiltintable` HashMap which
1250 // never reflects disablement — so `disable echo; echo hi` kept
1251 // running the bin_echo builtin. Bug #106 in docs/BUGS.md.
1252 //
1253 // dispatch_builtin (the high-level wrapper used by the BUILTIN_*
1254 // opcode handlers and reg_passthru! callsites) is the correct
1255 // gate: `dispatch_builtin_raw` is the low-level entry point
1256 // used by `bin_builtin` itself which MUST bypass the disabled
1257 // set (man zshbuiltins: `builtin name` runs the builtin
1258 // regardless of disable state). Place the check here so the
1259 // bypass path stays clean.
1260 let disabled = crate::ported::builtin::BUILTINS_DISABLED
1261 .lock()
1262 .map(|s| s.contains(name))
1263 .unwrap_or(false);
1264 if disabled {
1265 let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1266 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1267 let mut synth = crate::ported::zsh_h::job::default();
1268 crate::ported::jobs::waitonejob(&mut synth);
1269 return status;
1270 }
1271 // c:Src/Modules/files.c:806-814 — `mkdir`, `rm`, `mv`, `ln`, `chmod`,
1272 // `chown`, `chgrp`, `sync`, `rmdir` are bound by the `zsh/files`
1273 // module's boot_/setup_ chain. Without explicit `zmodload zsh/files`,
1274 // these bare names fall through to PATH (`/bin/rm`, `/usr/bin/chmod`,
1275 // etc.) in zsh; `type rm` reports `rm is /bin/rm`. The `zf_*`
1276 // aliases (`zf_rm`, `zf_chmod`, …) are bound by the same module
1277 // and gated the same way. Bug #28 in docs/BUGS.md.
1278 if module_gated_files_builtin(name) {
1279 if !crate::ported::module::MODULESTAB
1280 .lock()
1281 .unwrap()
1282 .is_loaded("zsh/files")
1283 {
1284 // PATH lookup uses the literal name. In --zsh parity mode
1285 // `zf_rm` must 127 like zsh -fc (no /bin/zf_rm); default
1286 // zshrs mode keeps the convenience zf_-strip so the alias
1287 // still reaches the system binary.
1288 let path_name = if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1289 name
1290 } else {
1291 name.strip_prefix("zf_").unwrap_or(name)
1292 };
1293 let status =
1294 with_executor(|exec| exec.execute_external(path_name, &args, &[])).unwrap_or(127);
1295 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1296 let mut synth = crate::ported::zsh_h::job::default();
1297 crate::ported::jobs::waitonejob(&mut synth);
1298 return status;
1299 }
1300 }
1301 // c:Src/exec.c:3997 `int q = queue_signal_level();`
1302 // c:Src/exec.c:4231 `dont_queue_signals();`
1303 // c:Src/exec.c:4243 `restore_queue_signals(q);`
1304 //
1305 // C runs EVERY builtin with signal queueing switched OFF. Two
1306 // consequences the zshrs port was missing:
1307 //
1308 // 1. `dont_queue_signals()` DRAINS the pending queue (it calls
1309 // run_queued_signals()), so a signal that arrived while an
1310 // enclosing scope held queue_signals() — doshfunc holds one
1311 // for the whole call, c:Src/exec.c:5835 — fires its trap at
1312 // the NEXT command boundary rather than at function exit.
1313 // 2. While the builtin runs, queueing stays off, so a signal the
1314 // builtin sends to itself (`kill -USR1 $$`) dispatches the
1315 // trap synchronously inside the builtin — which is why zsh
1316 // prints pre/trap/post for
1317 // `f() { print pre; kill -USR1 $$; print post }`.
1318 //
1319 // Without this bracket every trap raised inside a function was
1320 // deferred to the enclosing unqueue_signals() (i.e. script end).
1321 // c:Src/exec.c:3546 — `setunderscore((args && nonempty(args)) ?
1322 // ((char *) getdata(lastnode(args))) : "");`. execcmd_exec sets `$_`
1323 // to the last word of the command it is ABOUT to run — after the
1324 // words were expanded, before the builtin/external executes — so a
1325 // builtin that READS `_` at run time (`typeset -p _`, `${(P)…}`,
1326 // `$parameters[_]`) sees its own last argument, not the previous
1327 // command's. zshrs only did this for a handful of builtins (echo,
1328 // print, true, false, `:`) and for the external/function paths;
1329 // every reg_passthru! builtin was left reading the stale value.
1330 // C's `args` list carries argv[0], so a bare `cat` sets `_=cat` —
1331 // hence the fallback to `name` when there are no arguments.
1332 let underscore = args.last().cloned().unwrap_or_else(|| name.to_string()); // c:3546
1333 crate::ported::params::set_zunderscore(std::slice::from_ref(&underscore)); // c:3546
1334 let q = crate::ported::signals_h::queue_signal_level(); // c:3997
1335 crate::ported::signals_h::dont_queue_signals(); // c:4231
1336 let status = dispatch_builtin_raw(name, args);
1337 crate::ported::signals_h::restore_queue_signals(q); // c:4243
1338 // c:Src/jobs.c:1748 waitonejob — canonical single-command pipestats update.
1339 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1340 let mut synth = crate::ported::zsh_h::job::default();
1341 crate::ported::jobs::waitonejob(&mut synth);
1342 // c:Src/exec.c:4367-4386 — done: tail. A PSPECIAL builtin that
1343 // raised errflag under POSIX_BUILTINS exits the non-interactive
1344 // shell with status 1 ("hard error in POSIX" — e.g. bin_dot's
1345 // zerrnam at Src/builtin.c:6133). Arm the deferred-exit pair so
1346 // the next ERREXIT_CHECK unwinds; EXIT_VAL=1 matches C's
1347 // hardcoded exit(1), NOT the builtin's own status (dot returns
1348 // 127 but POSIX exits 1).
1349 if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
1350 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
1351 && builtin_is_pspecial(name)
1352 && (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
1353 & crate::ported::zsh_h::ERRFLAG_ERROR)
1354 != 0
1355 {
1356 use std::sync::atomic::Ordering;
1357 crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
1358 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
1359 }
1360 status
1361}
1362
1363/// c:Src/zsh.h:1467 BINF_PSPECIAL — true when `name` is a POSIX
1364/// special builtin per the canonical builtin table flags
1365/// (Src/builtin.c:48-129: `.`, `:`, break, continue, declare, eval,
1366/// exit, export, float, integer, local, readonly, return, set,
1367/// shift, source, times, trap, typeset, unset).
1368fn builtin_is_pspecial(name: &str) -> bool {
1369 crate::ported::builtin::createbuiltintable()
1370 .get(name)
1371 .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_PSPECIAL) != 0)
1372 .unwrap_or(false)
1373}
1374
1375/// c:Src/zsh.h:1486 BINF_ASSIGN — the assignment-builtin family
1376/// (typeset / declare / local / export / readonly / integer / float /
1377/// private). Their `name=value` args are handled as postassigns
1378/// (c:Src/exec.c:4162-4295), whose errflag-abort skips execbuiltin and
1379/// preserves the prior `lastval`. Read the flag straight from the
1380/// builtin table (same pattern as `builtin_is_pspecial`).
1381fn builtin_is_assign_family(name: &str) -> bool {
1382 crate::ported::builtin::createbuiltintable()
1383 .get(name)
1384 .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_ASSIGN) != 0)
1385 .unwrap_or(false)
1386}
1387
1388// The former `install_exec_hooks()` fn-pointer registry is gone. Code
1389// under `src/ported/` now reaches `ShellExecutor` operations
1390// (array/assoc storage, script eval, function dispatch, command
1391// substitution) through the `crate::ported::exec::*` accessor wrappers,
1392// which resolve the live executor via `try_with_executor`
1393// (`CURRENT_EXECUTOR`). The bridge lives in exec.rs — the sanctioned
1394// fusevm-access exception — per `feedback_no_exec_script_from_ported` /
1395// `feedback_no_shellexecutor_in_ported`.
1396
1397/// Register all zsh builtins with the VM.
1398pub(crate) fn register_builtins(vm: &mut fusevm::VM) {
1399 // src/ported/ reaches the live executor (param store, function
1400 // dispatch, nested script/cmdsubst exec) through the
1401 // `crate::ported::exec::*` accessor wrappers, which read
1402 // `CURRENT_EXECUTOR` via `try_with_executor`. No install step is
1403 // needed: the executor is in scope for the duration of any VM run
1404 // (set by `ExecutorContext::enter`), so the wrappers resolve it
1405 // directly. (Replaces the former `exec_hooks` OnceLock fn-ptr
1406 // registry, now deleted.)
1407 // Engage fusevm's tiered JIT (block + tracing) so hot, fully-eligible
1408 // numeric chunks run in native code and — with the `jit-disk-cache`
1409 // feature (on by default) — persist that native code to
1410 // `~/.cache/fusevm-jit`, letting repeated zsh invocations skip Cranelift
1411 // codegen. fusevm gates the JIT on per-chunk eligibility and warms up by
1412 // an invocation threshold, falling back to the interpreter for any chunk
1413 // it cannot compile (e.g. host-builtin/`Extended` command dispatch), so
1414 // enabling it here never changes observable behaviour — it only caches
1415 // the numeric hot path. Idempotent: re-enabling on each VM is a no-op.
1416 vm.enable_tracing_jit();
1417 // Macro for builtins that user functions are allowed to shadow.
1418 // zsh dispatch order is alias → function → builtin; without the
1419 // try_user_fn_override probe a `cat() { ... }; cat` would silently
1420 // run the C builtin and ignore the user function.
1421 macro_rules! reg_overridable {
1422 ($vm:expr, $id:expr, $name:literal, $method:ident) => {
1423 $vm.register_builtin($id, |vm, argc| {
1424 let args = pop_args(vm, argc);
1425 // c:Src/exec.c getproc + Src/jobs.c deletefilelist —
1426 // close `>(cmd)` write ends owned by this command
1427 // once it finishes (shadows bypass dispatch_builtin
1428 // and ZshrsHost::exec, so they need their own guard:
1429 // `tee >(wc -c) </dev/null` left wc blocked).
1430 let _psub_fds = PsubFdGuard;
1431 if let Some(s) = try_user_fn_override($name, &args) {
1432 return Value::Status(s);
1433 }
1434 // c:Src/exec.c — redirect failure in the current
1435 // scope means the command must NOT run. coreutils
1436 // shadows (cat / head / tail / etc.) take a separate
1437 // dispatch path from dispatch_builtin, so they need
1438 // their own gate. Without this `cat <&3` after a
1439 // closed-fd diagnostic still ran the shadow and
1440 // overwrote $? from the forced 1.
1441 let redir_failed = with_executor(|exec| {
1442 let f = exec.redirect_failed;
1443 exec.redirect_failed = false;
1444 f
1445 });
1446 if redir_failed {
1447 return Value::Status(1);
1448 }
1449 // `[builtins].coreutils_shadows = off` in
1450 // ~/.zshrs/zshrs.toml (or `ZSHRS_NO_COREUTILS_SHADOWS=1`
1451 // env override) bypasses the in-process shadow and
1452 // fork-execs the real /bin/X. Safety valve for any
1453 // script that hits an edge-case divergence between
1454 // the zshrs shadow and system coreutils. Cached
1455 // after first call, so the hot path is one atomic
1456 // load per shadowed-builtin invocation.
1457 // c:Src/exec.c:3545-3547 — these shadows stand in for
1458 // EXTERNAL commands (`cat`, `head`, …), which in zsh reach
1459 // execcmd_exec and set `$_` to the command's last word
1460 // before running. Both arms below bypass dispatch_builtin
1461 // AND execute_external_bg (the shadow runs in-process; the
1462 // opt-out arm spawns through exec_system_command), so
1463 // without this `cat f; print $_` reported the PREVIOUS
1464 // command's last argument.
1465 {
1466 let last = args.last().cloned().unwrap_or_else(|| $name.to_string());
1467 crate::ported::params::set_zunderscore(std::slice::from_ref(&last));
1468 // c:3546
1469 }
1470 if !crate::daemon_presence::coreutils_shadows_enabled() {
1471 return Value::Status(exec_system_command($name, &args));
1472 }
1473 let status = with_executor(|exec| exec.$method(&args));
1474 Value::Status(status)
1475 });
1476 };
1477 }
1478
1479 // Pure-passthru builtin: pops args, routes to canonical
1480 // `dispatch_builtin(name, args)` (which goes via execbuiltin →
1481 // BUILTINS[name] → bin_X). No pre/post bridge work. Used by
1482 // ~25 handlers that were 4-line copy-paste boilerplate.
1483 macro_rules! reg_passthru {
1484 ($vm:expr, $id:expr, $name:literal) => {
1485 $vm.register_builtin($id, |vm, argc| {
1486 let args = pop_args(vm, argc);
1487 // function > builtin: a same-named user function wins over
1488 // the builtin on the normal (CallBuiltin) invocation path.
1489 // The compiler's `user_function_shadow` already routes the
1490 // same-compile-unit case through CallFunction; this probe
1491 // extends that to the cross-unit / interactive case (define
1492 // `zstyle() { … }` on one line, call it on the next). The
1493 // forced `builtin NAME` / `command NAME` paths dispatch
1494 // through their own handlers, not this one, so they still
1495 // reach the builtin as required.
1496 if let Some(s) = try_user_fn_override($name, &args) {
1497 return Value::Status(s);
1498 }
1499 Value::Status(dispatch_builtin($name, args))
1500 });
1501 };
1502 }
1503
1504 // zshrs-original extension builtins (async / peach / doctor / …) that
1505 // route to an ExecutorContext method. Like `reg_overridable!`, they
1506 // probe `try_user_fn_override` FIRST so a user function of the same
1507 // name wins — zsh's alias → function → builtin dispatch order. Without
1508 // the probe, `doctor() { … }; doctor` silently ran the builtin and
1509 // ignored the function (function > builtin violated for these).
1510 macro_rules! reg_ext_overridable {
1511 ($vm:expr, $id:expr, $name:literal, $method:ident) => {
1512 $vm.register_builtin($id, |vm, argc| {
1513 let args = pop_args(vm, argc);
1514 if let Some(s) = try_user_fn_override($name, &args) {
1515 return Value::Status(s);
1516 }
1517 Value::Status(with_executor(|exec| exec.$method(&args)))
1518 });
1519 };
1520 }
1521
1522 // Core builtins
1523 vm.register_builtin(BUILTIN_CD, |vm, argc| {
1524 let args = pop_args(vm, argc);
1525 if let Some(s) = try_user_fn_override("cd", &args) {
1526 return Value::Status(s);
1527 }
1528 let status = dispatch_builtin("cd", args);
1529 // c:Src/builtin.c:1258 — `callhookfunc("chpwd", NULL, 1, NULL)`
1530 // after cd succeeds. The canonical port at
1531 // src/ported/utils.rs:1532 handles both the `chpwd` shfunc
1532 // dispatch AND the `chpwd_functions` array walk.
1533 if status == 0 {
1534 crate::ported::utils::callhookfunc("chpwd", None, 1, std::ptr::null_mut());
1535 }
1536 Value::Status(status)
1537 });
1538
1539 vm.register_builtin(BUILTIN_PWD, |vm, argc| {
1540 let args = pop_args(vm, argc);
1541 if let Some(s) = try_user_fn_override("pwd", &args) {
1542 return Value::Status(s);
1543 }
1544 // Route through the canonical execbuiltin path so the `rLP`
1545 // optstr at BUILTINS["pwd"] is parsed into `ops`.
1546 let status = dispatch_builtin("pwd", args);
1547 Value::Status(status)
1548 });
1549
1550 vm.register_builtin(BUILTIN_ECHO, |vm, argc| {
1551 let args = pop_args(vm, argc);
1552 if let Some(s) = try_user_fn_override("echo", &args) {
1553 return Value::Status(s);
1554 }
1555 // Update `$_` to the last arg before running. C zsh sets
1556 // zunderscore in execcmd_exec for every simple command,
1557 // including builtins.
1558 crate::ported::params::set_zunderscore(&args);
1559 let status = dispatch_builtin("echo", args);
1560 Value::Status(status)
1561 });
1562
1563 vm.register_builtin(BUILTIN_PRINT, |vm, argc| {
1564 let args = pop_args(vm, argc);
1565 if let Some(s) = try_user_fn_override("print", &args) {
1566 return Value::Status(s);
1567 }
1568 crate::ported::params::set_zunderscore(&args);
1569 let status = dispatch_builtin("print", args);
1570 Value::Status(status)
1571 });
1572
1573 reg_passthru!(vm, BUILTIN_PRINTF, "printf");
1574 reg_passthru!(vm, BUILTIN_EXPORT, "export");
1575 reg_passthru!(vm, BUILTIN_UNSET, "unset");
1576 // `source` (Src/builtin.c c:116) wired to bin_dot via BUILTINS.
1577 reg_passthru!(vm, BUILTIN_SOURCE, "source");
1578 reg_passthru!(vm, BUILTIN_DOT, ".");
1579 reg_passthru!(vm, BUILTIN_LOGOUT, "logout");
1580
1581 vm.register_builtin(BUILTIN_EXIT, |vm, argc| {
1582 let args = pop_args(vm, argc);
1583 let status = dispatch_builtin("exit", args);
1584 Value::Status(status)
1585 });
1586
1587 vm.register_builtin(BUILTIN_RETURN, |vm, argc| {
1588 let args = pop_args(vm, argc);
1589 // zsh: bare `return` (no arg) returns with the status of
1590 // the most recently executed command — `false; return`
1591 // returns 1, not 0. Direct port of zsh's bin_break/RETURN.
1592 // The executor's `last_status` is stale here (synced at
1593 // statement boundaries, not after each VM op), so read
1594 // the live `vm.last_status` instead.
1595 let live_status = vm.last_status;
1596 let status = {
1597 // Sync canonical LASTVAL to the VM's view BEFORE
1598 // bin_break("return") reads it for the no-arg fallback.
1599 with_executor(|exec| exec.set_last_status(live_status));
1600 dispatch_builtin("return", args)
1601 };
1602 Value::Status(status)
1603 });
1604
1605 vm.register_builtin(BUILTIN_TRUE, |vm, argc| {
1606 let args = pop_args(vm, argc);
1607 if let Some(s) = try_user_fn_override("true", &args) {
1608 return Value::Status(s);
1609 }
1610 // c:Src/exec.c:1257 — zsh sets `zunderscore` AT THE END of
1611 // each command (the `if (!noerrs)` block runs `zsfree(prev_argv0); …;
1612 // zunderscore = …`). For no-arg `true`, $_ becomes the
1613 // command name itself. Set DIRECTLY (not via pending_underscore)
1614 // so the NEXT command's argv-expansion of `$_` reads "true",
1615 // not the stale prior value — pending_underscore is consumed
1616 // by pop_args which runs AFTER argv expansion, too late.
1617 // c:Src/exec.c:1257 — `zunderscore = …` at end-of-command.
1618 // With args, $_ = args.last(). Without args, $_ = command name.
1619 // Write DIRECTLY to the canonical zunderscore static (the
1620 // underscoregetfn at params.rs:7003 reads from there); the
1621 // paramtab "_" slot is shadowed by lookup_special_var so
1622 // set_scalar on it has no effect on `$_` reads.
1623 if args.is_empty() {
1624 crate::ported::params::set_zunderscore(&["true".to_string()]);
1625 } else {
1626 crate::ported::params::set_zunderscore(&args);
1627 }
1628 // Route through canonical execbuiltin so PS4 xtrace fires
1629 // via the c:442 printprompt4 path.
1630 Value::Status(dispatch_builtin("true", args))
1631 });
1632 vm.register_builtin(BUILTIN_FALSE, |vm, argc| {
1633 let args = pop_args(vm, argc);
1634 if let Some(s) = try_user_fn_override("false", &args) {
1635 return Value::Status(s);
1636 }
1637 // Direct set; see BUILTIN_TRUE above for rationale.
1638 if args.is_empty() {
1639 crate::ported::params::set_zunderscore(&["false".to_string()]);
1640 } else {
1641 crate::ported::params::set_zunderscore(&args);
1642 }
1643 // Route through canonical execbuiltin — see BUILTIN_TRUE
1644 // above for the same rationale (xtrace + fast-path removal).
1645 let status = dispatch_builtin("false", args);
1646 Value::Status(status)
1647 });
1648 vm.register_builtin(BUILTIN_COLON, |vm, argc| {
1649 let args = pop_args(vm, argc);
1650 // Direct set; see BUILTIN_TRUE above for rationale.
1651 if args.is_empty() {
1652 crate::ported::params::set_zunderscore(&[":".to_string()]);
1653 } else {
1654 crate::ported::params::set_zunderscore(&args);
1655 }
1656 let status = dispatch_builtin(":", args);
1657 Value::Status(status)
1658 });
1659
1660 vm.register_builtin(BUILTIN_TEST, |vm, argc| {
1661 let args = pop_args(vm, argc);
1662 // Distinguish `[ … ]` from `test …` by sniffing the trailing
1663 // `]` — `[` requires it (c:Src/builtin.c:7241), `test` rejects
1664 // it. The compile path emits BUILTIN_TEST for both, so the
1665 // dispatch name carries the `[` vs `test` semantic for
1666 // execbuiltin's funcid (BIN_BRACKET=21 vs BIN_TEST=20). Without
1667 // this, bin_test's `if func == BIN_BRACKET` arm (which pops
1668 // the trailing `]`) never fired for `[` calls, so the `]`
1669 // leaked into evalcond as a positional and silently changed
1670 // the result. Bug surfaced via test_test_dashdash_unknown_condition.
1671 let name = if args.last().map(|s| s.as_str()) == Some("]") {
1672 "["
1673 } else {
1674 "test"
1675 };
1676 let status = dispatch_builtin(name, args);
1677 Value::Status(status)
1678 });
1679
1680 // Variable declaration. `local` (Src/builtin.c bin_local) handles
1681 // the scope chain (`pm->old = oldpm` at Src/params.c:1137 inside
1682 // createparam, `pm->level = locallevel` at Src/builtin.c:2576).
1683 // `typeset` / `declare` are aliases — fusevm maps both to
1684 // BUILTIN_TYPESET; compile_zsh special-cases `declare` to keep
1685 // the `declare:` error prefix.
1686 reg_passthru!(vm, BUILTIN_LOCAL, "local");
1687 reg_passthru!(vm, BUILTIN_TYPESET, "typeset");
1688
1689 reg_passthru!(vm, BUILTIN_DECLARE, "declare");
1690 reg_passthru!(vm, BUILTIN_READONLY, "readonly");
1691 reg_passthru!(vm, BUILTIN_INTEGER, "integer");
1692 reg_passthru!(vm, BUILTIN_FLOAT, "float");
1693 reg_passthru!(vm, BUILTIN_READ, "read");
1694 // c:Bug #504 — fusevm reserves BUILTIN_MAPFILE for the bash
1695 // mapfile/readarray builtins. Neither exists in zsh; in --zsh
1696 // parity mode the dispatch must emit "command not found" + rc=127
1697 // matching zsh's external-command-lookup miss. The previous wiring
1698 // left BUILTIN_MAPFILE unregistered, so fusevm's VM treated the op
1699 // as a no-op rc=0 — `mapfile` (and `readarray`) silently succeeded
1700 // in --zsh mode. The host gate in `dispatch_builtin_raw` never
1701 // fired because the compile path emitted `Op::CallBuiltin(31, ..)`
1702 // directly. Register the slot so the gate runs (or a future
1703 // non-zsh mode can wire in a real impl).
1704 vm.register_builtin(fusevm::shell_builtins::BUILTIN_MAPFILE, |vm, argc| {
1705 let args = pop_args(vm, argc);
1706 // The fusevm name→id map collapses both `mapfile` and
1707 // `readarray` to the same opcode; pick the right diagnostic
1708 // by sniffing the user's actual invocation. The xtrace ARGS
1709 // push earlier records the cmd-prefix as the bottom of the
1710 // popped argv, but `args` here excludes the prefix — so we
1711 // can't recover the user-typed name from the stack. Default
1712 // to `mapfile` (the more-common spelling); both produce
1713 // identical diagnostics in any case.
1714 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1715 eprintln!("zsh:1: command not found: mapfile");
1716 let _ = args;
1717 return Value::Status(127);
1718 }
1719 // Non-zsh modes (bash drop-in): mapfile / readarray reads lines
1720 // from stdin (or `-u fd`) into an array. Handled by the ported
1721 // bash builtin in ext_builtins.
1722 Value::Status(crate::extensions::ext_builtins::readarray(&args))
1723 });
1724 reg_passthru!(vm, BUILTIN_BREAK, "break");
1725 reg_passthru!(vm, BUILTIN_CONTINUE, "continue");
1726 reg_passthru!(vm, BUILTIN_SHIFT, "shift");
1727
1728 vm.register_builtin(BUILTIN_EVAL, |vm, argc| {
1729 // Direct port of `bin_eval(UNUSED(char *nam), char **argv, UNUSED(Options ops), UNUSED(int func))` body from Src/builtin.c:6151:
1730 // `if (!*argv) return 0;`
1731 // `prog = parse_string(zjoin(argv, ' ', 1), 1);`
1732 // `execode(prog, 1, 0, "eval");`
1733 // The execode invocation lives here (not in the canonical
1734 // free-fn) because it must run through the bytecode VM's
1735 // current executor — the same VM that's mid-dispatch.
1736 let mut args = pop_args(vm, argc);
1737 // c:Src/builtin.c:407-411 — generic `--` end-of-options
1738 // strip applied by `execbuiltin` for builtins that have
1739 // NULL optstr AND no BINF_HANDLES_OPTS. `eval` qualifies
1740 // (Src/builtin.c:65 `BUILTIN("eval", BINF_PSPECIAL, ...,
1741 // NULL, NULL)`). The BUILTIN_EVAL fast-path bypasses
1742 // execbuiltin, so we mirror the strip inline. Bug #319.
1743 if args.first().is_some_and(|s| s == "--") {
1744 args.remove(0);
1745 }
1746 if args.is_empty() {
1747 return Value::Status(0); // c:6160
1748 }
1749 let src = args.join(" "); // c:6166
1750 // c:Src/builtin.c:6164-6165 — `if (!ineval) scriptname =
1751 // "(eval)";`. Diagnostics emitted while the eval body runs
1752 // (command-not-found, parse errors, etc.) use scriptname as
1753 // the source-context prefix. Without setting it here the
1754 // BUILTIN_EVAL fast-path leaked the outer "zsh" prefix
1755 // through, breaking the `(eval):N:` convention zsh uses
1756 // for in-eval errors. Bug #420.
1757 // c:Src/builtin.c:6209 — `execode(prog, 1, 0, "eval");`. execode
1758 // (c:Src/exec.c:1245-1266) APPENDS its context argument to
1759 // `zsh_eval_context` for the duration of the body, so code inside
1760 // `eval` sees `cmdarg:eval` (and `cmdarg:shfunc:eval` when the eval is
1761 // in a function). zshrs pushed "shfunc" and, since #1065, "cmdsubst",
1762 // but never "eval". Popped on every return path by the guard, matching
1763 // execode's stack discipline. Bug #1065 (eval leg).
1764 let _eval_ctx_guard = crate::ported::exec::EvalContextFrame::push("eval");
1765 // c:Src/builtin.c:6163-6178 — `eval` pushes a funcstack frame named
1766 // "(eval)" (tp = FS_EVAL), gated on `ineval = !isset(EVALLINENO)` /
1767 // `if (!ineval)` — i.e. pushed when EVAL_LINENO is SET, which is the
1768 // zsh default. zshrs already set `scriptname = "(eval)"` (below) but
1769 // never pushed the frame, so `eval '…${#funcstack}'` reported 0 where
1770 // zsh reports 1, and inside a function `${(j:,:)funcstack}` was `f`
1771 // rather than `(eval),f`. Both shells already agreed under
1772 // `unsetopt evallineno` (no frame), so the option gate is load-bearing
1773 // and is mirrored here. Popped on every return path by the guard.
1774 // Bug #1066.
1775 // The push itself is the canonical port (`EvalFuncstackFrame::push`,
1776 // exec.rs, c:6155-6193) — shared with `eval_string`, which the
1777 // compsys `_dispatch` port uses for its `eval "$comp"` sites so both
1778 // eval entry points produce a byte-identical `(eval)` frame. It was
1779 // inline here and set `lineno`/`flineno` to 0 with no `filename`,
1780 // which C computes at c:6161 / c:6174-6188 — so `$functrace` read
1781 // `<caller>:0` and `$funcfiletrace` lost the defining file.
1782 let _eval_fs_guard = crate::ported::exec::EvalFuncstackFrame::push();
1783 let oscriptname = crate::ported::utils::scriptname_get();
1784 crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
1785 // Recursion backstop — c:Src/jobs.c:1878-1884. zsh caps eval recursion
1786 // via its job table (every eval'd pipeline grabs a job slot; the table
1787 // caps at MAX_MAXJOBS → "job table full or recursion limit exceeded").
1788 // The fusevm runtime allocates no job per pipeline, and nested evals
1789 // push no funcstack frame (INEVAL suppression, c:6164), so eval nesting
1790 // is invisible to both the job table AND FUNCNEST/FUNCSTACK — runaway
1791 // `eval`-string recursion overflowed the 256 MB main-thread stack →
1792 // uncatchable SIGBUS. Track eval re-entry depth (the Rust proxy for
1793 // held job slots) and refuse at the same MAX_MAXJOBS ceiling.
1794 let eval_depth = crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| {
1795 let v = d.get() + 1;
1796 d.set(v);
1797 v
1798 });
1799 let mut status = if eval_depth >= crate::ported::jobs::MAX_MAXJOBS {
1800 crate::ported::utils::zerr("job table full or recursion limit exceeded");
1801 1
1802 } else {
1803 with_executor(|exec| {
1804 // c:6175 execode
1805 exec.execute_script(&src).unwrap_or(1)
1806 })
1807 };
1808 crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
1809 // c:Src/builtin.c:6211-6212 — `if (errflag && !lastval)
1810 // lastval = errflag;`
1811 // c:Src/builtin.c:6221 — `errflag &= ~ERRFLAG_ERROR;`
1812 // eval is a CONTAINMENT boundary: an error inside the eval
1813 // body (readonly reassign, bad assoc set, ${unset?msg}, …)
1814 // breaks the eval body's lists via errflag, then eval clears
1815 // the flag and returns lastval, and the CALLER's next list
1816 // runs. zsh 5.9: `eval 'assoc=(odd)'; echo "after $?"`
1817 // prints `after 1` in -c, script, and stdin contexts.
1818 {
1819 use std::sync::atomic::Ordering;
1820 let ef = crate::ported::utils::errflag.load(Ordering::Relaxed)
1821 & crate::ported::zsh_h::ERRFLAG_ERROR;
1822 if ef != 0 && status == 0 {
1823 status = ef; // c:6212 lastval = errflag
1824 }
1825 crate::ported::utils::errflag
1826 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
1827 }
1828 crate::ported::utils::set_scriptname(oscriptname);
1829 Value::Status(status)
1830 });
1831
1832 // `builtin foo args…`: precmd-modifier that forces builtin dispatch,
1833 // bypassing alias AND function lookup. Without this, `builtin cd /`
1834 // inside a user `cd () { … }` wrapper recurses (real-world ZPWR pattern).
1835 // Handler pops argc args from the stack, treats args[0] as the builtin
1836 // name, and dispatches the rest via `dispatch_builtin` → `execbuiltin`
1837 // → `bin_*` directly. No function/alias lookup happens.
1838 vm.register_builtin(BUILTIN_BUILTIN, |vm, argc| {
1839 let args = pop_args(vm, argc);
1840 // c:Src/exec.c:3483-3487 — the precommand-modifier walk checks
1841 // `shfunctab` for the command word BEFORE `builtintab`, and only
1842 // skips that check once a prefix has already been consumed
1843 // (`cflags & (BINF_BUILTIN|BINF_COMMAND)`). So on the FIRST word a
1844 // shell function literally named `builtin` shadows the builtin —
1845 // `builtin() { ... }; builtin whence -va x` runs the function.
1846 // zshrs resolves `builtin` at bytecode-compile time (no function
1847 // table yet), so the shadow test has to happen here, at dispatch.
1848 if let Some(status) = try_user_fn_override("builtin", &args) {
1849 return Value::Status(status);
1850 }
1851 let Some((name, rest)) = args.split_first() else {
1852 // `builtin` with no args → list builtins (zsh emits nothing,
1853 // exit 0). Match that behavior; the BIN_BUILTIN bin_* in C
1854 // does the same default-list-nothing.
1855 return Value::Status(0);
1856 };
1857 // zshrs extension builtins (daemon z* family: zd, zcache, zjob,
1858 // …) are dispatched by name via try_dispatch instead of living
1859 // in builtintab — but they ARE builtins, so the `builtin`
1860 // precommand must reach them (`builtin zd ping` errored
1861 // "no such builtin: zd" while bare `zd ping` worked).
1862 if crate::daemon::builtins::is_zshrs_builtin(name) {
1863 let argv: Vec<String> = std::iter::once(name.to_string())
1864 .chain(rest.iter().cloned())
1865 .collect();
1866 return Value::Status(crate::daemon::builtins::try_dispatch(name, &argv).unwrap_or(1));
1867 }
1868 // c:Src/exec.c:3435-3436 — `builtin NAME` with NAME not in
1869 // builtintab emits `zwarn("no such builtin: %s", cmdarg)`
1870 // and returns 1. zshrs's dispatch_builtin_raw bare-returned 1
1871 // silently. Probe the table here so the diagnostic fires
1872 // before dispatch.
1873 let tab = crate::ported::builtin::createbuiltintable();
1874 if !tab.contains_key(name.as_str()) {
1875 // zshrs-original opcode builtins (async, doctor, peach, …) aren't
1876 // in builtintab; `builtin NAME` must still reach them.
1877 if let Some(status) = try_run_registered_builtin(name, rest) {
1878 return Value::Status(status);
1879 }
1880 // c:Src/exec.c:3436 — `zwarn("no such builtin: %s", cmdarg);`.
1881 // Route through the ported `zwarn` rather than formatting the
1882 // prefix by hand: zwarn emits zsh's `zsh:LINE:` prefix, and the
1883 // hand-rolled `eprintln!` here printed `zshrs:1:` instead. Of the
1884 // twelve error shapes probed this was the ONLY one carrying the
1885 // wrong prefix — the ported twin at exec.rs:9878 already used
1886 // zwarn correctly, so this was a reimplementation shadowing a
1887 // faithful port (same shape as #1027 / #1031 / #1044 / #1050).
1888 // Bug #1063.
1889 crate::ported::utils::zwarn(&format!("no such builtin: {}", name));
1890 return Value::Status(1);
1891 }
1892 // `builtin foo` MUST bypass function shadow — that's the whole
1893 // point of the prefix. Use the _raw helper, not the shadow-aware
1894 // one. Without this, `cd () { builtin cd "$@"; }` recurses.
1895 Value::Status(dispatch_builtin_raw(name, rest.to_vec()))
1896 });
1897
1898 // `command foo args…` — BINF_COMMAND prefix (Src/builtin.c:44). Zsh
1899 // semantic: bypass alias+function lookup, search builtin then $PATH.
1900 // Without this, `cd () { command cd "$@" }` would re-invoke the user
1901 // wrapper (same root cause as the `builtin` bug). Flags `-p`/`-v`/`-V`
1902 // route to bin_whence with BIN_COMMAND funcid; bare `command foo`
1903 // dispatches builtin if present, else external (no fork — direct
1904 // spawn via execute_external since zshrs is non-forking).
1905 // BUILTIN_COMMAND — `command [-p] [-v|-V] cmd args…` BIN_PREFIX
1906 // (Src/builtin.c:45). PURE PASSTHRU: prepend "command" and hand
1907 // to `exec::execcmd_compile_head` (the fusevm-bytecode-time head
1908 // resolver mirroring `Src/exec.c::execcmd_exec` precommand-modifier
1909 // walk at c:3104-3187). That helper already does the -p / -v / -V
1910 // option parsing, surfaces `has_command_vv` for the whence
1911 // redirect, and reports the dispatch shape (is_builtin vs external).
1912 vm.register_builtin(BUILTIN_COMMAND, |vm, argc| {
1913 let args = pop_args(vm, argc);
1914 // c:Src/exec.c:3483-3487 — same shfunctab-before-builtintab rule
1915 // as BUILTIN_BUILTIN above: a shell function named `command`
1916 // shadows the `command` precommand modifier on the first word.
1917 if let Some(status) = try_user_fn_override("command", &args) {
1918 return Value::Status(status);
1919 }
1920 let mut full = Vec::with_capacity(args.len() + 1);
1921 full.push("command".to_string());
1922 full.extend(args.clone());
1923 let dispatch =
1924 crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
1925 let post = &full[dispatch.precmd_skip..];
1926 // c:Src/builtin.c:4500 — `command -p` resets PATH for the
1927 // exec to the POSIX-defined default (`getconf PATH`), so
1928 // standard utilities resolve even when the caller has
1929 // emptied $PATH. zsh restores the original PATH after the
1930 // command returns. Mirror via a scoped env::set_var.
1931 //
1932 // command's OWN options end at the first non-flag arg —
1933 // everything after the command name belongs to IT. The
1934 // previous `.any()` scan over ALL args stole `-p` from
1935 // `command mkdir -p DIR` (zconvey.plugin.zsh:44), stripping
1936 // the flag before /bin/mkdir ran → "File exists" errors on
1937 // every re-source.
1938 let mut lead = 0usize;
1939 let mut dash_p = false;
1940 let mut kept_flags: Vec<String> = Vec::new();
1941 for a in post.iter() {
1942 let s = a.as_str();
1943 if s == "--" {
1944 lead += 1;
1945 break;
1946 }
1947 if s.starts_with('-')
1948 && s.len() >= 2
1949 && s[1..].chars().all(|c| c == 'p' || c == 'v' || c == 'V')
1950 {
1951 if s.contains('p') {
1952 dash_p = true;
1953 }
1954 // -v / -V drive the whence-style lookup downstream —
1955 // keep them in post (only the PATH-reset `p` is
1956 // consumed here).
1957 let rest: String = s[1..].chars().filter(|c| *c != 'p').collect();
1958 if !rest.is_empty() {
1959 kept_flags.push(format!("-{}", rest));
1960 }
1961 lead += 1;
1962 continue;
1963 }
1964 break;
1965 }
1966 let mut post: Vec<String> = {
1967 let mut v = kept_flags;
1968 v.extend(post[lead..].iter().cloned());
1969 v
1970 };
1971 // c:Src/exec.c:3176-3177 — `BINF_COMMAND` arm strips a single
1972 // leading `--` end-of-options marker.
1973 // `execcmd_compile_head` (src/ported/exec.rs:1042) performs
1974 // this removal on its LOCAL `preargs` Vec but doesn't surface
1975 // the modified args; the caller still sees `--` in `full` and
1976 // tried to dispatch it as the command name. Bug #251. Mirror
1977 // the C strip here so `command -- echo hi` and
1978 // `command -p -- echo hi` route correctly.
1979 if let Some(first) = post.first() {
1980 if first == "--" {
1981 post.remove(0);
1982 }
1983 }
1984 let post = post.as_slice();
1985 let _path_guard = if dash_p {
1986 let saved = env::var("PATH").ok();
1987 let default_path = std::process::Command::new("getconf")
1988 .arg("PATH")
1989 .output()
1990 .ok()
1991 .and_then(|o| String::from_utf8(o.stdout).ok())
1992 .map(|s| s.trim().to_string())
1993 .filter(|s| !s.is_empty())
1994 .unwrap_or_else(|| "/usr/bin:/bin:/usr/sbin:/sbin".to_string());
1995 env::set_var("PATH", &default_path);
1996 crate::ported::params::setsparam("PATH", &default_path);
1997 Some(saved)
1998 } else {
1999 None
2000 };
2001 struct PathGuard {
2002 saved: Option<String>,
2003 active: bool,
2004 }
2005 impl Drop for PathGuard {
2006 fn drop(&mut self) {
2007 if !self.active {
2008 return;
2009 }
2010 match self.saved.take() {
2011 Some(p) => {
2012 env::set_var("PATH", &p);
2013 crate::ported::params::setsparam("PATH", &p);
2014 }
2015 None => {
2016 env::remove_var("PATH");
2017 crate::ported::params::setsparam("PATH", "");
2018 }
2019 }
2020 }
2021 }
2022 let _restore = PathGuard {
2023 saved: _path_guard.unwrap_or(None),
2024 active: dash_p,
2025 };
2026 if dispatch.has_command_vv {
2027 // `-v` / `-V` → bin_whence with BIN_COMMAND funcid.
2028 let mut ops = options {
2029 ind: [0u8; MAX_OPS],
2030 args: Vec::new(),
2031 argscount: 0,
2032 argsalloc: 0,
2033 };
2034 let mut name_pos = 0usize;
2035 let mut flag_byte = b'v';
2036 for (i, a) in post.iter().enumerate() {
2037 if a.starts_with('-') && a.len() >= 2 {
2038 let body = &a.as_bytes()[1..];
2039 if body.contains(&b'V') {
2040 flag_byte = b'V';
2041 }
2042 name_pos = i + 1;
2043 } else {
2044 name_pos = i;
2045 break;
2046 }
2047 }
2048 ops.ind[flag_byte as usize] = 1;
2049 let whence_args: Vec<String> = post[name_pos..].to_vec();
2050 return Value::Status(crate::ported::builtin::bin_whence(
2051 "command",
2052 &whence_args,
2053 &ops,
2054 crate::ported::hashtable_h::BIN_COMMAND,
2055 ));
2056 }
2057 if dispatch.is_empty_command {
2058 return Value::Status(0);
2059 }
2060 let Some((name, rest)) = post.split_first() else {
2061 return Value::Status(0);
2062 };
2063 // c:Src/exec.c:3275-3278 — `execcmd_compile_head` cleared
2064 // hn for the BINF_COMMAND + !POSIXBUILTINS case, surfacing
2065 // is_builtin=false. Run as external. Under POSIXBUILTINS
2066 // dispatch.is_builtin would be true; honour it.
2067 let n = name.clone();
2068 let r = rest.to_vec();
2069 if dispatch.is_builtin
2070 && crate::ported::builtin::BUILTINS
2071 .iter()
2072 .any(|b| b.node.nam == n.as_str())
2073 {
2074 return Value::Status(dispatch_builtin_raw(&n, r));
2075 }
2076 // c:Src/exec.c:3275-3278 — `command NAME` asks for the thing on
2077 // `PATH`, not the in-process one. The host-registered native commands
2078 // (`extensions/native_cmds.rs`) are caught inside `execute_external`,
2079 // which is this very call, so they are marked as explicitly forced
2080 // past for its duration — matching what `command cat` already does to
2081 // the coreutils shadow.
2082 let _forced = crate::native_cmds::force_external();
2083 Value::Status(with_executor(|exec| exec.execute_external(&n, &r, &[])).unwrap_or(127))
2084 });
2085
2086 // `exec cmd args…` — BINF_EXEC prefix (Src/builtin.c:45). Zsh
2087 // semantic: replace the current shell process with `cmd`. On Unix
2088 // this is `execvp(2)`; the call only returns on error. zshrs is
2089 // non-forking, so the shell process IS the calling process —
2090 // execvp here directly replaces it. Options `-a name` (override
2091 // argv[0]), `-c` (clean env), `-l` (login shell — prepend `-`)
2092 // ported minimally; advanced redirect-only `exec >file` is handled
2093 // upstream by compile_zsh and never reaches this handler.
2094 vm.register_builtin(BUILTIN_EXEC, |vm, argc| {
2095 let mut args = pop_args(vm, argc);
2096 let mut argv0_override: Option<String> = None;
2097 let mut clean_env = false;
2098 let mut login = false;
2099 let mut i = 0;
2100 // c:Src/builtin.c:1075-1080 — track if any flag was consumed.
2101 // `exec -c`, `exec -l`, `exec -a NAME` without a following
2102 // command emit "exec requires a command to execute" rc=1.
2103 // Bare `exec` (no args at all) is the silent-redirect-apply
2104 // form per POSIX.
2105 let mut saw_flag = false;
2106 while i < args.len() {
2107 let a = &args[i];
2108 if a == "--" {
2109 args.remove(i);
2110 break;
2111 }
2112 // c:Src/builtin.c:42 `BIN_PREFIX("-", BINF_DASH)`. A bare
2113 // `-` is its own BINF_PREFIX builtin (BINF_DASH flag —
2114 // "login shell, prepend `-` to argv[0]"). In the canonical
2115 // precmd-walk at Src/exec.c:3056-3091 a bare `-` after
2116 // `exec` is recognized AS a builtin and stripped from
2117 // preargs (precmd_skip++), accumulating BINF_DASH into
2118 // cflags. The fast-path here bypasses execcmd_compile_head,
2119 // so we mirror the strip locally: bare `-` → set login,
2120 // remove, continue. Without this `exec -` (with no command
2121 // following) tried to exec `-` as a literal command and
2122 // exited the shell. Bug #252.
2123 if a == "-" {
2124 saw_flag = true;
2125 login = true;
2126 args.remove(i);
2127 continue;
2128 }
2129 if !a.starts_with('-') || a.len() < 2 {
2130 break;
2131 }
2132 match a.as_str() {
2133 // c:Src/exec.c:3268-3273 — the exec flag word is scanned
2134 // CHARACTER by character (`for (cmdopt = &argdata[1];
2135 // *cmdopt; ++cmdopt)`), and `case 'a'` takes the REST OF
2136 // THE SAME WORD when there is one:
2137 // if (cmdopt[1]) { exec_argv0 = cmdopt+1;
2138 // cmdopt += strlen(cmdopt+1); }
2139 // Matching whole words only left `exec -a/bin/SPLOOSH
2140 // /bin/sh -c '…'` (A01grammar.ztst:135) treating the flag
2141 // word itself as the command name.
2142 inline_a if inline_a.starts_with("-a") && inline_a.len() > 2 => {
2143 saw_flag = true;
2144 argv0_override = Some(inline_a[2..].to_string()); // c:3269
2145 args.remove(i);
2146 }
2147 "-a" => {
2148 saw_flag = true;
2149 args.remove(i);
2150 if i < args.len() {
2151 argv0_override = Some(args.remove(i));
2152 }
2153 }
2154 "-c" => {
2155 saw_flag = true;
2156 clean_env = true;
2157 args.remove(i);
2158 }
2159 "-l" => {
2160 saw_flag = true;
2161 login = true;
2162 args.remove(i);
2163 }
2164 _ => {
2165 // c:Src/exec.c:3196-3208 — when an unrecognized
2166 // `-X`-style arg has NO following arg, the lexer's
2167 // IS_DASH walk hits the "no next node" branch at
2168 // c:3199 before the unknown-flag-letter switch at
2169 // c:3249, so the canonical message is "exec
2170 // requires a command to execute" rc=1 (verified vs
2171 // `/opt/homebrew/bin/zsh -fc 'exec --bad'`).
2172 // Consume the lone flag so the post-loop check
2173 // fires. When a following arg exists, leave the
2174 // unknown-flag arg in place — that arg becomes
2175 // the command name and execution proceeds.
2176 if args.len() == 1 {
2177 saw_flag = true;
2178 args.remove(i);
2179 continue;
2180 }
2181 break;
2182 }
2183 }
2184 }
2185 let Some(cmd) = args.first().cloned() else {
2186 if saw_flag {
2187 // c:Src/builtin.c:1078-1080 — flags consumed but no
2188 // command follows → "exec requires a command to
2189 // execute" rc=1.
2190 eprintln!("zshrs:1: exec requires a command to execute");
2191 return Value::Status(1);
2192 }
2193 // `exec` with no command + no redirects = no-op success.
2194 return Value::Status(0);
2195 };
2196 let rest: Vec<String> = args[1..].to_vec();
2197 let display_argv0 = match argv0_override {
2198 Some(a) => a,
2199 None => {
2200 if login {
2201 format!("-{}", cmd)
2202 } else {
2203 cmd.clone()
2204 }
2205 }
2206 };
2207 // c:Src/exec.c:3468/3582 — execcmd bails out before running anything
2208 // once a redirection has failed: the failure calls zerr, which sets
2209 // errflag, and both bail-outs test it. `exec` is not exempt, so
2210 // exec ls 3>&98; print after
2211 // in zsh reports the bad fd, does NOT run ls, and the SHELL SURVIVES
2212 // to run `print after`. zshrs consumed the flag in
2213 // BUILTIN_EXEC_PERM_REDIRS (returning status 1) but then dispatched
2214 // the command regardless — replacing the shell with it, so anything
2215 // after the exec never ran, and `exec 99>&98` reported a spurious
2216 // `command not found: 99` for the leftover fd word.
2217 if with_executor(|exec| {
2218 let f = exec.redirect_failed;
2219 exec.redirect_failed = false;
2220 f
2221 }) {
2222 vm.last_status = 1;
2223 return Value::Status(1);
2224 }
2225
2226 // c:Src/exec.c::execcmd — `exec funcname` runs the function
2227 // in-process as the shell's last act, then exits with the
2228 // function's status. zsh's dispatcher falls through from the
2229 // BINF_EXEC prefix into the normal Builtin/External/Function
2230 // resolution and only execvp's if the target ISN'T a
2231 // function. Bug #101 in docs/BUGS.md: zshrs's exec went
2232 // straight to execvp and errored `not found` for shell
2233 // functions.
2234 //
2235 // For both subshell and top-level contexts: dispatch through
2236 // the function/builtin lookup first; only fall through to
2237 // execvp/spawn if the name isn't shell-resolvable.
2238 let has_user_fn = with_executor(|exec| exec.functions_compiled.contains_key(&cmd));
2239 if has_user_fn {
2240 let status =
2241 with_executor(|exec| exec.dispatch_function_call(&cmd, &rest).unwrap_or(127));
2242 // Top-level `exec funcname` — exit the shell with the
2243 // function's status (mirrors C's "exec replaces shell as
2244 // last act"). Subshell `(exec funcname)` — return through
2245 // the EXIT_PENDING path so the subshell body aborts and
2246 // the parent resumes via subshell_end.
2247 let in_subshell_now = with_executor(|exec| !exec.subshell_snapshots.is_empty());
2248 if in_subshell_now {
2249 crate::ported::builtin::EXIT_VAL
2250 .store(status, std::sync::atomic::Ordering::Relaxed);
2251 crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
2252 return Value::Status(status);
2253 }
2254 std::process::exit(status);
2255 }
2256 // c:Src/exec.c — builtin path: `exec builtin` runs the
2257 // builtin in-process and exits.
2258 let bn_in_tab = crate::ported::builtin::createbuiltintable().contains_key(&cmd);
2259 if bn_in_tab {
2260 let status = dispatch_builtin_raw(&cmd, rest.clone());
2261 let in_subshell_now = with_executor(|exec| !exec.subshell_snapshots.is_empty());
2262 if in_subshell_now {
2263 crate::ported::builtin::EXIT_VAL
2264 .store(status, std::sync::atomic::Ordering::Relaxed);
2265 crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
2266 return Value::Status(status);
2267 }
2268 std::process::exit(status);
2269 }
2270 // c:Src/exec.c — `exec` inside a subshell (`(exec cmd)`)
2271 // replaces ONLY the subshell child process; the parent shell
2272 // continues. C zsh always forks for `(...)`, so the actual
2273 // execvp lands in the forked child. zshrs runs subshells via
2274 // a snapshot/restore pattern in the SAME process — calling
2275 // execvp here would replace the parent too. Bug #94 in
2276 // docs/BUGS.md.
2277 //
2278 // Detect subshell context via the non-empty
2279 // `subshell_snapshots` stack. When in a subshell: spawn the
2280 // command as a child, wait for it, then signal the subshell
2281 // body to abort (return Status(N) and the caller's
2282 // subshell_end will pop the snapshot and resume the parent).
2283 let in_subshell = with_executor(|exec| !exec.subshell_snapshots.is_empty());
2284 if in_subshell {
2285 let mut command = std::process::Command::new(&cmd);
2286 command.arg0(&display_argv0);
2287 command.args(&rest);
2288 if clean_env {
2289 command.env_clear();
2290 }
2291 // Queue signals across spawn+wait so the SIGCHLD reaper
2292 // can't reap this child before child.wait() does — see
2293 // ForegroundWaitGuard.
2294 let _wait_guard = ForegroundWaitGuard::enter();
2295 let status = match command.spawn() {
2296 Ok(mut child) => match child.wait() {
2297 Ok(s) => s.code().unwrap_or(127),
2298 Err(_) => 127,
2299 },
2300 Err(e) => {
2301 // c:Src/exec.c:797 — `zerr("%e: %s", lerrno, arg0)`
2302 // when arg0 contains `/`.
2303 // c:872-876 — when arg0 has no `/` (PATH search
2304 // path), C tracks the "good" errno
2305 // via `isgooderr`; if all PATH entries
2306 // were ENOENT-not-good, eno stays 0
2307 // and C emits `command not found: %s`
2308 // instead of strerror.
2309 // %e expands to strerror(errno) with the first
2310 // letter lowercased (unless errno == EIO; see
2311 // Src/utils.c:362-368). `zerr` prepends the
2312 // scriptname:lineno: prefix — matching zsh's
2313 // canonical `zsh:N: <errmsg>: <cmd>` pattern.
2314 // Previously emitted `zshrs: exec: {}: not found`
2315 // (wrong prefix, hardcoded message, missing
2316 // lineno). Bug #140 in docs/BUGS.md.
2317 let errno = e.raw_os_error().unwrap_or(libc::ENOENT);
2318 let has_slash = cmd.contains('/');
2319 if !has_slash && errno == libc::ENOENT {
2320 // c:876 — PATH search exhausted with no good
2321 // errno → `command not found: arg0`.
2322 crate::ported::utils::zerr(&format!("command not found: {}", cmd));
2323 } else {
2324 let mut errmsg = crate::ported::compat::strerror(errno);
2325 if errno != libc::EIO {
2326 if let Some(c) = errmsg.chars().next() {
2327 errmsg = format!(
2328 "{}{}",
2329 c.to_ascii_lowercase(),
2330 &errmsg[c.len_utf8()..]
2331 );
2332 }
2333 }
2334 crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
2335 }
2336 // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
2337 if errno == libc::EACCES || errno == libc::ENOEXEC {
2338 126
2339 } else {
2340 127
2341 }
2342 }
2343 };
2344 // Mark the subshell as exec-replaced so subsequent body
2345 // commands skip — mirrors the post-execvp "child process
2346 // is gone" reality in C. EXIT_PENDING + EXIT_VAL drive
2347 // the next ERREXIT_CHECK to unwind to the subshell-end
2348 // patch.
2349 crate::ported::builtin::EXIT_VAL.store(status, std::sync::atomic::Ordering::Relaxed);
2350 crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
2351 return Value::Status(status);
2352 }
2353 let mut command = std::process::Command::new(&cmd);
2354 command.arg0(&display_argv0);
2355 command.args(&rest);
2356 if clean_env {
2357 command.env_clear();
2358 }
2359 use std::os::unix::process::CommandExt;
2360 // `exec` returns the OS error iff exec(2) failed; on success
2361 // it never returns. Match zsh: print the error to stderr with
2362 // the `exec` prefix and exit 127 (cmd not found) or 126 (not
2363 // executable).
2364 let err = command.exec();
2365 // c:Src/exec.c:797 / c:872-876 — same format as in-subshell
2366 // branch. arg0-has-/ → `<strerror>: <cmd>`; arg0-no-/ +
2367 // ENOENT → `command not found: <cmd>`. Lowercase strerror
2368 // first letter unless EIO. Bug #140 in docs/BUGS.md.
2369 let errno = err.raw_os_error().unwrap_or(libc::ENOENT);
2370 let has_slash = cmd.contains('/');
2371 if !has_slash && errno == libc::ENOENT {
2372 crate::ported::utils::zerr(&format!("command not found: {}", cmd));
2373 } else {
2374 let mut errmsg = crate::ported::compat::strerror(errno);
2375 if errno != libc::EIO {
2376 if let Some(c) = errmsg.chars().next() {
2377 errmsg = format!("{}{}", c.to_ascii_lowercase(), &errmsg[c.len_utf8()..]);
2378 }
2379 }
2380 crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
2381 }
2382 // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
2383 let code = if errno == libc::EACCES || errno == libc::ENOEXEC {
2384 126
2385 } else {
2386 127
2387 };
2388 std::process::exit(code);
2389 });
2390
2391 reg_passthru!(vm, BUILTIN_LET, "let");
2392
2393 // Job control
2394 reg_passthru!(vm, BUILTIN_JOBS, "jobs");
2395 reg_passthru!(vm, BUILTIN_FG, "fg");
2396 reg_passthru!(vm, BUILTIN_BG, "bg");
2397 reg_passthru!(vm, BUILTIN_KILL, "kill");
2398 reg_passthru!(vm, BUILTIN_DISOWN, "disown");
2399 reg_passthru!(vm, BUILTIN_WAIT, "wait");
2400 reg_passthru!(vm, BUILTIN_SUSPEND, "suspend");
2401
2402 // History — `fc` / `history` / `r` all route to `bin_fc` (zsh
2403 // registers them as aliases of the same builtin per Src/builtin.c).
2404 reg_passthru!(vm, BUILTIN_FC, "fc");
2405 reg_passthru!(vm, BUILTIN_HISTORY, "history");
2406 reg_passthru!(vm, BUILTIN_R, "r");
2407
2408 // Aliases — alias is `BINF_MAGICEQUALS` per Src/builtin.c:50.
2409 // c:Src/exec.c:3298-3304 — when a builtin has BINF_MAGICEQUALS,
2410 // execcmd_exec sets esprefork = PREFORK_TYPESET and calls
2411 // `prefork(args, esprefork, NULL)` on the argv. prefork (subst.c:
2412 // 100) drives `filesub` on each word (c:133), which (c:677-686)
2413 // looks for the assignment Equals and runs `filesubstr` on the
2414 // VALUE side. That's how `alias bad===` triggers equalsubstr's
2415 // "= not found" via the inner Equals after the first `=`.
2416 //
2417 // The fusevm dispatch path doesn't go through execcmd_exec, so
2418 // BUILTIN_ALIAS previously passed args straight to bin_alias with
2419 // no expansion — `alias x=~/foo` stored literal `~/foo` (no tilde
2420 // expand), `alias bad===` stored a broken entry without firing
2421 // the "= not found" diagnostic. The prefork(PREFORK_TYPESET) runs
2422 // per arg word via BUILTIN_MAGIC_EQUALS_PREFORK ops that
2423 // compile_simple emits BEFORE the redirect scope opens (matching
2424 // c:3304 prefork-before-addfd order), so the dispatch here is a
2425 // plain passthrough — re-running prefork would double-fire the
2426 // "= not found" diagnostic.
2427 reg_passthru!(vm, BUILTIN_ALIAS, "alias");
2428 // c:Src/exec.c:3298-3304 — per-word magic-equals prefork; see the
2429 // const doc at BUILTIN_MAGIC_EQUALS_PREFORK. prefork's filesub
2430 // trigger (subst.c:678 `strchr(*namptr+1, Equals)`) looks for the
2431 // EQUALS TOKEN, not literal `=`. The fusevm path delivers args
2432 // already-untokenized, so re-tokenize each element via
2433 // `shtokenize` (the same call C's lexer makes implicitly when
2434 // assembling the word) so prefork sees Equals tokens at `=`
2435 // boundaries and Tilde tokens at `~` starts. After prefork
2436 // expands, untokenize for storage.
2437 vm.register_builtin(BUILTIN_MAGIC_EQUALS_PREFORK, |vm, _argc| {
2438 let raw = vm.pop();
2439 let inputs: Vec<String> = match raw {
2440 Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
2441 other => vec![other.to_str()],
2442 };
2443 let mut as_linklist: crate::ported::linklist::LinkList<String> = Default::default();
2444 for s in &inputs {
2445 let mut tokd = s.clone();
2446 crate::ported::glob::shtokenize(&mut tokd);
2447 as_linklist.push_back(tokd);
2448 }
2449 let mut rf = 0i32;
2450 crate::ported::subst::prefork(
2451 &mut as_linklist,
2452 crate::ported::zsh_h::PREFORK_TYPESET,
2453 &mut rf,
2454 );
2455 let mut expanded: Vec<String> = Vec::with_capacity(inputs.len());
2456 while let Some(s) = as_linklist.pop_front() {
2457 expanded.push(crate::ported::lex::untokenize(&s).to_string());
2458 }
2459 if expanded.len() == 1 {
2460 Value::str(expanded.into_iter().next().unwrap())
2461 } else {
2462 Value::array(expanded.into_iter().map(Value::str).collect())
2463 }
2464 });
2465
2466 // Options. `setopt` (BIN_SETOPT=0) / `unsetopt` (BIN_UNSETOPT=1)
2467 // share bin_setopt (options.c:580) — funcid bit discriminates
2468 // the polarity via BUILTINS table entries.
2469 reg_passthru!(vm, BUILTIN_SET, "set");
2470 reg_passthru!(vm, BUILTIN_SETOPT, "setopt");
2471 reg_passthru!(vm, BUILTIN_UNSETOPT, "unsetopt");
2472
2473 vm.register_builtin(BUILTIN_SHOPT, |vm, argc| {
2474 let args = pop_args(vm, argc);
2475 let status = crate::extensions::ext_builtins::shopt(&args);
2476 Value::Status(status)
2477 });
2478
2479 reg_passthru!(vm, BUILTIN_EMULATE, "emulate");
2480 reg_passthru!(vm, BUILTIN_GETOPTS, "getopts");
2481 reg_passthru!(vm, BUILTIN_AUTOLOAD, "autoload");
2482 reg_passthru!(vm, BUILTIN_FUNCTIONS, "functions");
2483 reg_passthru!(vm, BUILTIN_TRAP, "trap");
2484 reg_passthru!(vm, BUILTIN_DIRS, "dirs");
2485 // pushd / popd dispatch through canonical bin_cd via execbuiltin
2486 // — the BUILTINS table at src/ported/builtin.rs:9298 wires
2487 // `pushd` to bin_cd with funcid=BIN_PUSHD, and `popd` similarly
2488 // with BIN_POPD. Without these reg_passthru lines the fusevm
2489 // BUILTIN_PUSHD/POPD opcodes had no handler installed, so the
2490 // emitted CallBuiltin(110, …) silently returned a no-op and the
2491 // dirstack/$dirstack/pwd all stayed unchanged.
2492 reg_passthru!(vm, BUILTIN_PUSHD, "pushd");
2493 reg_passthru!(vm, BUILTIN_POPD, "popd");
2494 // type / whence / where / which all route through `bin_whence`
2495 // (canonical port at `src/ported/builtin.rs:3734` of
2496 // `Src/builtin.c:3975`). Each gets its own opcode so funcid +
2497 // defopts come from the BUILTINS table entry — execbuiltin
2498 // applies them correctly via the module-level dispatch_builtin.
2499 reg_passthru!(vm, BUILTIN_WHENCE, "whence");
2500 reg_passthru!(vm, BUILTIN_TYPE, "type");
2501 reg_passthru!(vm, BUILTIN_WHICH, "which");
2502 reg_passthru!(vm, BUILTIN_WHERE, "where");
2503 reg_passthru!(vm, BUILTIN_HASH, "hash");
2504 reg_passthru!(vm, BUILTIN_REHASH, "rehash");
2505
2506 // `unhash`/`unalias`/`unfunction` share `bin_unhash` (Src/builtin.c
2507 // c:4350) but each carries its own funcid (BIN_UNHASH /
2508 // BIN_UNALIAS / BIN_UNFUNCTION) in the BUILTINS table.
2509 reg_passthru!(vm, BUILTIN_UNHASH, "unhash");
2510 vm.register_builtin(BUILTIN_UNALIAS, |vm, argc| {
2511 let args = pop_args(vm, argc);
2512 Value::Status(dispatch_builtin("unalias", args))
2513 });
2514 vm.register_builtin(BUILTIN_UNFUNCTION, |vm, argc| {
2515 let args = pop_args(vm, argc);
2516 Value::Status(dispatch_builtin("unfunction", args))
2517 });
2518
2519 // Completion
2520 vm.register_builtin(BUILTIN_COMPGEN, |vm, argc| {
2521 let args = pop_args(vm, argc);
2522 // c:Bug #475/#555 — `compgen` is a bash-only builtin. In
2523 // `--zsh` mode emit "command not found" matching zsh's
2524 // external-command lookup miss — UNLESS a user FUNCTION of
2525 // that name exists: zsh has no such builtin, so bashcompinit's
2526 // `compgen() {...}` definition wins the dispatch there. The
2527 // unconditional 127 shadowed it and broke every
2528 // bashcompinit-style completion file (zsh-more-completions
2529 // _msync/_gocomplete/_qshell/_cw), spraying "command not
2530 // found: complete" at every deferred compinit load.
2531 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2532 if crate::ported::utils::getshfunc("compgen").is_some() {
2533 let status = with_executor(|exec| exec.dispatch_function_call("compgen", &args))
2534 .unwrap_or(127);
2535 return Value::Status(status);
2536 }
2537 eprintln!("zsh:1: command not found: compgen");
2538 let _ = args;
2539 return Value::Status(127);
2540 }
2541 let status = with_executor(|exec| exec.builtin_compgen(&args));
2542 Value::Status(status)
2543 });
2544
2545 vm.register_builtin(BUILTIN_COMPLETE, |vm, argc| {
2546 let args = pop_args(vm, argc);
2547 // c:Bug #475 — `complete` is a bash-only builtin. Same gate +
2548 // user-function precedence as BUILTIN_COMPGEN above.
2549 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2550 if crate::ported::utils::getshfunc("complete").is_some() {
2551 let status = with_executor(|exec| exec.dispatch_function_call("complete", &args))
2552 .unwrap_or(127);
2553 return Value::Status(status);
2554 }
2555 eprintln!("zsh:1: command not found: complete");
2556 let _ = args;
2557 return Value::Status(127);
2558 }
2559 let status = with_executor(|exec| exec.builtin_complete(&args));
2560 Value::Status(status)
2561 });
2562
2563 reg_passthru!(vm, BUILTIN_COMPADD, "compadd");
2564 reg_passthru!(vm, BUILTIN_COMPSET, "compset");
2565
2566 // See the const's doc comment for the contract. Stack (bottom→top):
2567 // base, e1, …, eN — argc = N + 1.
2568 vm.register_builtin(BUILTIN_TYPESET_PAREN_PACK, |vm, argc| {
2569 let mut vals: Vec<Value> = Vec::with_capacity(argc as usize);
2570 for _ in 0..argc {
2571 vals.push(vm.pop());
2572 }
2573 vals.reverse();
2574 let mut it = vals.into_iter();
2575 let mut out = it.next().map(|v| v.to_str()).unwrap_or_default();
2576 for v in it {
2577 match v {
2578 // Array → splice items as separate elements (splat);
2579 // empty array contributes nothing (empty elision).
2580 Value::Array(items) => {
2581 for item in items.iter() {
2582 out.push('\u{1f}');
2583 out.push_str(&item.to_str());
2584 }
2585 }
2586 other => {
2587 out.push('\u{1f}');
2588 out.push_str(&other.to_str());
2589 }
2590 }
2591 }
2592 Value::str(out)
2593 });
2594
2595 vm.register_builtin(BUILTIN_TYPESET_PAREN_CLOSE, |vm, _argc| {
2596 let base = vm.pop().to_str();
2597 Value::str(format!("{}\u{1f})", base))
2598 });
2599
2600 vm.register_builtin(BUILTIN_COMPDEF, |vm, argc| {
2601 let args = pop_args(vm, argc);
2602 // ACTUALLY A ZSH FUNCTION: compdef is defined by `compinit`, it is
2603 // never a builtin. Without the completion system set up it is
2604 // command-not-found (127) in every mode — `zsh -f; compdef` prints
2605 // "command not found: compdef". A user/compsys `compdef` FUNCTION
2606 // (autoload compinit → compinit defines compdef) wins and runs the
2607 // fast native impl; otherwise it's command-not-found. Previously the
2608 // extension builtin ran in native mode (bare `compdef` → "I need
2609 // arguments"), diverging from zsh.
2610 // compinit installs a `compdef` function stub (see
2611 // NATIVE_COMPDEF_MARKER) purely so `${+functions[compdef]}` is
2612 // true; route that exact body to the fast native impl instead of
2613 // dispatching the stub. A genuine user/compsys compdef function
2614 // (any other body) still wins via try_user_fn_override below.
2615 let is_native_stub = crate::ported::hashtable::shfunctab_lock()
2616 .read()
2617 .ok()
2618 .and_then(|t| t.get("compdef").and_then(|shf| shf.body.clone()))
2619 .map(|b| b.trim() == crate::extensions::ext_builtins::NATIVE_COMPDEF_MARKER)
2620 .unwrap_or(false);
2621 if is_native_stub {
2622 return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
2623 }
2624 if let Some(s) = try_user_fn_override("compdef", &args) {
2625 return Value::Status(s);
2626 }
2627 if with_executor(|exec| exec.function_exists("compdef")) {
2628 return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
2629 }
2630 eprintln!("zsh:1: command not found: compdef");
2631 Value::Status(127)
2632 });
2633
2634 vm.register_builtin(BUILTIN_COMPINIT, |vm, argc| {
2635 let args = pop_args(vm, argc);
2636 // ACTUALLY A ZSH FUNCTION: compinit is a contrib FUNCTION (autoloaded
2637 // from $fpath), never a builtin. Without `autoload -Uz compinit` it is
2638 // command-not-found
2639 // (127) in every mode — `zsh -f; compinit` prints
2640 // "command not found: compinit". zshrs previously ran its builtin
2641 // unconditionally, so bare `compinit` succeeded. Gate on a compinit
2642 // function entry existing (which `autoload -Uz compinit` creates);
2643 // once the user has autoloaded/defined it, run zshrs's implementation.
2644 if !with_executor(|exec| exec.function_exists("compinit")) {
2645 eprintln!("zsh:1: command not found: compinit");
2646 let _ = args;
2647 return Value::Status(127);
2648 }
2649 Value::Status(with_executor(|exec| exec.builtin_compinit(&args)))
2650 });
2651
2652 reg_ext_overridable!(vm, BUILTIN_CDREPLAY, "cdreplay", builtin_cdreplay);
2653
2654 // Zsh-specific
2655 reg_passthru!(vm, BUILTIN_ZSTYLE, "zstyle");
2656 reg_passthru!(vm, BUILTIN_ZMODLOAD, "zmodload");
2657 reg_passthru!(vm, BUILTIN_BINDKEY, "bindkey");
2658 reg_passthru!(vm, BUILTIN_ZLE, "zle");
2659 reg_passthru!(vm, BUILTIN_VARED, "vared");
2660 reg_passthru!(vm, BUILTIN_ZCOMPILE, "zcompile");
2661 reg_passthru!(vm, BUILTIN_ZFORMAT, "zformat");
2662 reg_passthru!(vm, BUILTIN_ZPARSEOPTS, "zparseopts");
2663 reg_passthru!(vm, BUILTIN_ZREGEXPARSE, "zregexparse");
2664
2665 // Resource limits
2666 reg_passthru!(vm, BUILTIN_ULIMIT, "ulimit");
2667 reg_passthru!(vm, BUILTIN_LIMIT, "limit");
2668 reg_passthru!(vm, BUILTIN_UNLIMIT, "unlimit");
2669 reg_passthru!(vm, BUILTIN_UMASK, "umask");
2670
2671 // Misc
2672 reg_passthru!(vm, BUILTIN_TIMES, "times");
2673
2674 vm.register_builtin(BUILTIN_CALLER, |vm, argc| {
2675 let args = pop_args(vm, argc);
2676 // c:Bug #475 — `caller` is a bash-only builtin. In `--zsh`
2677 // mode emit the canonical "command not found" diagnostic
2678 // and rc=127 matching zsh's external-command-lookup miss.
2679 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2680 eprintln!("zsh:1: command not found: caller");
2681 let _ = args;
2682 return Value::Status(127);
2683 }
2684 Value::Status(with_executor(|exec| exec.builtin_caller(&args)))
2685 });
2686
2687 vm.register_builtin(BUILTIN_HELP, |vm, argc| {
2688 let args = pop_args(vm, argc);
2689 // c:Bug #475 — `help` is a bash-only builtin. Same gate as
2690 // BUILTIN_CALLER above.
2691 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2692 eprintln!("zsh:1: command not found: help");
2693 let _ = args;
2694 return Value::Status(127);
2695 }
2696 Value::Status(with_executor(|exec| exec.builtin_help(&args)))
2697 });
2698
2699 reg_passthru!(vm, BUILTIN_ENABLE, "enable");
2700 reg_passthru!(vm, BUILTIN_DISABLE, "disable");
2701 reg_passthru!(vm, BUILTIN_TTYCTL, "ttyctl");
2702 reg_passthru!(vm, BUILTIN_SYNC, "sync");
2703 reg_passthru!(vm, BUILTIN_MKDIR, "mkdir");
2704 reg_passthru!(vm, BUILTIN_STRFTIME, "strftime");
2705
2706 vm.register_builtin(BUILTIN_ZSLEEP, |vm, argc| {
2707 let args = pop_args(vm, argc);
2708 // function > builtin: a user `zsleep() { … }` wins.
2709 if let Some(s) = try_user_fn_override("zsleep", &args) {
2710 return Value::Status(s);
2711 }
2712 Value::Status(crate::extensions::ext_builtins::zsleep(&args))
2713 });
2714
2715 reg_passthru!(vm, BUILTIN_ZSYSTEM, "zsystem");
2716
2717 // PCRE
2718 reg_passthru!(vm, BUILTIN_PCRE_COMPILE, "pcre_compile");
2719 reg_passthru!(vm, BUILTIN_PCRE_MATCH, "pcre_match");
2720 reg_passthru!(vm, BUILTIN_PCRE_STUDY, "pcre_study");
2721
2722 // Database (GDBM)
2723 reg_passthru!(vm, BUILTIN_ZTIE, "ztie");
2724 reg_passthru!(vm, BUILTIN_ZUNTIE, "zuntie");
2725 reg_passthru!(vm, BUILTIN_ZGDBMPATH, "zgdbmpath");
2726
2727 // Prompt
2728 vm.register_builtin(BUILTIN_PROMPTINIT, |vm, argc| {
2729 let args = pop_args(vm, argc);
2730 // ACTUALLY A ZSH FUNCTION: promptinit is a contrib FUNCTION
2731 // (autoloaded from $fpath), never a builtin. Command-not-found until
2732 // `autoload -Uz promptinit`; once autoloaded, run the native impl.
2733 if !with_executor(|exec| exec.function_exists("promptinit")) {
2734 eprintln!("zsh:1: command not found: promptinit");
2735 let _ = args;
2736 return Value::Status(127);
2737 }
2738 Value::Status(crate::extensions::ext_builtins::promptinit(&args))
2739 });
2740
2741 vm.register_builtin(BUILTIN_PROMPT, |vm, argc| {
2742 let args = pop_args(vm, argc);
2743 Value::Status(crate::extensions::ext_builtins::prompt(&args))
2744 });
2745
2746 // Async / Parallel (zshrs extensions) — all overridable by a
2747 // same-named user function (function > builtin).
2748 reg_ext_overridable!(vm, BUILTIN_ASYNC, "async", builtin_async);
2749 reg_ext_overridable!(vm, BUILTIN_AWAIT, "await", builtin_await);
2750 reg_ext_overridable!(vm, BUILTIN_PMAP, "pmap", builtin_pmap);
2751 reg_ext_overridable!(vm, BUILTIN_PGREP, "pgrep", builtin_pgrep);
2752 reg_ext_overridable!(vm, BUILTIN_PEACH, "peach", builtin_peach);
2753 reg_ext_overridable!(vm, BUILTIN_BARRIER, "barrier", builtin_barrier);
2754
2755 // Intercept (AOP)
2756 reg_ext_overridable!(vm, BUILTIN_INTERCEPT, "intercept", builtin_intercept);
2757 reg_ext_overridable!(
2758 vm,
2759 BUILTIN_INTERCEPT_PROCEED,
2760 "intercept_proceed",
2761 builtin_intercept_proceed
2762 );
2763
2764 // Debug / Profile
2765 reg_ext_overridable!(vm, BUILTIN_DOCTOR, "doctor", builtin_doctor);
2766 reg_ext_overridable!(vm, BUILTIN_DBVIEW, "dbview", builtin_dbview);
2767 reg_ext_overridable!(vm, BUILTIN_PROFILE, "profile", builtin_profile);
2768 reg_ext_overridable!(vm, BUILTIN_PROVENANCE, "provenance", builtin_provenance);
2769
2770 reg_passthru!(vm, BUILTIN_ZPROF, "zprof");
2771
2772 // ═══════════════════════════════════════════════════════════════════════
2773 // Coreutils builtins (anti-fork, gated by !posix_mode)
2774 //
2775 // All of these are routinely wrapped by user functions in real
2776 // dotfiles (zpwr, oh-my-zsh, etc.) — `cat() { ... }`, `ls() { ... }`,
2777 // `find() { ... }`. Each handler MUST consult try_user_fn_override
2778 // first (via reg_overridable!) so the user definition wins, matching
2779 // zsh's alias → function → builtin dispatch order.
2780 // ═══════════════════════════════════════════════════════════════════════
2781
2782 reg_overridable!(vm, BUILTIN_CAT, "cat", builtin_cat);
2783 reg_overridable!(vm, BUILTIN_HEAD, "head", builtin_head);
2784 reg_overridable!(vm, BUILTIN_TAIL, "tail", builtin_tail);
2785 reg_overridable!(vm, BUILTIN_WC, "wc", builtin_wc);
2786 reg_overridable!(vm, BUILTIN_BASENAME, "basename", builtin_basename);
2787 reg_overridable!(vm, BUILTIN_DIRNAME, "dirname", builtin_dirname);
2788 reg_overridable!(vm, BUILTIN_TOUCH, "touch", builtin_touch);
2789 reg_overridable!(vm, BUILTIN_REALPATH, "realpath", builtin_realpath);
2790 reg_overridable!(vm, BUILTIN_SORT, "sort", builtin_sort);
2791 reg_overridable!(vm, BUILTIN_FIND, "find", builtin_find);
2792 reg_overridable!(vm, BUILTIN_UNIQ, "uniq", builtin_uniq);
2793 reg_overridable!(vm, BUILTIN_CUT, "cut", builtin_cut);
2794 reg_overridable!(vm, BUILTIN_TR, "tr", builtin_tr);
2795 reg_overridable!(vm, BUILTIN_SEQ, "seq", builtin_seq);
2796 reg_overridable!(vm, BUILTIN_REV, "rev", builtin_rev);
2797 reg_overridable!(vm, BUILTIN_TEE, "tee", builtin_tee);
2798 reg_overridable!(vm, BUILTIN_SLEEP, "sleep", builtin_sleep);
2799 reg_overridable!(vm, BUILTIN_WHOAMI, "whoami", builtin_whoami);
2800 reg_overridable!(vm, BUILTIN_ID, "id", builtin_id);
2801
2802 reg_overridable!(vm, BUILTIN_HOSTNAME, "hostname", builtin_hostname);
2803 reg_overridable!(vm, BUILTIN_UNAME, "uname", builtin_uname);
2804 reg_overridable!(vm, BUILTIN_DATE, "date", builtin_date);
2805 reg_overridable!(vm, BUILTIN_MKTEMP, "mktemp", builtin_mktemp);
2806 // `cp` — zshrs extension (NOT in upstream zsh; upstream's
2807 // zsh/files module ships `ln`/`mv`/`rm`/`chmod`/`chown` but no
2808 // `cp`). In-process implementation in
2809 // `ext_builtins::cp_impl` — recursive copy with -r/-R, -f, -i,
2810 // -n, -p (chown + utimensat), -v. ID 263 is the first slot
2811 // past fusevm's built-in range (260-262) and before BUILTIN_MAX
2812 // (280).
2813 /// `BUILTIN_CP` constant.
2814 pub const BUILTIN_CP: u16 = 263;
2815 reg_overridable!(vm, BUILTIN_CP, "cp", builtin_cp);
2816
2817 // Pipeline execution — bytecode-native fork-per-stage. Pops N sub-chunk
2818 // indices, forks N children with stdin/stdout wired through N-1 pipes,
2819 // each child runs its stage's compiled bytecode and exits. Parent waits
2820 // and returns the last stage's status.
2821 //
2822 // Caveats: post-fork in a multi-threaded program, only async-signal-safe
2823 // ops are POSIX-safe. We violate this (running the bytecode VM after fork
2824 // touches mutexes like REGEX_CACHE). In practice, most pipeline stages
2825 // don't touch shared mutex state — externals fork/exec away, builtins do
2826 // pure I/O. Risks are bounded; if a stage does touch a held mutex, the
2827 // child deadlocks.
2828 vm.register_builtin(BUILTIN_RUN_PIPELINE, |vm, argc| {
2829 let n = argc as usize;
2830 if n == 0 {
2831 return Value::Status(0);
2832 }
2833
2834 // c:Src/exec.c — every pipeline stage forks from the current
2835 // shell state, so each stage observes the pre-pipeline $? until
2836 // it runs its own command. Stage sub-VMs start fresh with
2837 // last_status=0, so seed them with the parent's lastval; without
2838 // this `false; echo $? | cat` prints 0 instead of zsh's 1.
2839 let parent_status = vm.last_status;
2840
2841 // Pop N sub-chunk indices (LIFO → reverse to stage order)
2842 let mut indices: Vec<u16> = Vec::with_capacity(n);
2843 for _ in 0..n {
2844 indices.push(vm.pop().to_int() as u16);
2845 }
2846 indices.reverse();
2847
2848 // Clone each stage's sub-chunk
2849 let stages: Vec<fusevm::Chunk> = indices
2850 .iter()
2851 .filter_map(|&i| vm.chunk.sub_chunks.get(i as usize).cloned())
2852 .collect();
2853 if stages.len() != n {
2854 return Value::Status(1);
2855 }
2856
2857 // Single stage — no pipe, just run inline
2858 if n == 1 {
2859 let stage = stages.into_iter().next().unwrap();
2860 crate::fusevm_disasm::maybe_print_stdout("pipeline:single", &stage);
2861 let mut stage_vm = fusevm::VM::new(stage);
2862 stage_vm.last_status = parent_status;
2863 register_builtins(&mut stage_vm);
2864 let _ = stage_vm.run();
2865 return Value::Status(stage_vm.last_status);
2866 }
2867
2868 // Build N-1 pipes
2869 let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(n - 1);
2870 for _ in 0..n - 1 {
2871 let mut fds = [0i32; 2];
2872 if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
2873 // Cleanup any pipes we already created
2874 for (r, w) in &pipes {
2875 unsafe {
2876 libc::close(*r);
2877 libc::close(*w);
2878 }
2879 }
2880 return Value::Status(1);
2881 }
2882 pipes.push((fds[0], fds[1]));
2883 }
2884
2885 // zsh runs the LAST stage of a pipeline in the CURRENT shell
2886 // (not a forked child) so a trailing `read x` keeps its
2887 // assignment in the parent. Other shells (bash) fork every
2888 // stage. Honor zsh by leaving stage N-1 inline. Forks the
2889 // first N-1 stages with fork(); runs the last in this process
2890 // with stdin dup2'd to the last pipe's read end and stdout
2891 // restored after.
2892 let last_idx = n - 1;
2893 let stages_vec: Vec<fusevm::Chunk> = stages.into_iter().collect();
2894
2895 let mut child_pids: Vec<libc::pid_t> = Vec::with_capacity(n - 1);
2896 for (i, chunk) in stages_vec.iter().take(last_idx).enumerate() {
2897 match unsafe { libc::fork() } {
2898 -1 => {
2899 // fork failed — kill any children we already started
2900 for pid in &child_pids {
2901 unsafe { libc::kill(*pid, libc::SIGTERM) };
2902 }
2903 for (r, w) in &pipes {
2904 unsafe {
2905 libc::close(*r);
2906 libc::close(*w);
2907 }
2908 }
2909 return Value::Status(1);
2910 }
2911 0 => {
2912 // Reset SIGPIPE to default so a broken-pipe write
2913 // kills the child cleanly instead of triggering a
2914 // Rust println! panic. The parent shell ignores
2915 // SIGPIPE so it can handle EPIPE itself, but child
2916 // pipeline stages should die quietly when their
2917 // downstream stage closes early (e.g. `seq | head -3`).
2918 unsafe {
2919 libc::signal(libc::SIGPIPE, libc::SIG_DFL);
2920 }
2921 // c:Src/exec.c — pipeline children are forked
2922 // subshells; their EXIT trap context is reset so
2923 // the parent's `trap '...' EXIT` doesn't fire when
2924 // the child exits. Mirror by dropping EXIT from
2925 // the inherited traps_table inside the child.
2926 // c:Src/exec.c:2917-2918 — a forked PIPELINE stage enters
2927 // the subshell with ESUB_KEEPTRAP:
2928 // if ((type != WC_SUBSH) && !(how & Z_ASYNC))
2929 // flags |= ESUB_KEEPTRAP;
2930 // so entersubsh's c:1127 reset loop is SKIPPED and the
2931 // stage keeps the parent's traps. Only the EXIT trap goes,
2932 // so the parent's `trap '…' EXIT` does not fire when the
2933 // stage exits. Applying the full reset here instead was
2934 // wrong: it wiped the inherited-SIGQUIT record and the
2935 // parent's other trap flags inside every pipeline stage.
2936 //
2937 // Drop it from BOTH stores, since a body-less entry lives
2938 // only in sigtrapped and the `trap` listing now reads it
2939 // (c:Src/builtin.c:7358-7361); clearing just the body left
2940 // `trap | grep -c EXIT` reporting the stale flag.
2941 if let Ok(mut tt) = crate::ported::builtin::traps_table().lock() {
2942 tt.remove("EXIT");
2943 }
2944 if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
2945 if let Some(slot) = st.get_mut(crate::ported::signals_h::SIGEXIT as usize) {
2946 *slot = 0;
2947 }
2948 }
2949 // c:Src/exec.c:2862 → 1219 — pipeline children run
2950 // entersubsh with ESUB_PGRP, which clears the job
2951 // table (clearjobtab, Src/jobs.c:1780). Without
2952 // this, `sleep 5 & jobs -p | wc -l` reports 1 in
2953 // the forked stage where zsh reports 0. The fork
2954 // already copy-isolates the statics, so mutating
2955 // them here can't leak to the parent.
2956 with_executor(|exec| {
2957 let monitor =
2958 crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
2959 crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
2960 });
2961 // c:Src/exec.c:1153-1154 — the same entersubsh call sets
2962 // `subsh = 1` in the forked stage. PRINT_EXIT_VALUE reads
2963 // it (c:4309 `&& !subsh`), which is why zsh prints nothing
2964 // for the failing stage of `false | true`.
2965 crate::ported::exec::subsh.store(1, std::sync::atomic::Ordering::Relaxed);
2966 *crate::ported::jobs::THISJOB
2967 .get_or_init(|| std::sync::Mutex::new(-1))
2968 .lock()
2969 .unwrap() = -1;
2970 // c:Src/exec.c:3720-3724 — the stage's own fds go
2971 // onto 0/1 only AFTER its argument words have been
2972 // expanded (prefork c:3304 / globlist c:3702), so
2973 // park them and let the stage chunk's
2974 // BUILTIN_PIPE_FDS_INSTALL do the dup2 at the
2975 // C-faithful point. `print -rl -- c a b |
2976 // print -r -- "[$(cat)]" | cat` therefore prints
2977 // `[]` — the middle stage's `$(cat)` reads the
2978 // shell's stdin, not the pipe.
2979 let in_fd = if i > 0 { pipes[i - 1].0 } else { -1 };
2980 let out_fd = pipes[i].1;
2981 // (Pipe-output MULTIOS marking — c:Src/exec.c:3724 —
2982 // is emitted INTO the stage chunk by compile_pipe
2983 // via BUILTIN_PIPE_OUTPUT_MARK, gated on the stage's
2984 // top-level command actually carrying redirects, so
2985 // a nested `{ echo a > f; } | cat` body redirect
2986 // does not wrongly join the pipe.)
2987 // Close every pipe fd this stage doesn't need. The
2988 // two it does keep are closed by the install op
2989 // right after their dup2.
2990 for (r, w) in &pipes {
2991 unsafe {
2992 if *r != in_fd && *r != out_fd {
2993 libc::close(*r);
2994 }
2995 if *w != in_fd && *w != out_fd {
2996 libc::close(*w);
2997 }
2998 }
2999 }
3000 stage_fds_park(in_fd, out_fd);
3001
3002 // Run this stage's bytecode on a fresh VM
3003 crate::fusevm_disasm::maybe_print_stdout(
3004 &format!("pipeline:child:stage:{i}"),
3005 chunk,
3006 );
3007 let mut stage_vm = fusevm::VM::new(chunk.clone());
3008 stage_vm.last_status = parent_status;
3009 register_builtins(&mut stage_vm);
3010 let _ = stage_vm.run();
3011 // Flush any buffered output before exiting
3012 let _ = std::io::stdout().flush();
3013 let _ = std::io::stderr().flush();
3014 std::process::exit(stage_vm.last_status);
3015 }
3016 pid => {
3017 child_pids.push(pid);
3018 }
3019 }
3020 }
3021
3022 // Parent runs the LAST stage inline. Save stdin, park the last
3023 // pipe's read end for the chunk's BUILTIN_PIPE_FDS_INSTALL
3024 // (c:Src/exec.c:3722 `addfd(..., 0, input, 0, NULL)` — after
3025 // the stage's args are expanded, so `… | print -r -- "[$(cat)]"`
3026 // has its `$(cat)` read the shell's stdin, not the pipe), run
3027 // the chunk, restore stdin. Close every other pipe fd so the
3028 // producer side gets EOF when the last upstream stage exits.
3029 // Shell-internal save — keep it out of the script's fd range (movefd,
3030 // c:Src/exec.c:2425).
3031 let saved_stdin = unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD, 10) };
3032 let last_in_fd = if last_idx > 0 {
3033 pipes[last_idx - 1].0
3034 } else {
3035 -1
3036 };
3037 // Close all pipe fds in the parent except the one the last
3038 // stage still has to install. (Children already have their own
3039 // copies; the install op closes the read end after its dup2.)
3040 for (r, w) in &pipes {
3041 unsafe {
3042 if *r != last_in_fd {
3043 libc::close(*r);
3044 }
3045 libc::close(*w);
3046 }
3047 }
3048 let outer_stage_fds = stage_fds_park(last_in_fd, -1);
3049
3050 // Run the last stage's bytecode on a sub-VM with the host wired up.
3051 // By default (zsh semantics) the sub-VM runs IN THIS PROCESS so the
3052 // last stage's reads/assignments update the parent's state directly
3053 // (`echo x | read v` sets $v; `cmd | mapfile arr` sets arr).
3054 //
3055 // !!! BASH-MODE GATE !!! bash forks EVERY pipeline stage (unless
3056 // `shopt -s lastpipe`), so the last stage runs in a SUBSHELL and its
3057 // variable/array assignments do NOT persist — `echo x | read v; echo
3058 // $v` prints an empty line, `cmd | mapfile arr` leaves arr unset.
3059 // Fork the last stage under `--bash` to match. The parent's existing
3060 // `stage_fds_take()` below closes its `last_in_fd` copy; the forked
3061 // child inherits the parked pipe fd and installs it onto stdin, and
3062 // the writer stages (already forked) supply its input.
3063 let last_stage_status = if crate::dash_mode::bash_mode() {
3064 let last_chunk = stages_vec.into_iter().last().unwrap();
3065 crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
3066 match unsafe { libc::fork() } {
3067 -1 => 1,
3068 0 => {
3069 // Subshell child: run the last stage, then _exit with its
3070 // status. Reset SIGPIPE + drop the EXIT trap like the
3071 // other pipeline children above.
3072 unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL) };
3073 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
3074 t.remove("EXIT");
3075 }
3076 let mut stage_vm = fusevm::VM::new(last_chunk);
3077 stage_vm.last_status = parent_status;
3078 register_builtins(&mut stage_vm);
3079 stage_vm.set_shell_host(Box::new(ZshrsHost));
3080 let _ = stage_vm.run();
3081 let st = stage_vm.last_status;
3082 let _ = std::io::stdout().flush();
3083 let _ = std::io::stderr().flush();
3084 unsafe { libc::_exit(st) };
3085 }
3086 pid => {
3087 // Same EINTR retry as the stage reap loop below.
3088 match waitpid_eintr(pid) {
3089 Some(status) if libc::WIFEXITED(status) => libc::WEXITSTATUS(status),
3090 Some(status) if libc::WIFSIGNALED(status) => 128 + libc::WTERMSIG(status),
3091 Some(_) => 1,
3092 None => 0,
3093 }
3094 }
3095 }
3096 } else {
3097 let last_chunk = stages_vec.into_iter().last().unwrap();
3098 crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
3099 let mut stage_vm = fusevm::VM::new(last_chunk);
3100 stage_vm.last_status = parent_status;
3101 register_builtins(&mut stage_vm);
3102 stage_vm.set_shell_host(Box::new(ZshrsHost));
3103 let _ = stage_vm.run();
3104 let _ = std::io::stdout().flush();
3105 let _ = std::io::stderr().flush();
3106 stage_vm.last_status
3107 };
3108
3109 // Reclaim the read end if the stage chunk never reached its
3110 // install op (an expansion error aborted it, or the stage was
3111 // a shape that dispatches without one), then restore the outer
3112 // stage's still-pending fds for a nested pipeline.
3113 let (leftover_in, _) = stage_fds_take();
3114 if leftover_in >= 0 {
3115 unsafe { libc::close(leftover_in) };
3116 }
3117 stage_fds_park(outer_stage_fds.0, outer_stage_fds.1);
3118
3119 // Restore stdin
3120 if saved_stdin >= 0 {
3121 unsafe {
3122 libc::dup2(saved_stdin, libc::STDIN_FILENO);
3123 libc::close(saved_stdin);
3124 }
3125 }
3126
3127 // Wait for all forked stages, capture per-stage statuses for PIPESTATUS.
3128 let mut pipestatus: Vec<i32> = Vec::with_capacity(n);
3129 for pid in child_pids {
3130 // EINTR retry: the SIGCHLD handler interrupts this wait and
3131 // leaves `status` untouched, which used to read back as a
3132 // clean exit 0 for every forked stage. See waitpid_eintr.
3133 let s = match waitpid_eintr(pid) {
3134 Some(status) if libc::WIFEXITED(status) => libc::WEXITSTATUS(status),
3135 Some(status) if libc::WIFSIGNALED(status) => 128 + libc::WTERMSIG(status),
3136 Some(_) => 1,
3137 // Unreapable (ECHILD — the handler got there first).
3138 // Nothing better to report than success; the stage's
3139 // real status is gone.
3140 None => 0,
3141 };
3142 pipestatus.push(s);
3143 }
3144 // Append the in-parent last-stage status so `pipestatus` ends
3145 // with N entries (one per stage).
3146 pipestatus.push(last_stage_status);
3147 // Pipeline exit status: by default, the LAST stage's status.
3148 // With `setopt pipefail` (or `set -o pipefail`), use the
3149 // first non-zero stage status (so failures earlier in the
3150 // pipeline propagate even if the last stage succeeded).
3151 let pipefail_on = with_executor(|exec| opt_state_get("pipefail").unwrap_or(false));
3152 let last_status = if pipefail_on {
3153 pipestatus
3154 .iter()
3155 .copied()
3156 .rfind(|&s| s != 0)
3157 .or_else(|| pipestatus.last().copied())
3158 .unwrap_or(0)
3159 } else {
3160 *pipestatus.last().unwrap_or(&0)
3161 };
3162
3163 // c:Src/params.c:265,438 — only `pipestatus` (lowercase) is the
3164 // zsh special parameter; bash's `PIPESTATUS` doesn't exist in
3165 // zsh's special-params table. Prior port also populated
3166 // `PIPESTATUS` "for portability" — but that's a real divergence
3167 // from zsh: a script doing `[[ -z $PIPESTATUS ]]` to detect
3168 // zsh-vs-bash would mis-classify. Bug #64 in docs/BUGS.md.
3169 with_executor(|exec| {
3170 // c:Src/jobs.c:83 `int pipestats[MAX_PIPESTATS]` — the values
3171 // live in that C GLOBAL, reached through `pipestatus`'s GSU
3172 // (c:Src/params.c pipestatgetfn). A `typeset -h +g pipestatus`
3173 // local shadow carries no PM_SPECIAL and no GSU, so the C
3174 // writer cannot reach it; skip the paramtab mirror likewise or
3175 // the shadow loses its PM_UNSET (B02typeset.ztst:37,38).
3176 let shadowed = crate::ported::params::paramtab()
3177 .read()
3178 .ok()
3179 .and_then(|t| {
3180 t.get("pipestatus")
3181 .map(|pm| (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) == 0)
3182 })
3183 .unwrap_or(false);
3184 if !shadowed {
3185 let strs: Vec<String> = pipestatus.iter().map(|s| s.to_string()).collect();
3186 exec.set_array("pipestatus".to_string(), strs);
3187 }
3188 });
3189
3190 Value::Status(last_status)
3191 });
3192
3193 // Array→String join. Pops one value; if it's an Array (e.g. from Op::Glob),
3194 // joins string-coerced elements with a single space. Pass-through for
3195 // non-arrays so the op is safe to chain after any String-or-Array producer.
3196 // Scalar coercion of an assembled word: pop a Value; if it's an
3197 // Array (produced by a splice segment like `"$@"` / `"${arr[@]}"`),
3198 // IFS[0]-join it to a single scalar; a scalar passes through. This
3199 // is the assignment-context coercion C zsh applies in multsub when
3200 // the expansion is the RHS of a SCALAR assignment (Src/subst.c
3201 // c:3032 sepjoin under ssub) — `v="$@"` joins the positionals with
3202 // ${IFS[1]} rather than leaving an array whose splat would lose all
3203 // but the first element. Joins via sepjoin so a custom / empty IFS
3204 // is honored (not a hardcoded space).
3205 vm.register_builtin(BUILTIN_ARRAY_JOIN, |vm, _argc| {
3206 let val = vm.pop();
3207 match val {
3208 Value::Array(items) => {
3209 let strs: Vec<String> = items.iter().map(|v| v.to_str()).collect();
3210 Value::str(crate::ported::utils::sepjoin(&strs, None))
3211 }
3212 other => other,
3213 }
3214 });
3215
3216 // `cmd &` background execution. Compile_list emits this for any item
3217 // followed by ListOp::Amp: the job text + the cmd's sub-chunk index are
3218 // pushed, then this builtin pops both, looks up the chunk, forks. The
3219 // child detaches via setsid (so SIGINT to the foreground job doesn't kill
3220 // it), runs the bytecode on a fresh VM with builtins re-registered, exits
3221 // with the last status. The parent registers the job in the canonical
3222 // JOBTAB (c:Src/exec.c::execpline Z_ASYNC arm) and returns Status(0).
3223 vm.register_builtin(BUILTIN_RUN_BG, |vm, _argc| {
3224 // `&|` / `&!` set disown → the job is dropped from the table (no
3225 // `[N] pid` announcement, no `[N] done`), matching C exec.c:1752-1758.
3226 let disown = vm.pop().to_int() != 0;
3227 let sub_idx = vm.pop().to_int() as usize;
3228 let job_text = vm.pop().to_str();
3229 let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
3230 Some(c) => c,
3231 None => return Value::Status(1),
3232 };
3233
3234 match unsafe { libc::fork() } {
3235 -1 => Value::Status(1),
3236 0 => {
3237 // Child: detach and run.
3238 unsafe { libc::setsid() };
3239 crate::fusevm_disasm::maybe_print_stdout("background_job", &chunk);
3240 let mut bg_vm = fusevm::VM::new(chunk);
3241 register_builtins(&mut bg_vm);
3242 let _ = bg_vm.run();
3243 let _ = std::io::stdout().flush();
3244 let _ = std::io::stderr().flush();
3245 std::process::exit(bg_vm.last_status);
3246 }
3247 pid => {
3248 // Parent: record the PID into `$!` (most recent
3249 // backgrounded job's pid). zsh exposes this for any
3250 // script that needs `wait $!`. Also register the
3251 // bare-pid job so a no-args `wait` can synchronize.
3252 // c:Src/jobs.c:73 — `lastpid = pid;` after a
3253 // background fork. zshrs's `$!` getter
3254 // (params.rs::lookup_special_var "!") reads from
3255 // the same atomic, so a single store here is the
3256 // canonical writer.
3257 crate::ported::modules::clone::lastpid
3258 .store(pid, std::sync::atomic::Ordering::Relaxed);
3259 // c:Src/exec.c:1700 — `thisjob = newjob = initjob()`:
3260 // allocate the canonical jobtab slot. c:Src/exec.c:2950
3261 // zfork path → addproc(pid, text, 0, &bgtime, ...) hangs
3262 // the proc entry (with its display text) off the job.
3263 // c:Src/exec.c:1744-1746 — `clearoldjobtab();
3264 // jobtab[thisjob].stat |= STAT_NOSTTY;` then c:1758
3265 // `spawnjob()` promotes it to curjob (top-level shell
3266 // only), marks STAT_LOCKED and resets thisjob.
3267 {
3268 use crate::ported::jobs;
3269 use std::sync::Mutex;
3270 let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
3271 let idx = {
3272 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
3273 let idx = jobs::initjob(&mut tab); // c:exec.c:1700
3274 jobs::addproc(
3275 &mut tab[idx],
3276 pid,
3277 &job_text,
3278 false,
3279 Some(std::time::Instant::now()),
3280 -1,
3281 -1,
3282 ); // c:exec.c:2950 addproc
3283 tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
3284 idx
3285 };
3286 jobs::clearoldjobtab(); // c:exec.c:1744
3287 if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
3288 *tj = idx as i32;
3289 }
3290 if disown {
3291 // c:exec.c:1752-1755 — `pipecleanfilelist(...);
3292 // deletejob(jobtab + thisjob, 1); thisjob = -1;` — a
3293 // disowned job leaves the table entirely, so neither
3294 // spawnjob's `[N] pid` nor the later `[N] done` prints.
3295 // This is what keeps zinit-turbo's `… &|` completion
3296 // jobs silent (they load inside a `zle -F` handler).
3297 {
3298 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
3299 jobs::pipecleanfilelist(&mut tab[idx], false); // c:1753
3300 jobs::deletejob(&mut tab[idx], true); // c:1754
3301 }
3302 if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
3303 *tj = -1; // c:1755
3304 }
3305 } else {
3306 jobs::spawnjob(); // c:exec.c:1758
3307 }
3308 }
3309 with_executor(|exec| {
3310 exec.jobs
3311 .add_pid_job(pid, job_text.clone(), JobState::Running);
3312 });
3313 Value::Status(0)
3314 }
3315 }
3316 });
3317
3318 // ── Indexed-array storage ─────────────────────────────────────────────
3319 //
3320 // Stack: pushed values then name (LAST). `arr=(a b c)` → 4 args
3321 // (a, b, c, arr). `arr=($(cmd))` → 2 args (FlatArray, arr).
3322 //
3323 // PURE PASSTHRU: pop name + values, dispatch to canonical
3324 // `setaparam` / `sethparam` (C port of `Src/params.c:3595/3602`).
3325 // assignaparam already handles PM_UNIQUE dedupe, type-flag flip,
3326 // PM_NAMEREF rejection, ASSPM_AUGMENT prepend, and createparam
3327 // for fresh names.
3328 vm.register_builtin(BUILTIN_SET_ARRAY, |vm, argc| {
3329 // `${~spec}` carrier: an assignment statement is a word-
3330 // pipeline boundary too — restore the user's GLOB_SUBST
3331 // before the NEXT word expands (`Z[d]=${~Z[d]}; print
3332 // ${options[globsubst]}` must read the user value).
3333 consume_tilde_globsubst_carrier();
3334 let n = argc as usize;
3335 let mut popped: Vec<Value> = Vec::with_capacity(n);
3336 for _ in 0..n {
3337 popped.push(vm.pop());
3338 }
3339 popped.reverse();
3340 if popped.is_empty() {
3341 return Value::Status(1);
3342 }
3343 let name = popped.pop().unwrap().to_str();
3344 let mut values: Vec<String> = Vec::new();
3345 for v in popped {
3346 flatten_array_value(v, &mut values);
3347 }
3348 // Bash sparse: a full `a=(...)` reassign resets the array to dense
3349 // (drops any prior holes from subscript-assign / unset).
3350 if crate::dash_mode::sparse_arrays() {
3351 crate::bash_arrays::clear(&name);
3352 }
3353 let blocked = with_executor(|exec| {
3354 // Assoc init `typeset -A m; m=(k v k v ...)` — route to
3355 // canonical sethparam (Src/params.c:3602) which parses the
3356 // flat (k,v) pair list internally.
3357 if exec.assoc(&name).is_some() {
3358 // `[k]=v` / `[k]+=v` elements arrive from the compiler
3359 // as Marker / key / value triples (compile_zsh's port
3360 // of keyvalpairelement, c:Src/subst.c:49-79).
3361 let marker = crate::ported::zsh_h::Marker;
3362 let values = if values.iter().any(|e| e.starts_with(marker)) {
3363 // c:Src/params.c:3544-3560 — under ASSPM_KEY_VALUE
3364 // assocs strictly enforce `[key]=value`: every
3365 // stride-of-3 element must be a Marker. Mixing
3366 // plain pairs with kv triads is an error.
3367 let mut i = 0usize;
3368 while i < values.len() {
3369 if !values[i].starts_with(marker) {
3370 crate::ported::utils::zerr(
3371 "bad [key]=value syntax for associative array",
3372 );
3373 crate::ported::utils::errflag.fetch_or(
3374 crate::ported::zsh_h::ERRFLAG_ERROR,
3375 std::sync::atomic::Ordering::Relaxed,
3376 );
3377 exec.set_last_status(1);
3378 return true;
3379 }
3380 i += 3;
3381 }
3382 if values.len() % 3 != 0 {
3383 // c:Src/params.c:4124-4131 arrhashsetfn — a
3384 // truncated triad leaves an odd non-Marker
3385 // count → "bad set of key/value pairs".
3386 crate::ported::utils::zerr(
3387 "bad set of key/value pairs for associative array",
3388 );
3389 crate::ported::utils::errflag.fetch_or(
3390 crate::ported::zsh_h::ERRFLAG_ERROR,
3391 std::sync::atomic::Ordering::Relaxed,
3392 );
3393 exec.set_last_status(1);
3394 return true;
3395 }
3396 // c:Src/params.c:4136-4168 arrhashsetfn — whole
3397 // assignment builds a FRESH table; a `Marker +`
3398 // triad (`[k]+=v`) appends to the value inserted
3399 // EARLIER IN THIS SAME LITERAL (assignstrvalue
3400 // with eltflags=ASSPM_AUGMENT against the new ht),
3401 // so `h=([k]=a [k]+=b)` yields "ab". Resolve the
3402 // appends here, then hand flat pairs to sethparam.
3403 let mut order: Vec<String> = Vec::new();
3404 let mut map: std::collections::HashMap<String, String> =
3405 std::collections::HashMap::new();
3406 for ch in values.chunks(3) {
3407 let elt_append = ch[0].chars().nth(1) == Some('+');
3408 let k = ch[1].clone();
3409 let v = ch[2].clone();
3410 let nv = if elt_append {
3411 format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
3412 } else {
3413 v
3414 };
3415 if !map.contains_key(&k) {
3416 order.push(k.clone());
3417 }
3418 map.insert(k, nv);
3419 }
3420 order
3421 .into_iter()
3422 .flat_map(|k| {
3423 let v = map.get(&k).cloned().unwrap_or_default();
3424 [k, v]
3425 })
3426 .collect()
3427 } else {
3428 values
3429 };
3430 // Odd-count rejection lives in the canonical chain:
3431 // sethparam → setarrvalue (c:3651/c:2920) →
3432 // arrhashsetfn's zerr "bad set of key/value pairs"
3433 // (Src/params.c:4128-4131). zerr sets ERRFLAG_ERROR,
3434 // which aborts the remaining list at the next command
3435 // boundary (BUILTIN_ERREXIT_CHECK trigger 4) —
3436 // matching `zsh -fc 'typeset -A m; m=(odd); print x'`
3437 // printing nothing after the error. Like C's sethparam
3438 // (c:3652-3653 returns v->pm regardless), the Rust
3439 // port returns Some on the odd-count path — the
3440 // failure travels via errflag, so check BOTH.
3441 let pre_err = crate::ported::utils::errflag
3442 .load(std::sync::atomic::Ordering::Relaxed)
3443 & crate::ported::zsh_h::ERRFLAG_ERROR;
3444 let res = crate::ported::params::sethparam(&name, values.clone());
3445 let now_err = crate::ported::utils::errflag
3446 .load(std::sync::atomic::Ordering::Relaxed)
3447 & crate::ported::zsh_h::ERRFLAG_ERROR;
3448 if res.is_none() || (pre_err == 0 && now_err != 0) {
3449 // c:Src/exec.c:2632-2633 addvars — `if
3450 // (!assignaparam(name, arr, myflags)) lastval = 1;`
3451 // — failed assignment sets lastval so the errflag
3452 // abort exits 1 (init.c loop() breaks, zsh_main
3453 // returns lastval).
3454 exec.set_last_status(1);
3455 return true;
3456 }
3457 #[cfg(feature = "recorder")]
3458 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3459 let ctx = exec.recorder_ctx();
3460 let attrs = exec.recorder_attrs_for(&name);
3461 let mut pairs: Vec<(String, String)> = Vec::with_capacity(values.len() / 2);
3462 let mut iter = values.iter().cloned();
3463 while let Some(k) = iter.next() {
3464 if let Some(v) = iter.next() {
3465 pairs.push((k, v));
3466 }
3467 }
3468 crate::recorder::emit_assoc_assign(&name, pairs, attrs, false, ctx);
3469 }
3470 return false;
3471 }
3472 // Indexed-array: setaparam (Src/params.c:3766) wraps
3473 // assignaparam with ASSPM_WARN — handles PM_UNIQUE dedupe,
3474 // type-flag flip, PM_READONLY rejection.
3475 //
3476 // `[k]=v` elements arrive as Marker / key / value triples
3477 // (compile_zsh's keyvalpairelement port). Mirror
3478 // c:Src/exec.c:2552-2553 — `if (prefork_ret &
3479 // PREFORK_KEY_VALUE) myflags |= ASSPM_KEY_VALUE;` — so
3480 // assignaparam runs its kv-resolution block (sparse fill
3481 // for PM_ARRAY, c:3447-3541; strict-triad enforcement for
3482 // special PM_HASHED targets like `options`, c:3544-3560).
3483 let values = values;
3484 let has_kv = values
3485 .iter()
3486 .any(|e| e.starts_with(crate::ported::zsh_h::Marker));
3487 // The tied-array mirror to a PM_TIED scalar
3488 // (`typeset -T PATH path`) lives canonically in
3489 // setarrvalue's dispatch in C zsh; until that wires
3490 // through assignaparam, mirror here so PATH stays in sync
3491 // after `path=(/x)`.
3492 //
3493 // The mirrored value must be the array AS STORED, which for a
3494 // PM_UNIQUE tie means deduped. c:4066-4076 arrsetfn fixes the
3495 // order:
3496 // if (pm->node.flags & PM_UNIQUE) uniqarray(x);
3497 // pm->u.arr = x;
3498 // if (pm->ename && x) arrfixenv(pm->ename, x);
3499 // — the dedupe happens FIRST, so the scalar publishes the same
3500 // list the array holds and the two halves of a tie always agree.
3501 // Mirroring the raw `values` broke exactly that:
3502 // typeset -U path; path=(/a /b /a)
3503 // $path → /a /b (right)
3504 // $PATH → /a:/b:/a (wrong; zsh gives /a:/b)
3505 // i.e. `typeset -U path`, the standard PATH-dedup idiom in
3506 // essentially every .zshrc. assignaparam's own arrfixenv does not
3507 // rescue it: that call is gated on the param having a gsu_a wired,
3508 // and `path` has none.
3509 //
3510 // The dedupe is applied here rather than by reading the array back
3511 // after assignaparam, because this mirror must stay BEFORE it.
3512 // `exec.set_scalar` is heavier than C's arrfixenv — arrfixenv only
3513 // rewrites the environment string, while set_scalar re-derives the
3514 // ARRAY from the scalar. Running it afterwards makes `path=()`
3515 // publish PATH="" and then re-split that back into a one-element
3516 // `path=("")`, where zsh leaves 0 elements.
3517 if let Some((scalar_name, sep)) = exec.tied_array_to_scalar.get(&name).cloned() {
3518 let uniq = crate::ported::params::paramtab()
3519 .read()
3520 .ok()
3521 .and_then(|t| t.get(&name).map(|p| p.node.flags))
3522 .map(|f| (f as u32 & crate::ported::zsh_h::PM_UNIQUE) != 0)
3523 .unwrap_or(false);
3524 let mirror = if uniq {
3525 crate::ported::params::simple_arrayuniq(values.clone()) // c:4068
3526 } else {
3527 values.clone()
3528 };
3529 exec.set_scalar(scalar_name, mirror.join(&sep)); // c:4074-4075
3530 }
3531 // c:Src/exec.c:2632-2633 addvars — `if (!assignaparam(...))
3532 // lastval = 1;` — a failed assignment (bad subscript, bad
3533 // [key]=value syntax, readonly) exits 1 and the errflag
3534 // abort stops the remaining list. Track errflag pre/post
3535 // like the assoc branch above.
3536 let kv_flag = if has_kv {
3537 crate::ported::zsh_h::ASSPM_KEY_VALUE
3538 } else {
3539 0
3540 };
3541 let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3542 & crate::ported::zsh_h::ERRFLAG_ERROR;
3543 let res = crate::ported::params::assignaparam(
3544 &name,
3545 values.clone(),
3546 crate::ported::zsh_h::ASSPM_WARN | kv_flag,
3547 );
3548 let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3549 & crate::ported::zsh_h::ERRFLAG_ERROR;
3550 if res.is_none() && pre_err == 0 && now_err != 0 {
3551 exec.set_last_status(1);
3552 return true;
3553 }
3554 // Bash sparse: an explicit-index array literal `a=([2]=x [5]=y)`
3555 // leaves the un-indexed slots as HOLES (bash: count 2, indices
3556 // {2,5}), not dense empties. assignaparam has already placed each
3557 // value at its 0-based index; mark every OTHER slot a hole. Gated
3558 // to a PURE indexed literal (every element a `[idx]=val` triple) —
3559 // a mixed positional/indexed literal (`a=(x [3]=y z)`) needs the
3560 // positional-counter replay we don't model, so it stays dense.
3561 if crate::dash_mode::sparse_arrays() && has_kv {
3562 let marker = crate::ported::zsh_h::Marker;
3563 let pure_indexed = !values.is_empty()
3564 && values.len() % 3 == 0
3565 && values.chunks(3).all(|ch| ch[0].starts_with(marker));
3566 if pure_indexed {
3567 let mut explicit: std::collections::BTreeSet<usize> =
3568 std::collections::BTreeSet::new();
3569 for ch in values.chunks(3) {
3570 if let Ok(i) = ch[1].trim().parse::<usize>() {
3571 explicit.insert(i);
3572 }
3573 }
3574 let len = exec.array(&name).map(|a| a.len()).unwrap_or(0);
3575 for i in 0..len {
3576 if !explicit.contains(&i) {
3577 crate::bash_arrays::note_unset(&name, i);
3578 }
3579 }
3580 }
3581 }
3582 #[cfg(feature = "recorder")]
3583 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3584 let ctx = exec.recorder_ctx();
3585 let attrs = exec.recorder_attrs_for(&name);
3586 emit_path_or_assign(&name, &values, attrs, false, &ctx);
3587 }
3588 false
3589 });
3590 let status = if blocked { 1 } else { 0 };
3591 // c:Src/jobs.c:1748-1757 waitonejob — in C an array-assignment
3592 // simple command goes through execpline → waitjobs; with no
3593 // procs the else-branch stores `pipestats[0] = lastval;
3594 // numpipestats = 1`. Bare SCALAR assignments never create a
3595 // job (no waitjobs), so this clobber is array/assoc-assignment
3596 // specific: `false|true; x=(1 2); echo $pipestatus` → `0` in
3597 // zsh while `x=1` preserves `1 0`. Bug #373.
3598 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
3599 let mut synth = crate::ported::zsh_h::job::default();
3600 crate::ported::jobs::waitonejob(&mut synth);
3601 Value::Status(status)
3602 });
3603 // `arr+=(d e f)` — array append. Same calling conventions as SET_ARRAY.
3604 //
3605 // PURE PASSTHRU shape: pop name + values, dispatch through the
3606 // canonical assoc / array setter. assignaparam's ASSPM_AUGMENT
3607 // flag handles the C-source-equivalent "preserve prior value"
3608 // semantics; for now we read the current array, extend with
3609 // new values, write through set_array (which routes to
3610 // setaparam → assignaparam where PM_UNIQUE dedupe lands).
3611 vm.register_builtin(BUILTIN_APPEND_ARRAY, |vm, argc| {
3612 let n = argc as usize;
3613 let mut popped: Vec<Value> = Vec::with_capacity(n);
3614 for _ in 0..n {
3615 popped.push(vm.pop());
3616 }
3617 popped.reverse();
3618 if popped.is_empty() {
3619 return Value::Status(1);
3620 }
3621 let name = popped.pop().unwrap().to_str();
3622 let mut values: Vec<String> = Vec::new();
3623 for v in popped {
3624 flatten_array_value(v, &mut values);
3625 }
3626 let blocked = with_executor(|exec| -> bool {
3627 // Assoc append `m+=(k1 v1 ...)`: merge the (k,v) pairs into
3628 // the existing map and write back via canonical sethparam
3629 // (Src/params.c:3602). The canonical C path would go
3630 // assignaparam(ASSPM_AUGMENT) → arrhashsetfn(ASSPM_AUGMENT)
3631 // at Src/params.c:3850, but the zshrs port of
3632 // arrhashsetfn doesn't yet implement value-storage
3633 // (pending Param.u_hash backend wireup) — until that
3634 // lands, do the augment + write here so the storage
3635 // actually mutates.
3636 if exec.assoc(&name).is_some() {
3637 // `[k]=v` / `[k]+=v` elements arrive as Marker / key /
3638 // value triples (compile_zsh's port of
3639 // keyvalpairelement, c:Src/subst.c:49-79).
3640 let marker = crate::ported::zsh_h::Marker;
3641 let mut map = exec.assoc(&name).unwrap_or_default();
3642 if values.iter().any(|e| e.starts_with(marker)) {
3643 // c:Src/params.c:3544-3560 — strict triad rule.
3644 let mut i = 0usize;
3645 while i < values.len() {
3646 if !values[i].starts_with(marker) {
3647 crate::ported::utils::zerr(
3648 "bad [key]=value syntax for associative array",
3649 );
3650 crate::ported::utils::errflag.fetch_or(
3651 crate::ported::zsh_h::ERRFLAG_ERROR,
3652 std::sync::atomic::Ordering::Relaxed,
3653 );
3654 exec.set_last_status(1);
3655 return true;
3656 }
3657 i += 3;
3658 }
3659 if values.len() % 3 != 0 {
3660 // c:Src/params.c:4124-4131 — odd pair count.
3661 crate::ported::utils::zerr(
3662 "bad set of key/value pairs for associative array",
3663 );
3664 crate::ported::utils::errflag.fetch_or(
3665 crate::ported::zsh_h::ERRFLAG_ERROR,
3666 std::sync::atomic::Ordering::Relaxed,
3667 );
3668 exec.set_last_status(1);
3669 return true;
3670 }
3671 // c:Src/params.c:4133-4168 arrhashsetfn with
3672 // ASSPM_AUGMENT — ht = the EXISTING table, so
3673 // `[k]+=v` appends to the current value
3674 // (assignstrvalue eltflags=ASSPM_AUGMENT,
3675 // c:4144-4150) and `[k]=v` overwrites.
3676 for ch in values.chunks(3) {
3677 let elt_append = ch[0].chars().nth(1) == Some('+');
3678 let k = ch[1].clone();
3679 let v = ch[2].clone();
3680 let nv = if elt_append {
3681 format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
3682 } else {
3683 v
3684 };
3685 map.insert(k, nv);
3686 }
3687 } else {
3688 // c:Src/params.c:4076-4085 arrhashsetfn — the SAME odd-count
3689 // gate the non-augment form uses runs before ASSPM_AUGMENT
3690 // merges anything:
3691 // for (aptr = val; *aptr; ++aptr)
3692 // if (**aptr != Marker) ++alen;
3693 // if (alen % 2) { freearray(val);
3694 // zerr("bad set of key/value pairs for associative
3695 // array"); return; }
3696 // c:4086 `if (flags & ASSPM_AUGMENT)` is reached only AFTER
3697 // it, so `h+=(k2)` with a lone key is refused and the hash is
3698 // left alone. The walk below just dropped the unpaired key
3699 // (`if let Some(v) = it.next()`), so the append silently
3700 // no-opped at status 0 where zsh errors and exits 1.
3701 if values.len() % 2 != 0 {
3702 crate::ported::utils::zerr(
3703 "bad set of key/value pairs for associative array",
3704 ); // c:4083
3705 crate::ported::utils::errflag.fetch_or(
3706 crate::ported::zsh_h::ERRFLAG_ERROR,
3707 std::sync::atomic::Ordering::Relaxed,
3708 );
3709 // c:Src/exec.c:2632-2633 — a failed assignment sets
3710 // lastval 1, the same tail the triad form above uses.
3711 exec.set_last_status(1);
3712 return true;
3713 }
3714 let mut it = values.iter().cloned();
3715 while let Some(k) = it.next() {
3716 if let Some(v) = it.next() {
3717 map.insert(k, v);
3718 }
3719 }
3720 }
3721 exec.set_assoc(name, map);
3722 return false;
3723 }
3724 // Indexed-array append `arr+=(d e f)` — route directly
3725 // through canonical assignaparam with ASSPM_AUGMENT
3726 // (`Src/params.c:3570-3585` append-on-array branch).
3727 // assignaparam reads the prior array internally and
3728 // appends the new values, so the bridge no longer needs
3729 // to pre-concat manually. Marker triples from `[k]=v`
3730 // elements add ASSPM_KEY_VALUE (c:Src/exec.c:2552-2553)
3731 // so the kv sparse-fill block (c:Src/params.c:3447-3541)
3732 // resolves them against the existing elements.
3733 let kv_flag = if values
3734 .iter()
3735 .any(|e| e.starts_with(crate::ported::zsh_h::Marker))
3736 {
3737 crate::ported::zsh_h::ASSPM_KEY_VALUE
3738 } else {
3739 0
3740 };
3741 let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3742 & crate::ported::zsh_h::ERRFLAG_ERROR;
3743 let res = crate::ported::params::assignaparam(
3744 &name,
3745 values.clone(),
3746 crate::ported::zsh_h::ASSPM_AUGMENT | kv_flag,
3747 );
3748 let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3749 & crate::ported::zsh_h::ERRFLAG_ERROR;
3750 if res.is_none() && pre_err == 0 && now_err != 0 {
3751 // c:Src/exec.c:2632-2633 — failed assignment → lastval 1.
3752 exec.set_last_status(1);
3753 return true;
3754 }
3755 #[cfg(feature = "recorder")]
3756 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3757 let ctx = exec.recorder_ctx();
3758 let attrs = exec.recorder_attrs_for(&name);
3759 emit_path_or_assign(&name, &values, attrs, true, &ctx);
3760 }
3761 // Tied-scalar mirror — TODO faithful: should live in
3762 // setarrvalue's gsu dispatch once boot_ paramtab wiring
3763 // lands (Task #16). Re-read the canonical post-augment
3764 // array so the joined scalar matches.
3765 let tied_scalar = exec.tied_array_to_scalar.get(&name).cloned();
3766 if let Some((scalar_name, sep)) = tied_scalar {
3767 let merged = exec.array(&name).unwrap_or_default();
3768 let joined = merged.join(&sep);
3769 exec.set_scalar(scalar_name.clone(), joined.clone());
3770 let _ = crate::ported::params::zputenv(&format!("{}={}", &scalar_name, &joined));
3771 // c:Src/params.c:5354
3772 }
3773 false
3774 });
3775 // c:Src/jobs.c:1748-1757 waitonejob — `arr+=(...)` is an
3776 // array-assignment simple command and clobbers pipestats to
3777 // `[lastval]` exactly like `arr=(...)` above. Bug #373.
3778 let status = if blocked { 1 } else { 0 };
3779 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
3780 let mut synth = crate::ported::zsh_h::job::default();
3781 crate::ported::jobs::waitonejob(&mut synth);
3782 Value::Status(status)
3783 });
3784 // `name[@]=(...)` / `name[*]=(...)` — whole-array SET with the assoc
3785 // guard (c:Src/params.c:3324-3327). Stack: [v0..vn, name].
3786 vm.register_builtin(BUILTIN_SET_ARRAY_AT, |vm, argc| {
3787 let (name, values) = pop_array_args_with_name(vm, argc);
3788 let status = with_executor(|exec| {
3789 if exec.assoc(&name).is_some() {
3790 // c:Src/params.c:3324-3327 — `[@]` (any slice) on a
3791 // PM_HASHED target is an error.
3792 crate::ported::utils::zerr(&format!(
3793 "{}: attempt to set slice of associative array",
3794 name
3795 ));
3796 crate::ported::utils::errflag.fetch_or(
3797 crate::ported::zsh_h::ERRFLAG_ERROR,
3798 std::sync::atomic::Ordering::Relaxed,
3799 );
3800 exec.set_last_status(1);
3801 return 1;
3802 }
3803 exec.set_array(name, values); // whole replace (c:3528 setarrvalue)
3804 0
3805 });
3806 Value::Status(status)
3807 });
3808 // `name[@]+=(...)` / `name[*]+=(...)` — whole-array APPEND (push) with
3809 // the same assoc guard.
3810 vm.register_builtin(BUILTIN_APPEND_ARRAY_AT, |vm, argc| {
3811 let (name, values) = pop_array_args_with_name(vm, argc);
3812 let status = with_executor(|exec| {
3813 if exec.assoc(&name).is_some() {
3814 crate::ported::utils::zerr(&format!(
3815 "{}: attempt to set slice of associative array",
3816 name
3817 ));
3818 crate::ported::utils::errflag.fetch_or(
3819 crate::ported::zsh_h::ERRFLAG_ERROR,
3820 std::sync::atomic::Ordering::Relaxed,
3821 );
3822 exec.set_last_status(1);
3823 return 1;
3824 }
3825 let mut cur = exec.array(&name).unwrap_or_default();
3826 cur.extend(values);
3827 exec.set_array(name, cur); // c:3511-3528 AUGMENT on array → push
3828 0
3829 });
3830 Value::Status(status)
3831 });
3832 vm.register_builtin(BUILTIN_RUN_SELECT, |vm, argc| {
3833 if argc < 2 {
3834 return Value::Status(1);
3835 }
3836 let n = argc as usize;
3837 let mut popped: Vec<Value> = Vec::with_capacity(n);
3838 for _ in 0..n {
3839 popped.push(vm.pop());
3840 }
3841 // popped: [sub_idx, name, word_N, ..., word_1] (popping from top)
3842 let sub_idx_val = popped.remove(0);
3843 let name_val = popped.remove(0);
3844 // c:Src/loop.c — `select` flattens Array values (from `$@`,
3845 // `${arr[@]}`, etc.) into the menu. Without per-element
3846 // splice, `select x do ... done` (bare, iterating $@)
3847 // collapsed all positionals into one joined entry.
3848 let mut words: Vec<String> = Vec::new();
3849 for v in popped.into_iter().rev() {
3850 match v {
3851 Value::Array(items) => {
3852 for item in items.iter() {
3853 words.push(item.to_str());
3854 }
3855 }
3856 other => words.push(other.to_str()),
3857 }
3858 }
3859
3860 let sub_idx = sub_idx_val.to_int() as usize;
3861 let name = name_val.to_str();
3862
3863 // c:Src/loop.c:248-252 — `if (!args || empty(args)) {
3864 // state->pc = end; ... return 0; }`. An empty option list
3865 // skips the body entirely; without this gate the prompt loop
3866 // runs indefinitely (or twice on the EOF stdin case before
3867 // exiting). Bug #401.
3868 if words.is_empty() {
3869 return Value::Status(0);
3870 }
3871
3872 let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
3873 Some(c) => c,
3874 None => return Value::Status(1),
3875 };
3876
3877 let prompt =
3878 with_executor(|exec| exec.scalar("PROMPT3").unwrap_or_else(|| "?# ".to_string()));
3879
3880 let stdin = std::io::stdin();
3881 let mut reader = stdin.lock();
3882 let mut last_status: i32 = 0;
3883
3884 // c:Src/loop.c:264 — `more = selectlist(args, 0);` renders the menu
3885 // ONCE, BEFORE the selection loop. C reprints it ONLY when the user
3886 // enters an EMPTY line (c:290, inside the inner read loop) — never per
3887 // body iteration. This was a single conflated loop that re-rendered at
3888 // the top of every pass, so `printf "1\n2\n" | select x in a b; do
3889 // print $x; done` redrew the list before each prompt where zsh prints
3890 // it once.
3891 //
3892 // (`selectlist` is also ported at src/ported/loop.rs:127, but that copy
3893 // derives its row budget from adjustlines()/adjustcolumns() — an ioctl
3894 // on fd 1 — and so renders nothing when stdout is not a tty, which is
3895 // exactly this path. Keeping the working inline render here.)
3896 let render_menu = || {
3897 // Direct port of zsh's selectlist from
3898 // src/zsh/Src/loop.c:347-409. Layout is column-major
3899 // ("down columns, then across") — NOT row-major. With
3900 // 6 items in 3 cols zsh produces:
3901 // 1 3 5
3902 // 2 4 6
3903 // The previous Rust impl walked row-major which
3904 // produced 1 2 3 / 4 5 6 (visually similar but wrong
3905 // for prompts that mention ordering and breaks scripts
3906 // that rely on column count == ceil(N/rows)).
3907 //
3908 // C variable mapping:
3909 // ct -> word count (n)
3910 // longest -> max item width + 1, then plus digits-of-ct
3911 // fct -> column count
3912 // fw -> per-column width
3913 // colsz -> row count = ceil(ct / fct)
3914 // t1 -> row index, walks 0..colsz
3915 // ap -> item pointer; advances by colsz to step
3916 // DOWN a column.
3917 let term_width: usize = env::var("COLUMNS")
3918 .ok()
3919 .and_then(|v| v.parse().ok())
3920 .unwrap_or(80);
3921 let ct = words.len();
3922 // loop.c:354-363 — find longest item width.
3923 let mut longest = 1usize;
3924 for w in &words {
3925 let aplen = w.chars().count();
3926 if aplen > longest {
3927 longest = aplen;
3928 }
3929 }
3930 // loop.c:365-367 — `longest++` then add digits of `ct`.
3931 longest += 1;
3932 let mut t0 = ct;
3933 while t0 > 0 {
3934 t0 /= 10;
3935 longest += 1;
3936 }
3937 // loop.c:369-373 — fct = (cols - 1) / (longest + 3); if
3938 // 0, fct = 1; else fw = (cols - 1) / fct.
3939 let raw_fct = (term_width.saturating_sub(1)) / (longest + 3);
3940 let (fct, fw) = if raw_fct == 0 {
3941 (1, longest + 3)
3942 } else {
3943 (raw_fct, (term_width.saturating_sub(1)) / raw_fct)
3944 };
3945 // loop.c:374 — colsz = (ct + fct - 1) / fct.
3946 let colsz = ct.div_ceil(fct);
3947 // loop.c:375-395 — for each row t1, walk down columns.
3948 for t1 in 0..colsz {
3949 let mut ap_idx = t1;
3950 while ap_idx < ct {
3951 let w = &words[ap_idx];
3952 let n = ap_idx + 1;
3953 let _ = write!(std::io::stderr(), "{}) {}", n, w);
3954 let mut t2 = w.chars().count() + 2;
3955 let mut t3 = n;
3956 while t3 > 0 {
3957 t2 += 1;
3958 t3 /= 10;
3959 }
3960 // Pad to fw (loop.c:389-390).
3961 while t2 < fw {
3962 let _ = write!(std::io::stderr(), " ");
3963 t2 += 1;
3964 }
3965 ap_idx += colsz;
3966 }
3967 let _ = writeln!(std::io::stderr());
3968 }
3969 };
3970 render_menu(); // c:264 — once, before the loop
3971
3972 'select: loop {
3973 // c:266-290 — inner read loop: prompt and read until a NON-EMPTY
3974 // line arrives; each empty line reprints the menu and re-reads.
3975 let trimmed = loop {
3976 let _ = write!(std::io::stderr(), "{}", prompt);
3977 let _ = std::io::stderr().flush();
3978
3979 let mut line = String::new();
3980 match reader.read_line(&mut line) {
3981 Ok(0) => {
3982 // c:277-285 — EOF (user pressed Ctrl+D): REPLY="",
3983 // a newline to stderr, then leave the construct.
3984 with_executor(|exec| {
3985 exec.set_scalar("REPLY".to_string(), String::new());
3986 });
3987 let _ = writeln!(std::io::stderr());
3988 let _ = std::io::stderr().flush();
3989 break 'select;
3990 }
3991 Ok(_) => {}
3992 Err(_) => break 'select,
3993 }
3994 let t = line.trim_end_matches(['\n', '\r'][..].as_ref()).to_string();
3995 // c:288-289 — `if (*str) break;`
3996 if !t.is_empty() {
3997 break t;
3998 }
3999 // c:290 — `more = selectlist(args, more);` on an empty line.
4000 render_menu();
4001 };
4002 // c:291 `setsparam("REPLY", ztrdup(str));` — REPLY is set once the
4003 // inner loop yields a non-empty line. An empty line never reaches
4004 // here: c:290 reprints and re-reads instead.
4005 with_executor(|exec| {
4006 exec.set_scalar("REPLY".to_string(), trimmed.clone());
4007 });
4008
4009 // c:293 `i = atoi(str);` — atoi(3) reads a LEADING integer:
4010 // optional blanks, optional sign, then digits, ignoring whatever
4011 // trails, and yields 0 when there are no digits at all.
4012 // `parse::<usize>()` is strict and rejected `1 2` / `1abc` / `+2`
4013 // / ` 1`, so a reply with anything after the number selected
4014 // NOTHING where zsh selects the leading number's item.
4015 let i: i64 = {
4016 let b = trimmed.as_bytes();
4017 let mut p = 0;
4018 while p < b.len() && (b[p] == b' ' || b[p] == b'\t') {
4019 p += 1;
4020 }
4021 let neg = p < b.len() && b[p] == b'-';
4022 if p < b.len() && (b[p] == b'-' || b[p] == b'+') {
4023 p += 1;
4024 }
4025 let mut v: i64 = 0;
4026 while p < b.len() && b[p].is_ascii_digit() {
4027 v = v.saturating_mul(10).saturating_add((b[p] - b'0') as i64);
4028 p += 1;
4029 }
4030 if neg {
4031 -v
4032 } else {
4033 v
4034 }
4035 };
4036 // c:294-301 — `if (!i) str = "";` else walk i-1 nodes and take
4037 // that word; running off the end leaves "". A NEGATIVE i walks
4038 // until the list is exhausted (`n && i`), which also lands on "".
4039 let chosen = if i <= 0 {
4040 String::new()
4041 } else {
4042 words.get((i - 1) as usize).cloned().unwrap_or_default()
4043 };
4044
4045 with_executor(|exec| {
4046 exec.set_scalar(name.clone(), chosen);
4047 });
4048
4049 // Reset canonical BREAKS/CONTFLAG before running the body
4050 // so a stale value from a sibling construct doesn't leak in.
4051 crate::ported::builtin::BREAKS.store(0, SeqCst);
4052 crate::ported::builtin::CONTFLAG.store(0, SeqCst);
4053
4054 // c:Src/loop.c — `select` increments LOOPS for the body so
4055 // `break` / `continue` inside the body see loops > 0 and
4056 // don't emit `not in while, until, select, or repeat loop`.
4057 // Mirrors execwhile/execrepeat's `LOOPS.fetch_add` pattern.
4058 // The decrement happens after the body call so a body that
4059 // explicitly returns / errors still leaves the counter
4060 // balanced for the next iteration.
4061 crate::ported::builtin::LOOPS.fetch_add(1, SeqCst);
4062
4063 crate::fusevm_disasm::maybe_print_stdout("select:body", &chunk);
4064 let mut body_vm = fusevm::VM::new(chunk.clone());
4065 register_builtins(&mut body_vm);
4066 let _ = body_vm.run();
4067 last_status = body_vm.last_status;
4068
4069 crate::ported::builtin::LOOPS.fetch_sub(1, SeqCst);
4070
4071 // Drain the canonical BREAKS/CONTFLAG counters. Mirrors
4072 // loop.c:529-534's `if (breaks) { breaks--; if (breaks ||
4073 // !contflag) break; contflag = 0; }` drain pattern.
4074 // The legacy `BREAK_SELECT=1` env-var sentinel is still
4075 // honored for backward compat.
4076 let break_legacy = with_executor(|exec| {
4077 let v = exec.scalar("BREAK_SELECT");
4078 exec.unset_scalar("BREAK_SELECT");
4079 v.map(|s| s != "0" && !s.is_empty()).unwrap_or(false)
4080 });
4081 use std::sync::atomic::Ordering::SeqCst;
4082 let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
4083 if breaks > 0 {
4084 let cont = crate::ported::builtin::CONTFLAG.load(SeqCst);
4085 crate::ported::builtin::BREAKS.fetch_sub(1, SeqCst);
4086 if breaks - 1 > 0 || cont == 0 {
4087 break;
4088 }
4089 crate::ported::builtin::CONTFLAG.store(0, SeqCst);
4090 continue;
4091 }
4092 if break_legacy {
4093 break;
4094 }
4095 }
4096
4097 Value::Status(last_status)
4098 });
4099
4100 // Magic special-parameter assoc lookup. Synthesizes values from
4101 // shell state for zsh's shell-introspection assocs:
4102 // commands, aliases, galiases, saliases, dis_aliases, dis_galiases,
4103 // dis_saliases, functions, dis_functions, builtins, dis_builtins,
4104 // reswords, options, parameters, jobtexts, jobdirs, jobstates,
4105 // nameddirs, userdirs, modules.
4106 // Returns None if `name` isn't a recognized magic name.
4107
4108 // `${arr[idx]}` — pop name, then idx_str. zsh is 1-based for positive
4109 // indices; we honor that. `@`/`*` return the whole array as Value::Array
4110 // so Op::Exec splice produces N argv slots. For `${foo[key]}` where foo
4111 // is an assoc, the idx is a string key — we check assoc_arrays first
4112 // when the idx isn't `@`/`*` and the name has an assoc binding.
4113 // BUILTIN_ARRAY_INDEX — `${name[idx]}` paramsubst dispatch.
4114 // PURE PASSTHRU: pops the idx + name, hands the canonical
4115 // `${name[idx]}` form to `subst::paramsubst` (C port of
4116 // `Src/subst.c::paramsubst`). All subscript-flag dispatch
4117 // ((I)pat / (R)pat / (i)/(r)/(K)/(k), range slices `[N,M]`,
4118 // negative indices, magic-assoc shape lookup, DQ-join collapse)
4119 // lives inside paramsubst → fetchvalue → getarg in params.rs.
4120 //
4121 // Outer-flag dispatch (`(@)` / `(@k)` / `(v)NAME[(I)pat]` / etc.)
4122 // routes through BUILTIN_BRIDGE_BRACE_ARRAY at the compile path
4123 // (canonical paramsubst flag parser owns dispatch at Src/subst.c:2147+),
4124 // so BUILTIN_ARRAY_INDEX receives clean name+key with no sentinel
4125 // prefixes.
4126 vm.register_builtin(BUILTIN_ARRAY_INDEX, |vm, _argc| {
4127 let idx = vm.pop().to_str();
4128 let name = vm.pop().to_str();
4129 array_index_lookup(&name, &idx)
4130 });
4131 // BUILTIN_ARRAY_INDEX_UNBRACED — bare `$name[idx]` (no braces).
4132 // Same subscript dispatch as BUILTIN_ARRAY_INDEX when KSHARRAYS
4133 // is unset, but under KSHARRAYS the UNBRACED form does NOT
4134 // subscript at all:
4135 // c:Src/subst.c:2800-2802 — fetchvalue's bracket-parse arg is
4136 // `(unset(KSHARRAYS) || inbrace) ? 1 : -1`; -1 inhibits
4137 // subscript parsing for the bare form under KSHARRAYS.
4138 // c:Src/subst.c:2867 — the bracket-consuming loop only runs
4139 // `while (v || ((inbrace || (unset(KSHARRAYS) && vunset)) &&
4140 // isbrack(*s)))` — bare + KSHARRAYS leaves `[...]` as literal
4141 // trailing text.
4142 // The bare `$name` expands (first element for identifier-named
4143 // arrays per c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`),
4144 // the literal `[idx]` (+ any literal suffix) joins the last word,
4145 // and the word undergoes filename generation: `[...]` is a glob
4146 // char class, so unquoted it hits the c:Src/glob.c:1873-1886
4147 // nomatch/nullglob dispatch (reused via exec.expand_glob).
4148 // Operands: [name, idx, suffix, quoted] — `quoted` set when the
4149 // word carries DQ markers (no filename generation in DQ; zsh 5.9:
4150 // `setopt ksharrays; a=(x y z); print "$a[0]"` → `x[0]`).
4151 // Verbatim zsh 5.9 ground truth for the unquoted form:
4152 // `setopt ksharrays; a=(x y z); print -- $a[0]` →
4153 // stderr `zsh:1: no matches found: x[0]`, rc=1, empty stdout.
4154 vm.register_builtin(BUILTIN_ARRAY_INDEX_UNBRACED, |vm, _argc| {
4155 let quoted = vm.pop().to_str() == "1";
4156 let suffix = vm.pop().to_str();
4157 let idx = vm.pop().to_str();
4158 let name = vm.pop().to_str();
4159 if !crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
4160 let v = array_index_lookup(&name, &idx);
4161 if suffix.is_empty() {
4162 return v;
4163 }
4164 // Mirrors the previous compile-shape `ARRAY_INDEX +
4165 // Op::Concat` exactly: Concat stringifies via as_str_cow
4166 // (fusevm value.rs:132-146, arrays join with " ").
4167 return Value::str(format!("{}{}", v.to_str(), suffix));
4168 }
4169 // KSHARRAYS bare form: no subscript. Bare-`$name` words +
4170 // literal `[idx]suffix` glued onto the last word.
4171 let mut words = ksharrays_bare_words(&name);
4172 let last = format!("{}[{}]{}", words.pop().unwrap_or_default(), idx, suffix);
4173 if quoted {
4174 // DQ context — no filename generation, bracket text stays
4175 // literal.
4176 words.push(last);
4177 return Value::str(words.join(" "));
4178 }
4179 // c:Src/glob.c:1873-1886 — expand_glob handles nullglob /
4180 // NOMATCH (zerr "no matches found" + errflag + the
4181 // current_command_glob_failed cell consumed at the command
4182 // dispatch boundary) / literal passthrough for glob-free text.
4183 let matches = with_executor(|exec| exec.expand_glob(&last));
4184 let mut out: Vec<Value> = words.into_iter().map(Value::str).collect();
4185 out.extend(matches.into_iter().map(Value::str));
4186 if out.len() == 1 {
4187 return out.pop().unwrap();
4188 }
4189 Value::array(out)
4190 });
4191 // BUILTIN_ASSOC_HAS_KEY — `${(k)assoc[name]}` key-existence query.
4192 // Pops [assoc_name, key]; returns key (Str) if present in the
4193 // assoc, empty Str otherwise. Mirrors zsh's `${(k)h[name]}`
4194 // documented semantics in zshparam(1) "Parameter Expansion Flags".
4195 // Distinct from BUILTIN_ARRAY_INDEX (which returns the VALUE) and
4196 // from `${+h[name]}` (which returns "0"/"1"). Bug #145.
4197 vm.register_builtin(BUILTIN_ASSOC_HAS_KEY, |vm, _argc| {
4198 let key = vm.pop().to_str();
4199 let name = vm.pop().to_str();
4200 // c:Src/params.c getindex — the subscript text is substituted
4201 // (singsub) before the lookup; `${(k)H[$k]}` must resolve $k.
4202 // The compiler hands this opcode the RAW subscript text, so a
4203 // dynamic key arrived literally ("$k") and matched nothing.
4204 // singsub is identity for plain keys.
4205 let key = if key.contains('$') || key.contains('`') || key.contains('\u{8c}') {
4206 crate::ported::subst::singsub(&key)
4207 } else {
4208 key
4209 };
4210 // c:Src/params.c:3131 gethkparam covers ordinary PM_HASHED
4211 // paramtab entries only. Special/magic hashes (`parameters`,
4212 // `options`, … — the zsh/parameter module's partab-backed
4213 // params, Src/Modules/parameter.c) aren't in that storage, so
4214 // a None here doesn't mean "no such assoc". Route those
4215 // through paramsubst, whose assoc materialization handles the
4216 // magic hashes and whose getarg port (c:Src/params.c:1591 +
4217 // Src/subst.c:2922) returns the KEY for `(k)` on a plain
4218 // subscript. zsh 5.9: `${(k)parameters[PATH]}` → "PATH".
4219 // c:Src/params.c:1602-1612 — on a hash subscript C dispatches
4220 // `ht->getnode(ht, s)`; it never enumerates. For the magic hashes that
4221 // distinction is observable: `getfunction_source` (c:Src/Modules/
4222 // parameter.c:549-566) answers for names its scan never lists, `mapfile`
4223 // answers for any readable file, and the job trio calls `getjob(name,
4224 // NULL)` whose `job not found` diagnostic (Src/jobs.c:2150-2151) must be
4225 // emitted by the read. `gethkparam` answers Some(<enumerated keys>) for
4226 // these names, which shortcut the dispatch entirely — `${(k)jobstates[x]}`
4227 // stayed silent and `${(k)functions_source[x]}` returned "" where zsh
4228 // returns the key. Send PARTAB names to paramsubst, which owns the
4229 // getnode path (c:Src/subst.c:2923-2925 — key when set, "" when unset).
4230 // Keys carrying `]`/`}` can't survive the flat rebuild (see
4231 // array_index_lookup), so those keep the enumeration answer.
4232 let magic_getnode = !key.contains(']')
4233 && !key.contains('}')
4234 && crate::ported::modules::parameter::PARTAB
4235 .iter()
4236 .any(|e_| e_.name == name);
4237 match crate::ported::params::gethkparam(&name) {
4238 Some(keys) if !magic_getnode => {
4239 if keys.iter().any(|k| k == &key) {
4240 Value::str(key)
4241 } else {
4242 Value::str("")
4243 }
4244 }
4245 _ => paramsubst_to_value(&format!("${{(k){}[{}]}}", name, key)),
4246 }
4247 });
4248 vm.register_builtin(BUILTIN_BRIDGE_BRACE_ARRAY, |vm, _argc| {
4249 // Inner body of `${(...)...}` (already stripped of `${`/`}` by
4250 // the caller). The compiler optionally prefixes Qstring
4251 // (\u{8c}) to signal "expanded in DQ context" — strip it
4252 // here and bump in_dq_context for the paramsubst call so the
4253 // SUB_ZIP and other qt-aware paths fire.
4254 let body = vm.pop().to_str();
4255 let (dq, inner) = if let Some(rest) = body.strip_prefix('\u{8c}') {
4256 (true, rest.to_string())
4257 } else {
4258 (false, body)
4259 };
4260 if dq {
4261 with_executor(|exec| exec.in_dq_context += 1);
4262 }
4263 let v = paramsubst_to_value(&format!("${{{}}}", inner));
4264 if dq {
4265 with_executor(|exec| exec.in_dq_context -= 1);
4266 }
4267 v
4268 });
4269
4270 // BUILTIN_PARAM_FLAG — `${(flags)name}` paramsubst dispatch.
4271 // PURE PASSTHRU: pops sentinel-tagged flags + name, hands the
4272 // canonical `${(flags)name}` form to `subst::paramsubst` (C port
4273 // of `Src/subst.c::paramsubst`). The bridge does no flag
4274 // walking, no DQ-context branching, no array/scalar shape
4275 // selection — all of that lives inside paramsubst. Compile-time
4276 // context (DQ / scalar-assign-RHS) flows through executor cells
4277 // (in_dq_context, in_scalar_assign) bumped by BUILTIN_EXPAND_TEXT.
4278 vm.register_builtin(BUILTIN_PARAM_FLAG, |vm, argc| {
4279 // argc 3 = the compiler flagged this expansion as the VALUE of a
4280 // scalar assignment (`x=…` / `local x=…`), which C preforks with
4281 // PREFORK_SINGLE|PREFORK_ASSIGN (c:Src/exec.c:2603 / :4239-4241).
4282 // PREFORK_SINGLE is paramsubst's `ssub` (c:Src/subst.c:1761) and
4283 // gates off c:3913's `force_split`, so `(s::)` / `(f)` / `(0)` do
4284 // not split there. argc 2 = ordinary word, no ssub.
4285 let ssub = if argc >= 3 {
4286 vm.pop().to_int() != 0
4287 } else {
4288 false
4289 };
4290 let flags = vm.pop().to_str();
4291 let name = vm.pop().to_str();
4292 let body = format!("${{({}){}}}", flags, name);
4293 let pf_flags = if ssub {
4294 crate::ported::zsh_h::PREFORK_SINGLE
4295 } else {
4296 0
4297 };
4298 paramsubst_to_value_pf(&body, pf_flags)
4299 });
4300
4301 // `foo[key]=val` — single-key set on an assoc array. Stack: [name, key, value].
4302 // PURE PASSTHRU: assignsparam with `name[key]` form (C port of
4303 // `Src/params.c::assignsparam` subscript path at c:3210-3231)
4304 // already does the indexed-array vs assoc decision, PM_HASHED
4305 // auto-vivification, numeric-subscript bounds handling, and
4306 // PM_READONLY rejection.
4307 /// Assign `val` to one element of the PM_HASHED parameter `name`.
4308 ///
4309 /// This is the tail of C's `assignsparam` for a subscripted target:
4310 /// c:Src/params.c:3251 `getvalue(&vbuf, &t, 1)` (→ `fetchvalue` →
4311 /// `getindex`) followed by c:3343 `assignstrvalue(v, val, flags)`.
4312 ///
4313 /// C hands `getindex` the FLAT `"name[subscript]"` text, and that is
4314 /// safe there because its subscript is still the SOURCE spelling: the
4315 /// bracket walk at c:2008 `parse_subscript` runs BEFORE the
4316 /// `parsestr`/`singsub` round at c:1585-1592, so a `]` that arrives by
4317 /// expansion can never terminate it. zshrs expands a subscript before
4318 /// this builtin runs, so re-flattening to `name[key]` and re-splitting
4319 /// corrupts any key containing `]` (`k='x]y'; h[$k]=5` stored `x`). The
4320 /// two halves therefore stay separate the whole way down:
4321 ///
4322 /// * `sub` is the EXPANDED subscript — the key text.
4323 /// * `sub_src` is the SOURCE subscript, used for ONE decision, the
4324 /// one C makes at c:1410: `if (v->pm && (*s == '(' || *s == Inpar))`
4325 /// — is there a flag block? Flags can only be literal (they are read
4326 /// at c:1409, before any expansion), so `x='(r)v'; h[$x]=Z` has no
4327 /// flag block and stores the literal key `(r)v`, while `h[(r)$x]=Z`
4328 /// does have one and is a search.
4329 ///
4330 /// With no flag block there is nothing for `getindex` to resolve beyond
4331 /// the exact-key rebind at c:1596-1616, so the key goes straight to the
4332 /// element store and never meets a parser. With one, `getindex` runs on
4333 /// the EXPANDED text: its flag block is byte-identical to the source's
4334 /// (flags are literal) and its pattern is already substituted, which is
4335 /// what c:1585-1592 would have produced anyway.
4336 fn assign_hash_element(name: &str, sub: &str, sub_src: &str, val: &str) -> i32 {
4337 use crate::ported::zsh_h::{Inpar, PM_HASHED, PM_READONLY, SCANPM_ARRONLY};
4338 let pm = crate::ported::params::paramtab()
4339 .read()
4340 .ok()
4341 .and_then(|t| t.get(name).cloned());
4342 // c:3216-3221 — `if (v->pm->node.flags & PM_READONLY)`.
4343 if pm
4344 .as_ref()
4345 .is_some_and(|p| (p.node.flags as u32 & PM_READONLY) != 0)
4346 {
4347 crate::ported::utils::zerr(&format!("read-only variable: {}", name)); // c:3217
4348 return 1; // c:3221
4349 }
4350 // c:1410 — `if (v->pm && (*s == '(' || *s == Inpar))`, read off the
4351 // SOURCE spelling.
4352 let has_flags = sub_src.starts_with('(') || sub_src.starts_with(Inpar);
4353 if has_flags {
4354 let mut v = crate::ported::zsh_h::value {
4355 pm,
4356 arr: Vec::new(),
4357 // c:2274-2280 — fetchvalue promotes a PM_ARRAY/PM_HASHED
4358 // value with no caller flags to SCANPM_ARRONLY; assignsparam
4359 // arrives through `getvalue`, i.e. flags 0.
4360 scanflags: SCANPM_ARRONLY as i32,
4361 valflags: 0,
4362 start: 0,
4363 end: -1, // c:2279
4364 };
4365 let bracketed = format!("[{}]", sub); // c:2281 `*s == '['`
4366 let mut sp: &str = &bracketed;
4367 if crate::ported::params::getindex(&mut sp, &mut v, 0) != 0 {
4368 // c:2020-2022 — `zerr("invalid subscript")` already reported.
4369 return 1;
4370 }
4371 let elem_is_hash = v
4372 .pm
4373 .as_ref()
4374 .is_some_and(|p| crate::ported::zsh_h::PM_TYPE(p.node.flags as u32) == PM_HASHED);
4375 if elem_is_hash {
4376 // A search subscript: the c:1596 exact-key rebind did not
4377 // happen, so the value still refers to the whole association
4378 // and c:3343 `assignstrvalue` reports it — either "attempt to
4379 // set slice of associative array" (c:2701-2706, the scanflags
4380 // survived the c:2179 clear) or "attempt to set associative
4381 // array to scalar" (c:2831-2839, they did not and no member
4382 // was found).
4383 let pre = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
4384 crate::ported::params::assignstrvalue(
4385 Some(&mut v),
4386 Some(val.to_string()),
4387 crate::ported::zsh_h::ASSPM_WARN,
4388 ); // c:3343
4389 let post = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
4390 return if post != pre { 1 } else { 0 };
4391 }
4392 // c:1596-1616 — a non-search flag group (`(e)`, `(w)`, `(n:N:)`,
4393 // `(p)`, or an unrecognised one, c:1498 `flagerr`): `v->pm` is now
4394 // the ELEMENT and its name is the subscript with the group already
4395 // consumed. That name IS the key.
4396 let key =
4397 v.pm.as_ref()
4398 .map_or(sub, |p| p.node.nam.as_str())
4399 .to_string();
4400 return store_hash_element(name, &key, val);
4401 }
4402 // c:1596-1616 with no flag group at all — the subscript is the key
4403 // verbatim. Nothing to parse, so an expanded `]` stays intact.
4404 store_hash_element(name, sub, val)
4405 }
4406
4407 /// c:Src/params.c:2841 — `foundparam->gsu.s->setfn(foundparam, val)`,
4408 /// the write to one member of an association. This port keeps assoc
4409 /// members as plain strings in `paramtab_hashed_storage` rather than as
4410 /// Params carrying their own `strsetfn` (the same substitution
4411 /// `arrhashsetfn` makes at c:4113), so the member write is a map insert.
4412 fn store_hash_element(name: &str, key: &str, val: &str) -> i32 {
4413 if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
4414 store
4415 .entry(name.to_string())
4416 .or_default()
4417 .insert(key.to_string(), val.to_string()); // c:2841
4418 }
4419 0
4420 }
4421
4422 vm.register_builtin(BUILTIN_SET_ASSOC, |vm, _argc| {
4423 // `${~spec}` carrier: an assignment statement is a word-
4424 // pipeline boundary too — restore the user's GLOB_SUBST
4425 // before the NEXT word expands (`Z[d]=${~Z[d]}; print
4426 // ${options[globsubst]}` must read the user value).
4427 consume_tilde_globsubst_carrier();
4428 // argc 4 = compile flagged the subscript as DYNAMIC (`H[$k]`):
4429 // an EXPANDED-empty key is then a legal assoc key (C's
4430 // assignsparam isident gate sees the raw `$k` text and the
4431 // empty key stores at getindex time — zinit's
4432 // ZINIT_SICE[$1…$2] relies on it). argc 3 = source-literal
4433 // key; `H[]` stays the "not an identifier" error.
4434 // Stack shapes (compile_zsh::compile_assign):
4435 // argc 4 = [name, key, value, key_src] — literal key
4436 // argc 5 = [name, key, value, key_src, dynamic] — expanded key
4437 // `key_src` is the SOURCE spelling of the subscript, kept beside
4438 // the expanded one so nothing downstream has to re-flatten
4439 // `name[key]` and re-split it (c:Src/params.c:2008 parses the
4440 // subscript BEFORE expansion; see `assign_hash_element`).
4441 let key_is_dynamic = if _argc >= 5 {
4442 vm.pop().to_int() != 0
4443 } else {
4444 false
4445 };
4446 let key_src = if _argc >= 4 {
4447 Some(vm.pop().to_str())
4448 } else {
4449 None
4450 };
4451 let value = vm.pop().to_str();
4452 let key = vm.pop().to_str();
4453 let name = vm.pop().to_str();
4454 let key_src = key_src.unwrap_or_else(|| key.clone());
4455 // c:Src/params.c:3203-3207 — `if (!isident(s)) { zerr("not an
4456 // identifier: %s", s); errflag |= ERRFLAG_ERROR; return NULL; }`.
4457 // Every subscripted assignment passes through that gate, and isident
4458 // rejects an empty subscript at c:1334 `if (!(ss =
4459 // parse_subscript(++ss, 1, ']'))) return 0;` — the LHS text is
4460 // untokenized by then, so the `]` IS parse_subscript's literal endchar
4461 // and c:Src/lex.c:1748 returns NULL. `m[]=z` / `A[]=z` / `s[]=z` are
4462 // therefore all `not an identifier: NAME[]`, never a store.
4463 // Only the SOURCE-LITERAL empty subscript is affected: with a dynamic
4464 // key (`H[$k]=v`) C's isident sees the unexpanded `$k` text, passes,
4465 // and getindex stores the expanded — possibly empty — key.
4466 // The gate lives here because the PM_HASHED fast path and the numeric
4467 // pre-resolve below both reach the store without calling assignsparam
4468 // (the empty key resolved to "" for a hash and to math-0 for an
4469 // indexed array, so `A[]=z` reported "assignment to invalid subscript
4470 // range" instead). Route through assignsparam so the diagnostic and
4471 // the errflag are the canonical ones.
4472 if !key_is_dynamic && key.is_empty() {
4473 crate::ported::params::assignsparam(
4474 &format!("{}[]", name), // c:3203 — the LHS spelling zsh reports
4475 &value,
4476 crate::ported::zsh_h::ASSPM_WARN,
4477 );
4478 return Value::Status(1);
4479 }
4480 // Bash sparse-array tracking for `a[i]=v` (scalar single-index). A set
4481 // that pads the dense Vec past its old end leaves old_len..i as holes;
4482 // on an undefined array, `a[5]=q` leaves only index 5 (count 1). Only
4483 // for INDEXED arrays (assoc keys are strings), bash mode, numeric key.
4484 // Captured before the assign; applied after (on the array path).
4485 let sparse_track: Option<(String, usize, usize)> =
4486 if crate::dash_mode::sparse_arrays() && !key.contains(',') {
4487 key.trim().parse::<usize>().ok().and_then(|i| {
4488 with_executor(|exec| {
4489 if !exec.has_assoc(&name) {
4490 let old_len = exec.array(&name).map(|a| a.len()).unwrap_or(0);
4491 Some((name.clone(), old_len, i))
4492 } else {
4493 None
4494 }
4495 })
4496 })
4497 } else {
4498 None
4499 };
4500 if key_is_dynamic && key.is_empty() {
4501 with_executor(|exec| {
4502 let _ = exec;
4503 });
4504 // Mirror assignsparam's PM_HASHED tail directly (the
4505 // textual `name[]` reconstruction can't pass isident).
4506 if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
4507 let entry = store.entry(name.clone()).or_default();
4508 let newval = if let Some(old) = entry.get("") {
4509 // `+=` arrives pre-concatenated by the compile
4510 // read-modify-write; plain `=` overwrites.
4511 let _ = old;
4512 value.clone()
4513 } else {
4514 value.clone()
4515 };
4516 entry.insert(String::new(), newval);
4517 }
4518 return Value::Status(0);
4519 }
4520 with_executor(|exec| {
4521 #[cfg(feature = "recorder")]
4522 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
4523 let ctx = exec.recorder_ctx();
4524 let attrs = exec.recorder_attrs_for(&name);
4525 crate::recorder::emit_assoc_assign(
4526 &name,
4527 vec![(key.clone(), value.clone())],
4528 attrs,
4529 true,
4530 ctx,
4531 );
4532 }
4533 let _ = exec;
4534 });
4535 // Build `name[key]=value` shape for assignsparam's subscript
4536 // dispatch. Arith-evaluate numeric subscripts on an existing
4537 // indexed array (`a[i+1]=v` form) before handing off — the
4538 // canonical port currently only handles literal int / string
4539 // keys, so pre-resolve here.
4540 let resolved_key = with_executor(|exec| {
4541 // Existence probes only — use the non-cloning `has_*`
4542 // checks. `exec.assoc()` / `exec.array()` return owned
4543 // clones of the whole map/vector, so probing `.is_some()`
4544 // here copied the entire associative array on every
4545 // `h[k]=v` (O(n) per store → O(n²) for a fill loop). The
4546 // profiler flagged `ShellExecutor::assoc → IndexMap::clone`
4547 // as the dominant cost.
4548 let is_indexed = exec.has_array(&name);
4549 let is_assoc = exec.has_assoc(&name);
4550 let is_scalar = !is_indexed && !is_assoc && exec.has_scalar(&name);
4551 // c:Src/params.c::getindex — `(i)pat` / `(I)pat` / `(R)pat`
4552 // / `(r)pat` subscript flags on an indexed array LHS resolve
4553 // to a numeric index (first / last match of pat). On a
4554 // SCALAR LHS the same flags resolve to a CHAR position
4555 // (1-based first/last match of pat in the scalar string)
4556 // for the c:2748+ char-splice assignment. zshrs's
4557 // read-form `${a[(i)pat]}` already implements both shapes;
4558 // the LHS assignment path silently stored the literal
4559 // "(i)pat" as an assoc key (for scalar: auto-vivified to
4560 // PM_HASHED via the assignsparam unknown-subscript
4561 // fallback). Bug #293 (array) / scalar sibling.
4562 //
4563 // Detect the `(flags)pat` shape and resolve to a numeric
4564 // index before assignsparam.
4565 if is_indexed || is_scalar {
4566 if let Some(rest) = key.strip_prefix('(') {
4567 if let Some(close) = rest.find(')') {
4568 let flags = &rest[..close];
4569 let pat = &rest[close + 1..];
4570 if !flags.is_empty()
4571 && flags
4572 .chars()
4573 .all(|c| matches!(c, 'I' | 'R' | 'i' | 'r' | 'n' | 'e'))
4574 {
4575 // Resolve via the array's contents.
4576 if let Some(arr) = exec.array(&name) {
4577 let return_index = true; // LHS write — index needed
4578 let down = flags.contains('I') || flags.contains('R');
4579 let exact = flags.contains('e');
4580 let iter: Box<dyn Iterator<Item = (usize, &String)>> = if down {
4581 Box::new(arr.iter().enumerate().rev())
4582 } else {
4583 Box::new(arr.iter().enumerate())
4584 };
4585 let mut found: Option<usize> = None;
4586 for (idx, elem) in iter {
4587 let matched = if exact {
4588 elem == pat
4589 } else {
4590 crate::ported::pattern::patcompile(
4591 &{
4592 let mut __pat_tok = (pat).to_string();
4593 crate::ported::glob::tokenize(&mut __pat_tok);
4594 __pat_tok
4595 },
4596 crate::ported::zsh_h::PAT_HEAPDUP as i32,
4597 None,
4598 )
4599 .map_or(false, |p| crate::ported::pattern::pattry(&p, elem))
4600 };
4601 if matched {
4602 found = Some(idx);
4603 break;
4604 }
4605 }
4606 let _ = return_index;
4607 // (i)/(r) return 1-based index of match,
4608 // arr.len()+1 (or 1 for I/R) on miss
4609 // per zsh docs. We mirror the read-form
4610 // semantics from subst.rs.
4611 let idx_1based = match found {
4612 Some(i) => (i + 1) as i64,
4613 None => (arr.len() + 1) as i64,
4614 };
4615 return idx_1based.to_string();
4616 }
4617 // Scalar LHS — resolve to a CHAR position
4618 // (1-based first/last match of pat in the
4619 // string). c:Src/params.c:1411-1418 — the
4620 // scalar path returns the char index from
4621 // sliding-window pattern match against
4622 // pm.u_str. Same algorithm as the read-form
4623 // at subst.rs:5283-5306. Bug (scalar
4624 // sibling of #293): `a=hello; a[(I)l]=X`
4625 // previously auto-vivified `a` into
4626 // PM_HASHED with key "(I)l" instead of
4627 // splicing X at the last 'l' position
4628 // (yielding "helXo").
4629 if is_scalar {
4630 let s = exec.scalar(&name).unwrap_or_default();
4631 let s_chars: Vec<char> = s.chars().collect();
4632 let n = s_chars.len();
4633 let want_last = flags.contains('I') || flags.contains('R');
4634 let exact = flags.contains('e');
4635 let mut found: Option<usize> = None;
4636 'outer: for start in 0..=n {
4637 let lengths: Box<dyn Iterator<Item = usize>> = if want_last {
4638 Box::new((1..=(n - start)).rev())
4639 } else {
4640 Box::new(1..=(n - start))
4641 };
4642 for len in lengths {
4643 let cand: String =
4644 s_chars[start..start + len].iter().collect();
4645 let matched = if exact {
4646 cand == pat
4647 } else {
4648 crate::ported::pattern::patcompile(
4649 &{
4650 let mut __pat_tok = (pat).to_string();
4651 crate::ported::glob::tokenize(&mut __pat_tok);
4652 __pat_tok
4653 },
4654 crate::ported::zsh_h::PAT_HEAPDUP as i32,
4655 None,
4656 )
4657 .map_or(false, |p| {
4658 crate::ported::pattern::pattry(&p, &cand)
4659 })
4660 };
4661 if matched {
4662 found = Some(start);
4663 if !want_last {
4664 break 'outer;
4665 }
4666 break;
4667 }
4668 }
4669 }
4670 // (I)/(R): scan again to find LAST.
4671 if want_last {
4672 let mut last_found: Option<usize> = found;
4673 for start in (0..=n).rev() {
4674 for len in 1..=(n - start) {
4675 let cand: String =
4676 s_chars[start..start + len].iter().collect();
4677 let matched = if exact {
4678 cand == pat
4679 } else {
4680 crate::ported::pattern::patcompile(
4681 &{
4682 let mut __pat_tok = (pat).to_string();
4683 crate::ported::glob::tokenize(
4684 &mut __pat_tok,
4685 );
4686 __pat_tok
4687 },
4688 crate::ported::zsh_h::PAT_HEAPDUP as i32,
4689 None,
4690 )
4691 .map_or(false, |p| {
4692 crate::ported::pattern::pattry(&p, &cand)
4693 })
4694 };
4695 if matched {
4696 last_found = Some(start);
4697 break;
4698 }
4699 }
4700 if last_found.is_some() && last_found.unwrap() >= start {
4701 break;
4702 }
4703 }
4704 found = last_found;
4705 }
4706 let idx_1based = match found {
4707 Some(i) => (i + 1) as i64,
4708 // (i) miss → len+1 (one past end).
4709 None => (n + 1) as i64,
4710 };
4711 return idx_1based.to_string();
4712 }
4713 }
4714 }
4715 }
4716 }
4717 if is_indexed && key.trim().parse::<i64>().is_err() {
4718 crate::ported::math::mathevali(&crate::ported::subst::singsub(&key))
4719 .map(|n| n.to_string())
4720 .unwrap_or(key.clone())
4721 } else {
4722 key.clone()
4723 }
4724 });
4725 // c:Src/params.c getindex — C parses the subscript from the
4726 // TOKENIZED source word, so a `]`/`}` that arrived via `$key`
4727 // expansion is plain data and can never terminate the
4728 // subscript. The textual `name[key]` rebuild below re-parses
4729 // the FLAT string, where an expanded `]` splits the key at the
4730 // first bracket (`c[$k]=5` with k='x]y' stored key "x" and
4731 // spilled junk — zpwr expandstats died on the spill in a later
4732 // math expr). For a PM_HASHED target the compile-time split
4733 // already isolated the exact key: store it directly via the
4734 // canonical hashed storage (same mechanism as the
4735 // dynamic-empty-key arm above / assignsparam's PM_HASHED
4736 // tail), with the readonly guard assignsparam would apply.
4737 let target_flags = with_executor(|exec| exec.param_flags(&name));
4738 // PM_SPECIAL exclusion: the zsh/parameter magic assocs
4739 // (functions / aliases / galiases / saliases / options / …)
4740 // have per-key setfns with SIDE EFFECTS — `functions[x]=body`
4741 // must parse the body into shfunctab (Src/Modules/
4742 // parameter.c:296 setfunction), `aliases[x]=v` must write
4743 // aliastab. The direct hashed-storage store below silently
4744 // swallowed those: zinit's tmp-subst wrappers
4745 // (`functions[autoload]=':zinit-tmp-subst-autoload "$@";'`)
4746 // never became real functions, so every
4747 // `.zinit-tmp-subst-off` spammed `unfunction: no such hash
4748 // table element: autoload/compdef/bindkey/…`. Route specials
4749 // through assignsparam's canonical per-name arms instead.
4750 if (target_flags as u32 & crate::ported::zsh_h::PM_HASHED) != 0
4751 && (target_flags as u32 & crate::ported::zsh_h::PM_SPECIAL) == 0
4752 {
4753 // c:3251 + c:3343 — run the real chain (getindex →
4754 // assignstrvalue) instead of storing the subscript verbatim.
4755 // Storing it verbatim is what made `h[(r)v]=Z` invent a key
4756 // named `(r)v` where zsh reports a slice assignment, and it
4757 // also skipped the c:2701 / c:2831 guards entirely.
4758 let _ = &resolved_key;
4759 return Value::Status(assign_hash_element(&name, &key, &key_src, &value));
4760 }
4761 let subscripted = format!("{}[{}]", name, resolved_key);
4762 crate::ported::params::assignsparam(&subscripted, &value, crate::ported::zsh_h::ASSPM_WARN);
4763 if let Some((nm, old_len, i)) = sparse_track {
4764 crate::bash_arrays::note_subscript_set(&nm, old_len, i);
4765 }
4766 Value::Status(0)
4767 });
4768
4769 // Brace expansion. Routes through executor.xpandbraces (already
4770 // implemented for the pre-fusevm executor). Returns Value::Array.
4771 // BUILTIN_ARRAY_DROP_EMPTY — filter out empty Value::Str entries
4772 // from a Value::Array on the stack. Used by `for x in $@` /
4773 // `for x in $*` unquoted forms which drop empty positionals
4774 // (POSIX-like) but do NOT IFS-split each element internally
4775 // (zsh-specific — scalar word splitting is off by default).
4776 // Distinct from BUILTIN_WORD_SPLIT which routes through
4777 // multsub PREFORK_SPLIT (full IFS-split). Bug #166.
4778 vm.register_builtin(BUILTIN_ARRAY_DROP_EMPTY, |vm, _argc| {
4779 let v = vm.pop();
4780 match v {
4781 Value::Array(items) => {
4782 let filtered: Vec<Value> = items
4783 .iter()
4784 .filter(|x| !x.to_str().is_empty())
4785 .cloned()
4786 .collect();
4787 Value::array(filtered)
4788 }
4789 Value::Str(s) if s.is_empty() => Value::array(Vec::new()),
4790 other => other,
4791 }
4792 });
4793
4794 // zsh nofork command substitution (c:Src/subst.c:1904-2100) — and the
4795 // ksh93 funsub / mksh valsub it subsumes. See the BUILTIN_KSH_FUNSUB
4796 // doc comment.
4797 vm.register_builtin(BUILTIN_KSH_FUNSUB, |vm, _argc| {
4798 let mut qt = vm.pop().to_int() != 0;
4799 let kind = vm.pop().to_int();
4800 let rplyvar = vm.pop().to_str();
4801 let body = vm.pop().to_str();
4802 // !!! POSIX-FAMILY GATE !!! bash, dash and sh have no nofork
4803 // command substitution — all three answer `bad substitution` and
4804 // fail, measured on this host:
4805 // bash -c 'printf "%s\\n" "${ printf inner; }"' -> rc 1
4806 // dash -c 'printf "%s\\n" "${ printf inner; }"' -> rc 2
4807 // sh -c 'printf "%s\\n" "${ printf inner; }"' -> rc 1
4808 // The Korn family DOES have it (funsub/valsub) and so does zsh
4809 // 5.10 (`${ … }` / `${| … }` / `${{VAR} … }`), so the gate is the
4810 // bare bash/sh/dash drop-in only: `posix_faithful()` without the
4811 // Korn leg. `--zsh` and native zshrs keep the substitution.
4812 if crate::dash_mode::posix_faithful() && !crate::dash_mode::korn_mode() {
4813 crate::ported::utils::zerr("bad substitution");
4814 crate::ported::utils::errflag.fetch_or(
4815 crate::ported::zsh_h::ERRFLAG_ERROR,
4816 std::sync::atomic::Ordering::Relaxed,
4817 );
4818 with_executor(|exec| exec.set_last_status(1));
4819 return Value::str("");
4820 }
4821 let live_status = vm.last_status;
4822 // c:Src/subst.c:1625 — paramsubst's `qt` is "am I inside double
4823 // quotes", which is a LEXICAL property of the whole word. The
4824 // compiler can see it only when the substitution IS the entire
4825 // word; `"${ print INNER } $?"` reaches here as a segment whose own
4826 // text carries no quotes, so pick the enclosing quoting up from the
4827 // executor's live DQ flag as well (D10nofork.ztst "return statement
4828 // inside, part 1+": trim must NOT eat `print`'s newline there).
4829 if !qt && with_executor(|exec| exec.in_dq_context) != 0 {
4830 qt = true;
4831 }
4832 // c:Src/subst.c — the split decision is read BEFORE the body runs.
4833 // D10nofork.ztst "test word splitting on result" pins that
4834 // explicitly ("setting option inside is too late for that
4835 // substitution"): a `setopt shwordsplit` executed by the body
4836 // must not retroactively split the value it produced.
4837 let split = !qt
4838 && (crate::dash_mode::korn_mode()
4839 || crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT));
4840 let out = with_executor(|exec| {
4841 exec.set_last_status(live_status);
4842 // c:2016 — `startparamscope(); /* "local" behaves as if in a
4843 // function */`, paired with c:2093 `endparamscope()`. All three
4844 // forms take it (the C block is under `if (rplyvar)`), which is
4845 // what makes `outer=GLOBAL; ${| local outer=LOCAL; … }` leave
4846 // the outer value alone (D10nofork.ztst "local declaration
4847 // inside").
4848 crate::ported::utils::inc_locallevel(); // c:2016
4849 let val = match kind {
4850 1 => {
4851 // c:2018-2024 — `${| cmd }`: `rplypm = createparam(
4852 // "REPLY", PM_LOCAL|PM_UNSET|PM_HIDE)` inside a
4853 // `startparamscope()`, so the body sees NO outer REPLY
4854 // and the outer value is intact afterwards
4855 // (D10nofork.ztst "Basic substitution and REPLY
4856 // scoping": `REPLY=OUTER; purr ${| REPLY=INNER } $REPLY`
4857 // → `INNER OUTER`). mksh's valsub is identical
4858 // (`mksh -c 'REPLY=outer; y=${|:;}; print "[$y][$REPLY]"'`
4859 // → `[][outer]`), so one save/clear/restore serves both.
4860 let saved = crate::ported::params::getsparam(&rplyvar);
4861 crate::ported::params::unsetparam(&rplyvar);
4862 let st = exec.execute_script(&body).unwrap_or(0);
4863 exec.set_last_status(st);
4864 let reply = crate::ported::params::getsparam(&rplyvar).unwrap_or_default();
4865 match saved {
4866 Some(v) => {
4867 crate::ported::params::setsparam(&rplyvar, &v);
4868 }
4869 None => {
4870 crate::ported::params::unsetparam(&rplyvar);
4871 }
4872 }
4873 Value::str(reply)
4874 }
4875 2 => {
4876 // c:2026-2033 — `${{VAR} cmd }`: VAR is the result
4877 // parameter and gets NO local scope (`rplypm` stays
4878 // NULL for the Inbrace form), so an assignment inside
4879 // is global. c:2082-2083 then re-enters the ordinary
4880 // parameter path with `s = dyncat(rplyvar, s)`, which
4881 // is why an ARRAY result stays an array
4882 // (D10nofork.ztst "Basic substitution, brace quoting,
4883 // and array result").
4884 let st = exec.execute_script(&body).unwrap_or(0);
4885 exec.set_last_status(st);
4886 match exec.array(&rplyvar) {
4887 Some(items) => {
4888 Value::array(items.into_iter().map(Value::str).collect::<Vec<_>>())
4889 }
4890 None => Value::str(
4891 crate::ported::params::getsparam(&rplyvar).unwrap_or_default(),
4892 ),
4893 }
4894 }
4895 _ => {
4896 // c:2035-2075 — `${ cmd }`: C redirects the body's
4897 // stdout into a temp file (`">| %s {\n%s\n;}"`,
4898 // c:2107) and reads it back, so the body still runs in
4899 // the CURRENT shell. `run_shared_state_substitution`
4900 // is the same thing with an fd-level capture instead
4901 // of a temp file.
4902 let captured = exec.run_shared_state_substitution(&body);
4903 // c:1908 — `int trim = (!EMULATION(EMULATE_ZSH)) ? 2 : !qt;`
4904 // and c:2062-2069: trim==2 strips EVERY trailing
4905 // newline (ksh/bash behaviour), trim==1 strips exactly
4906 // one, trim==0 strips none.
4907 let trim: i32 = if crate::dash_mode::korn_mode()
4908 || !crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_ZSH)
4909 {
4910 2
4911 } else if qt {
4912 0
4913 } else {
4914 1
4915 };
4916 let mut b = captured;
4917 // c:2064-2069 — `while (rplylen > 0 && cmdarg[rplylen-1]
4918 // == '\n') { rplylen--; if (trim == 1) break; }`
4919 while trim > 0 && b.ends_with('\n') {
4920 b.pop();
4921 if trim == 1 {
4922 break;
4923 }
4924 }
4925 Value::str(b)
4926 }
4927 };
4928 crate::ported::params::endparamscope(); // c:2093
4929 val
4930 });
4931 // A nofork substitution IS a command substitution, so it publishes
4932 // the body's exit the way `$( … )` does: `ksh -c 'v=${ false; };
4933 // print "rc=$?"'` → `rc=1`. `run_shared_state_substitution` /
4934 // `execute_script` leave it in the executor; the VM's own counter
4935 // is what BUILTIN_SET_VAR hands back as the assignment's status
4936 // (c:Src/exec.c:3396 `lastval = cmdoutval`), so mirror it here.
4937 vm.last_status = with_executor(|exec| exec.last_status());
4938 // An UNQUOTED substitution is word-split only when the shell splits
4939 // ordinary expansions: always under ksh/mksh, and under zsh only
4940 // with SH_WORD_SPLIT. `split` was decided above, before the body
4941 // ran — see the comment there.
4942 match out {
4943 Value::Str(ref s) if split => {
4944 let (_joined, parts, _isarr, _flags) =
4945 crate::ported::subst::multsub(s, crate::ported::zsh_h::PREFORK_SPLIT);
4946 Value::array(parts.into_iter().map(Value::str).collect::<Vec<_>>())
4947 }
4948 other => other,
4949 }
4950 });
4951
4952 // c:Src/subst.c:3032 `val = sepjoin(aval, sep, 1)` — see the
4953 // BUILTIN_QUOTED_STAR_ONE_WORD doc comment.
4954 vm.register_builtin(BUILTIN_QUOTED_STAR_ONE_WORD, |vm, _argc| match vm.pop() {
4955 Value::Array(items) if items.is_empty() => Value::str(String::new()),
4956 other => other,
4957 });
4958
4959 // BUILTIN_QUOTEDZPUTS — re-wrap top-of-stack scalar via the
4960 // canonical quotedzputs (Src/utils.c:6464). Non-printable bytes
4961 // come back as `$'…'` C-string form so the cond xtrace prefix
4962 // line preserves the source-quoting form for `[[ -n $'\C-[OP' ]]`
4963 // instead of leaking raw ESC + "OP" bytes through the terminal.
4964 vm.register_builtin(BUILTIN_QUOTEDZPUTS, |vm, _argc| {
4965 let s = vm.pop().to_str();
4966 Value::str(crate::ported::utils::quotedzputs(&s))
4967 });
4968
4969 // BUILTIN_QUOTE_TOKENIZED_OUTPUT — char-aware mirror of
4970 // c:Src/exec.c:2114 `quote_tokenized_output`. The canonical
4971 // port at exec::quote_tokenized_output operates on bytes
4972 // (zsh's metafied encoding); zshrs strings are UTF-8 so
4973 // `\u{87}` Star is `[0xC2, 0x87]`, and a byte walk writes
4974 // 0xC2 raw (invalid UTF-8 lead → U+FFFD on lossy decode).
4975 // Walk by char and dispatch the same switch the byte port
4976 // uses, but with the token chars matching the UTF-8 form.
4977 vm.register_builtin(BUILTIN_QUOTE_TOKENIZED_OUTPUT, |vm, _argc| {
4978 let s = vm.pop().to_str();
4979 let mut out = String::with_capacity(s.len());
4980 let chars: Vec<char> = s.chars().collect();
4981 let mut i = 0;
4982 while i < chars.len() {
4983 let c = chars[i];
4984 // c:2120 — Meta-quoted byte: emit `*++s ^ 32`.
4985 // In UTF-8 strings Meta is `\u{83}`; the next char is
4986 // the metafied payload.
4987 if c == '\u{83}' {
4988 if let Some(&n) = chars.get(i + 1) {
4989 if (n as u32) < 0x80 {
4990 out.push(((n as u8) ^ 32) as char);
4991 } else {
4992 out.push(n);
4993 }
4994 i += 2;
4995 continue;
4996 }
4997 i += 1;
4998 continue;
4999 }
5000 // c:2124 — Nularg: skip.
5001 if c == '\u{a1}' {
5002 i += 1;
5003 continue;
5004 }
5005 // c:2128-2143 — ASCII specials get backslash-prefixed
5006 // then fall through to emit the literal char.
5007 match c {
5008 '\\' | '<' | '>' | '(' | '|' | ')' | '^' | '#' | '~' | '[' | ']' | '*' | '?'
5009 | '$' | ' ' => {
5010 out.push('\\');
5011 out.push(c);
5012 i += 1;
5013 continue;
5014 }
5015 '\t' => {
5016 out.push_str("$'\\t'");
5017 i += 1;
5018 continue;
5019 }
5020 '\n' => {
5021 out.push_str("$'\\n'");
5022 i += 1;
5023 continue;
5024 }
5025 '\r' => {
5026 out.push_str("$'\\r'");
5027 i += 1;
5028 continue;
5029 }
5030 '=' => {
5031 if i == 0 {
5032 out.push('\\');
5033 }
5034 out.push(c);
5035 i += 1;
5036 continue;
5037 }
5038 _ => {}
5039 }
5040 // c:2163 — `if (itok(*s)) putc(ztokens[*s - Pound]);`
5041 // Map zsh token chars (`\u{84}`..`\u{a1}` range, the
5042 // ones the lexer emits for `#$^*()…`) back to their
5043 // source ASCII via the `ztokens` table.
5044 let cp = c as u32;
5045 if (0x84..=0xa1).contains(&cp) {
5046 let idx = (cp - 0x84) as usize;
5047 let ztokens = crate::ported::lex::ztokens.as_bytes();
5048 if idx < ztokens.len() {
5049 out.push(ztokens[idx] as char);
5050 i += 1;
5051 continue;
5052 }
5053 }
5054 out.push(c);
5055 i += 1;
5056 }
5057 Value::str(out)
5058 });
5059
5060 // BUILTIN_WORD_SPLIT — `${=var}` IFS-split runtime.
5061 // PURE PASSTHRU: route through canonical `subst::multsub` with
5062 // PREFORK_SPLIT flag (C port of `Src/subst.c::multsub` at c:544
5063 // — the IFS-split walker with whitespace-vs-non-whitespace
5064 // gating, quote-aware parsing, and empty-field handling).
5065 vm.register_builtin(BUILTIN_WORD_SPLIT, |vm, _argc| {
5066 let s = vm.pop().to_str();
5067 let (_joined, parts, _isarr, _flags) =
5068 crate::ported::subst::multsub(&s, crate::ported::zsh_h::PREFORK_SPLIT);
5069 // Empty single-string special case → empty Array (drop empty arg).
5070 // The Array is a carrier for "no argv word", not an array-SHAPED
5071 // result: a single empty field came from an empty SCALAR (c:3922-3923
5072 // `if (!aval || !aval[0]) val = dupstring("");`). Record that, or
5073 // under RC_EXPAND_PARAM `concat_plan9` reads a stale bit and applies
5074 // c:4362's `uremnode` to a word zsh keeps — `x$(true)y` and
5075 // `v=""; x${=v}y` are each the single word `xy`, not zero words.
5076 if parts.len() == 1 && parts[0].is_empty() {
5077 note_empty_is_scalar(true);
5078 return Value::array(Vec::new());
5079 }
5080 // Zero parts is the same story reached by a different route: an empty
5081 // command substitution splits to nothing at all rather than to one
5082 // empty field. `to_str()` above means this builtin's input is always
5083 // a scalar, so an empty result here is never array-shaped.
5084 restore_empty_shape(nodes_to_value(parts), true)
5085 });
5086
5087 // BUILTIN_FORCE_SPLIT — `${=name}` / SH_WORD_SPLIT forced split.
5088 // c:Src/subst.c:3920-3928 —
5089 // if (force_split && !isarr) {
5090 // aval = sepsplit(val, spsep, 0, 1);
5091 // if (!aval || !aval[0]) val = dupstring("");
5092 // else if (!aval[1]) val = aval[0];
5093 // else isarr = nojoin ? 1 : 2;
5094 // }
5095 // with spsep == NULL for the `=` flag, so sepsplit falls through to
5096 // Src/utils.c:3711 spacesplit(s, allownull=0). See BUILTIN_FORCE_SPLIT's
5097 // doc comment for the empty-field rule and the argc contract.
5098 vm.register_builtin(BUILTIN_FORCE_SPLIT, |vm, argc| {
5099 let s = vm.pop().to_str();
5100 let keep_empties = argc == 1;
5101 // c:3921 — `sepsplit(val, spsep, 0, 1)`; spsep NULL → spacesplit.
5102 let raw = crate::ported::utils::sepsplit(&s, None, false);
5103 // c:Src/subst.c:36 `char nulstring[] = {Nularg, '\0'};` — spacesplit
5104 // emits this for an empty field delimited by IFS-NON-whitespace
5105 // (c:Src/utils.c:3732 / :3752); it survives prefork's empty-node
5106 // delete and remnulargs (c:Src/glob.c:3649) turns it back into "".
5107 // A plain "" field (c:3734 / :3757) is what a skipped run of
5108 // IFS-WHITESPACE leaves behind, and prefork DOES delete that one.
5109 let nulstring = crate::ported::zsh_h::Nularg.to_string();
5110 let mut out: Vec<String> = Vec::with_capacity(raw.len());
5111 for w in raw {
5112 if w == nulstring {
5113 out.push(String::new());
5114 } else if w.is_empty() {
5115 if keep_empties {
5116 out.push(String::new());
5117 }
5118 } else {
5119 out.push(w);
5120 }
5121 }
5122 if out.is_empty() {
5123 // c:3922-3923 — `if (!aval || !aval[0]) val = dupstring("");`:
5124 // the split produced nothing, so the value is the empty SCALAR.
5125 // Quoted, that is one empty word (c:4465 `if (qt && !*y) y =
5126 // dupstring(nulstring);` → `a=( "${=v}" )` has one element);
5127 // unquoted, prefork deletes it and the word vanishes.
5128 if keep_empties {
5129 return Value::str(String::new());
5130 }
5131 note_empty_is_scalar(true);
5132 return Value::array(Vec::new());
5133 }
5134 if out.len() == 1 {
5135 // c:3924 — `else if (!aval[1]) val = aval[0];` — a one-field
5136 // split stays a SCALAR (this is why `${#${(f)v}}` counts
5137 // characters when the split yields a single line).
5138 return Value::str(out.into_iter().next().unwrap());
5139 }
5140 // c:3927 — `isarr = nojoin ? 1 : 2;`
5141 Value::array(out.into_iter().map(Value::str).collect())
5142 });
5143
5144 vm.register_builtin(BUILTIN_BRACE_EXPAND, |vm, _argc| {
5145 // c:Src/glob.c::xpandbraces — brace expansion runs per word.
5146 // When the upstream produced an array (e.g. `${a:e}` splat),
5147 // expand braces on each element separately so the splat
5148 // survives. `pop().to_str()` would join with space and lose
5149 // the array shape. Parity bug #28 cousin: the BRACE_EXPAND
5150 // emit always fires for any word containing `{` (including
5151 // `${...}` param-expansion braces), so its collapse hit even
5152 // pure-paramsubst args.
5153 let raw = vm.pop();
5154 // Brace expansion runs BETWEEN the expansion builtin that produced
5155 // this word and the concat that consumes it (`x${${P}}y` compiles to
5156 // EXPAND_TEXT, BRACE_EXPAND, CONCAT_DISTRIBUTE). It cannot change
5157 // whether an empty result was scalar- or array-SHAPED — c:Src/glob.c
5158 // xpandbraces only ever rewrites the text of existing words. But both
5159 // exits below funnel through `nodes_to_value`, which records
5160 // `note_empty_is_scalar(false)` for an empty result, so the shape bit
5161 // its producer set was being overwritten with "array" before
5162 // `concat_plan9` could read it. Under RC_EXPAND_PARAM that deleted
5163 // words zsh keeps: `unset P; x${${P}}y` and `x$(true)y` are each the
5164 // single word `xy` (c:4438-4467 scalar arm), not zero words.
5165 // Carry the incoming bit across an empty→empty pass-through.
5166 let incoming_empty_is_scalar = empty_is_scalar();
5167 // c:Src/options.c — `no_brace_expand` (negated braceexpand)
5168 // disables brace expansion entirely. When set, `{a,b}` stays
5169 // literal. Mirror by short-circuiting xpandbraces; pass the
5170 // input through unchanged.
5171 let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
5172 let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
5173 // c:Src/glob.c xpandbraces rewrites word TEXT; it never turns a
5174 // scalar word into an array one. Remember the incoming shape so a
5175 // scalar that brace-expands to exactly one word stays SCALAR:
5176 // nodes_to_value collapses a lone EMPTY node to zero words unless
5177 // in_dq_context > 0, and BUILTIN_EXPAND_TEXT has already decremented
5178 // that by the time this runs — so a quoted word whose expansion came
5179 // out empty, and which carries the Inbrace token so it reaches this
5180 // builtin at all, lost its argument entirely.
5181 let raw_was_scalar = !matches!(raw, Value::Array(_));
5182 let inputs: Vec<String> = match raw {
5183 Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
5184 other => vec![other.to_str()],
5185 };
5186 if !brace_expand {
5187 return restore_empty_shape(nodes_to_value(inputs), incoming_empty_is_scalar);
5188 }
5189 let mut out: Vec<String> = Vec::with_capacity(inputs.len());
5190 for s in inputs {
5191 for w in crate::ported::glob::xpandbraces(&s, brace_ccl) {
5192 out.push(w);
5193 }
5194 }
5195 if raw_was_scalar && out.len() == 1 {
5196 let mut only = out.into_iter().next().unwrap();
5197 crate::ported::glob::remnulargs(&mut only);
5198 return Value::str(only);
5199 }
5200 restore_empty_shape(nodes_to_value(out), incoming_empty_is_scalar)
5201 });
5202
5203 // `*(qual)` glob qualifier filter. Stack: [pattern, qualifier].
5204 // Pattern is glob-expanded normally, then each result is filtered by the
5205 // qualifier predicate. Common qualifiers:
5206 // . — regular files only
5207 // / — directories only
5208 // @ — symlinks
5209 // x — executable
5210 // r/w/x — readable/writable/executable
5211 // N — nullglob (no error if no match)
5212 // L+N / L-N — size > N / size < N (in bytes)
5213 // mh-N / mh+N — modified within N hours / older than N hours
5214 // md-N / md+N — modified within N days / older than N days
5215 // on/On — sort by name asc/desc (default)
5216 // oL/OL — sort by length
5217 // om/Om — sort by mtime
5218 // Pop a scalar pattern, run expand_glob, push Value::Array. Used
5219 // by the segment-concat compile path for `$D/*`-style words.
5220 vm.register_builtin(BUILTIN_GLOB_EXPAND, |vm, _argc| {
5221 // c:Src/glob.c:1872 — honour `setopt noglob` / `noglob CMD`
5222 // precommand. When the option is on, the word stays literal
5223 // (zsh skips the glob expansion entirely). Without this, the
5224 // segment-fast-path BUILTIN_GLOB_EXPAND fired even after
5225 // `noglob` set the option, so `noglob echo *.xyz` saw the
5226 // NOMATCH error instead of the literal pass-through.
5227 let raw = vm.pop();
5228 let noglob =
5229 opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
5230 glob_expand_word_value(raw, noglob)
5231 });
5232 // Redirect-target variant of BUILTIN_GLOB_EXPAND. c:Src/glob.c:
5233 // 2161-2167 xpandredir — `prefork(&fake, isset(MULTIOS) ? 0 :
5234 // PREFORK_SINGLE, NULL)` then "Globbing is only done for
5235 // multios.": a redirect target word is only globbed when the
5236 // MULTIOS option is set. With it unset, `echo hi > *.txt`
5237 // creates the literal file `*.txt`, and `wc -c < *.txt` errors
5238 // "no such file or directory: *.txt". Bug #36 follow-up in
5239 // docs/BUGS.md.
5240 vm.register_builtin(BUILTIN_REDIR_GLOB_EXPAND, |vm, _argc| {
5241 let raw = vm.pop();
5242 let noglob =
5243 opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
5244 let multios = opt_state_get("multios").unwrap_or(true);
5245 // c:Src/glob.c:2164-2166 — `in_expandredir = 1; globlist(&fake, 0);
5246 // in_expandredir = 0;`. The flag is what lets `zglob`'s no-match
5247 // dispatch (c:1888-1894) tell a redirect target apart from an
5248 // ordinary word: under NULL_GLOB an ordinary word is dropped, but
5249 // a redirect still needs exactly one target, so the empty result
5250 // is `redirection failed (no match)` instead.
5251 crate::ported::glob::IN_EXPANDREDIR.store(1, std::sync::atomic::Ordering::SeqCst); // c:2164
5252 let out = glob_expand_word_value(raw, noglob || !multios); // c:2165
5253 crate::ported::glob::IN_EXPANDREDIR.store(0, std::sync::atomic::Ordering::SeqCst); // c:2166
5254 out
5255 });
5256 // Clear the default-word glob-pending carrier before the word's
5257 // expansion runs, so a flag set by a prior word never leaks in.
5258 vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB_RESET, |_vm, _argc| {
5259 crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| c.set(false));
5260 Value::Status(0)
5261 });
5262 // After the word is assembled, run filename generation ONLY if the
5263 // default/alternate paramsubst arm flagged a source-glob default
5264 // (DEFAULT_WORD_GLOB_PENDING). Otherwise pass the word through
5265 // literally — a parameter VALUE must not glob. c:Src/subst.c globlist.
5266 vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB, |vm, _argc| {
5267 let raw = vm.pop();
5268 let pending = crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| {
5269 let v = c.get();
5270 c.set(false); // read + clear
5271 v
5272 });
5273 if !pending {
5274 return raw;
5275 }
5276 let noglob =
5277 opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
5278 glob_expand_word_value(raw, noglob)
5279 });
5280
5281 // `break`/`continue` from a sub-VM body. The compile path emits
5282 // these when the keyword appears at chunk top-level (no enclosing
5283 // for/while in the current chunk's patch lists). Outer-loop
5284 // builtins (BUILTIN_RUN_SELECT and any future loop-via-builtin
5285 // construct) drain canonical BREAKS/CONTFLAG after each iteration.
5286 //
5287 // Writes match `bin_break`'s c:5836+ pattern:
5288 // continue: contflag = 1; breaks++ (Src/builtin.c::bin_break)
5289 // break: breaks++
5290 vm.register_builtin(BUILTIN_SET_BREAK, |_vm, _argc| {
5291 use std::sync::atomic::Ordering::SeqCst;
5292 crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
5293 Value::Status(0)
5294 });
5295 vm.register_builtin(BUILTIN_SET_CONTINUE, |_vm, _argc| {
5296 use std::sync::atomic::Ordering::SeqCst;
5297 crate::ported::builtin::CONTFLAG.store(1, SeqCst);
5298 crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
5299 Value::Status(0)
5300 });
5301
5302 // `break N`/`continue N` with a RUNTIME level count. Pops [count,
5303 // name]; math-evaluates count (c:builtin.c:5811 `mathevali`); on
5304 // count <= 0 emits `argument is not positive: N` via zerrnam (sets
5305 // errflag → abort, c:5813) and pushes Int(0) (matches no jump-table
5306 // entry → control falls through to the errflag abort). Otherwise
5307 // pushes Int(count) for the compiled jump table to dispatch on.
5308 vm.register_builtin(BUILTIN_BREAK_COUNT_VALIDATE, |vm, _argc| {
5309 let name = vm.pop().to_str();
5310 let count_s = vm.pop().to_str();
5311 let count = crate::ported::math::mathevali(&count_s).unwrap_or(0);
5312 if count <= 0 {
5313 crate::ported::utils::zerrnam(&name, &format!("argument is not positive: {count}"));
5314 return Value::Int(0);
5315 }
5316 Value::Int(count)
5317 });
5318
5319 // `${arr[*]}` — join array elements with the first IFS char into
5320 // a single string. Matches zsh: in DQ context this preserves the
5321 // join; in array context too the result is one Value::Str.
5322 // Set or clear a shell option directly. Used by `noglob CMD ...`
5323 // precommand wrapping — the compiler emits SET_RAW_OPT to flip the
5324 // option ON before compiling the inner words and OFF after, so glob
5325 // expansion of the inner args sees the temporary state.
5326 vm.register_builtin(BUILTIN_SET_RAW_OPT, |vm, _argc| {
5327 let on = vm.pop().to_int() != 0;
5328 let opt = vm.pop().to_str();
5329 // Pure passthru: canonical port lives in
5330 // src/ported/options.rs::opt_state_set_via_alias and
5331 // handles negation-alias resolution per c:Src/options.c.
5332 crate::ported::options::opt_state_set_via_alias(&opt, on);
5333 Value::Status(0)
5334 });
5335
5336 // c:Src/options.c GLOB_SUBST — runtime glob expansion of
5337 // substituted words. Pop a Value (Str or Array); when
5338 // GLOB_SUBST is ON, run expand_glob on each string element;
5339 // when OFF, pass through unchanged. Bug #119 in docs/BUGS.md.
5340 vm.register_builtin(BUILTIN_GLOB_SUBST_EXPAND, |vm, _argc| {
5341 let raw = vm.pop();
5342 let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
5343 if !glob_subst {
5344 return raw;
5345 }
5346 // Collect input strings (Str → vec![s]; Array → multiple).
5347 let inputs: Vec<String> = match raw {
5348 Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
5349 other => vec![other.to_str()],
5350 };
5351 // Run expand_glob on each. Empty matches collapse to a
5352 // single literal pass-through to mirror nullglob-off default.
5353 let mut out: Vec<String> = Vec::with_capacity(inputs.len());
5354 for pattern in inputs {
5355 // c:Src/subst.c — GLOB_SUBST subjects the value to the FULL
5356 // filename-generation pipeline: `filesub` (tilde/`=` expansion)
5357 // BEFORE globbing (prefork runs filesub then globlist). zshrs
5358 // globbed but skipped filesub, so `${~x}` / `setopt globsubst`
5359 // left `~/foo` un-expanded. filesubstr matches the Tilde TOKEN,
5360 // so shtokenize the value first (`~`→Tilde, glob metas active),
5361 // run filesub, then untokenize the surviving glob metas back to
5362 // raw for expand_glob (which re-tokenizes internally).
5363 let pattern = if pattern.contains('~') || pattern.contains('=') {
5364 let mut tok = pattern.clone();
5365 crate::ported::glob::shtokenize(&mut tok);
5366 let fs = crate::ported::subst::filesub(&tok, 0);
5367 crate::ported::lex::untokenize(&fs)
5368 } else {
5369 pattern
5370 };
5371 let matches = with_executor(|exec| exec.expand_glob(&pattern));
5372 if matches.is_empty() {
5373 // No match: keep the literal (like nullglob off).
5374 out.push(pattern);
5375 } else {
5376 for m in matches {
5377 out.push(m);
5378 }
5379 }
5380 }
5381 if out.len() == 1 {
5382 Value::str(out.into_iter().next().unwrap())
5383 } else {
5384 Value::array(out.into_iter().map(Value::str).collect())
5385 }
5386 });
5387
5388 // c:Src/math.c:336-364 — `getmathparam` for ArithCompiler pre-load.
5389 // Pop a variable name, return its math value.
5390 //
5391 // This used to be a second, smaller getmathparam: `getsparam` then
5392 // `parse::<i64>` / `parse::<f64>` / `mathevali`. Two consequences,
5393 // both invisible until you compared the two arithmetic backends on
5394 // the same expression. It had no FORCEFLOAT coercion (c:359-362) and
5395 // no `unset(UNSET)` diagnostic (c:345-346), so which of `setopt
5396 // force_float` and `set -u` applied to `$(( x ))` depended on
5397 // whether `compile_arith` had routed the expression to the
5398 // ArithCompiler or to BUILTIN_ARITH_EVAL. And it re-derived a
5399 // typed param's value from `getsparam`'s printed form, the same
5400 // convbase round-trip c:2641 exists to avoid.
5401 //
5402 // There is one `getmathparam` in C; there is one here now.
5403 vm.register_builtin(BUILTIN_GET_MATH_VAR, |vm, _argc| {
5404 let name = vm.pop().to_str();
5405 let n = crate::ported::math::getmathparam(&name); // c:337
5406 if n.type_ == crate::ported::zsh_h::MN_FLOAT {
5407 Value::Float(n.d)
5408 } else {
5409 Value::Int(n.l)
5410 }
5411 });
5412
5413 // c:Src/options.c GLOB_SUBST + Src/cond.c:552 cond_match.
5414 // Pop pattern string; when GLOB_SUBST is OFF, escape every glob
5415 // metachar with `\` so the downstream StrMatch + patcompile
5416 // treat them as literals (matching C's tokenization-based
5417 // gate). When GLOB_SUBST is ON, pass through unchanged.
5418 // See BUILTIN_GLOB_SUBST_GUARD docs above for full rationale.
5419 vm.register_builtin(BUILTIN_GLOB_SUBST_GUARD, |vm, _argc| {
5420 let p = vm.pop().to_str();
5421 let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
5422 if glob_subst {
5423 return Value::str(p);
5424 }
5425 let mut out = String::with_capacity(p.len() * 2);
5426 for c in p.chars() {
5427 match c {
5428 // c:Src/lex.c:1390-1404 — `-` / `!` are Dash / Bang TOKENS
5429 // only when the LEXER sees them unquoted; pattern.c's range
5430 // parser (c:1483) and negation test look for the tokens, so
5431 // a SUBSTITUTED `-` / `!` must stay an ordinary character
5432 // with GLOB_SUBST off. Without these two,
5433 // `cset='^a-z'; [[ - = ["$cset"] ]]` built a live `a-z`
5434 // range out of substituted text.
5435 '*' | '?' | '[' | ']' | '(' | ')' | '|' | '<' | '>' | '#' | '^' | '~' | '-'
5436 | '!' | '\\' => {
5437 out.push('\\');
5438 out.push(c);
5439 }
5440 _ => out.push(c),
5441 }
5442 }
5443 Value::str(out)
5444 });
5445
5446 vm.register_builtin(BUILTIN_ARRAY_JOIN_STAR, |vm, _argc| {
5447 let name = vm.pop().to_str();
5448 let (joined, ifs_full, in_dq) = with_executor(|exec| {
5449 // c:Src/params.c — `"$*"` joins by IFS[0]. zsh
5450 // distinguishes IFS=unset (→ default `" "`) from
5451 // IFS="" (→ EMPTY separator → fields concatenate).
5452 // chars().next() collapsed both into the default, so
5453 // IFS="" was treated as IFS=" ".
5454 let ifs_full = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
5455 let sep = ifs_full
5456 .chars()
5457 .next()
5458 .map(|c| c.to_string())
5459 .unwrap_or_default();
5460 let in_dq = exec.in_dq_context > 0;
5461 let joined = if let Some(v) = crate::dash_mode::bash_special_array(&name) {
5462 // bash `"${PIPESTATUS[*]}"` / `"${FUNCNAME[*]}"` join.
5463 v.join(&sep)
5464 } else if name == "@" || name == "*" || name == "argv" {
5465 exec.pparams().join(&sep)
5466 } else if let Some(assoc_map) = exec.assoc(&name) {
5467 // c:Src/params.c — assoc-splat values for
5468 // `"${h[@]}"` / `"${h[*]}"`. Bug #109 in
5469 // docs/BUGS.md.
5470 assoc_map.values().cloned().collect::<Vec<_>>().join(&sep)
5471 } else if let Some(arr) = exec.array(&name) {
5472 // bash sparse arrays: `"${a[*]}"` joins only LIVE elements,
5473 // dropping hole slots. No-op in --zsh (no holes tracked).
5474 crate::bash_arrays::compact(&name, arr).join(&sep)
5475 } else if let Some(arr) = crate::ported::subst::arrays_get(&name) {
5476 // c:Src/Modules/parameter.c:2239-2291 partab[] — the PM_ARRAY
5477 // magic specials (reswords/patchars/dis_*/…) are getfn-backed,
5478 // so `getaparam`'s `pm->u.arr` read (behind `exec.array`) comes
5479 // back NULL and the join fell through to the scalar fallback,
5480 // which is empty. Same omission the `[@]` splat had.
5481 arr.join(&sep)
5482 } else if let Some(map) = crate::ported::subst::assoc_get(&name) {
5483 // c:Src/Modules/parameter.c:2235-2298 partab[] — the PM_HASHED
5484 // magic assocs (aliases/functions/options/…) live behind a
5485 // scanfn+getfn pair, not in the executor's assoc storage, so
5486 // `exec.assoc` above misses them and `${aliases[*]}` joined an
5487 // empty scalar where zsh joins the alias VALUES.
5488 map.values().cloned().collect::<Vec<_>>().join(&sep)
5489 } else {
5490 exec.get_variable(&name)
5491 };
5492 (joined, ifs_full, in_dq)
5493 });
5494 // c:Src/subst.c — UNQUOTED `${name[*]}` (or `$*`) goes
5495 // through the canonical "join via IFS[0], then word-split
5496 // via IFS" pipeline. The fast-path bypassed paramsubst
5497 // entirely so it never word-split, producing one joined
5498 // string instead of N argv entries. Bug #428.
5499 //
5500 // In QUOTED (`"${name[*]}"`) context, the result IS a
5501 // single scalar — return it as Str without splitting.
5502 if in_dq {
5503 return Value::str(joined);
5504 }
5505 if joined.is_empty() {
5506 return Value::array(Vec::new());
5507 }
5508 // IFS word-split — every IFS char is a separator. Empty
5509 // resulting fields are dropped (the canonical
5510 // "remove empty unquoted words" pass from
5511 // Src/subst.c::prefork c:184-187).
5512 let parts: Vec<String> = joined
5513 .split(|c: char| ifs_full.contains(c))
5514 .filter(|s| !s.is_empty())
5515 .map(String::from)
5516 .collect();
5517 if parts.is_empty() {
5518 Value::array(Vec::new())
5519 } else if parts.len() == 1 {
5520 Value::str(parts.into_iter().next().unwrap())
5521 } else {
5522 Value::array(parts.into_iter().map(Value::str).collect())
5523 }
5524 });
5525
5526 vm.register_builtin(BUILTIN_ARRAY_ALL, |vm, _argc| {
5527 let name = vm.pop().to_str();
5528 // c:Src/params.c:2027-2029 — a `[@]`/`[*]` subscript sets
5529 // SCANPM_ISVAR_AT, i.e. `isarr != 0` (c:2915). An empty result is
5530 // therefore an empty ARRAY, and plan9 deletes the whole word
5531 // (c:4362) rather than keeping the surrounding text.
5532 //
5533 // The note describes the EMPTY value this expansion produces, so it
5534 // must not survive a NON-empty one: the bit is read by concat_plan9
5535 // when the word folds left-associatively, and a later non-empty
5536 // segment overwriting it made `setopt rcexpandparam; e=''; a=(x y);
5537 // print -rl -- ${e}${a}` delete the whole word instead of printing
5538 // `x` and `y` (c:4437 keeps the surrounding text for a scalar
5539 // empty; only c:4362's empty ARRAY deletes it). Restore the
5540 // incoming bit whenever the result is not an empty array.
5541 let saved_empty_is_scalar = empty_is_scalar();
5542 note_empty_is_scalar(false);
5543 let array_all = |vm: &mut fusevm::VM| -> Value {
5544 let _ = &vm;
5545 // bash `"${PIPESTATUS[@]}"` / `"${FUNCNAME[@]}"` / `"${BASH_VERSINFO[@]}"`
5546 // splat — alias the zsh-native special. No-op in --zsh.
5547 if let Some(v) = crate::dash_mode::bash_special_array(&name) {
5548 return Value::array(v.into_iter().map(Value::str).collect());
5549 }
5550 with_executor(|exec| {
5551 // Special positional names — splice the positional list.
5552 if name == "@" || name == "*" || name == "argv" {
5553 return Value::array(exec.pparams().iter().map(Value::str).collect());
5554 }
5555 // c:Src/Modules/parameter.c — funcstack/funcfiletrace/
5556 // funcsourcetrace/functrace are PM_ARRAY|PM_READONLY
5557 // specials backed by the canonical FUNCSTACK Vec.
5558 // `${funcstack[@]}` inside a function call should splat
5559 // the innermost-first names; without this branch the
5560 // runtime fell to the scalar fallback (get_variable
5561 // returns empty for these specials) and `[@]` came out
5562 // empty. Bug #276 in docs/BUGS.md. Mirrors the parallel
5563 // arrays_get handler at src/ported/subst.rs ~10685.
5564 // c:Src/Modules/datetime.c:256 — `epochtime` PM_ARRAY|
5565 // PM_READONLY backed by getcurrenttime(). Same parallel
5566 // arrangement as the FUNCSTACK-backed specials below.
5567 if name == "epochtime" {
5568 // c:Src/params.c:589-594 getparamnode → c:563-585 loadparamnode —
5569 // the `[@]` splat resolves the NAME, clearing PM_AUTOLOAD so
5570 // paramtypestr (c:Src/Modules/parameter.c:48-50) reports the real
5571 // type. Mirrors the arrays_get arm in src/ported/subst.rs.
5572 if !crate::vm_helper::magic_special_shadowed(&name) {
5573 crate::vm_helper::mark_module_param_used(&name);
5574 }
5575 let arr = crate::ported::modules::datetime::getcurrenttime();
5576 return Value::array(arr.into_iter().map(Value::str).collect());
5577 }
5578 if matches!(
5579 name.as_str(),
5580 "funcstack" | "funcfiletrace" | "funcsourcetrace" | "functrace"
5581 ) {
5582 // c:Src/params.c:589-594 — see the epochtime arm above.
5583 if !crate::vm_helper::magic_special_shadowed(&name) {
5584 crate::vm_helper::mark_module_param_used(&name);
5585 }
5586 // Route the three trace arrays through the canonical
5587 // ported getfns (Src/Modules/parameter.c:648/:679/:711)
5588 // — the previous inline copy emitted wrong shapes
5589 // (bare filename for funcfiletrace, `name:lineno` for
5590 // functrace instead of `caller:lineno`); same dedup as
5591 // the parallel arrays_get handler in subst.rs.
5592 let vals: Vec<String> = match name.as_str() {
5593 "funcstack" => crate::ported::modules::parameter::FUNCSTACK
5594 .lock()
5595 .map(|f| f.iter().rev().map(|fs| fs.name.clone()).collect())
5596 .unwrap_or_default(),
5597 "funcfiletrace" => crate::ported::modules::parameter::funcfiletracegetfn(
5598 std::ptr::null_mut(),
5599 ),
5600 "funcsourcetrace" => {
5601 crate::ported::modules::parameter::funcsourcetracegetfn(
5602 std::ptr::null_mut(),
5603 )
5604 }
5605 _ => {
5606 crate::ported::modules::parameter::functracegetfn(std::ptr::null_mut())
5607 }
5608 };
5609 return Value::array(vals.into_iter().map(Value::str).collect());
5610 }
5611 // c:Src/params.c — `${assoc[@]}` enumerates VALUES (per
5612 // params.c:1696-1750 hashparam splat). Check assoc
5613 // storage BEFORE the scalar fallback so an associative
5614 // array named X resolves `${X[@]}` to the values, not
5615 // empty. Bug #109 in docs/BUGS.md: `${h[@]}` on an
5616 // assoc routed through BUILTIN_ARRAY_ALL, which only
5617 // consulted `exec.array(name)` (the indexed-array map)
5618 // — that lookup missed for assocs, fell through to
5619 // `get_variable("h")` (also empty for an assoc-only
5620 // name), and returned `Array(vec![])`. zsh's expected
5621 // behavior is to enumerate values.
5622 if let Some(assoc_map) = exec.assoc(&name) {
5623 return Value::array(assoc_map.values().cloned().map(Value::str).collect());
5624 }
5625 match exec.array(&name) {
5626 Some(v) => {
5627 // bash sparse arrays: `"${a[@]}"` splats only LIVE
5628 // elements, dropping hole slots (`a[5]=q` padding,
5629 // `unset a[i]`). No-op in --zsh (no holes tracked).
5630 let v = crate::bash_arrays::compact(&name, v);
5631 Value::array(v.iter().map(Value::str).collect())
5632 }
5633 None => {
5634 // c:Src/Modules/parameter.c:2235-2298 partab[] — the
5635 // PM_HASHED magic assocs (aliases/functions/parameters/
5636 // options/commands/builtins/modules/widgets/nameddirs/…)
5637 // are real hash params in C, so `${aliases[@]}` takes the
5638 // ordinary getvaluearr path and enumerates their VALUES.
5639 // zshrs keeps them OUT of the executor's assoc storage
5640 // (they are synthesized on demand by `subst::assoc_get`),
5641 // so the `exec.assoc` probe above missed and this arm fell
5642 // through to the scalar fallback, which returned an EMPTY
5643 // array: `alias foo=bar; print -r -- "${aliases[@]}"` gave
5644 // nothing where zsh gives `bar man whence`. Every other
5645 // form already routed through paramsubst's own magic-assoc
5646 // arms; only the flagless `[@]`/`[*]` splat compiles to
5647 // BUILTIN_ARRAY_ALL and reached here.
5648 //
5649 // Placed in the `None` arm so a real indexed array or a
5650 // user-defined assoc of the same name still wins, and so
5651 // no ordinary array read pays for the PARTAB scan.
5652 //
5653 // Same gap on the PM_ARRAY side (c:2239-2291 partab[]
5654 // rows: reswords/dis_reswords/patchars/dis_patchars/…):
5655 // `getaparam` reads `pm->u.arr`, which is NULL on the
5656 // placeholder node zshrs installs for a getfn-backed
5657 // special, so `${reswords[@]}` splatted nothing. Route
5658 // through the canonical `arrays_get` getfn dispatch.
5659 if let Some(arr) = crate::ported::subst::arrays_get(&name) {
5660 return Value::array(arr.into_iter().map(Value::str).collect());
5661 }
5662 if let Some(map) = crate::ported::subst::assoc_get(&name) {
5663 return Value::array(map.values().cloned().map(Value::str).collect());
5664 }
5665 // Fall back to scalar lookup. zsh (unlike bash)
5666 // does NOT IFS-split a scalar variable in a for
5667 // list — `for w in $scalar` iterates ONCE with the
5668 // scalar value. Word-splitting requires either
5669 // sh_word_split option or explicit `${(s.,.)scalar}`.
5670 let val = exec.get_variable(&name);
5671 if val.is_empty() && !exec.has_scalar(&name) && env::var(&name).is_err() {
5672 // c:Src/subst.c:3480-3485 — `${arr[@]}` on a genuinely
5673 // UNSET parameter under NO_UNSET is a "parameter not set"
5674 // error (vunset > 0 && unset(UNSET)), exactly like the
5675 // scalar `$arr`, the `${arr[*]}` splat, and `${arr[1]}`
5676 // — all of which already fire it via GET_VAR. The `[@]`
5677 // splat path returned an empty array silently, so
5678 // `setopt NO_UNSET; print "${arr[@]}"` exited 0 where zsh
5679 // exits 1. A DECLARED-but-empty array (`arr=()`) resolves
5680 // to `Some(vec![])` above and never reaches here, so it
5681 // still splats to nothing without erroring — matching zsh.
5682 if opt_state_get("nounset").unwrap_or(false) {
5683 crate::ported::utils::zerr(&format!("{}: parameter not set", name));
5684 crate::ported::utils::errflag.fetch_or(
5685 crate::ported::zsh_h::ERRFLAG_ERROR,
5686 std::sync::atomic::Ordering::Relaxed,
5687 );
5688 exec.set_last_status(1);
5689 }
5690 // c:Src/subst.c:3480-3485 — an UNSET parameter takes the
5691 // `vunset` arm: `val = dupstring("")` with isarr left at
5692 // 0. That is a SCALAR empty, so plan9 keeps the
5693 // surrounding text (`setopt rcexpandparam;
5694 // print -r -- "[${unset[@]}]"` → `[]`), unlike a
5695 // DECLARED-but-empty array (`arr=()`, matched by the
5696 // `Some(vec![])` arm above), which sets isarr and gets
5697 // the word deleted at c:4362.
5698 note_empty_is_scalar(true);
5699 // c:Src/subst.c:3603-3610 — an UNSET parameter leaves `isarr` at 0
5700 // and yields `val = ""`, i.e. a SCALAR empty, so a quoted
5701 // `"${u[@]}"` is ONE empty word (`f "${u[@]}"` → $# == 1) while
5702 // `"${empty_array[@]}"` is zero. Returning an empty ARRAY here
5703 // collapsed both to zero words. The unquoted form still drops it:
5704 // the compiler emits BUILTIN_ARRAY_DROP_EMPTY after this call for
5705 // non-DQ splices (compile_zsh.rs:6149), and that builtin maps an
5706 // empty Str to an empty array.
5707 Value::str(String::new())
5708 } else if opt_state_get("shwordsplit").unwrap_or(false) {
5709 // c:3921 `aval = sepsplit(val, spsep, 0, 1)` — same
5710 // splitter as `${=name}` (Src/utils.c:3711 spacesplit),
5711 // not a naive `split().filter(non-empty)`: only the
5712 // IFS-WHITESPACE-derived empty fields are elided; the
5713 // `nulstring` ones an IFS-NON-whitespace separator makes
5714 // survive (c:Src/subst.c:36).
5715 let nulstring = crate::ported::zsh_h::Nularg.to_string();
5716 let parts: Vec<Value> =
5717 crate::ported::utils::sepsplit(&val, None, false)
5718 .into_iter()
5719 .filter_map(|w| {
5720 if w == nulstring {
5721 Some(Value::str(String::new()))
5722 } else if w.is_empty() {
5723 None // c:184-187 prefork uremnode
5724 } else {
5725 Some(Value::str(w))
5726 }
5727 })
5728 .collect();
5729 Value::array(parts)
5730 } else {
5731 Value::array(vec![Value::str(val)])
5732 }
5733 }
5734 }
5735 })
5736 };
5737 let result = array_all(vm);
5738 if !matches!(&result, Value::Array(a) if a.is_empty()) {
5739 note_empty_is_scalar(saved_empty_is_scalar);
5740 }
5741 result
5742 });
5743
5744 // BUILTIN_ARRAY_FLATTEN(N): pops N values, flattens one level of Array
5745 // nesting, pushes the resulting Array AND its length as a separate Int.
5746 // The two-value return shape lets the caller (for-loop compile path)
5747 // SetSlot the length before SetSlot'ing the array, without re-deriving
5748 // the length from the array via a second builtin call.
5749 // `coproc [name] { body }` — bidirectional pipe to backgrounded body.
5750 // Stack discipline (top first): [name (str, "" for default), sub_idx (int)].
5751 // On success: parent's `executor.arrays[name]` becomes [write_fd, read_fd]
5752 // and Status(0) is returned. The caller writes to the child's stdin via
5753 // write_fd, reads its stdout via read_fd, and closes both when done.
5754 //
5755 // Bash's coproc convention is `${NAME[0]}` = read_fd, `${NAME[1]}` =
5756 // write_fd. We follow that: arrays[name] = [read_fd_str, write_fd_str].
5757 vm.register_builtin(BUILTIN_RUN_COPROC, |vm, _argc| {
5758 let sub_idx = vm.pop().to_int() as usize;
5759 let job_text = vm.pop().to_str();
5760 let raw_name = vm.pop().to_str();
5761 let name = if raw_name.is_empty() {
5762 "COPROC".to_string()
5763 } else {
5764 raw_name
5765 };
5766 let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
5767 Some(c) => c,
5768 None => return Value::Status(1),
5769 };
5770
5771 // c:Src/exec.c:1710-1712 — starting a new coproc closes the
5772 // previous one's fds FIRST:
5773 // if (coprocin >= 0) { zclose(coprocin); zclose(coprocout); }
5774 // The old coproc child then sees EOF on its stdin and exits on
5775 // its own schedule (its job-table entry stays until it's
5776 // reaped) — zsh does NOT deletejob it here. This is also what
5777 // makes the `exec 4<&p; coproc exit; read -u4` EOF idiom work:
5778 // the replacement coproc closes the shell's write end to the
5779 // old one.
5780 {
5781 use std::sync::atomic::Ordering;
5782 let old_in = crate::ported::modules::clone::coprocin.load(Ordering::Relaxed);
5783 if old_in >= 0 {
5784 let old_out = crate::ported::modules::clone::coprocout.load(Ordering::Relaxed);
5785 unsafe {
5786 libc::close(old_in);
5787 if old_out >= 0 {
5788 libc::close(old_out);
5789 }
5790 }
5791 crate::ported::modules::clone::coprocin.store(-1, Ordering::Relaxed);
5792 crate::ported::modules::clone::coprocout.store(-1, Ordering::Relaxed);
5793 }
5794 }
5795
5796 // (parent_read ← child_stdout)
5797 let mut p2c = [0i32; 2]; // parent writes, child reads
5798 let mut c2p = [0i32; 2]; // child writes, parent reads
5799 if unsafe { libc::pipe(p2c.as_mut_ptr()) } < 0 {
5800 return Value::Status(1);
5801 }
5802 if unsafe { libc::pipe(c2p.as_mut_ptr()) } < 0 {
5803 unsafe {
5804 libc::close(p2c[0]);
5805 libc::close(p2c[1]);
5806 }
5807 return Value::Status(1);
5808 }
5809 // c:Src/exec.c:5160 mpipe — both pipes' fds are moved above
5810 // the user-visible range (movefd → F_DUPFD ≥ 10) so the
5811 // coproc fds never collide with explicit user fds like
5812 // `exec 3>&p`.
5813 for fd in p2c.iter_mut().chain(c2p.iter_mut()) {
5814 *fd = crate::ported::utils::movefd(*fd);
5815 }
5816
5817 match unsafe { libc::fork() } {
5818 -1 => {
5819 unsafe {
5820 libc::close(p2c[0]);
5821 libc::close(p2c[1]);
5822 libc::close(c2p[0]);
5823 libc::close(c2p[1]);
5824 }
5825 Value::Status(1)
5826 }
5827 0 => {
5828 // Child: stdin from p2c[0], stdout to c2p[1]. Close all
5829 // unused fds. setsid so SIGINT to fg doesn't hit us.
5830 unsafe {
5831 libc::dup2(p2c[0], libc::STDIN_FILENO);
5832 libc::dup2(c2p[1], libc::STDOUT_FILENO);
5833 libc::close(p2c[0]);
5834 libc::close(p2c[1]);
5835 libc::close(c2p[0]);
5836 libc::close(c2p[1]);
5837 libc::setsid();
5838 }
5839 crate::fusevm_disasm::maybe_print_stdout("coproc:child", &chunk);
5840 let mut co_vm = fusevm::VM::new(chunk);
5841 register_builtins(&mut co_vm);
5842 let _ = co_vm.run();
5843 let _ = std::io::stdout().flush();
5844 let _ = std::io::stderr().flush();
5845 std::process::exit(co_vm.last_status);
5846 }
5847 pid => {
5848 // Parent: close child ends, store [read_fd, write_fd] in NAME.
5849 unsafe {
5850 libc::close(p2c[0]);
5851 libc::close(c2p[1]);
5852 }
5853 let read_fd = c2p[0];
5854 let write_fd = p2c[1];
5855 with_executor(|exec| {
5856 exec.unset_scalar(&name);
5857 exec.set_array(name, vec![read_fd.to_string(), write_fd.to_string()]);
5858 });
5859 // c:Src/exec.c — `coprocin`/`coprocout` are the
5860 // canonical globals that bin_read's `-p` arm
5861 // (Src/builtin.c:6510) and bin_print's `-p` arm
5862 // (Src/builtin.c:4827) read to find the
5863 // coprocess fds. The Rust port has the atomic
5864 // declarations at src/ported/modules/clone.rs:262
5865 // but the coproc-launch path never updated them,
5866 // so `read -p` / `print -p` always errored with
5867 // "-p: no coprocess" even when a coproc was
5868 // running. Bug #388 in docs/BUGS.md. Update them
5869 // here so the canonical builtins find the live
5870 // pipe.
5871 crate::ported::modules::clone::coprocin
5872 .store(read_fd, std::sync::atomic::Ordering::Relaxed);
5873 crate::ported::modules::clone::coprocout
5874 .store(write_fd, std::sync::atomic::Ordering::Relaxed);
5875 // c:Src/exec.c:1725 — `fdtable[coprocin] =
5876 // fdtable[coprocout] = FDT_UNUSED;`: the two kept ends
5877 // are user-reachable (via `>&p` / `<&p`), so they drop
5878 // the FDT_INTERNAL mark movefd gave them.
5879 crate::ported::utils::fdtable_set(read_fd, crate::ported::zsh_h::FDT_UNUSED);
5880 crate::ported::utils::fdtable_set(write_fd, crate::ported::zsh_h::FDT_UNUSED);
5881 // c:Src/exec.c:2837 — `lastpid = (zlong) pid;`. zsh
5882 // sets the `$!` global to the coproc child's PID so
5883 // subsequent `$!` reads return it. The Rust port at
5884 // exec.rs:6773 mirrors this for regular background
5885 // jobs but the coproc launch path was missing the
5886 // assignment, leaving `$!` at 0 after `coproc cmd`.
5887 crate::ported::modules::clone::lastpid
5888 .store(pid, std::sync::atomic::Ordering::Relaxed);
5889 // c:Src/exec.c:1700-1758 — the coproc rides the SAME
5890 // Z_ASYNC job-table path as `cmd &`: `thisjob = newjob
5891 // = initjob()` (c:1700), addproc hangs the pid+text
5892 // proc entry off the job, `jobtab[thisjob].stat |=
5893 // STAT_NOSTTY` (c:1746), `clearoldjobtab()` (c:1744)
5894 // and `spawnjob()` (c:1758) promote it to curjob. This
5895 // is what makes `jobs` list the coproc as
5896 // `[1] + running cat` and `kill %1` resolve it.
5897 // Mirrors the BUILTIN_RUN_BG parent arm exactly.
5898 {
5899 use crate::ported::jobs;
5900 use std::sync::Mutex;
5901 let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
5902 let idx = {
5903 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
5904 let idx = jobs::initjob(&mut tab); // c:exec.c:1700
5905 jobs::addproc(
5906 &mut tab[idx],
5907 pid,
5908 &job_text,
5909 false,
5910 Some(std::time::Instant::now()),
5911 -1,
5912 -1,
5913 );
5914 tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
5915 idx
5916 };
5917 jobs::clearoldjobtab(); // c:exec.c:1744
5918 if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
5919 *tj = idx as i32;
5920 }
5921 jobs::spawnjob(); // c:exec.c:1758
5922 }
5923 with_executor(|exec| {
5924 exec.jobs
5925 .add_pid_job(pid, job_text.clone(), JobState::Running);
5926 });
5927 Value::Status(0)
5928 }
5929 }
5930 });
5931
5932 vm.register_builtin(BUILTIN_ARRAY_FLATTEN, |vm, argc| {
5933 // `${~spec}` carrier: a `for`/`select` WORD LIST is a word-
5934 // pipeline boundary too. In C the `globsubst` flag is a
5935 // paramsubst-LOCAL int (Src/subst.c:1671 `int globsubst =
5936 // isset(GLOBSUBST);`, forced to 2 by `${~…}` at
5937 // Src/subst.c:2603) whose only lasting effect is the
5938 // `shtokenize` of that substitution's own result — it never
5939 // reaches the option table, so `execfor`'s list prefork
5940 // (Src/loop.c:196-235) cannot leak it into the loop BODY.
5941 // zshrs carries the flag through the global option table so
5942 // the compile-emitted glob ops of the SAME word can see it
5943 // (documented deviation at subst.rs:3190), and restores it at
5944 // command-dispatch boundaries — but a `for` list has no
5945 // trailing dispatch of its own, so `for i in "${a:#${~p}*}"`
5946 // left GLOB_SUBST ON and filename-generated the FIRST body
5947 // command's words (`_parameters:34` → `ary+=($i:"$v")` glob-
5948 // erroring "bad pattern: HISTCHARS:!^#", which aborted the
5949 // whole `pr<TAB>` completion). This builtin ends EVERY for/
5950 // select list expansion and runs AFTER each word's
5951 // GLOB_SUBST_EXPAND op, so the carrier has been read by then.
5952 consume_tilde_globsubst_carrier();
5953 let n = argc as usize;
5954 let start = vm.stack.len().saturating_sub(n);
5955 let raw: Vec<Value> = vm.stack.drain(start..).collect();
5956 let mut flat: Vec<Value> = Vec::with_capacity(raw.len());
5957 for v in raw {
5958 match v {
5959 Value::Array(items) => flat.extend(items.iter().cloned()),
5960 other => flat.push(other),
5961 }
5962 }
5963 let len = flat.len() as i64;
5964 // Push the array first; the Int(len) becomes the builtin's return
5965 // value (which CallBuiltin already pushes). Caller consumes in
5966 // reverse: SetSlot(len_slot) pops Int, SetSlot(arr_slot) pops Array.
5967 vm.push(Value::array(flat));
5968 Value::Int(len)
5969 });
5970
5971 // Shell variable get/set — routes through executor.variables so nested
5972 // VMs (function calls) and tree-walker callers see the same storage.
5973 // GET_VAR / GET_VAR_DQ share one body via `get_var_impl`; the only
5974 // difference is `force_dq`, which the compiler sets for QUOTED simple
5975 // reads (`"$name"`) so an array's empty elements are preserved (the
5976 // `in_dq_context` runtime flag is 0 for these compiler-direct reads).
5977 fn get_var_impl(vm: &mut fusevm::VM, argc: u8, force_dq: bool) -> Value {
5978 let args = pop_args(vm, argc);
5979 let name = args.into_iter().next().unwrap_or_default();
5980 let live_status = vm.last_status;
5981 // `$@` and `$*` need splice semantics — return Value::Array of
5982 // positional params so for-loop's BUILTIN_ARRAY_FLATTEN spreads them
5983 // and pop_args splits them into argv slots. zsh's `"$@"` bslashquote-each-
5984 // word semantics matches: each pos-param becomes its own arg.
5985 // Same for arrays accessed by name (e.g. `$arr` in some contexts).
5986 //
5987 // vm.last_status is authoritative: `subshell_end` now returns
5988 // Some(status) and fusevm's `Op::SubshellEnd` writes it into
5989 // vm.last_status, so a deferred subshell `exit N` is visible
5990 // here. Suppressing this sync (as an older revision did, back
5991 // when the host hook returned nothing) made LASTVAL win over
5992 // any status the VM set AFTER SubshellEnd — which dropped the
5993 // `!` negation of `Src/exec.c:1979-1980`
5994 // if ((slflags & WC_SUBLIST_NOT) && !errflag && !retflag)
5995 // lastval = !lastval;
5996 // for `! (exit 7)` (emit_negate_status' SetStatus updated
5997 // vm.last_status, then `$?` read the stale LASTVAL=7).
5998 let sync_status = |exec: &mut ShellExecutor| {
5999 exec.set_last_status(live_status);
6000 };
6001 if name == "@" || name == "*" {
6002 // Quoting decides empty-word retention (c:Src/subst.c:
6003 // 184-187): the COMPILE site knows it and emits
6004 // BUILTIN_ARRAY_DROP_EMPTY after this read for the
6005 // unquoted form only — in_dq_context is NOT a valid
6006 // discriminator here (the quoted "$@" fast path emits
6007 // GET_VAR directly without an EXPAND_TEXT wrapper).
6008 let pp = with_executor(|exec| {
6009 sync_status(exec);
6010 exec.pparams()
6011 });
6012 // c:Src/subst.c:1817 — `int nojoin = (pf_flags &
6013 // PREFORK_SHWORDSPLIT) ? !(ifs && *ifs) && !qt : 0;`
6014 // c:Src/subst.c:3908-3911 — `if (nojoin == 0 || sep) { val =
6015 // sepjoin(aval, sep, 1); isarr = 0; }`
6016 // c:Src/subst.c:3919-3921 — `if (force_split && !isarr) { aval =
6017 // sepsplit(val, spsep, 0, 1); … }`
6018 //
6019 // So under SH_WORD_SPLIT an UNQUOTED `$@`/`$*` with a NON-EMPTY
6020 // `$IFS` is first JOINED on `$IFS[1]` and then re-split on `$IFS`
6021 // — which is why `setopt shwordsplit; set -- one:two b:c; IFS=:;
6022 // print -l $@` is four words in zsh (and in bash, and in ksh).
6023 // The port returned the raw positional list, so an element
6024 // carrying an IFS byte was never broken up
6025 // (D04parameter.ztst "Splitting of $@ on IFS: single element";
6026 // `zshrs --bash -c 'set -- "a b" c; printf "[%s]\n" $@'` printed
6027 // `[a b]` where bash prints `[a]` `[b]`).
6028 //
6029 // The gates are C's, verbatim: `!force_dq` is `!qt`, an UNSET or
6030 // EMPTY `$IFS` leaves `nojoin` at 1 (no join, no split), and the
6031 // whole rule is inert without SH_WORD_SPLIT (`nojoin = 0` there,
6032 // but `force_split` at c:3913 is `!ssub && (spbreak || spsep)`,
6033 // all clear, so neither branch runs).
6034 if !force_dq && crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT) {
6035 let ifs = crate::ported::params::getsparam("IFS").unwrap_or_default(); // c:1817
6036 if !ifs.is_empty() {
6037 // c:3909 `sepjoin(aval, sep, 1)` with sep NULL → $IFS[1].
6038 let sep0: String = ifs.chars().next().map(String::from).unwrap_or_default();
6039 let joined = pp.join(&sep0);
6040 // c:3919 `sepsplit(val, spsep, 0, 1)` with spsep NULL →
6041 // split on $IFS; multsub's PREFORK_SPLIT walker is the
6042 // port of that (subst.rs:1603).
6043 let (_j, parts, _isarr, _f) =
6044 crate::ported::subst::multsub(&joined, crate::ported::zsh_h::PREFORK_SPLIT);
6045 return Value::array(parts.into_iter().map(Value::str).collect());
6046 }
6047 }
6048 return Value::array(pp.iter().map(Value::str).collect());
6049 }
6050 // RC_EXPAND_PARAM: when the option is set and `name` refers to
6051 // an array, return Value::Array so the enclosing word's
6052 // BUILTIN_CONCAT_DISTRIBUTE distributes element-wise. Without
6053 // the option, arrays still join to a space-separated scalar
6054 // (zsh's default unquoted-array-as-scalar semantics).
6055 let rc_expand = with_executor(|exec| opt_state_get("rcexpandparam").unwrap_or(false));
6056 // c:Src/subst.c — under KSHARRAYS a bare `$name` (no [@]/[*] subscript;
6057 // this GET_VAR path only handles the bare form) is element 1 ONLY — a
6058 // scalar. RC_EXPAND_PARAM then has a single value to distribute, so
6059 // `$acc` → "p1", NOT the whole array. Skip the whole-array rc_expand
6060 // shortcut when KSHARRAYS is set and fall through to the normal path,
6061 // which applies the element-1 collapse. Without this gate,
6062 // `setopt KSH_ARRAYS rc_expand_param; print -r -- $acc` splatted every
6063 // element while zsh prints just "p1".
6064 let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
6065 // c:Src/subst.c:4245 `if (isarr)` gates the whole plan9 block
6066 // (c:4316), and TWO earlier arms have already zeroed `isarr` for a
6067 // bare array read that is quoted or scalar-substituted:
6068 // c:3032 `if (qt && !getlen && isarr > 0) { val = sepjoin(aval,
6069 // sep, 1); isarr = 0; }` — DQ context
6070 // c:3905 `if (nojoin == 0 || sep) { val = sepjoin(aval, sep, 1);
6071 // isarr = 0; }` under `if (ssub || …)` at c:3901
6072 // — PREFORK_SINGLE (scalar
6073 // assignment RHS)
6074 // So RC_EXPAND_PARAM never cross-products a plain `"$a"` / `b=$a`;
6075 // the array collapses to one IFS-joined scalar first. `force_dq` is
6076 // exactly the compiler's flag for those two contexts (it is set for
6077 // `in_dq || scalar_assign_depth > 0 || assign_builtin_arg_depth > 0`),
6078 // Without this gate `setopt rcexpandparam; a=(x y); print -rl -- "$a"Z`
6079 // emitted `xZ` / `yZ` instead of zsh's single `x yZ` — which is how
6080 // `_sqlite`'s `"($exclusive)"$^dashes'-header[…]'` reached
6081 // `comparguments` as five words starting `(-noheader`.
6082 //
6083 // Only the compile-time flag is consulted. The runtime
6084 // `in_dq_context` counter stays set while a `$(…)` INSIDE double
6085 // quotes runs its body, so reading it here would join an unquoted
6086 // `$a` in `"$(print -l -- $a)"`.
6087 if rc_expand && !ksh_arrays && !force_dq {
6088 let arr_val = with_executor(|exec| {
6089 sync_status(exec);
6090 exec.array(&name)
6091 });
6092 if let Some(arr) = arr_val {
6093 // c:4245 — a real array reference (`isarr != 0`). An empty one
6094 // takes plan9's word-removal path (c:4362), so clear the
6095 // scalar bit; a preceding empty-SCALAR expansion in the same
6096 // word would otherwise leave it set and keep the word alive
6097 // (`empty=''; e=(); a=("$empty"); print -rl -- x$e y`).
6098 // Only an EMPTY array may clear it: a non-empty one carries
6099 // no emptiness of its own, and clearing on it wiped the bit a
6100 // preceding empty SCALAR had set — `setopt rcexpandparam;
6101 // e=''; a=(x y); print -rl -- $e$a` lost the whole word.
6102 if arr.is_empty() {
6103 note_empty_is_scalar(false);
6104 }
6105 return Value::array(arr.into_iter().map(Value::str).collect());
6106 }
6107 }
6108 // Magic-assoc fallback FIRST — `${aliases}` / `${functions}`
6109 // / `${commands}` / etc. should return the value list per
6110 // zsh's bare-assoc semantics. Without this, those names fell
6111 // through to `get_variable` which is empty (they live in
6112 // separate executor tables, not `assoc_arrays`). Return as
6113 // a Value::Array so `arr=(${aliases})` distributes into
6114 // multiple elements, matching zsh's array-context word
6115 // splitting for assoc-bare references.
6116 let magic_vals = with_executor(|exec| {
6117 sync_status(exec);
6118 // Canonical PARTAB dispatch (Src/Modules/parameter.c:2235-
6119 // 2298 + SPECIALPMDEFs in mapfile/terminfo/termcap/system/
6120 // zleparameter): PARTAB_ARRAY entries → whole-array getfn;
6121 // PARTAB entries → scan keys + per-key getpm/scanpm fn
6122 // pointers.
6123 let _ = exec;
6124 if let Some(values) = partab_array_get(&name) {
6125 Some(values)
6126 } else if let Some(keys) = partab_scan_keys(&name) {
6127 Some(
6128 keys.iter()
6129 .map(|k| partab_get(&name, k).unwrap_or_default())
6130 .collect::<Vec<_>>(),
6131 )
6132 } else {
6133 None
6134 }
6135 });
6136 if let Some(vals) = magic_vals {
6137 // Distinguish "name IS a magic-assoc with no entries"
6138 // (return Array(empty)) from "name is unknown — fall
6139 // through to get_variable".
6140 // c:Src/params.c:2293-2296 — KSHARRAYS bare reference
6141 // collapses to the FIRST element in scan order
6142 // (`v->end = 1, v->isarr = 0`). For `options` the scan
6143 // order is optiontab bucket order (OPTIONTAB), so zsh 5.9
6144 // prints `off` (posixargzero) for
6145 // `setopt ksharrays; print $options`.
6146 if opt_state_get("ksharrays").unwrap_or(false) {
6147 return Value::str(vals.into_iter().next().unwrap_or_default());
6148 }
6149 return Value::array(vals.into_iter().map(Value::str).collect());
6150 }
6151 // Indexed-array path: return Value::Array so pop_args splats
6152 // each element into its own argv slot. Direct port of zsh's
6153 // unquoted `$arr` semantics — each element becomes a separate
6154 // word in command-arg position.
6155 //
6156 // DQ context exception: inside `"...$arr..."`, zsh joins with
6157 // the first char of $IFS (default space) so the DQ word stays
6158 // a single argv slot. Detect via in_dq_context (bumped by
6159 // BUILTIN_EXPAND_TEXT mode 1) and return the joined scalar.
6160 // Direct port of Src/subst.c:1759-1813 nojoin/sepjoin: in DQ
6161 // (qt=1) without explicit `(@)`, sepjoin runs and the result
6162 // is one word.
6163 let arr_assoc_data = with_executor(|exec| {
6164 sync_status(exec);
6165 let in_dq = force_dq || exec.in_dq_context > 0;
6166 // KSH_ARRAYS: bare `$arr` returns ONLY arr[0] (zero-
6167 // based first-element-only semantics). Direct port of
6168 // Src/params.c getstrvalue's KSH_ARRAYS gate which
6169 // returns aval[0] instead of the whole array.
6170 let ksh_arrays = opt_state_get("ksharrays").unwrap_or(false);
6171 if let Some(arr) = exec.array(&name) {
6172 if ksh_arrays {
6173 return Some((vec![arr.first().cloned().unwrap_or_default()], in_dq));
6174 }
6175 return Some((arr.clone(), in_dq));
6176 }
6177 if exec.assoc(&name).is_some() {
6178 // c:Src/params.c:2351-2358 — under KSH EMULATION a bare
6179 // `$assoc` is `${assoc[0]}` (a KEY-"0" lookup), so it is
6180 // EMPTY unless the hash actually has a key "0". This is
6181 // EMULATION-gated, not KSHARRAYS-option-gated: `emulate -L
6182 // ksh; typeset -A h=(a 1 b 2); print $h` is empty, whereas
6183 // `setopt ksharrays; …; print $h` collapses to the bucket-
6184 // first value below.
6185 if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
6186 let v = crate::ported::subst::assoc_get(&name)
6187 .and_then(|m| m.get("0").cloned())
6188 .unwrap_or_default();
6189 return Some((vec![v], in_dq));
6190 }
6191 // c:Src/hashtable.c scanhashtable — a bare `$assoc` joins its
6192 // VALUES in zsh hash-BUCKET order (the same order `(k)`/`(v)`
6193 // enumerate), NOT sorted or insertion order. `assoc_get`
6194 // rebuilds zsh's bucket layout; use it so `$as` matches
6195 // `${(v)as}` (`as=(zebra 9 apple 1)` → `9 1`, not the
6196 // alphabetical `1 9`). Under KSHARRAYS the bare form collapses
6197 // to the bucket-FIRST value (`9`), matching zsh.
6198 let values: Vec<String> = crate::ported::subst::assoc_get(&name)
6199 .map(|m| m.values().cloned().collect())
6200 .unwrap_or_default();
6201 if ksh_arrays {
6202 return Some((vec![values.into_iter().next().unwrap_or_default()], in_dq));
6203 }
6204 return Some((values, in_dq));
6205 }
6206 None
6207 });
6208 if let Some((items, in_dq)) = arr_assoc_data {
6209 // c:Src/subst.c:184-187 — prefork's `else if (!keep)
6210 // uremnode(list, node)`: UNQUOTED expansion drops empty
6211 // list nodes before they reach argv, so `a=(y '' x);
6212 // print -- $a` passes TWO args in zsh (`y x`), while the
6213 // quoted "${a[@]}" splat keeps the empty slot. The
6214 // paramsubst splat path already does this (Bug #578
6215 // retain); this GET_VAR fast path bypassed it and leaked
6216 // empty argv slots (visible double-space, wrong arg
6217 // counts in `for`/`print -l`).
6218 let items: Vec<String> = if in_dq {
6219 items
6220 } else {
6221 items.into_iter().filter(|s| !s.is_empty()).collect()
6222 };
6223 if in_dq {
6224 // c:Src/utils.c:3936-3945 sepjoin default-sep rule:
6225 // set-but-empty IFS joins with "" (`IFS=""; echo
6226 // "$arr"` concatenates); only unset / space-leading
6227 // IFS yields " ". The previous get_variable read
6228 // couldn't distinguish unset from set-empty.
6229 return Value::str(crate::ported::utils::sepjoin(&items, None));
6230 }
6231 // c:4245 — a real array reference: `isarr != 0`, so an empty
6232 // one takes plan9's word-removal path, not the scalar path.
6233 // Only note it when the array IS empty — see the rc_expand arm
6234 // above for why a non-empty read must not touch the bit.
6235 if items.is_empty() {
6236 note_empty_is_scalar(false);
6237 }
6238 return Value::array(items.into_iter().map(Value::str).collect());
6239 }
6240 let (val, in_dq, is_known) = with_executor(|exec| {
6241 sync_status(exec);
6242 let v = exec.get_variable(&name);
6243 // For nounset detection: a name is "known" when it has a
6244 // paramtab/array/assoc/env entry. Special chars ($?, $#,
6245 // $@, $*, $-, $$, $!, $_, $0) always count as known
6246 // regardless of value. Pure-digit positional params
6247 // count as known iff index <= $# (set -- has populated
6248 // that slot). c:Src/subst.c:1689 — NOUNSET fires on
6249 // unset positional param too: `set --; echo "$1"` with
6250 // nounset must diagnose.
6251 let is_special_single = name.len() == 1
6252 && matches!(
6253 name.chars().next().unwrap(),
6254 '?' | '#' | '@' | '*' | '-' | '$' | '!' | '_' | '0'
6255 );
6256 let is_pure_digit = !name.is_empty() && name.chars().all(|c| c.is_ascii_digit());
6257 let positional_known = if is_pure_digit {
6258 let idx: usize = name.parse().unwrap_or(0);
6259 if idx == 0 {
6260 true // $0 always set
6261 } else {
6262 idx <= exec.pparams().len()
6263 }
6264 } else {
6265 false
6266 };
6267 let known = !v.is_empty()
6268 || name.is_empty()
6269 || is_special_single
6270 || positional_known
6271 || crate::ported::params::paramtab()
6272 .read()
6273 .ok()
6274 .map(|t| t.contains_key(&name))
6275 .unwrap_or(false)
6276 || env::var(&name).is_ok();
6277 (v, force_dq || exec.in_dq_context > 0, known)
6278 });
6279 // c:Src/subst.c:1689 — NO_UNSET / nounset: reading an unset
6280 // parameter fires "parameter not set" diagnostic and aborts
6281 // the substitution. Direct port of the noerrs gate at c:1689
6282 // (zerr + errflag). Matches `set -u` POSIX semantics.
6283 if !is_known && opt_state_get("nounset").unwrap_or(false) {
6284 crate::ported::utils::zerr(&format!("{}: parameter not set", name));
6285 crate::ported::utils::errflag.fetch_or(
6286 crate::ported::zsh_h::ERRFLAG_ERROR,
6287 std::sync::atomic::Ordering::Relaxed,
6288 );
6289 with_executor(|exec| exec.set_last_status(1));
6290 return Value::str("");
6291 }
6292 // Empty unquoted scalar → drop the arg (zsh "remove empty
6293 // unquoted words" rule). Returning empty Value::Array makes
6294 // pop_args contribute zero items. DQ context keeps the empty
6295 // string so "$a" stays a single empty arg. Direct port of
6296 // subst.c's elide-empty pass.
6297 if val.is_empty() && !in_dq {
6298 // c:1650-1656 / c:4437 — a SCALAR parameter has `isarr == 0`,
6299 // so it never reaches plan9's word-removal at c:4362. Flag the
6300 // empty Array below as a scalar so `setopt rcexpandparam;
6301 // v=; print -rl -- x$v y` still emits `x` (only an empty
6302 // ARRAY deletes the word).
6303 note_empty_is_scalar(true);
6304 return Value::array(Vec::new());
6305 }
6306 // c:Src/subst.c:1759 SH_WORD_SPLIT — when shwordsplit is set and
6307 // we're in unquoted command-arg position (not DQ), split scalar
6308 // value on IFS into multiple words. Matches BUILTIN_ARRAY_ALL's
6309 // shwordsplit arm (fusevm_bridge.rs:2200). Without this, bare
6310 // `$s` in `print $s` stayed a single arg even with the option
6311 // set, breaking POSIX-style scalar word-splitting.
6312 if !in_dq && opt_state_get("shwordsplit").unwrap_or(false) {
6313 // c:1705 — `spbreak = (pf_flags & PREFORK_SHWORDSPLIT) && !qt`,
6314 // then c:3902 `force_split = !ssub && (spbreak || spsep)` and
6315 // c:3921 `aval = sepsplit(val, spsep, 0, 1)`. SH_WORD_SPLIT runs
6316 // the SAME splitter as `${=name}`, so route it through the same
6317 // port. The previous `split(|c| ifs.contains(c)).filter(non-empty)`
6318 // dropped every empty field, but spacesplit (Src/utils.c:3711)
6319 // only elides the ones a run of IFS-WHITESPACE produces — an
6320 // IFS-NON-whitespace separator preserves them as `nulstring`.
6321 // `IFS=x; v=xaxbx; setopt shwordsplit; print -rl -- $v` is four
6322 // words in zsh (``, a, b, ``), not two.
6323 let raw = crate::ported::utils::sepsplit(&val, None, false); // c:3921
6324 let nulstring = crate::ported::zsh_h::Nularg.to_string(); // c:36
6325 let parts: Vec<Value> = raw
6326 .into_iter()
6327 .filter_map(|w| {
6328 if w == nulstring {
6329 Some(Value::str(String::new()))
6330 } else if w.is_empty() {
6331 // c:184-187 — prefork deletes the truly-empty node.
6332 None
6333 } else {
6334 Some(Value::str(w))
6335 }
6336 })
6337 .collect();
6338 if parts.is_empty() {
6339 // c:3922 — `val = dupstring("")`: an empty SCALAR, not an
6340 // empty array (see EMPTY_EXPANSION_IS_SCALAR).
6341 note_empty_is_scalar(true);
6342 return Value::array(Vec::new());
6343 } else if parts.len() == 1 {
6344 // c:3924 — `else if (!aval[1]) val = aval[0];`
6345 return parts.into_iter().next().unwrap();
6346 } else {
6347 return Value::array(parts); // c:3927
6348 }
6349 }
6350 Value::str(val)
6351 }
6352 // Provenance: BUILTIN_GET_VAR / _DQ are the bytecode-level parameter
6353 // READ ops, so this is the tap that hands a tracked parameter's
6354 // lineage to the value the read produced. The name is peeked off the
6355 // stack before `get_var_impl` consumes it.
6356 fn get_var_prov(vm: &mut fusevm::VM, argc: u8, force_dq: bool) -> Value {
6357 if !crate::provenance::active() {
6358 return get_var_impl(vm, argc, force_dq);
6359 }
6360 let name = vm.peek().to_str();
6361 let value = get_var_impl(vm, argc, force_dq);
6362 crate::provenance::on_param_read(&name, &value);
6363 value
6364 }
6365 vm.register_builtin(BUILTIN_GET_VAR, |vm, argc| get_var_prov(vm, argc, false));
6366 vm.register_builtin(BUILTIN_GET_VAR_DQ, |vm, argc| get_var_prov(vm, argc, true));
6367
6368 // `name+=val` (no parens) — runtime dispatch:
6369 // - if `name` is in `arrays` → push `val` as new element
6370 // - if `name` is in `assoc_arrays` → refuse (zsh errors here)
6371 // - else → scalar concat (existing behavior)
6372 // Stack: [name, value].
6373 vm.register_builtin(BUILTIN_APPEND_SCALAR_OR_PUSH, |vm, argc| {
6374 let args = pop_args(vm, argc);
6375 let mut iter = args.into_iter();
6376 let name = iter.next().unwrap_or_default();
6377 let value = iter.next().unwrap_or_default();
6378 with_executor(|exec| {
6379 // Array form: `arr+=elem` pushes a single element.
6380 // Routes through canonical assignaparam(name, [value],
6381 // ASSPM_AUGMENT) — Src/params.c:3357 c:3402-3412 augment
6382 // path prepends prior scalar / appends to existing array.
6383 // Existence probe uses the non-cloning `has_array` — the
6384 // owning `exec.array()` clone here made `arr+=x` in a loop
6385 // O(n²) (see the assoc-store fix above).
6386 if exec.has_array(&name) {
6387 // c:Src/params.c — under KSHARRAYS a bare array name
6388 // addresses element 0 (ksh), so `a+=X` (scalar augment)
6389 // CONCATENATES onto the first element ("firstlast second"),
6390 // it does NOT push a new element. C routes scalar `+=`
6391 // through assignsparam (which targets the elem-0 value);
6392 // zshrs's APPEND_SCALAR_OR_PUSH would otherwise push.
6393 if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
6394 let mut arr = exec.array(&name).unwrap_or_default();
6395 if arr.is_empty() {
6396 arr.push(value.clone());
6397 } else {
6398 arr[0] = format!("{}{}", arr[0], value);
6399 }
6400 exec.set_array(name.clone(), arr);
6401 #[cfg(feature = "recorder")]
6402 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
6403 let ctx = exec.recorder_ctx();
6404 let attrs = exec.recorder_attrs_for(&name);
6405 emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
6406 }
6407 return;
6408 }
6409 let _ = crate::ported::params::assignaparam(
6410 &name,
6411 vec![value.clone()],
6412 crate::ported::zsh_h::ASSPM_AUGMENT,
6413 );
6414 #[cfg(feature = "recorder")]
6415 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
6416 let ctx = exec.recorder_ctx();
6417 let attrs = exec.recorder_attrs_for(&name);
6418 emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
6419 }
6420 return;
6421 }
6422 if exec.has_assoc(&name) {
6423 eprintln!("zshrs: {}: cannot use += on assoc without (key val)", name);
6424 return;
6425 }
6426 // Scalar / integer / float form: route through canonical
6427 // assignsparam(name, value, ASSPM_AUGMENT) which
6428 // dispatches PM_TYPE — PM_SCALAR concats, PM_INTEGER
6429 // arith-adds (c:2775-2778), PM_FLOAT float-adds.
6430 let _ = crate::ported::params::assignsparam(
6431 &name,
6432 &value,
6433 crate::ported::zsh_h::ASSPM_AUGMENT,
6434 );
6435 #[cfg(feature = "recorder")]
6436 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
6437 let ctx = exec.recorder_ctx();
6438 let attrs = exec.recorder_attrs_for(&name);
6439 // Re-read the canonical value via get_variable for the
6440 // recorder bundle (assignsparam may have transformed it
6441 // through integer/float arithmetic).
6442 let final_val = exec.get_variable(&name);
6443 let lower = name.to_ascii_lowercase();
6444 if matches!(
6445 lower.as_str(),
6446 "path" | "fpath" | "manpath" | "module_path" | "cdpath"
6447 ) {
6448 emit_path_or_assign(&name, std::slice::from_ref(&final_val), attrs, true, &ctx);
6449 } else {
6450 crate::recorder::emit_assign_typed(&name, &final_val, attrs, ctx);
6451 }
6452 }
6453 });
6454 Value::Status(0)
6455 });
6456
6457 // BUILTIN_SET_VAR — `name=value` runtime scalar assignment.
6458 // PURE PASSTHRU: hand to canonical `setsparam` (C port of
6459 // `Src/params.c::setsparam`). That walks assignsparam →
6460 // assignstrvalue which already does:
6461 // - readonly rejection (zerr + errflag at c:2701)
6462 // - PM_INTEGER math evaluation (mathevali at c:3590)
6463 // - PM_EFLOAT / PM_FFLOAT float coercion (c:3608)
6464 // - PM_LOWER / PM_UPPER case fold (via setstrvalue)
6465 // - GSU special-param dispatch (homesetfn / ifssetfn / etc.)
6466 // - allexport env mirror via the PM_EXPORTED setfn
6467 //
6468 // Bridge-only concerns kept here:
6469 // - inline_env_stack (zsh `X=foo cmd` scoped env)
6470 // - recorder emission (PFA-SMR)
6471 // - vm.last_status propagation for `a=$(cmd)` exit-code chaining
6472 // Sets the GLOB_ASSIGN-eligibility flag consumed by the NEXT BUILTIN_SET_VAR.
6473 // Emitted only when a scalar-assign RHS had an unquoted glob token. Takes no
6474 // args; its pushed return is discarded by a following Op::Pop.
6475 vm.register_builtin(BUILTIN_MARK_GLOB_ELIGIBLE, |_vm, _argc| {
6476 SET_VAR_GLOB_ELIGIBLE.with(|c| c.set(true));
6477 fusevm::Value::Int(0)
6478 });
6479 vm.register_builtin(BUILTIN_SET_VAR, |vm, argc| {
6480 // `${~spec}` carrier: an assignment statement is a word-
6481 // pipeline boundary too — restore the user's GLOB_SUBST
6482 // before the NEXT word expands (`Z[d]=${~Z[d]}; print
6483 // ${options[globsubst]}` must read the user value).
6484 consume_tilde_globsubst_carrier();
6485 // Snapshot the raw Values BEFORE pop_args's to_str
6486 // flattening — needed to distinguish Int (arith assignment,
6487 // integer-typed param) from Str (scalar assignment).
6488 let mut raw_values: Vec<fusevm::Value> = Vec::with_capacity(argc as usize);
6489 for _ in 0..argc {
6490 raw_values.push(vm.pop());
6491 }
6492 raw_values.reverse();
6493 let name = raw_values.first().map(|v| v.to_str()).unwrap_or_default();
6494 let value_raw = raw_values.get(1).cloned();
6495 let value = value_raw.as_ref().map(|v| v.to_str()).unwrap_or_default();
6496 // c:Src/params.c — when the bytecode hands us an Int value
6497 // (only the arith assignment paths emit this — `(( X = N ))`
6498 // is the canonical site), route through setiparam so the
6499 // param ends up PM_INTEGER + inherits the math layer's
6500 // `lastbase` for display formatting (`(( X = 16#ff ));
6501 // echo \$X` → `16#FF`). Scalar `X=val` and `$((expr))`
6502 // assignments still take the setsparam path below.
6503 let int_assign = matches!(value_raw, Some(fusevm::Value::Int(_)));
6504 let float_assign = matches!(value_raw, Some(fusevm::Value::Float(_)));
6505 let mut assign_failed = false;
6506 with_executor(|exec| {
6507 // c:Src/params.c assignsparam — PM_READONLY rejection
6508 // BEFORE any env mutation. The inline-env-prefix path
6509 // (`X=2 env`) called env::set_var unconditionally before
6510 // the readonly check fired in setsparam, so the OS env
6511 // got X=2 even though the assignment errored. env then
6512 // inherited the polluted env from fork, leaking the
6513 // attempted override past the readonly guard. Mirror
6514 // C's order: readonly check → zerr → bail; only mutate
6515 // env when the assignment is admissible. Bug #551
6516 // (security-relevant).
6517 if exec.is_readonly_param(&name) {
6518 crate::ported::utils::zerr(&format!("read-only variable: {}", name));
6519 return;
6520 }
6521 // Inline-assignment frame tracking (`X=foo cmd` reverts on
6522 // command return). Only the PREFIX assignments belong in
6523 // the frame: c:Src/exec.c:4410 save_params snapshots the
6524 // parsed WC_ASSIGN chain and nothing else. The frame stays
6525 // on the stack while the command runs, so gate on
6526 // `recording` (cleared by SEAL_INLINE_ENV once the prefix
6527 // assignments have committed) — otherwise every assignment
6528 // the command itself makes gets recorded and then reverted
6529 // (`X=y . file` wiped every global the file defined).
6530 if exec
6531 .inline_env_stack
6532 .last()
6533 .is_some_and(|frame| frame.recording)
6534 {
6535 let prev_var = crate::ported::params::getsparam(&name);
6536 let prev_env = env::var(&name).ok();
6537 exec.inline_env_stack.last_mut().unwrap().saved.push((
6538 name.clone(),
6539 prev_var,
6540 prev_env,
6541 ));
6542 let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value));
6543 // c:Src/params.c:5354
6544 }
6545 // Canonical setsparam handles readonly, integer math, case
6546 // fold, GSU dispatch. For Int values (arith assigns) route
6547 // through setiparam so the param is PM_INTEGER + inherits
6548 // the math layer's lastbase for display formatting. For
6549 // Float (arith assigns producing MN_FLOAT) route through
6550 // setnparam so the param is PM_FFLOAT — `(( b = a * 2 ))`
6551 // with scalar `a="3.14"` should create b as typeset -F,
6552 // not a scalar holding "6.28".
6553 if int_assign {
6554 if let Some(fusevm::Value::Int(i)) = value_raw {
6555 crate::ported::params::setiparam(&name, i);
6556 } else {
6557 assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
6558 }
6559 } else if float_assign {
6560 if let Some(fusevm::Value::Float(f)) = value_raw {
6561 // ArithCompiler returns Value::Float whenever any
6562 // operand came through Str (BUILTIN_GET_VAR yields
6563 // Value::Str even for integer-shaped scalars). To
6564 // avoid forcing every `(( b = a + 3 ))` to PM_FFLOAT
6565 // when `a="5"` (integer-shaped), detect integer-
6566 // valued floats and route through setiparam instead.
6567 // True floats (non-integral) reach setnparam →
6568 // PM_FFLOAT so `typeset -p b` shows `typeset -F …`.
6569 if f.fract() == 0.0 && f.is_finite() && f.abs() <= i64::MAX as f64 {
6570 crate::ported::params::setiparam(&name, f as i64);
6571 } else {
6572 let mnval = crate::ported::math::mnumber {
6573 l: 0,
6574 d: f,
6575 type_: crate::ported::math::MN_FLOAT,
6576 };
6577 crate::ported::params::setnparam(&name, mnval);
6578 }
6579 } else {
6580 assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
6581 }
6582 } else {
6583 // c:Src/exec.c:2554-2567 — GLOB_ASSIGN. When the
6584 // `globassign` option is on and the scalar RHS is a glob
6585 // pattern, glob it and recreate the parameter as a scalar
6586 // (≤1 match) or array (>1) — csh-style assignment. The
6587 // bridge hands `value` UNTOKENIZED, so re-tokenize
6588 // (shtokenize) before haswilds/globlist; zsh's wordcode
6589 // value arrives pre-tokenized via `htok`. The
6590 // `isset(GLOBASSIGN)` gate is first and cheap (option off
6591 // by default), so the common path is unchanged.
6592 let mut globbed = false;
6593 // Only glob the RHS when the compiler flagged an UNQUOTED glob
6594 // token in the literal wordcode (SET_VAR_GLOB_ELIGIBLE). zsh's
6595 // GLOB_ASSIGN (Src/exec.c:2554) globs literal patterns only —
6596 // `x="/tmp/*"`, `x='/tmp/*'`, `x=$param`, `x=$(cmd)` all assign
6597 // verbatim. The value arrives here untokenized (DQ-wrapped by
6598 // the compiler), so this compile-time flag is the only surviving
6599 // signal of whether the pattern was quote-protected.
6600 let glob_eligible = SET_VAR_GLOB_ELIGIBLE.with(|c| c.replace(false));
6601 if glob_eligible && crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBASSIGN) {
6602 let mut tv = value.clone();
6603 crate::ported::glob::shtokenize(&mut tv);
6604 if crate::ported::pattern::haswilds(&tv) {
6605 // Committed to the glob path: never fall back to
6606 // assigning the literal pattern (zsh errors on
6607 // no-match instead).
6608 globbed = true;
6609 // globlist tokenizes its input internally (for
6610 // haswilds + glob_path) and prints the ORIGINAL
6611 // string verbatim in its "no matches found" error,
6612 // so feed it the UNtokenized value — passing the
6613 // tokenized form would leak the Star/Quest token
6614 // bytes into the error message.
6615 let mut ll: crate::ported::linklist::LinkList<String> = Default::default();
6616 ll.push_back(value.clone());
6617 crate::ported::subst::globlist(&mut ll, 0); // c:2556
6618 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
6619 == 0
6620 {
6621 let matches: Vec<String> = ll
6622 .nodes
6623 .iter()
6624 .map(|s| crate::ported::lex::untokenize(s).to_string())
6625 .collect();
6626 crate::ported::params::unsetparam(&name); // c:2562
6627 if matches.len() <= 1 {
6628 let v = matches.into_iter().next().unwrap_or_default();
6629 assign_failed =
6630 crate::ported::params::setsparam(&name, &v).is_none();
6631 } else {
6632 crate::ported::params::setaparam(&name, matches);
6633 }
6634 }
6635 // errflag set → globlist already reported
6636 // "no matches found"; leave the param unassigned
6637 // to match zsh's abort.
6638 }
6639 }
6640 // c:Src/exec.c addvars — a NULL return from
6641 // assignsparam (e.g. nameref resolving out of scope,
6642 // createparam refusal at c:1108-1118) fails the
6643 // assignment with status 1.
6644 if !globbed {
6645 assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
6646 }
6647 }
6648 // PM_EXPORTED / allexport env mirror — read AFTER setsparam
6649 // so the flag bit reflects any GSU setfn side-effects.
6650 let allexport = opt_state_get("allexport").unwrap_or(false);
6651 let already_exported =
6652 (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
6653 if allexport || already_exported {
6654 // c:Src/params.c:3024 — the env mirror is `addenv(pm, value)`,
6655 // and addenv builds its string with `mkenvstr(nam, value,
6656 // pm->flags)` (c:5463) so `copyenvstr` (c:5434) can apply the
6657 // PM_LOWER / PM_UPPER fold. Formatting `name=value` by hand
6658 // skipped that: `typeset -lx v; v=HeLLo` exported `HeLLo`
6659 // where zsh exports `hello`. The fold has to happen HERE
6660 // because the paramtab now stores the value verbatim.
6661 let envstr = crate::ported::params::mkenvstr(
6662 &name,
6663 &value,
6664 exec.param_flags(&name), // c:5463 pm->flags
6665 );
6666 let _ = crate::ported::params::zputenv(&envstr); // c:Src/params.c:5354
6667 }
6668 #[cfg(feature = "recorder")]
6669 if crate::recorder::is_enabled()
6670 && exec.local_scope_depth == 0
6671 && !matches!(
6672 name.as_str(),
6673 "PPID" | "LINENO" | "ZSH_ARGZERO" | "argv0" | "ARGC" | "?" | "_" | "RANDOM"
6674 )
6675 {
6676 let ctx = exec.recorder_ctx();
6677 let attrs = exec.recorder_attrs_for(&name);
6678 crate::recorder::emit_assign_typed(&name, &value, attrs, ctx);
6679 }
6680 // c:Src/exec.c:1367-1370 — `if (code == WC_ASSIGN) { cmdoutval = 0;
6681 // addvars(state, state->pc - 1, 0); setunderscore(""); … }`. A
6682 // simple command consisting ONLY of scalar assignments clears `$_`;
6683 // it never goes through execcmd's c:3545-3547
6684 // `setunderscore(lastnode(args))`. src/ported/exec.rs:6946-6950
6685 // already ports that arm, but the WC_ASSIGN wordcode never
6686 // executes under fusevm — a bare `x=1` arrives here as
6687 // BUILTIN_SET_VAR, so `$_` kept the PREVIOUS command's last
6688 // argument. Symptom: in the `unset <TAB>` listing (the user's
6689 // `_parameters` override runs `maxLen=50` right before the
6690 // `$parameters` walk) zsh shows `_` empty while zshrs showed the
6691 // completion-internal `^a*` — `_parameters -g '^a*'`'s last arg.
6692 //
6693 // Two exclusions, both verified against zsh 5.9.2
6694 // (`true aa; <form>; print -r -- "[$_]"`):
6695 // * PREFIX assignments (`x=1 true dd` → `dd`) are part of a
6696 // command, so c:3545-3547 owns `$_`. They are exactly the
6697 // assignments recorded into an open inline-env frame above.
6698 // * `(( q = 1 ))` (→ `aa`, unchanged) is WC_ARITH, not
6699 // WC_ASSIGN; the arith paths are the only ones that hand this
6700 // builtin an Int/Float `Value` (see the `int_assign` note).
6701 if !int_assign
6702 && !float_assign
6703 && !exec
6704 .inline_env_stack
6705 .last()
6706 .is_some_and(|frame| frame.recording)
6707 {
6708 // c:1369 — assignment-only command clears `$_`. The
6709 // former DUAL-STATE note here is obsolete: `set_zunderscore`
6710 // and the ported `setunderscore` now write the SAME
6711 // `init::zunderscore` global (params.rs `zunderscore_lock`
6712 // points at it), matching C's single store, so this one call
6713 // is the whole effect.
6714 crate::ported::exec::setunderscore(""); // c:1369
6715 }
6716 });
6717 Value::Status(vm.last_status)
6718 });
6719
6720 // c:Src/exec.c execfor → Src/params.c:6362 setloopvar — the
6721 // for-loop variable bind. Distinct from BUILTIN_SET_VAR because a
6722 // PM_NAMEREF loop variable REBINDS (new refname) instead of
6723 // assigning through the resolved chain.
6724 vm.register_builtin(BUILTIN_SET_LOOP_VAR, |vm, argc| {
6725 let args = pop_args(vm, argc);
6726 let name = args.first().cloned().unwrap_or_default();
6727 let value = args.get(1).cloned().unwrap_or_default();
6728 if crate::vm_helper::is_nameref(&name) {
6729 let ef_before =
6730 crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
6731 crate::ported::params::setloopvar(&name, &value); // c:6362
6732 let ef_after = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
6733 if (ef_after & crate::ported::utils::ERRFLAG_ERROR) != 0 && ef_after != ef_before {
6734 // zerr fired (read-only reference / invalid self
6735 // reference) — abort the loop, status 1 (C errflag).
6736 vm.last_status = 1;
6737 return Value::Bool(false);
6738 }
6739 return Value::Bool(true);
6740 }
6741 // Plain loop var — canonical scalar path (same shape as
6742 // BUILTIN_SET_VAR's setsparam arm).
6743 with_executor(|exec| {
6744 if exec.is_readonly_param(&name) {
6745 crate::ported::utils::zerr(&format!("read-only variable: {}", name));
6746 return;
6747 }
6748 crate::ported::params::setsparam(&name, &value);
6749 let allexport = opt_state_get("allexport").unwrap_or(false);
6750 let already_exported =
6751 (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
6752 if allexport || already_exported {
6753 // c:Src/params.c:3024 — the env mirror is `addenv(pm, value)`,
6754 // and addenv builds its string with `mkenvstr(nam, value,
6755 // pm->flags)` (c:5463) so `copyenvstr` (c:5434) can apply the
6756 // PM_LOWER / PM_UPPER fold. Formatting `name=value` by hand
6757 // skipped that: `typeset -lx v; v=HeLLo` exported `HeLLo`
6758 // where zsh exports `hello`. The fold has to happen HERE
6759 // because the paramtab now stores the value verbatim.
6760 let envstr = crate::ported::params::mkenvstr(
6761 &name,
6762 &value,
6763 exec.param_flags(&name), // c:5463 pm->flags
6764 );
6765 let _ = crate::ported::params::zputenv(&envstr); // c:Src/params.c:5354
6766 }
6767 });
6768 Value::Bool(true)
6769 });
6770
6771 // Pre-compiled function registration — used by compile_zsh.rs's
6772 // FuncDef path. Stack: [name, base64-bincode-of-Chunk]. We decode
6773 // the base64, deserialize the Chunk, and store directly in
6774 // executor.functions_compiled. Bypasses the ShellCommand JSON layer.
6775 // BUILTIN_VAR_EXISTS — `[[ -v name ]]` set-test.
6776 // PURE PASSTHRU: build `${+name}` and route through canonical
6777 // `subst::paramsubst` which returns "1" for set / "0" for unset
6778 // (C port of `Src/subst.c::paramsubst` plus-prefix arm).
6779 // paramsubst handles all the shapes the 48-line hand-roll did:
6780 // - bare scalar / array / assoc
6781 // - subscripted `a[N]` / `h[key]`
6782 // - positional params (any digit-only name)
6783 // - env-var fallback (`HOME` set via getsparam → lookup_special_var)
6784 vm.register_builtin(BUILTIN_VAR_EXISTS, |vm, _argc| {
6785 let name = vm.pop().to_str();
6786 // c:Src/cond.c:361 `case 'v': return !issetvar(left)`. `-v` is
6787 // NOT `${+name}` — issetvar (params.c:751) additionally rejects
6788 // trailing chars after the parsed name/subscript (`arr[3]extra`,
6789 // nested `arr[2][1]`) and validates array-slice bounds (an
6790 // out-of-range `(i)`-not-found index is "unset"). `${+}` is
6791 // lenient and reported those as set.
6792 Value::Bool(crate::ported::params::issetvar(&name) != 0)
6793 });
6794
6795 // `time { compound; ... }` — runs the sub-chunk and prints elapsed
6796 // wall-clock time. zsh's full `time` also tracks user/system CPU via
6797 // getrusage on the *child*; we approximate via wall-time only since
6798 // the sub-chunk runs in-process (no fork). Output format matches
6799 // `time simple-cmd` (already implemented elsewhere via exectime).
6800 vm.register_builtin(BUILTIN_TIME_SUBLIST, |vm, argc| {
6801 // A negative sub-chunk index is the compiler's marker for the BARE
6802 // `time` keyword, which has no body to run.
6803 // c:Src/exec.c:5331-5334 exectime:
6804 // if (WC_TIMED_TYPE(state->pc[-1]) == WC_TIMED_EMPTY) {
6805 // shelltime(NULL,NULL,NULL,0);
6806 // return 0;
6807 // }
6808 // `shelltime(NULL, NULL, NULL, 0)` is the ONE call that prints the
6809 // shell/children pair: with delta==0 and both pointers NULL, both
6810 // `!delta == !shell` (c:Src/jobs.c:1964) and `!delta == !kids`
6811 // (c:1985) hold. Verified: `zsh -fc 'time'` prints
6812 // shell 0.00s user 0.00s system … / children 0.00s user …
6813 // while every `time <body>` form prints nothing (see the is_cursh
6814 // note below). zshrs previously compiled bare `time` to a plain
6815 // `status = 0` and printed nothing at all.
6816 let sub_idx_raw = vm.pop().to_int();
6817 let sub_idx = sub_idx_raw as usize;
6818 // c:Src/jobs.c:1028-1029 — `pn->text` arg to printtime. argc==2
6819 // means the compiler also pushed a desc string (bug #66 fix);
6820 // older callers with argc==1 push only sub_idx and we synthesize
6821 // an empty desc for backward compat with cached bytecode that
6822 // predates the desc-threading patch.
6823 let desc = if argc >= 2 {
6824 vm.pop().to_str().to_string()
6825 } else {
6826 String::new()
6827 };
6828 // c:Src/exec.c:3690 — the compiler's `is_cursh` verdict for the
6829 // timed body (see compile_zsh.rs `time_cursh_hint`): 1 = current
6830 // shell, 0 = forked job, 2 = decide from the command name below.
6831 // argc < 4 means bytecode cached before this operand pair existed;
6832 // fall back to the old fork-counter heuristic in that case.
6833 let (cursh_hint, cursh_name) = if argc >= 4 {
6834 let hint = vm.pop().to_int();
6835 let name = vm.pop().to_str().to_string();
6836 (hint, name)
6837 } else {
6838 (-1, String::new())
6839 };
6840 if sub_idx_raw < 0 {
6841 crate::ported::jobs::shelltime(None, None, None, 0); // c:5333
6842 return Value::Status(0); // c:5334
6843 }
6844 let chunk_opt = vm.chunk.sub_chunks.get(sub_idx).cloned();
6845 let Some(chunk) = chunk_opt else {
6846 return Value::Status(0);
6847 };
6848 // c:Src/jobs.c:1968 — `getrusage(RUSAGE_CHILDREN, &ti)` before
6849 // and after the timed sublist gives accurate per-stage user/sys
6850 // CPU. Wall-time-only approximation (0.7×/0.1× fudge factors)
6851 // produced bogus user/sys columns and ignored TIMEFMT. Bug #66
6852 // in docs/BUGS.md.
6853 let ru_before: libc::rusage = unsafe {
6854 let mut r: libc::rusage = std::mem::zeroed();
6855 libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
6856 r
6857 };
6858 // c:Src/jobs.c — zsh's `time` reports only for JOBS (forked
6859 // work). Builtins/brace-groups/functions run in the shell
6860 // process with no job, so `zsh -fc 'time true'` emits NOTHING.
6861 // Snapshot the fork-event counter; report only if the timed
6862 // body forked (external command or subshell).
6863 let forks_before = crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed);
6864 // c:Src/jobs.c:1943 — `getrusage(RUSAGE_SELF, &ti)` — shelltime's
6865 // "shell" line reports the SHELL PROCESS's own CPU delta, which is
6866 // where a current-shell (`is_cursh`) body's work lands.
6867 let ru_self_before: libc::rusage = unsafe {
6868 let mut r: libc::rusage = std::mem::zeroed();
6869 libc::getrusage(libc::RUSAGE_SELF, &mut r);
6870 r
6871 };
6872 let start = Instant::now();
6873 crate::fusevm_disasm::maybe_print_stdout("time_sublist", &chunk);
6874 let mut sub_vm = fusevm::VM::new(chunk);
6875 register_builtins(&mut sub_vm);
6876 let _ = sub_vm.run();
6877 let status = sub_vm.last_status;
6878 let elapsed = start.elapsed();
6879 let ru_self_after: libc::rusage = unsafe {
6880 let mut r: libc::rusage = std::mem::zeroed();
6881 libc::getrusage(libc::RUSAGE_SELF, &mut r);
6882 r
6883 };
6884 let ru_after: libc::rusage = unsafe {
6885 let mut r: libc::rusage = std::mem::zeroed();
6886 libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
6887 r
6888 };
6889 // Delta children rusage = timed work's CPU.
6890 let mut delta = ru_after;
6891 let sub = |a: libc::timeval, b: libc::timeval| -> libc::timeval {
6892 let mut sec = a.tv_sec - b.tv_sec;
6893 let mut usec = a.tv_usec as i64 - b.tv_usec as i64;
6894 if usec < 0 {
6895 sec -= 1;
6896 usec += 1_000_000;
6897 }
6898 libc::timeval {
6899 tv_sec: sec,
6900 tv_usec: usec as libc::suseconds_t,
6901 }
6902 };
6903 delta.ru_utime = sub(ru_after.ru_utime, ru_before.ru_utime);
6904 delta.ru_stime = sub(ru_after.ru_stime, ru_before.ru_stime);
6905 let ti = crate::ported::zsh_h::timeinfo::from_rusage(&delta);
6906 // c:Src/jobs.c:808-809 — `s = getsparam("TIMEFMT"); s ||
6907 // DEFAULT_TIMEFMT`. Honor user-set TIMEFMT, fall back to the
6908 // canonical default.
6909 let fmt = crate::ported::params::getsparam("TIMEFMT")
6910 .unwrap_or_else(|| crate::ported::zsh_system_h::DEFAULT_TIMEFMT.to_string());
6911 // c:Src/jobs.c:768 `desc` arg — for the `time { sublist }` /
6912 // `time simple-cmd` keyword path, zsh passes the sublist's
6913 // source text (used by %J via printtime). The compiler now
6914 // threads the rendered source text through as the desc operand
6915 // (compile_zsh.rs Time arm, argc==2 form). Bug #66.
6916 // c:Src/exec.c:3690 — resolve the compiler's verdict. Hint 2 means
6917 // "a simple command whose head word decides it": `is_builtin ||
6918 // is_shfunc`. A reserved-word / builtin / function head runs in the
6919 // current shell; anything else forks and becomes a job.
6920 let is_cursh = match cursh_hint {
6921 1 => true,
6922 0 => false,
6923 2 => {
6924 // c:3488-3491 — shfunctab is consulted BEFORE builtintab.
6925 let is_shfunc = crate::ported::hashtable::shfunctab_lock()
6926 .read()
6927 .map(|t| t.get(&cursh_name).is_some())
6928 .unwrap_or(false);
6929 is_shfunc
6930 || crate::ported::builtin::createbuiltintable()
6931 .contains_key(cursh_name.as_str())
6932 }
6933 // Bytecode cached before the hint operands existed: keep the
6934 // historical fork-counter heuristic (report only if something
6935 // forked) so a stale cache doesn't start emitting shell/children
6936 // lines for every builtin.
6937 _ => {
6938 let forked = crate::vm_helper::FORK_EVENTS
6939 .load(std::sync::atomic::Ordering::Relaxed)
6940 != forks_before;
6941 if forked {
6942 let line =
6943 crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
6944 eprintln!("{}", line);
6945 }
6946 return Value::Status(status);
6947 }
6948 };
6949 let _ = forks_before;
6950 if is_cursh {
6951 // c:Src/exec.c:4443-4444 — `if ((is_cursh || do_exec) && (how &
6952 // Z_TIMED)) shelltime(&shti, &chti, &then, 1);`
6953 //
6954 // c:Src/jobs.c:1933-1993 shelltime(shell, kids, then, delta=1):
6955 // getrusage(RUSAGE_SELF, &ti); dtime_tv(… shell delta …);
6956 // dtime_ts(&dtimespec, then, &now);
6957 // if (!delta == !shell) printtime(&dtimespec, &ti, "shell");
6958 // getrusage(RUSAGE_CHILDREN, &ti); dtime_tv(… kids delta …);
6959 // if (!delta == !kids) printtime(&dtimespec, &ti, "children");
6960 // With delta=1 and both pointers non-NULL, BOTH lines print.
6961 //
6962 // !!! DO NOT "FIX" THIS TO PRINT NOTHING !!!
6963 // The locally-installed `zsh` may print nothing here and look
6964 // like the oracle. It is not: this behaviour was ADDED by
6965 // upstream 53088 (ChangeLog 2024-09-14, Bart Schaefer) —
6966 // "Src/exec.c, Src/jobs.c, Test/A01grammar.ztst,
6967 // Test/A08time.ztst: enable `time' on builtins, assignments,
6968 // and other current-shell actions, including failed commands."
6969 // — which also ADDED Test/A08time.ztst chunks 8-15, the ones
6970 // that assert `shell*` / `children*` for `time x=1`,
6971 // `time echo $(…)`, `time for ((…))`, `time builtin nonesuch`
6972 // and `time false`. zsh 5.9 (2022-05) predates 53088, so a 5.9.x
6973 // binary is silent for every one of those shapes while both
6974 // vendored C trees (src/zsh 5.9.0.3-test and 5.9.999.3-test)
6975 // carry the c:4443 call. Corpus + C source are the spec here.
6976 let mut d_self = ru_self_after;
6977 d_self.ru_utime = sub(ru_self_after.ru_utime, ru_self_before.ru_utime); // c:1954
6978 d_self.ru_stime = sub(ru_self_after.ru_stime, ru_self_before.ru_stime); // c:1955
6979 let ti_self = crate::ported::zsh_h::timeinfo::from_rusage(&d_self);
6980 // c:1972 — `printtime(&dtimespec, &ti, "shell");`
6981 eprintln!(
6982 "{}",
6983 crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti_self, &fmt, "shell")
6984 );
6985 // c:1993 — `printtime(&dtimespec, &ti, "children");`
6986 eprintln!(
6987 "{}",
6988 crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, "children")
6989 );
6990 } else {
6991 // c:Src/jobs.c:1037 — the forked job's own printtime line, with
6992 // `pn->text` (the command source) as %J.
6993 let line = crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
6994 eprintln!("{}", line);
6995 }
6996 Value::Status(status)
6997 });
6998
6999 // `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocator.
7000 // Stack: [path, varid, op_byte]. Opens path with the appropriate mode
7001 // and stores the resulting fd number in $varid as a string. We use
7002 // a high starting fd (10+) by allocating then dup'ing — matches zsh's
7003 // "fresh fd >= 10" promise so subsequent commands don't collide on
7004 // stdin/out/err.
7005 vm.register_builtin(BUILTIN_OPEN_NAMED_FD, |vm, _argc| {
7006 use std::sync::atomic::Ordering;
7007 let op_byte = vm.pop().to_int() as u8;
7008 let varid = vm.pop().to_str();
7009 let path = vm.pop().to_str();
7010 // Param introspection used by both the open and close forms.
7011 let param_flags = crate::ported::params::paramtab()
7012 .read()
7013 .ok()
7014 .and_then(|t| t.get(&varid).map(|p| p.node.flags));
7015 let param_readonly = param_flags
7016 .map(|f| (f & crate::ported::zsh_h::PM_READONLY as i32) != 0)
7017 .unwrap_or(false);
7018 // `{varid}>&-` / `{varid}<&-` — REDIR_CLOSE with varid.
7019 // Direct port of Src/exec.c:3805-3850.
7020 if matches!(
7021 op_byte,
7022 b if b == fusevm::op::redirect_op::DUP_WRITE
7023 || b == fusevm::op::redirect_op::DUP_READ
7024 ) {
7025 let n = path.trim_start_matches('&');
7026 if n == "-" {
7027 let val = with_executor(|exec| exec.scalar(&varid)).unwrap_or_default();
7028 let fd1 = val.parse::<i32>();
7029 // c:3811-3816 — bad=1: parameter doesn't contain an fd.
7030 let Ok(fd1) = fd1 else {
7031 crate::ported::utils::zwarn(&format!(
7032 "parameter {} does not contain a file descriptor",
7033 varid
7034 ));
7035 with_executor(|exec| exec.redirect_failed = true);
7036 return Value::Status(1);
7037 };
7038 // c:3813-3814 — bad=2: readonly parameter.
7039 if param_readonly {
7040 crate::ported::utils::zwarn(&format!(
7041 "can't close file descriptor from readonly parameter {}",
7042 varid
7043 ));
7044 with_executor(|exec| exec.redirect_failed = true);
7045 return Value::Status(1);
7046 }
7047 // c:3830-3835 — bad=3: fd >= 10 marked FDT_INTERNAL.
7048 if fd1 >= 10
7049 && fd1 <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
7050 && crate::ported::utils::fdtable_get(fd1) == crate::ported::zsh_h::FDT_INTERNAL
7051 {
7052 crate::ported::utils::zwarn(&format!(
7053 "file descriptor {} used by shell, not closed",
7054 fd1
7055 ));
7056 with_executor(|exec| exec.redirect_failed = true);
7057 return Value::Status(1);
7058 }
7059 // c:3870-3873 — close; report failure (varid form
7060 // always reports, unlike bare `N>&-`).
7061 if crate::ported::utils::zclose(fd1) < 0 {
7062 crate::ported::utils::zwarn(&format!(
7063 "failed to close file descriptor {}: {}",
7064 fd1,
7065 std::io::Error::last_os_error()
7066 ));
7067 return Value::Status(1);
7068 }
7069 return Value::Status(0);
7070 }
7071 // `{varid}>&N` — dup N to a fresh fd >= 10, store in varid.
7072 if let Ok(src) = n.parse::<i32>() {
7073 if param_readonly {
7074 crate::ported::utils::zwarn(&format!(
7075 "can't allocate file descriptor to readonly parameter {}",
7076 varid
7077 ));
7078 with_executor(|exec| exec.redirect_failed = true);
7079 return Value::Status(1);
7080 }
7081 let dup = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
7082 if dup < 0 {
7083 crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src));
7084 with_executor(|exec| exec.redirect_failed = true);
7085 return Value::Status(1);
7086 }
7087 // c:2404-2412 addfd varid arm — movefd + FDT_EXTERNAL.
7088 let final_fd = crate::ported::utils::movefd(dup);
7089 crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
7090 with_executor(|exec| {
7091 exec.set_scalar(varid, final_fd.to_string());
7092 });
7093 return Value::Status(0);
7094 }
7095 return Value::Status(1);
7096 }
7097 // `{varid}<<HERE` / `{varid}<<<str` — op byte 255 (zshrs-side
7098 // contract with compile_redir; fusevm's redirect_op stops at
7099 // 8). C path: gethere/getherestr write the body to a temp
7100 // file (Src/exec.c:4660-4682), then addfd's varid arm moves
7101 // the read fd >= 10, marks FDT_EXTERNAL and sets the param
7102 // (c:2402-2412). `path` carries the BODY text here.
7103 if op_byte == 255 {
7104 if param_readonly {
7105 crate::ported::utils::zwarn(&format!(
7106 "can't allocate file descriptor to readonly parameter {}",
7107 varid
7108 ));
7109 with_executor(|exec| exec.redirect_failed = true);
7110 return Value::Status(1);
7111 }
7112 let body = format!("{}\n", path.trim_end_matches('\n'));
7113 let mut tmpl: Vec<u8> = b"/tmp/zshrs_hd_XXXXXX\0".to_vec();
7114 let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
7115 if write_fd < 0 {
7116 crate::ported::utils::zwarn(&format!(
7117 "can't create temp file for here document: {}",
7118 std::io::Error::last_os_error()
7119 ));
7120 return Value::Status(1);
7121 }
7122 let bytes = body.as_bytes();
7123 let mut off = 0;
7124 while off < bytes.len() {
7125 let n = unsafe {
7126 libc::write(
7127 write_fd,
7128 bytes[off..].as_ptr() as *const libc::c_void,
7129 bytes.len() - off,
7130 )
7131 };
7132 if n <= 0 {
7133 unsafe { libc::close(write_fd) };
7134 return Value::Status(1);
7135 }
7136 off += n as usize;
7137 }
7138 unsafe { libc::close(write_fd) };
7139 let read_fd =
7140 unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
7141 unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
7142 if read_fd < 0 {
7143 return Value::Status(1);
7144 }
7145 let final_fd = crate::ported::utils::movefd(read_fd);
7146 if final_fd < 0 {
7147 return Value::Status(1);
7148 }
7149 crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
7150 with_executor(|exec| {
7151 exec.set_scalar(varid, final_fd.to_string());
7152 });
7153 return Value::Status(0);
7154 }
7155 // Open form: `{varid}>file` etc.
7156 // c:Src/exec.c:2177-2215 checkclobberparam — gate BEFORE open.
7157 if param_readonly {
7158 // c:2191-2197
7159 crate::ported::utils::zwarn(&format!(
7160 "can't allocate file descriptor to readonly parameter {}",
7161 varid
7162 ));
7163 with_executor(|exec| exec.redirect_failed = true);
7164 return Value::Status(1);
7165 }
7166 // c:2199-2213 — NO_CLOBBER refuses to overwrite a parameter
7167 // already holding an OPEN fd (decimal value, fdtable says
7168 // FDT_EXTERNAL).
7169 if !isset(crate::ported::zsh_h::CLOBBER) && op_byte != fusevm::op::redirect_op::CLOBBER {
7170 if let Some(val) = with_executor(|exec| exec.scalar(&varid)) {
7171 if let Ok(fd) = val.parse::<i32>() {
7172 if fd >= 0
7173 && fd <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
7174 && crate::ported::utils::fdtable_get(fd)
7175 == crate::ported::zsh_h::FDT_EXTERNAL
7176 {
7177 crate::ported::utils::zwarn(&format!(
7178 "can't clobber parameter {} containing file descriptor {}",
7179 varid, fd
7180 ));
7181 with_executor(|exec| exec.redirect_failed = true);
7182 return Value::Status(1);
7183 }
7184 }
7185 }
7186 }
7187 let path_c = match CString::new(path.clone()) {
7188 Ok(c) => c,
7189 Err(_) => return Value::Status(1),
7190 };
7191 let flags = match op_byte {
7192 b if b == fusevm::op::redirect_op::READ => libc::O_RDONLY,
7193 b if b == fusevm::op::redirect_op::WRITE || b == fusevm::op::redirect_op::CLOBBER => {
7194 libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC
7195 }
7196 b if b == fusevm::op::redirect_op::APPEND => {
7197 libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND
7198 }
7199 b if b == fusevm::op::redirect_op::READ_WRITE => libc::O_RDWR | libc::O_CREAT,
7200 _ => return Value::Status(1),
7201 };
7202 let fd = unsafe { libc::open(path_c.as_ptr(), flags, 0o666) };
7203 if fd < 0 {
7204 // c:Src/exec.c:3790-3795 — report the open failure and mark the
7205 // redirect failed so the command is SKIPPED, matching the numeric-fd
7206 // (`3< file`) and non-varid (`< file`) paths. Previously this
7207 // silently returned Status(1) with no diagnostic and no
7208 // redirect_failed flag, so `{fd}< /nonexistent` ran the command
7209 // anyway with exit 0 (zsh errors "no such file or directory" +
7210 // skips the command). `%e: %s` = strerror(errno) : filename.
7211 let e = std::io::Error::last_os_error();
7212 let msg = redir_errno_msg(&e);
7213 crate::ported::utils::zwarn(&format!("{}: {}", msg, path));
7214 with_executor(|exec| exec.redirect_failed = true);
7215 return Value::Status(1);
7216 }
7217 // c:2404-2412 addfd varid arm — `fd1 = movefd(fd2);
7218 // fdtable[fd1] = FDT_EXTERNAL; setiparam(varid, fd1);`.
7219 // FDT_EXTERNAL (not INTERNAL): the user owns this fd — the
7220 // NO_CLOBBER gate above and `{fd}>&-` close both key off it.
7221 let final_fd = crate::ported::utils::movefd(fd);
7222 if final_fd < 0 {
7223 crate::ported::utils::zerr(&format!(
7224 "cannot move fd {}: {}",
7225 fd,
7226 std::io::Error::last_os_error()
7227 ));
7228 return Value::Status(1);
7229 }
7230 crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
7231 let _ = Ordering::Relaxed;
7232 with_executor(|exec| {
7233 exec.set_scalar(varid, final_fd.to_string());
7234 });
7235 Value::Status(0)
7236 });
7237
7238 // BUILTIN_SET_TRY_BLOCK_ERROR — capture the try-block's exit
7239 // status into `__zshrs_try_block_saved_status` (a scratch
7240 // scalar) so the always-arm can later restore it. Also set
7241 // `TRY_BLOCK_ERROR` per zsh semantics: it stays at -1 unless
7242 // the try-block fired an explicit error (errflag), per
7243 // c:Src/exec.c execlist's WC_TRYBLOCK arm.
7244 vm.register_builtin(BUILTIN_SET_TRY_BLOCK_ERROR, |vm, _argc| {
7245 use std::sync::atomic::Ordering;
7246 let vm_status = vm.last_status;
7247 // c:Src/exec.c WC_TRYBLOCK — the always-arm runs with a
7248 // clean escape state. Snapshot RETFLAG / BREAKS / CONTFLAG /
7249 // EXIT_PENDING here and clear them; RESTORE_TRY_BLOCK_STATUS
7250 // re-applies them at always-arm exit so the propagation jump
7251 // emitted by compile_zsh fires correctly.
7252 let ret_save = crate::ported::builtin::RETFLAG.swap(0, Ordering::Relaxed); // c:769-770
7253 let brk_save = crate::ported::builtin::BREAKS.swap(0, Ordering::Relaxed); // c:771-772
7254 let cont_save = crate::ported::builtin::CONTFLAG.swap(0, Ordering::Relaxed); // c:773-774
7255 let exit_save = crate::ported::builtin::EXIT_PENDING.swap(0, Ordering::Relaxed);
7256 // c:Src/loop.c:762-763 — `save_try_errflag = try_errflag;
7257 // save_try_interrupt = try_interrupt;`. Restored at c:778-779
7258 // by RESTORE_TRY_BLOCK_STATUS so a nested try block doesn't
7259 // clobber the enclosing one's `$TRY_BLOCK_ERROR`.
7260 let try_err_save = crate::ported::r#loop::try_errflag.load(Ordering::Relaxed); // c:762
7261 let try_int_save = crate::ported::r#loop::try_interrupt.load(Ordering::Relaxed); // c:763
7262 TRY_ESCAPE_SAVE.with(|s| {
7263 s.borrow_mut().push((
7264 ret_save,
7265 brk_save,
7266 cont_save,
7267 exit_save,
7268 try_err_save,
7269 try_int_save,
7270 ));
7271 });
7272 // c:Src/loop.c:764-766 — `try_errflag = (zlong)(errflag &
7273 // ERRFLAG_ERROR); try_interrupt = (zlong)((errflag &
7274 // ERRFLAG_INT) ? 1 : 0);`. Both are the RAW FLAG BITS, not the
7275 // try-list's exit status: ERRFLAG_ERROR is 1 (zsh.h:2972), so
7276 // `$TRY_BLOCK_ERROR` is 1-or-0 in zsh regardless of what the
7277 // failing command's `$?` was. The try-list's status is carried
7278 // separately in `__zshrs_try_block_saved_status`.
7279 let live_errflag = crate::ported::utils::errflag.load(Ordering::Relaxed);
7280 let try_err = (live_errflag & crate::ported::zsh_h::ERRFLAG_ERROR) as i64; // c:765
7281 let try_int = if (live_errflag & crate::ported::zsh_h::ERRFLAG_INT) != 0 {
7282 1i64
7283 } else {
7284 0i64
7285 }; // c:766
7286 crate::ported::r#loop::try_errflag.store(try_err, Ordering::Relaxed); // c:765
7287 crate::ported::r#loop::try_interrupt.store(try_int, Ordering::Relaxed); // c:766
7288 // c:Src/loop.c:755 — `endval = lastval ? lastval : errflag;`.
7289 // The status of the WHOLE `{…} always {…}` construct, captured
7290 // BEFORE the always-list runs (exectry returns it at c:801) and
7291 // deliberately including the errflag fallback: a try-list that
7292 // failed with `lastval == 0` but raised errflag still reports 1.
7293 let endval = if vm_status != 0 {
7294 vm_status
7295 } else {
7296 live_errflag
7297 }; // c:755
7298 with_executor(|exec| {
7299 // flags=0 (not setsparam's ASSPM_WARN): VM-internal scratch —
7300 // must never surface as a WARN_CREATE_GLOBAL diagnostic inside
7301 // a user function running `{...} always {...}` (f-sy-h's
7302 // `_zsh_highlight` does exactly that under warncreateglobal).
7303 crate::ported::params::assignsparam(
7304 "__zshrs_try_block_saved_status",
7305 &endval.to_string(),
7306 0,
7307 );
7308 let _ = exec;
7309 // Mirror into paramtab so `${parameters[TRY_BLOCK_ERROR]}`
7310 // and the PM_INTEGER `u_val` shadow agree with the atomic
7311 // the special-var getter reads. (setsparam → intsetfn's
7312 // TRY_BLOCK_ERROR arm re-stores the same value.)
7313 exec.set_scalar("TRY_BLOCK_ERROR".to_string(), try_err.to_string());
7314 exec.set_scalar("TRY_BLOCK_INTERRUPT".to_string(), try_int.to_string());
7315 });
7316 // c:Src/loop.c:768 — `errflag = 0;` ("We need to reset all
7317 // errors to allow the block to execute"). C clears the WHOLE
7318 // word, not just ERRFLAG_ERROR.
7319 crate::ported::utils::errflag.store(0, Ordering::Relaxed); // c:768
7320 Value::Status(0)
7321 });
7322
7323 // BUILTIN_BEGIN_INLINE_ENV / END_INLINE_ENV — wrap an
7324 // inline-assignment-prefixed command (`X=foo Y=bar cmd`):
7325 // BEGIN pushes a save frame; SET_VAR fires for each assign and
7326 // ALSO env::set_var's the value (visible to cmd's child); the
7327 // command runs; END pops the frame and restores both shell-var
7328 // and process-env state. Direct port of zsh's addvars() →
7329 // execute_simple → restore-after-exec contract.
7330 vm.register_builtin(BUILTIN_BEGIN_INLINE_ENV, |vm, argc| {
7331 // c:Src/exec.c:4114-4126 — whether the frame RECORDS anything is
7332 // `do_save`:
7333 // if (isset(POSIXBUILTINS)) {
7334 // if (is_shfunc || (hn->flags & (BINF_PSPECIAL|BINF_ASSIGN)))
7335 // do_save = (orig_cflags & BINF_COMMAND);
7336 // else
7337 // do_save = 1;
7338 // } else { ... }
7339 // if (do_save && varspc) save_params(...);
7340 // A frame is pushed either way so BEGIN/END stay balanced; an
7341 // empty save list simply restores nothing, which IS the
7342 // assignment persisting. POSIX.1-2017 XCU 2.9.1: "If the command
7343 // name is a special built-in utility, variable assignments shall
7344 // affect the current execution environment." Verified:
7345 // `dash|ksh|mksh -c 'v=0; v=1 :; printf "[%s]\n" "$v"'` → `[1]`,
7346 // and `v=2 true` → `[1]` because `true` is NOT special.
7347 //
7348 // The name arrives as a compile-time constant (empty when the
7349 // command word is an expansion, which takes the save arm).
7350 let name = if argc >= 1 {
7351 vm.pop().to_str()
7352 } else {
7353 String::new()
7354 };
7355 let mut frame = crate::vm_helper::InlineEnvFrame::new();
7356 // !!! bash EXCEPTION — bash(1), "POSIX Mode": "Assignment
7357 // statements preceding POSIX special builtins persist in the shell
7358 // environment after the builtin completes." bash does this ONLY in
7359 // posix mode, so default `--bash` keeps zsh's save/restore
7360 // (`bash -c 'v=0; v=1 :; printf "[%s]\n" "$v"'` → `[0]`, while
7361 // dash / ksh93 / mksh / bash-as-sh all print `[1]`). zshrs tracks
7362 // bash's `set -o posix` in dash_mode::BASH_ONLY_OPTS, so honor it.
7363 let bash_suppresses =
7364 crate::dash_mode::bash_mode() && !crate::dash_mode::bash_set_o_get("posix");
7365 if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
7366 && !name.is_empty()
7367 && !bash_suppresses
7368 {
7369 // c:4123 `do_save = (orig_cflags & BINF_COMMAND)` — the
7370 // `command` prefix is BINF_COMMAND (c:Src/builtin.c:44
7371 // `BIN_PREFIX("command", BINF_COMMAND)`) and resets the
7372 // behavior, so it keeps the save.
7373 let is_command_prefix = name == "command";
7374 // !!! dash / pdksh EXCEPTION — C's `is_shfunc` leg is not
7375 // universal. `f(){ :; }; v=0; v=4 f` leaves `v` at 0 in dash,
7376 // ash AND mksh, while ksh93 and bash-as-sh (the `--sh`
7377 // reference) leave it at 4. Only the builtin legs are shared.
7378 let is_shfunc = !crate::dash_mode::dash_strict()
7379 && !crate::dash_mode::pdksh_family()
7380 && crate::ported::hashtable::shfunctab_lock()
7381 .read()
7382 .map(|t| t.get(&name).is_some())
7383 .unwrap_or(false);
7384 if !is_command_prefix
7385 && (is_shfunc || builtin_is_pspecial(&name) || builtin_is_assign_family(&name))
7386 {
7387 frame.recording = false; // c:4122-4123 do_save = 0
7388 }
7389 }
7390 with_executor(|exec| {
7391 exec.inline_env_stack.push(frame);
7392 });
7393 Value::Status(0)
7394 });
7395 // Closes the frame's save list — see BUILTIN_SEAL_INLINE_ENV.
7396 vm.register_builtin(BUILTIN_SEAL_INLINE_ENV, |_vm, _argc| {
7397 with_executor(|exec| {
7398 if let Some(frame) = exec.inline_env_stack.last_mut() {
7399 frame.recording = false;
7400 }
7401 });
7402 Value::Status(0)
7403 });
7404 vm.register_builtin(BUILTIN_END_INLINE_ENV, |_vm, _argc| {
7405 with_executor(|exec| {
7406 if let Some(frame) = exec.inline_env_stack.pop() {
7407 for (name, prev_var, prev_env) in frame.saved.into_iter().rev() {
7408 match prev_var {
7409 Some(v) => {
7410 exec.set_scalar(name.clone(), v);
7411 }
7412 None => {
7413 exec.unset_scalar(&name);
7414 }
7415 }
7416 match prev_env {
7417 Some(v) => env::set_var(&name, &v),
7418 None => env::remove_var(&name),
7419 }
7420 }
7421 }
7422 });
7423 Value::Status(0)
7424 });
7425 // c:Src/exec.c:3969-3976 — bare-exec assignment epilogue: see the
7426 // const's doc block. POSIX_BUILTINS → assignments persist (pop the
7427 // frame, discard the saved state); otherwise → restore_params
7428 // (same walk as END_INLINE_ENV).
7429 vm.register_builtin(BUILTIN_EXEC_INLINE_ENV_DONE, |_vm, _argc| {
7430 let persist = isset(crate::ported::zsh_h::POSIXBUILTINS);
7431 with_executor(|exec| {
7432 if let Some(frame) = exec.inline_env_stack.pop() {
7433 if persist {
7434 return; // c:3971 — no save/restore under POSIX_BUILTINS
7435 }
7436 for (name, prev_var, prev_env) in frame.saved.into_iter().rev() {
7437 match prev_var {
7438 Some(v) => {
7439 exec.set_scalar(name.clone(), v);
7440 }
7441 None => {
7442 exec.unset_scalar(&name);
7443 }
7444 }
7445 match prev_env {
7446 Some(v) => env::set_var(&name, &v),
7447 None => env::remove_var(&name),
7448 }
7449 }
7450 }
7451 });
7452 Value::Status(0)
7453 });
7454
7455 // BUILTIN_RESTORE_TRY_BLOCK_STATUS — emitted at the end of an
7456 // `always` arm. Per zshmisc, the exit status of the entire
7457 // `{ try } always { finally }` construct is the try-list's
7458 // status, regardless of what happens in the always-list (the
7459 // exception is `return`/`exit` inside always, which short-
7460 // circuits and the cleanup is the only thing that runs). So
7461 // restore TRY_BLOCK_ERROR unconditionally — the always-list's
7462 // exit status is discarded for the construct.
7463 vm.register_builtin(BUILTIN_RESTORE_TRY_BLOCK_STATUS, |_vm, _argc| {
7464 use std::sync::atomic::Ordering;
7465 // c:Src/loop.c:801 — `return endval;`. The construct's exit
7466 // status is the try-list's (captured at c:755 by
7467 // SET_TRY_BLOCK_ERROR), never the always-list's.
7468 let saved = with_executor(|exec| {
7469 exec.scalar("__zshrs_try_block_saved_status")
7470 .and_then(|s| s.parse::<i32>().ok())
7471 .unwrap_or(0)
7472 });
7473 // c:Src/exec.c:1375 — `lastval = lv;` on the exectry return.
7474 // The always-list's own commands left their status in LASTVAL
7475 // (`always { : }` → 0); without this store the errflag re-raise
7476 // below aborts the shell with the always-list's 0 instead of
7477 // the try-list's failure status.
7478 crate::ported::builtin::LASTVAL.store(saved, Ordering::Relaxed); // c:1375
7479 // c:Src/loop.c:774-777 — the error RE-RAISE. This is the
7480 // whole point of TRY_BLOCK_ERROR being writable:
7481 //
7482 // if (try_errflag) errflag |= ERRFLAG_ERROR;
7483 // else errflag &= ~ERRFLAG_ERROR;
7484 // if (try_interrupt) errflag |= ERRFLAG_INT;
7485 // else errflag &= ~ERRFLAG_INT;
7486 //
7487 // SET_TRY_BLOCK_ERROR cleared errflag (c:768) so the always-arm
7488 // could run; the try-block's error is PARKED in `try_errflag`
7489 // and re-raised HERE unless the always-arm zeroed it
7490 // (`TRY_BLOCK_ERROR=0`, the documented swallow idiom — routed
7491 // to the atomic by intsetfn's IPDEF6 arm, params.rs).
7492 //
7493 // zshrs used to just drop the parked error, so
7494 // `f() { { typeset -r ro=1; ro=2 } always { … }; print reached }`
7495 // kept running and exited 0, where zsh aborts f with status 1.
7496 let te = crate::ported::r#loop::try_errflag.load(Ordering::Relaxed); // c:774
7497 let ti = crate::ported::r#loop::try_interrupt.load(Ordering::Relaxed); // c:776
7498 if te != 0 {
7499 crate::ported::utils::errflag
7500 .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
7501 // c:775
7502 } else {
7503 crate::ported::utils::errflag
7504 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
7505 // c:777
7506 }
7507 if ti != 0 {
7508 crate::ported::utils::errflag
7509 .fetch_or(crate::ported::zsh_h::ERRFLAG_INT, Ordering::Relaxed);
7510 // c:779
7511 } else {
7512 crate::ported::utils::errflag
7513 .fetch_and(!crate::ported::zsh_h::ERRFLAG_INT, Ordering::Relaxed);
7514 // c:781
7515 }
7516 // Re-apply the escape flags captured by SET_TRY_BLOCK_ERROR.
7517 // If the always-arm itself fired return/break/continue/exit,
7518 // its handler already overwrote the canonical atomics; let
7519 // those win — the always-arm's own escape always takes
7520 // priority over the try-block's deferred one.
7521 if let Some((ret, brk, cont, exit_p, try_err_save, try_int_save)) =
7522 TRY_ESCAPE_SAVE.with(|s| s.borrow_mut().pop())
7523 {
7524 // c:Src/loop.c:782-783 — `try_errflag = save_try_errflag;
7525 // try_interrupt = save_try_interrupt;`
7526 crate::ported::r#loop::try_errflag.store(try_err_save, Ordering::Relaxed); // c:782
7527 crate::ported::r#loop::try_interrupt.store(try_int_save, Ordering::Relaxed);
7528 // c:783
7529 if crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) == 0 {
7530 crate::ported::builtin::RETFLAG.store(ret, Ordering::Relaxed);
7531 }
7532 if crate::ported::builtin::BREAKS.load(Ordering::Relaxed) == 0 {
7533 crate::ported::builtin::BREAKS.store(brk, Ordering::Relaxed);
7534 }
7535 if crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed) == 0 {
7536 crate::ported::builtin::CONTFLAG.store(cont, Ordering::Relaxed);
7537 }
7538 if crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) == 0 {
7539 crate::ported::builtin::EXIT_PENDING.store(exit_p, Ordering::Relaxed);
7540 }
7541 }
7542 Value::Status(saved)
7543 });
7544
7545 // `[[ -r/-w/-x file ]]` — the cond path must use access(2) (the
7546 // C-faithful doaccess), NOT fusevm's generic Op::TestFile which only
7547 // checks existence for -r/-w (so a `chmod 000` file read as readable;
7548 // C02cond.ztst:13). Stack: [path, mode]; mode is the access(2) bit
7549 // (R_OK=4, W_OK=2, X_OK=1). Mirrors cond.rs:232/238/267 `doaccess`.
7550 vm.register_builtin(BUILTIN_COND_ACCESS, |vm, _argc| {
7551 let mode = vm.pop().to_int() as i32;
7552 let path = vm.pop().to_str();
7553 Value::Bool(crate::ported::cond::doaccess(&path, mode) != 0)
7554 });
7555
7556 // `[[ -prefix PAT ]]` / `-suffix` / `-after` / `-between` module condition.
7557 // Stack (pushed by the ModCond compile arm): arg0 … argN-1, then the
7558 // operator word last. argc = N+1.
7559 // c:Src/subst.c:4419-4420 `if (globsubst) shtokenize(y)` — see the
7560 // BUILTIN_COND_SHTOKENIZE doc for why a module condition needs it.
7561 vm.register_builtin(BUILTIN_COND_SHTOKENIZE, |vm, _argc| {
7562 let mut s = vm.pop().to_str();
7563 crate::ported::glob::shtokenize(&mut s);
7564 Value::str(s)
7565 });
7566
7567 vm.register_builtin(BUILTIN_COND_MOD, |vm, argc| {
7568 use crate::ported::zle::complete::{cond_psfix, cond_range, CVT_PREPAT, CVT_SUFPAT};
7569 let op = vm.pop().to_str(); // operator word (pushed last → popped first)
7570 let n = (argc as usize).saturating_sub(1);
7571 let mut args: Vec<String> = Vec::with_capacity(n);
7572 for _ in 0..n {
7573 args.push(vm.pop().to_str());
7574 }
7575 args.reverse(); // restore arg0 … argN-1 order
7576 // Dispatch the module/completion condition (C evalcond COND_MOD path:
7577 // condtab lookup + arity check, cond.c:149-185, over the four cotab[]
7578 // entries at complete.c:1697-1702). Handlers return 1=match/true.
7579 let name: String = op
7580 .trim_start_matches(|c: char| c == '-' || c == '\u{9b}')
7581 .to_string();
7582 // c:Src/cond.c:149-150 — `cd = getconddef((ctype == COND_MODI),
7583 // name + 1, 1)`. The `autol = 1` argument is what makes an
7584 // autoloadable condition LOAD its module: `getconddef`
7585 // (Src/module.c:647) sees the `c:`-stub's `p->module` and calls
7586 // `ensurefeature(p->module, "c:", name)`, then re-looks-up the
7587 // now-real definition that `zsh/complete`'s cotab installed.
7588 // Without this call zshrs answered `[[ -prefix … ]]` straight
7589 // from the compiled-in handler table and left `zsh/complete`
7590 // unloaded, where `zsh -f` reports it loaded after the first use.
7591 // `try_lock`: every other MODULESTAB caller holds the same mutex
7592 // for a moment and the load chain re-enters it; falling through
7593 // to the compiled-in table is the safe outcome, not a deadlock.
7594 let cd = match crate::ported::module::MODULESTAB.try_lock() {
7595 Ok(mut tab) => crate::ported::module::getconddef(0, &name, 1, &mut tab), // c:150
7596 Err(_) => None,
7597 };
7598 // c:151-155 — arity check against the conddef's own min/max
7599 // (`if (l < cd->min || (cd->max >= 0 && l > cd->max))`). The
7600 // fallback pins the same numbers the cotab rows carry
7601 // (complete.c:1698-1701) for the window before zsh/complete is
7602 // loaded, when `getconddef` has only the module-less stub.
7603 let (min, max): (usize, usize) = match cd.as_ref() {
7604 Some(c) if c.max >= 0 => (c.min.max(0) as usize, c.max as usize), // c:152
7605 _ => match name.as_str() {
7606 "prefix" | "suffix" => (1, 2), // c:1700-1701
7607 "after" => (1, 1), // c:1698
7608 "between" => (2, 2), // c:1699
7609 _ => {
7610 // c:Src/cond.c:186-193 — no conddef matched, so C falls out
7611 // of both `getconddef` arms to `zwarnnam(fromtest, "unknown
7612 // condition: %s", errname)` and then `return 2;` (the
7613 // "module not found, error" exit). Status 2 — not 1 — is
7614 // what `evalcond` hands back, and c:Src/exec.c:5216-5221
7615 // turns a 2 into a shell error. Arm the same carrier
7616 // BUILTIN_COND_UNKNOWN uses so the shared
7617 // BUILTIN_COND_STATUS_FROM_BOOL tail emits 2 and aborts;
7618 // returning a bare Bool(false) collapsed it to 1, so
7619 // `[[ -zz a ]]` exited 1 where zsh exits 2.
7620 COND_BAD_PATTERN.with(|c| c.set(true)); // c:193
7621 crate::ported::utils::zerr(&format!(
7622 "unknown condition: {}",
7623 op.replace('\u{9b}', "-")
7624 ));
7625 return Value::Bool(false);
7626 }
7627 },
7628 };
7629 if args.len() < min || args.len() > max {
7630 // c:Src/cond.c:177-181 — `if (l < cd->min || (cd->max >= 0 &&
7631 // l > cd->max)) { zwarnnam(fromtest, "unknown condition: %s",
7632 // errname); return 2; }`. Status 2, same as the module-not-found
7633 // arm above.
7634 //
7635 // This arm previously refused to arm the carrier because the
7636 // PARSER turned a zero-operand `-word` into a ModCond, so
7637 // `[[ -prefix ]]` would have exited 2 where zsh exits 0. That
7638 // parser bug is fixed (src/ported/parse.rs par_cond_2: a
7639 // multi-char `-word` with no operand now takes c:2590's
7640 // `par_cond_double("-n", s1)` string-test arm, and a two-char
7641 // one takes c:2592's `par_cond_multi(s1, newlinklist())`), so
7642 // the only way to reach here is a genuine arity violation —
7643 // `[[ -between a ]]`, which zsh answers 2.
7644 COND_BAD_PATTERN.with(|c| c.set(true)); // c:180
7645 crate::ported::utils::zerr(&format!(
7646 "unknown condition: {}",
7647 op.replace('\u{9b}', "-")
7648 ));
7649 return Value::Bool(false);
7650 }
7651 // c:158 — `return !cd->handler(strs, cd->condid);`
7652 let r = match cd.as_ref().and_then(|c| c.handler.map(|h| (h, c.condid))) {
7653 Some((handler, condid)) => handler(&args, condid),
7654 // Pre-load window: zsh/complete's cotab is not installed yet,
7655 // so dispatch through the compiled-in handlers directly.
7656 None => match name.as_str() {
7657 "prefix" => cond_psfix(&args, CVT_PREPAT),
7658 "suffix" => cond_psfix(&args, CVT_SUFPAT),
7659 "after" => cond_range(&args, 0),
7660 "between" => cond_range(&args, 1),
7661 _ => 0,
7662 },
7663 };
7664 Value::Bool(r == 1)
7665 });
7666
7667 vm.register_builtin(BUILTIN_IS_TTY, |vm, _argc| {
7668 let fd_str = vm.pop().to_str();
7669 let fd: i32 = fd_str.trim().parse().unwrap_or(-1);
7670 let is_tty = if fd < 0 {
7671 false
7672 } else {
7673 unsafe { libc::isatty(fd) != 0 }
7674 };
7675 Value::Bool(is_tty)
7676 });
7677
7678 // c:Src/exec.c:4918/5040/5069 — a process substitution used inside a
7679 // `[[ … ]]` cond operand errors "process substitution %s cannot be
7680 // used here" (getoutputfile/getproc run with thisjob == -1). Emitted
7681 // by the compiler in place of ProcessSubIn/Out when in_cond_operand.
7682 vm.register_builtin(BUILTIN_PROCSUB_COND_ERROR, |_vm, _argc| {
7683 let cmd = _vm.pop().to_str();
7684 crate::ported::utils::zerr(&format!("process substitution {} cannot be used here", cmd));
7685 // c:getoutputfile returns NULL with errflag set → the enclosing
7686 // statement aborts (empty stdout, exit 1), rather than the cond
7687 // merely evaluating false.
7688 crate::ported::utils::errflag.fetch_or(
7689 crate::ported::zsh_h::ERRFLAG_ERROR,
7690 std::sync::atomic::Ordering::Relaxed,
7691 );
7692 with_executor(|exec| exec.set_last_status(1));
7693 _vm.last_status = 1;
7694 Value::str("")
7695 });
7696
7697 // Set $LINENO before executing the next statement. Direct
7698 // port of zsh's `lineno` global tracking from Src/input.c
7699 // (`if ((inbufflags & INP_LINENO) || !strin) && c == '\n')
7700 // lineno++;`). The compiler emits one of these before each
7701 // top-level pipe in `compile_sublist`, carrying the line
7702 // number captured by the parser at `ZshPipe.lineno`. Pops
7703 // [n], updates `$LINENO` in the variable table.
7704 vm.register_builtin(BUILTIN_SET_LINENO, |vm, _argc| {
7705 let n = vm.pop().to_int();
7706 // c:Src/exec.c:1355 — `/* In evaluated traps, don't modify the
7707 // line number. */ if (!IN_EVAL_TRAP() && !ineval && code)
7708 // lineno = code - 1;` (same gate at c:1451 and c:2056).
7709 // `ineval` is set by `eval()` to `!isset(EVALLINENO)`
7710 // (Src/builtin.c:6155), so under NO_EVAL_LINENO the eval body
7711 // must NOT renumber $LINENO — the caller's line stands.
7712 //
7713 // c:1354 — `/* In evaluated traps, don't modify the line number. */`
7714 // The `IN_EVAL_TRAP()` half of the same gate was missing, so an
7715 // eval-form trap body (`trap 'print $LINENO' DEBUG`) renumbered
7716 // $LINENO to its own line 1 and — because nothing restores
7717 // `lineno` on the way out (C does, via execlist's oldlineno
7718 // save/restore at c:1429/1696) — every later statement in the
7719 // trapped scope reported line 1 as well.
7720 if crate::ported::zsh_h::IN_EVAL_TRAP()
7721 || crate::ported::builtin::ineval.load(std::sync::atomic::Ordering::Relaxed) != 0
7722 {
7723 return Value::Status(0);
7724 }
7725 // Provenance: mirror the line into the lineage ledger's own
7726 // counter. The param-write hooks run inside the parameter
7727 // table's lock, so they cannot read `$LINENO` back out of it.
7728 if crate::provenance::active() {
7729 crate::provenance::note_line(n.max(0) as usize);
7730 }
7731 // c:Src/exec.c:lineno = N — direct write to the param's
7732 // u_val. Cannot go through setsparam because LINENO carries
7733 // PM_READONLY (so `(t)LINENO` reads `integer-readonly-special`
7734 // per zsh); setsparam → assignstrvalue's PM_READONLY guard
7735 // would reject the internal write. C zsh handles this via the
7736 // PM_SPECIAL GSU vtable's setfn callback which bypasses the
7737 // generic readonly check; the Rust port writes the canonical
7738 // field directly instead.
7739 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
7740 if let Some(pm) = tab.get_mut("LINENO") {
7741 // c:Src/utils.c:121 `zlong lineno` — the value lives in the C
7742 // GLOBAL, reached through LINENO's GSU. A `typeset -h +g LINENO`
7743 // local shadow has no PM_SPECIAL and no GSU, so C's `lineno = N`
7744 // never touches it; skip the paramtab mirror for the same reason.
7745 if (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) != 0 {
7746 pm.u_val = n;
7747 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
7748 }
7749 }
7750 }
7751 // Mirror to the file-static `lineno` (utils.c:121) that
7752 // zerrmsg reads at utils.c:301 for the `:N: msg` prefix.
7753 crate::ported::utils::set_lineno(n as i32);
7754 // Also drive lex::LEX_LINENO — zerrmsg (utils.rs:376) reads
7755 // THAT counter for the `name:N:` prefix. C zsh interleaves
7756 // parse and execute per top-level list, so its single
7757 // `lineno` global serves both; zshrs compiles the whole
7758 // script before running, leaving LEX_LINENO parked at EOF.
7759 // Without this write, every runtime zwarn/zerr reported the
7760 // script's LAST line instead of the failing statement's.
7761 crate::ported::lex::set_lineno(n as u64);
7762 // DAP hook — checks breakpoints / step mode / pause-request
7763 // for the line we just landed on. O(1) no-op when DAP is off
7764 // (single atomic load on a OnceLock). Inside `--dap` mode
7765 // this is the call that blocks the executor on a Condvar
7766 // until the IDE sends `continue`. Mirrors strykelang's
7767 // `debugger.should_stop(line) → debugger.prompt(...)` flow.
7768 crate::extensions::dap::check_line(n as u32);
7769 Value::Status(0)
7770 });
7771
7772 // Direct port of Src/prompt.c:1623 cmdpush. Token is a `CS_*`
7773 // value (zsh.h:2775-2806) emitted by compile_zsh around each
7774 // compound command (if/while/[[…]]/((…))/$(…)) and consumed by
7775 // `%_` in PS4 / prompt expansion.
7776 vm.register_builtin(BUILTIN_CMD_PUSH, |vm, _argc| {
7777 let token = vm.pop().to_int() as u8;
7778 // Route through canonical cmdpush (Src/prompt.c:1623). The
7779 // prompt expander reads from the file-static `CMDSTACK` at
7780 // `prompt.rs:2006`, not `exec.cmd_stack` — without this,
7781 // `%_` in PS4 saw an empty stack during xtrace.
7782 if (token as i32) < crate::ported::zsh_h::CS_COUNT {
7783 crate::ported::prompt::cmdpush(token);
7784 }
7785 Value::Status(0)
7786 });
7787
7788 // Direct port of Src/prompt.c:1631 cmdpop.
7789 vm.register_builtin(BUILTIN_CMD_POP, |_vm, _argc| {
7790 crate::ported::prompt::cmdpop();
7791 Value::Status(0)
7792 });
7793
7794 vm.register_builtin(BUILTIN_OPTION_SET, |vm, _argc| {
7795 let name = vm.pop().to_str();
7796 // Direct port of `optison(char *name, char *s)` at Src/cond.c:502 — `[[ -o NAME ]]`
7797 // reads through the same `opts[]` array that `setopt NAME`
7798 // writes via `dosetopt`. Earlier code read a duplicate Executor
7799 // HashMap which never saw `bin_setopt`'s writes (those land in
7800 // `OPTS_LIVE` via `opt_state_set`). Routing through the canonical
7801 // C port restores the single-store invariant: one `opts[]`,
7802 // shared between setopt/unsetopt and `[[ -o ]]`.
7803 let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
7804 match r {
7805 0 => Value::Bool(true), // c:cond.c:520 set
7806 1 => Value::Bool(false), // c:cond.c:518/520 unset
7807 _ => {
7808 // c:cond.c:514 — unknown option. optison already emitted the
7809 // diagnostic (via zwarn, now that fromtest=NULL for
7810 // `[[ -o ]]`); re-printing here double-emitted for
7811 // `[[ ! -o bad ]]` / `[[ -o a || -o b ]]`.
7812 Value::Bool(false)
7813 }
7814 }
7815 });
7816 // Tri-state `-o` for compile_cond's direct status path. Returns
7817 // 0 / 1 / 3 as a Value::Int that compile_cond consumes via
7818 // Op::SetStatus. Mirrors zsh's `[[ -o invalid ]]` returning $?=3.
7819 vm.register_builtin(BUILTIN_OPTION_CHECK_TRISTATE, |vm, _argc| {
7820 let name = vm.pop().to_str();
7821 let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
7822 // optison itself prints the diagnostic via zwarnnam when r=3
7823 // and POSIXBUILTINS is unset (the canonical path). Don't
7824 // double-emit here. r is already 0/1/3.
7825 Value::Int(r as i64)
7826 });
7827
7828 // BUILTIN_PARAM_FILTER — `${var:#pat}` / `${var:|name}` etc.
7829 // PURE PASSTHRU: rebuild `${name:#pat}` and route to paramsubst.
7830 vm.register_builtin(BUILTIN_PARAM_FILTER, |vm, _argc| {
7831 let pattern = vm.pop().to_str();
7832 let name = vm.pop().to_str();
7833 let body = format!("${{{}:#{}}}", name, pattern);
7834 paramsubst_to_value(&body)
7835 });
7836
7837 // `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()`
7838 // — subscripted-array assign with array RHS. Stack pushed by
7839 // compile_assign as: [elem0, elem1, …, elemN-1, name, key].
7840 vm.register_builtin(BUILTIN_SET_SUBSCRIPT_RANGE, |vm, argc| {
7841 let n = argc as usize;
7842 let mut popped: Vec<Value> = Vec::with_capacity(n);
7843 for _ in 0..n {
7844 popped.push(vm.pop());
7845 }
7846 popped.reverse();
7847 if popped.len() < 3 {
7848 return Value::Status(1);
7849 }
7850 // c:Src/params.c:3511-3526 — trailing append flag. The ARRAY
7851 // subscript path (`a[N]+=(v)` / `a[lo,hi]+=(v)`) sets it so the
7852 // AUGMENT transform below collapses the range to an empty range
7853 // after the slice end and inserts ONLY the new value. The scalar
7854 // path pre-concats the old slice (ARRAY_INDEX+Concat) and passes
7855 // 0, so it keeps plain-replace semantics.
7856 // The trailing marker is 0/1 for the ARRAY-RHS emitter and 2 for
7857 // the SCALAR-RHS comma emitter (compile_zsh::compile_assign),
7858 // which also pushes the SOURCE subscript just below it. The
7859 // scalar form is the one C does NOT necessarily treat as a
7860 // range: c:Src/params.c:1515 `(c != Outbrack && (ishash || c !=
7861 // ','))` stops the comma from separating subscripts when the
7862 // parameter is a hash, so `h[1,2]=Z` is the ordinary key `1,2`.
7863 // The compile-time split cannot know the type, so it defers here.
7864 let marker = popped.pop().map_or(String::new(), |v| v.to_str());
7865 let scalar_rhs = marker == "2"; // c:1515 deferral
7866 let key_src = if scalar_rhs {
7867 popped.pop().map(|v| v.to_str())
7868 } else {
7869 None
7870 };
7871 let append = marker == "1";
7872 let key = popped.pop().unwrap().to_str();
7873 let name = popped.pop().unwrap().to_str();
7874 // c:Src/params.c:1585-1592 — `if (needtok) { parsestr(&s);
7875 // singsub(&s); }`: the subscript body is parameter-substituted
7876 // BEFORE it is read, whether it goes on to `mathevalarg`
7877 // (c:1601, the array/scalar range bounds) or straight into the
7878 // hash as a key (c:1596-1616). The ARRAY-RHS emitter pre-expands
7879 // its subscript at word-compile time, but the SCALAR-RHS comma
7880 // emitter hands over the raw source, so this round has to happen
7881 // here: `mathevali("$n")` is 0, which silently turned
7882 // `a=abcdef; n=3; a[$n,-1]=X` into a whole-string overwrite
7883 // (`X` instead of `abX`), and it is why the fzf-tab
7884 // `t[$#MATCH/2+1,-1]=""` form still lost its bound.
7885 //
7886 // Only for a source-level LIVE expansion: an escaped `\$`
7887 // reached `parsestr` as the Bnull marker and `singsub` leaves it
7888 // alone, so `h[\$x,y]` keys on the literal `$x,y`.
7889 let key = if scalar_rhs
7890 && key_src.as_deref().is_some_and(|src| {
7891 let b = src.as_bytes();
7892 (0..b.len())
7893 .any(|i| (b[i] == b'$' || b[i] == b'`') && (i == 0 || b[i - 1] != b'\\'))
7894 }) {
7895 crate::ported::subst::singsub(&key) // c:1592
7896 } else {
7897 key
7898 };
7899 let mut values: Vec<String> = Vec::new();
7900 for v in popped {
7901 match v {
7902 Value::Array(items) => {
7903 for it in items.iter() {
7904 values.push(it.to_str());
7905 }
7906 }
7907 other => values.push(other.to_str()),
7908 }
7909 }
7910 // Bash sparse-array tracking: a single-index `a[i]=v` that pads the
7911 // dense Vec past its old end leaves indices old_len..i as HOLES (not
7912 // real elements), so `${#a[@]}`/`${!a[@]}` skip them like bash. Only
7913 // in bash mode, only for a plain non-append single index (0-based
7914 // under ksharrays). Captured before the assign; applied after.
7915 let sparse_track: Option<(String, usize, usize)> =
7916 if crate::dash_mode::sparse_arrays() && !append && !key.contains(',') {
7917 key.trim().parse::<usize>().ok().map(|i| {
7918 let old_len =
7919 with_executor(|exec| exec.array(&name).map(|a| a.len()).unwrap_or(0));
7920 (name.clone(), old_len, i)
7921 })
7922 } else {
7923 None
7924 };
7925 // c:Src/params.c:3383-3389 — a subscripted ARRAY assignment to an
7926 // associative array is an error, whatever the subscript looks like:
7927 // if (v && PM_TYPE(v->pm->node.flags) == PM_HASHED) {
7928 // unqueue_signals();
7929 // zerr("%s: attempt to set slice of associative array",
7930 // v->pm->node.nam);
7931 // freearray(val);
7932 // errflag |= ERRFLAG_ERROR;
7933 // return NULL;
7934 // }
7935 // assignaparam (params.rs) ports this, but a single-key `h[k]=(1 2)`
7936 // never reaches it: the VM lowers subscripted assignment to this
7937 // builtin instead. The comma form `h[a,b]=(1 2)` DID error — it takes a
7938 // different route — so the gap looked like a subscript-parsing quirk
7939 // when it was really "the check lives on a path this form doesn't
7940 // take". Untreated, the assignment was SILENTLY DISCARDED: rc=0 and
7941 // `${h[k]}` still read its old value.
7942 //
7943 // Only array-valued assignment is rejected; `h[k]=x` is a scalar
7944 // element store and stays legal.
7945 {
7946 let is_hashed = crate::ported::params::paramtab()
7947 .read()
7948 .ok()
7949 .and_then(|t| {
7950 t.get(&name).map(|pm| {
7951 crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
7952 == crate::ported::zsh_h::PM_HASHED
7953 })
7954 })
7955 .unwrap_or(false);
7956 if is_hashed {
7957 if scalar_rhs {
7958 // c:1515 — for a hash the comma is not a subscript
7959 // separator, so this was never a range: hand the
7960 // WHOLE subscript to the element path. `h[1,2]=Z`
7961 // keys on `1,2` in zsh; rejecting it here was the
7962 // compile-time range split leaking through.
7963 let src = key_src.unwrap_or_else(|| key.clone());
7964 let val = values.first().cloned().unwrap_or_default();
7965 return Value::Status(assign_hash_element(&name, &key, &src, &val));
7966 }
7967 crate::ported::utils::zerr(&format!(
7968 "{name}: attempt to set slice of associative array" // c:3385
7969 ));
7970 crate::ported::utils::errflag.fetch_or(
7971 crate::ported::zsh_h::ERRFLAG_ERROR,
7972 std::sync::atomic::Ordering::Relaxed,
7973 ); // c:3387
7974 return Value::Status(1); // c:3388
7975 }
7976 }
7977
7978 with_executor(|exec| {
7979 // Parse subscript: slice `lo,hi` or single index `i`.
7980 // setarrvalue (Src/params.c:2895) expects 1-based start/
7981 // end inclusive where start==end means replace one
7982 // element. Negative bounds translate to len+n+1 (1-based).
7983 //
7984 // c:Src/params.c — the END side accepts 0 as a valid value
7985 // that signals "insert BEFORE start position" (the canonical
7986 // `a[N,N-1]=val` prepend / mid-insert idiom). Bug #275 in
7987 // docs/BUGS.md: the previous Rust port clamped end up to 1,
7988 // collapsing `a[1,0]=(X Y)` into `a[1,1]=(X Y)` which
7989 // OVERWRITES position 1 instead of prepending. Provide two
7990 // translators — start_translate clamps to 1 (1-based);
7991 // end_translate keeps 0 intact so the splice in
7992 // setarrvalue (start_idx=0..end_idx=0) inserts at the front.
7993 // Bug #589: for scalars (no array), use the scalar's char
7994 // count as `len` so negative-index translation (`a[2,-1]`)
7995 // computes against the actual string length, not 0.
7996 let len = exec
7997 .array(&name)
7998 .map(|a| a.len() as i64)
7999 .or_else(|| {
8000 crate::ported::params::paramtab().read().ok().and_then(|t| {
8001 t.get(&name).and_then(|pm| {
8002 if crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
8003 == crate::ported::zsh_h::PM_SCALAR
8004 {
8005 pm.u_str.as_ref().map(|s| s.chars().count() as i64)
8006 } else {
8007 None
8008 }
8009 })
8010 })
8011 })
8012 .unwrap_or(0);
8013 let start_translate = |raw: i64| -> i32 {
8014 if raw < 0 {
8015 (len + raw + 1).max(1) as i32
8016 } else {
8017 raw.max(1) as i32
8018 }
8019 };
8020 let end_translate = |raw: i64| -> i32 {
8021 if raw < 0 {
8022 (len + raw + 1).max(0) as i32
8023 } else {
8024 raw.max(0) as i32
8025 }
8026 };
8027 // c:Src/params.c — KSH_ARRAYS option flips array subscripts
8028 // from 1-based to 0-based. setarrvalue expects 1-based
8029 // inclusive bounds, so under KSH_ARRAYS we shift positive
8030 // inputs by +1 before translation. Negative bounds left
8031 // alone (count from end). Sibling of #610/#611/#612.
8032 // Bug #613.
8033 let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
8034 let ksh_shift = |raw: i64| -> i64 {
8035 if ksh_arrays && raw >= 0 {
8036 raw + 1
8037 } else {
8038 raw
8039 }
8040 };
8041 // c:Src/params.c getindex — subscript bounds are MATH
8042 // expressions, not bare integers: `a[(( ${#a}+1 ))]=(x)`,
8043 // `a[n+1]=(x)`. Plain `parse::<i64>()` returned 0 on any
8044 // arithmetic subscript, which the `i == 0` guard turned into
8045 // a silent no-op (computed-index append never landed). Parse
8046 // the literal fast-path first, then fall back to mathevali
8047 // (which handles `(( ))` grouping, var refs, and operators).
8048 // c:Src/params.c:2036 + c:2118 — getindex resolves EACH range
8049 // bound with `getarg`, not with mathevalarg directly, so a bound
8050 // may be a `(r)`/`(R)`/`(i)`/`(I)`/`(k)`/`(K)` SEARCH rather than
8051 // an arithmetic expression. On an array getarg's search arm
8052 // (c:1672-1719) returns the 1-BASED INDEX `r` of the match — the
8053 // value-vs-index distinction only sets `*inv`, which getindex
8054 // rejects for a range START (c:2121) and otherwise ignores. So map
8055 // the VALUE-returning direction letters onto their INDEX twins and
8056 // read the position back. Without this both bounds evaluated to 0
8057 // through mathevali and `array[(R)nomatch,(r)nomatch]=(…)` became
8058 // a front INSERT instead of the whole-array replace zsh performs.
8059 let cur_arr: Vec<String> = exec.array(&name).unwrap_or_default();
8060 let eval_bound = |s: &str| -> i64 {
8061 let t = s.trim();
8062 if let Some(rest) = t.strip_prefix('(') {
8063 if let Some(close) = rest.find(')') {
8064 let grp = &rest[..close];
8065 if !grp.is_empty()
8066 && grp
8067 .chars()
8068 .all(|c| matches!(c, 'r' | 'R' | 'i' | 'I' | 'k' | 'K' | 'e'))
8069 {
8070 // c:1394-1410 — r/k select the FIRST match, R/K the
8071 // LAST; i/I are the same searches returning the index.
8072 let mapped: String = grp
8073 .chars()
8074 .map(|c| match c {
8075 'r' | 'k' => 'i',
8076 'R' | 'K' => 'I',
8077 other => other,
8078 })
8079 .collect();
8080 let expr = format!("({}){}", mapped, &rest[close + 1..]);
8081 if let Some(crate::ported::params::getarg_out::Value(v)) =
8082 crate::ported::params::getarg(&expr, Some(&cur_arr), None, None)
8083 {
8084 return v.to_str().trim().parse::<i64>().unwrap_or(0);
8085 }
8086 }
8087 }
8088 }
8089 t.parse::<i64>()
8090 .ok()
8091 .or_else(|| crate::ported::math::mathevali(t).ok())
8092 .unwrap_or(0)
8093 };
8094 let (raw_start, raw_end) = if let Some((s_str, e_str)) = key.split_once(',') {
8095 (ksh_shift(eval_bound(s_str)), ksh_shift(eval_bound(e_str)))
8096 } else {
8097 let i = ksh_shift(eval_bound(&key));
8098 (i, i)
8099 };
8100 // c:Src/params.c:2124-2151 (getindex) — `start == 0 && end == 0`
8101 // is the range entirely off the START of the index range. With
8102 // KSH_ZERO_SUBSCRIPT it degrades to "the first element"
8103 // (`end = startnextlen;` c:2140, i.e. the 0-based range [0,1)).
8104 // Without it the range is flagged VALFLAG_EMPTY (c:2148) and
8105 // setarrvalue (c:2910) rejects the assignment with
8106 // "assignment to invalid subscript range". zshrs silently
8107 // returned for `a[0]=(x)` and front-INSERTED for `a[0,0]=(x)`.
8108 let mut valflags = 0i32;
8109 let (raw_start, raw_end) = if raw_start == 0 && raw_end == 0 {
8110 if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT) {
8111 (1, 1) // c:2140 — first element, in the 1-based convention below
8112 } else {
8113 valflags |= crate::ported::zsh_h::VALFLAG_EMPTY; // c:2148
8114 (-1, 0) // c:2149 — bounds unused; setarrvalue errors first
8115 }
8116 } else {
8117 (raw_start, raw_end)
8118 };
8119 // c:Src/params.c:2114 (getindex) — a SINGLE subscript is the
8120 // range `[i, i]`: `end = we ? we : start;` sets BOTH bounds to
8121 // the same RAW user index, and only the START is then shifted
8122 // down by one (`if (start > 0) start -= startprevlen;` c:2120).
8123 // setarrvalue (c:2944-2953) afterwards resolves each bound
8124 // INDEPENDENTLY — `start += len` clamped at 0, `end += len + 1`
8125 // clamped at 0 — so an out-of-range negative index such as
8126 // `a[-10]` on a 3-element array yields start==end==0, an EMPTY
8127 // range at the front that INSERTS. Running the end bound of a
8128 // single subscript through `start_translate` (which floors at 1)
8129 // instead collapsed that to [0,1) and OVERWROTE element 1:
8130 // `a=(some sunny day); a[-10]=(we'll meet again)` dropped "some"
8131 // (Test/D04parameter.ztst:1420 "Out of range negative array
8132 // subscripts"). Both forms therefore share one translator pair.
8133 let (start, end) = (start_translate(raw_start), end_translate(raw_end));
8134 // c:Src/params.c:3518-3520 (assignaparam ASSPM_AUGMENT) — a
8135 // subscripted `+=` to an array does NOT prepend the old slice;
8136 // it collapses the range to an EMPTY range positioned right
8137 // AFTER the slice end (`v->start = v->end--`) and splices in
8138 // ONLY the new value: `a[2]+=(d)` on (a b c) → (a b d c);
8139 // `a[2,3]+=(x)` on (1 2 3 4) → (1 2 3 x 4). In setarrvalue's
8140 // 1-based convention here that means start = end+1 (so
8141 // start_idx == end_idx == end → splice arr[end..end]).
8142 let (start, end) = if append && end > 0 {
8143 (end + 1, end)
8144 } else {
8145 (start, end)
8146 };
8147 // c:Src/params.c:392-430 IPDEF9("argv"/"@"/"*", &pparams) —
8148 // the positional parameters live in the `pparams` vector, NOT
8149 // paramtab, so a subscript splice (`argv[2]=(X Y Z)`,
8150 // `2=(X Y Z)`) must read/write pparams. Splice a synthetic
8151 // array param holding the current positionals via the
8152 // canonical setarrvalue, then store the result back to
8153 // pparams — mirroring assignaparam's argv/@/* special-case
8154 // (params.rs:6937) for the whole-array form.
8155 if name == "argv" || name == "@" || name == "*" {
8156 let mut pm = {
8157 crate::ported::params::createparam(
8158 &name,
8159 crate::ported::zsh_h::PM_ARRAY as i32,
8160 );
8161 crate::ported::params::paramtab()
8162 .write()
8163 .ok()
8164 .and_then(|mut t| t.remove(&name))
8165 };
8166 if let Some(ref mut p) = pm {
8167 p.u_arr = Some(exec.pparams());
8168 }
8169 let mut v = crate::ported::zsh_h::value {
8170 pm,
8171 arr: Vec::new(),
8172 scanflags: 0,
8173 valflags,
8174 start,
8175 end,
8176 };
8177 crate::ported::params::setarrvalue(&mut v, values);
8178 let result = v.pm.and_then(|p| p.u_arr).unwrap_or_default();
8179 exec.set_pparams(result);
8180 return;
8181 }
8182 // Route through canonical setarrvalue (Src/params.c:2895).
8183 // It handles PM_READONLY rejection, PM_HASHED slice-error,
8184 // PM_ARRAY splice + bounds clamp + padding (c:2980+).
8185 let taken = match crate::ported::params::paramtab().write() {
8186 Ok(mut tab) => tab.remove(&name),
8187 Err(_) => None,
8188 };
8189 // c:Src/exec.c:2640 / getvalue(…, 1) — a subscript assignment to
8190 // a NONEXISTENT parameter auto-creates it. getindex/fetchvalue
8191 // with the create flag calls createparam(name, PM_ARRAY) so the
8192 // splice has an array to write into; `unset u; u[1,2]=(a z)`
8193 // then yields the array (a z). Without this, setarrvalue saw
8194 // v.pm == None and silently stored nothing. The single-index
8195 // scalar-value path (SET_ASSOC/SET_ARRAY_AT) already vivifies;
8196 // this brings the range/array-value path to parity.
8197 let taken = taken.or_else(|| {
8198 crate::ported::params::createparam(&name, crate::ported::zsh_h::PM_ARRAY as i32);
8199 crate::ported::params::paramtab()
8200 .write()
8201 .ok()
8202 .and_then(|mut t| t.remove(&name))
8203 });
8204 // c:Src/params.c:2748+ — PM_SCALAR with subscript range
8205 // SPLICES the value into the scalar's char string. Bug
8206 // #589: zshrs's slice handler always called setarrvalue,
8207 // erroring "attempt to assign array value to non-array"
8208 // for `a=hello; a[2,3]=XYZ`. Detect PM_SCALAR and route
8209 // through assignstrvalue (which does scalar splice via
8210 // the PM_SCALAR arm at params.rs:3709-3789).
8211 let is_scalar = taken.as_ref().map_or(false, |pm| {
8212 crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
8213 == crate::ported::zsh_h::PM_SCALAR
8214 });
8215 let mut v = crate::ported::zsh_h::value {
8216 pm: taken,
8217 arr: Vec::new(),
8218 scanflags: 0,
8219 valflags,
8220 start,
8221 end,
8222 };
8223 if is_scalar {
8224 // Scalar splice — concat values, route through
8225 // assignstrvalue which dispatches by PM_TYPE.
8226 // start_translate returns 1-based positions; assignstrvalue's
8227 // PM_SCALAR arm at params.rs:3735+ expects 0-based start
8228 // (chars before start are kept) and 0-based end-exclusive
8229 // (chars from end are kept). Convert: start-=1.
8230 if v.start > 0 {
8231 v.start -= 1;
8232 }
8233 let val: String = values.join("");
8234 crate::ported::params::assignstrvalue(Some(&mut v), Some(val), 0);
8235 } else {
8236 crate::ported::params::setarrvalue(&mut v, values);
8237 }
8238 // Write the mutated Param back to paramtab — setarrvalue
8239 // mutated v.pm in-place; the prior `tab.remove(&name)` at
8240 // the top of this handler took ownership, so we re-insert
8241 // here. setarrvalue + this re-insert IS the canonical
8242 // store (Src/params.c:2895). No further mirror needed.
8243 if let Some(pm) = v.pm {
8244 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
8245 tab.insert(name, pm);
8246 }
8247 }
8248 });
8249 if let Some((nm, old_len, i)) = sparse_track {
8250 crate::bash_arrays::note_subscript_set(&nm, old_len, i);
8251 }
8252 Value::Status(0)
8253 });
8254
8255 // BUILTIN_CONCAT_SPLICE — word-segment concat for an expansion whose
8256 // ARRAY shape survives into the word (`${arr[@]}`, `$@`, `${(@)a}`,
8257 // `${=v}`, slices). c:Src/subst.c:4245 `if (isarr)` gates the two
8258 // emit shapes and c:1663 `int plan9 = isset(RCEXPANDPARAM);` picks
8259 // between them at RUNTIME — so the option, not the compile-time
8260 // segment shape, decides splice-vs-cross-product here.
8261 vm.register_builtin(BUILTIN_CONCAT_SPLICE, |vm, _argc| {
8262 let rhs = vm.pop();
8263 let lhs = vm.pop();
8264 if plan9_active() {
8265 return concat_plan9_prov(lhs, rhs);
8266 }
8267 concat_splice_prov(lhs, rhs)
8268 });
8269
8270 // BUILTIN_CONCAT_DISTRIBUTE — word-segment concat. With
8271 // rcexpandparam (zsh option), distributes element-wise (cartesian
8272 // product). Default mode: joins arrays with IFS first char to a
8273 // single scalar before concat, matching zsh's default unquoted
8274 // and DQ semantics. Direct port of Src/subst.c sepjoin path
8275 // (line ~1813) which gates element-vs-join on the rc_expand_param
8276 // option, defaulting to join.
8277 // BUILTIN_CONCAT_DISTRIBUTE_FORCED — same shape as
8278 // CONCAT_DISTRIBUTE, but always cartesian-distributes when one
8279 // side is Array. Used for compile-time-detected explicit
8280 // distribution forms (`${^arr}` etc.) where the source flag
8281 // overrides the rcexpandparam option default.
8282 // `${^arr}` — RC_EXPAND_PARAM forced on by the flag. concat_plan9 carries
8283 // both halves of C's plan9 block: the c:4316-4350 cartesian emit AND the
8284 // c:4362 `uremnode` word deletion for an empty array. The DISTRIBUTE_FORCED
8285 // handler below cannot be reused: it is shared with `${(@)a}` / `${(f)v}` /
8286 // `${a[@]}`, which KEEP the word on empty (`x${(@)a}y` → `xy`).
8287 vm.register_builtin(BUILTIN_CONCAT_PLAN9, |vm, _argc| {
8288 let rhs = vm.pop();
8289 let lhs = vm.pop();
8290 concat_plan9_prov(lhs, rhs)
8291 });
8292
8293 // `${^^arr}` — RC_EXPAND_PARAM forced OFF (c:2553-2555 `plan9 = 0`). Every
8294 // other concat builtin re-checks plan9_active(), which is the OPTION, so
8295 // under `setopt rcexpandparam` they cross-product regardless of the flag.
8296 // Go straight to concat_splice — C's non-plan9 path (c:4366-4437).
8297 vm.register_builtin(BUILTIN_CONCAT_SPLICE_NOPLAN9, |vm, _argc| {
8298 let rhs = vm.pop();
8299 let lhs = vm.pop();
8300 concat_splice_prov(lhs, rhs)
8301 });
8302
8303 vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE_FORCED, |vm, _argc| {
8304 let rhs = vm.pop();
8305 let lhs = vm.pop();
8306 match (lhs, rhs) {
8307 (Value::Array(la), Value::Array(ra)) => {
8308 if ra.is_empty() {
8309 return Value::Array(la);
8310 }
8311 if la.is_empty() {
8312 return Value::Array(ra);
8313 }
8314 let mut out = Vec::with_capacity(la.len() * ra.len());
8315 for a in la.iter() {
8316 let a_s = a.as_str_cow();
8317 for b in ra.iter() {
8318 let b_s = b.as_str_cow();
8319 let mut s = String::with_capacity(a_s.len() + b_s.len());
8320 s.push_str(&a_s);
8321 s.push_str(&b_s);
8322 out.push(Value::str(s));
8323 }
8324 }
8325 Value::array(out)
8326 }
8327 (Value::Array(la), rhs_scalar) => {
8328 // An EMPTY array contributes nothing to a concatenated
8329 // word — the surrounding scalar text survives. zsh:
8330 // `x${^a}y` (a=()) / `x${(P)scalar-empty}y` → "xy", NOT
8331 // a dropped word. Without this, a `(P)` indirect to an
8332 // unset/empty scalar (which nodes_to_value collapses to
8333 // Value::Array([]) for standalone-removal semantics)
8334 // cartesian-dropped the whole word — p10k's
8335 // `typeset -g _$2=${(P)2}` then arrived as a bare
8336 // `typeset` and dumped every parameter (~217× → 19 MB
8337 // terminal flood → startup hang).
8338 if la.is_empty() {
8339 return rhs_scalar;
8340 }
8341 let r = rhs_scalar.as_str_cow();
8342 let out: Vec<Value> = la
8343 .iter()
8344 .map(|a| {
8345 let a_s = a.as_str_cow();
8346 let mut s = String::with_capacity(a_s.len() + r.len());
8347 s.push_str(&a_s);
8348 s.push_str(&r);
8349 Value::str(s)
8350 })
8351 .collect();
8352 Value::array(out)
8353 }
8354 (lhs_scalar, Value::Array(ra)) => {
8355 // Symmetric empty-array-contributes-nothing rule; see
8356 // the (Array, scalar) arm above.
8357 if ra.is_empty() {
8358 return lhs_scalar;
8359 }
8360 let l = lhs_scalar.as_str_cow();
8361 let out: Vec<Value> = ra
8362 .iter()
8363 .map(|b| {
8364 let b_s = b.as_str_cow();
8365 let mut s = String::with_capacity(l.len() + b_s.len());
8366 s.push_str(&l);
8367 s.push_str(&b_s);
8368 Value::str(s)
8369 })
8370 .collect();
8371 Value::array(out)
8372 }
8373 (lhs_s, rhs_s) => {
8374 let l = lhs_s.as_str_cow();
8375 let r = rhs_s.as_str_cow();
8376 let mut s = String::with_capacity(l.len() + r.len());
8377 s.push_str(&l);
8378 s.push_str(&r);
8379 Value::str(s)
8380 }
8381 }
8382 });
8383
8384 vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE, |vm, argc| {
8385 let rhs = vm.pop();
8386 let lhs = vm.pop();
8387 // c:Src/subst.c:4245 `if (isarr)` — an unquoted array embedded
8388 // in a word ALWAYS emits one word per element, never a scalar
8389 // join. The shape (splice vs plan9 cross-product) is chosen at
8390 // RUNTIME by c:1663 `int plan9 = isset(RCEXPANDPARAM);`, exactly
8391 // as BUILTIN_CONCAT_SPLICE does. The only extra case DISTRIBUTE
8392 // handles is the DQ context: the compiler emits
8393 // CallBuiltin(BUILTIN_CONCAT_DISTRIBUTE, 1) when the parent word
8394 // is DQ-wrapped (compile_zsh.rs parent_is_dq), and inside DQ
8395 // `"pre${arr}post"` joins via $IFS[0] to a single scalar
8396 // regardless of the option (c:Src/subst.c:1650-1656 isarr
8397 // comment). The default UNQUOTED path emits argc=2 (lhs + rhs).
8398 // Bug #246 in docs/BUGS.md.
8399 if argc == 1 {
8400 // DQ context: join any Array side to scalar via sepjoin's
8401 // IFS default. c:Src/utils.c:3936-3945 — set-but-empty IFS
8402 // joins with "" (`IFS=""; echo "x$*y"` → `xabcy`); only
8403 // unset / space-leading IFS yields " ".
8404 let join_arr = |arr: &[Value]| -> String {
8405 let strs: Vec<String> = arr.iter().map(|v| v.as_str_cow().into_owned()).collect();
8406 crate::ported::utils::sepjoin(&strs, None)
8407 };
8408 // Provenance: the DQ-join arm consumes both operands, so
8409 // capture them first (Arc clones, only while armed).
8410 let prov_operands = crate::provenance::active().then(|| (lhs.clone(), rhs.clone()));
8411 let l = match lhs {
8412 Value::Array(a) => join_arr(&a),
8413 other => other.as_str_cow().into_owned(),
8414 };
8415 let r = match rhs {
8416 Value::Array(a) => join_arr(&a),
8417 other => other.as_str_cow().into_owned(),
8418 };
8419 let mut s = String::with_capacity(l.len() + r.len());
8420 s.push_str(&l);
8421 s.push_str(&r);
8422 let out = Value::str(s);
8423 if let Some((pl, pr)) = prov_operands {
8424 crate::provenance::on_concat(&pl, &pr, &out);
8425 }
8426 return out;
8427 }
8428 // Unquoted plain `${arr}`: same runtime dispatch as
8429 // BUILTIN_CONCAT_SPLICE — c:4245 `if (isarr)` always distributes
8430 // one word per element; c:1663 picks splice (default, first/last
8431 // sticking, c:4366-4437) vs plan9 cross-product (c:4316-4365).
8432 // concat_splice / concat_plan9 both honor EMPTY_EXPANSION_IS_SCALAR
8433 // so the p10k `${(P)2}` empty-array word-removal semantics survive.
8434 if plan9_active() {
8435 return concat_plan9_prov(lhs, rhs);
8436 }
8437 concat_splice_prov(lhs, rhs)
8438 });
8439
8440 // See BUILTIN_WORD_ASSEMBLE_PLAN9's doc comment for the stack contract.
8441 vm.register_builtin(BUILTIN_WORD_ASSEMBLE_PLAN9, |vm, argc| {
8442 // Pop argc values: descriptor was pushed FIRST (bottom), segments
8443 // after it, so popping top-first then reversing yields
8444 // [descriptor, seg0, …, seg(n-1)].
8445 let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
8446 for _ in 0..argc {
8447 popped.push(vm.pop());
8448 }
8449 popped.reverse();
8450 let mut it = popped.into_iter();
8451 let descriptor = it.next().map(|v| v.to_str()).unwrap_or_default();
8452 let plan9_flags: Vec<bool> = descriptor.chars().map(|c| c == '1').collect();
8453 let segments: Vec<Value> = it.collect();
8454 word_assemble_plan9(&segments, &plan9_flags)
8455 });
8456
8457 // `[[ a -ef b ]]` — same-inode test. Resolves both paths via fs::metadata
8458 // (follows symlinks the way zsh's -ef does) and compares (dev, inode).
8459 // Returns false on any I/O error (path missing, permission denied, etc.).
8460 vm.register_builtin(BUILTIN_SAME_FILE, |vm, _argc| {
8461 let b = vm.pop().to_str();
8462 let a = vm.pop().to_str();
8463 let same = match (fs::metadata(&a), fs::metadata(&b)) {
8464 (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
8465 _ => false,
8466 };
8467 Value::Bool(same)
8468 });
8469
8470 // `[[ -c path ]]` — character device.
8471 vm.register_builtin(BUILTIN_IS_CHARDEV, |vm, _argc| {
8472 let path = vm.pop().to_str();
8473 let result = fs::metadata(&path)
8474 .map(|m| m.file_type().is_char_device())
8475 .unwrap_or(false);
8476 Value::Bool(result)
8477 });
8478 // `[[ -b path ]]` — block device.
8479 vm.register_builtin(BUILTIN_IS_BLOCKDEV, |vm, _argc| {
8480 let path = vm.pop().to_str();
8481 let result = fs::metadata(&path)
8482 .map(|m| m.file_type().is_block_device())
8483 .unwrap_or(false);
8484 Value::Bool(result)
8485 });
8486 // `[[ -p path ]]` — FIFO (named pipe).
8487 vm.register_builtin(BUILTIN_IS_FIFO, |vm, _argc| {
8488 let path = vm.pop().to_str();
8489 let result = fs::metadata(&path)
8490 .map(|m| m.file_type().is_fifo())
8491 .unwrap_or(false);
8492 Value::Bool(result)
8493 });
8494 // `[[ -S path ]]` — socket.
8495 vm.register_builtin(BUILTIN_IS_SOCKET, |vm, _argc| {
8496 let path = vm.pop().to_str();
8497 let result = fs::symlink_metadata(&path)
8498 .map(|m| m.file_type().is_socket())
8499 .unwrap_or(false);
8500 Value::Bool(result)
8501 });
8502
8503 // `[[ -k path ]]` / `-u` / `-g` — sticky / setuid / setgid bit.
8504 vm.register_builtin(BUILTIN_HAS_STICKY, |vm, _argc| {
8505 let path = vm.pop().to_str();
8506 let result = fs::metadata(&path)
8507 .map(|m| m.permissions().mode() & libc::S_ISVTX as u32 != 0)
8508 .unwrap_or(false);
8509 Value::Bool(result)
8510 });
8511 vm.register_builtin(BUILTIN_HAS_SETUID, |vm, _argc| {
8512 let path = vm.pop().to_str();
8513 let result = fs::metadata(&path)
8514 .map(|m| m.permissions().mode() & libc::S_ISUID as u32 != 0)
8515 .unwrap_or(false);
8516 Value::Bool(result)
8517 });
8518 vm.register_builtin(BUILTIN_HAS_SETGID, |vm, _argc| {
8519 let path = vm.pop().to_str();
8520 let result = fs::metadata(&path)
8521 .map(|m| m.permissions().mode() & libc::S_ISGID as u32 != 0)
8522 .unwrap_or(false);
8523 Value::Bool(result)
8524 });
8525 vm.register_builtin(BUILTIN_OWNED_BY_USER, |vm, _argc| {
8526 let path = vm.pop().to_str();
8527 let euid = unsafe { libc::geteuid() };
8528 let result = fs::metadata(&path)
8529 .map(|m| m.uid() == euid)
8530 .unwrap_or(false);
8531 Value::Bool(result)
8532 });
8533 vm.register_builtin(BUILTIN_OWNED_BY_GROUP, |vm, _argc| {
8534 let path = vm.pop().to_str();
8535 let egid = unsafe { libc::getegid() };
8536 let result = fs::metadata(&path)
8537 .map(|m| m.gid() == egid)
8538 .unwrap_or(false);
8539 Value::Bool(result)
8540 });
8541
8542 // `[[ -N path ]]` — file's access time is NOT newer than its
8543 // modification time (zsh man: "true if file exists and its
8544 // access time is not newer than its modification time"). Used
8545 // by zsh's mailbox-watching code. The semantic is `atime <=
8546 // mtime` (equivalent to `mtime >= atime`) — equal counts as
8547 // true, which a strict `mtime > atime` check missed for newly
8548 // created files where both stamps are identical.
8549 vm.register_builtin(BUILTIN_FILE_MODIFIED_SINCE_ACCESS, |vm, _argc| {
8550 let path = vm.pop().to_str();
8551 let result = fs::metadata(&path)
8552 .map(|m| m.atime() <= m.mtime())
8553 .unwrap_or(false);
8554 Value::Bool(result)
8555 });
8556
8557 // `[[ a -nt b ]]` — true if `a`'s mtime is strictly later than `b`'s.
8558 // BOTH files must exist; if either is missing the result is false.
8559 // (Earlier behavior was bash's "missing == infinitely-old"; zsh
8560 // strictly requires both files to exist.)
8561 vm.register_builtin(BUILTIN_FILE_NEWER, |vm, _argc| {
8562 let b = vm.pop().to_str();
8563 let a = vm.pop().to_str();
8564 // Use SystemTime modified() for nanosecond precision —
8565 // MetadataExt::mtime() returns seconds only, so two files
8566 // touched within the same second compared equal even when
8567 // 500ms apart. zsh tracks ns and uses `>=` for ties (touching
8568 // a then b in quick succession should still report b newer).
8569 let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
8570 let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
8571 let result = match (ta, tb) {
8572 (Some(ta), Some(tb)) => ta > tb,
8573 _ => false,
8574 };
8575 Value::Bool(result)
8576 });
8577
8578 // `[[ a -ot b ]]` — mirror of -nt. Same both-must-exist contract.
8579 vm.register_builtin(BUILTIN_FILE_OLDER, |vm, _argc| {
8580 let b = vm.pop().to_str();
8581 let a = vm.pop().to_str();
8582 let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
8583 let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
8584 let result = match (ta, tb) {
8585 (Some(ta), Some(tb)) => ta < tb,
8586 _ => false,
8587 };
8588 Value::Bool(result)
8589 });
8590
8591 // `set -e` / `setopt errexit` post-command check. Compiler emits
8592 // this after each top-level command's SetStatus (skipped inside
8593 // conditionals/pipelines/&&||/`!`). If errexit is on AND the last
8594 // command exited non-zero AND it's not a `return` from a function,
8595 // exit the shell with that status.
8596 // `set -x` / `setopt xtrace` — print each command before it runs.
8597 // The compiler emits this BEFORE the actual builtin/external call
8598 // with the command's literal text as a single string arg. We
8599 // print to stderr if xtrace is on. Honors `$PS4` (default `+ `).
8600 //
8601 // ── XTRACE flow control ────────────────────────────────────────
8602 // Mirror of C zsh's `doneps4` flag in execcmd_exec (Src/exec.c).
8603 // When an assignment trace fires (XTRACE_ASSIGN), it emits PS4
8604 // and sets this flag so the subsequent XTRACE_ARGS skips its own
8605 // PS4 emission — the assignment + command end up on the SAME
8606 // line: `<PS4>a=1 echo hello\n`. XTRACE_ARGS / XTRACE_NEWLINE
8607 // reset the flag after emitting the trailing `\n`.
8608 vm.register_builtin(BUILTIN_XTRACE_IS_ON, |_vm, _argc| {
8609 // Push live xtrace state. Caller pairs this with JumpIfFalse
8610 // to skip the trace-string-building block when xtrace is off,
8611 // avoiding side-effectful operand re-evaluation. Bug #159 in
8612 // docs/BUGS.md.
8613 let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
8614 Value::Int(if on { 1 } else { 0 })
8615 });
8616
8617 vm.register_builtin(BUILTIN_XTRACE_LINE, |vm, _argc| {
8618 // Keep the Value; `to_str()` allocates a String, and this handler
8619 // runs on EVERY `(( … ))` / `[[ … ]]` / loop-head evaluation, where
8620 // xtrace is off essentially always. Defer the allocation to the
8621 // `on` branch below.
8622 let cmd_val = vm.pop();
8623 // Sync exec.last_status with the live vm.last_status BEFORE
8624 // the next command runs. Direct port of the zsh exec.c
8625 // contract — `$?` reads the exit status of the *most recent*
8626 // command. XTRACE_LINE is emitted by the compiler BEFORE
8627 // every simple command, so it's the natural sync point.
8628 let live = vm.last_status;
8629 with_executor(|exec| {
8630 exec.set_last_status(live);
8631 });
8632 // C zsh emits xtrace for `(( … ))` / `[[ … ]]` / `case` /
8633 // `if/while/until/for/repeat` head expressions via
8634 // `printprompt4(); fprintf(xtrerr, "%s\n", expr)` at
8635 // Src/exec.c:5240 (math), c:5286 (cond), c:4117 (for), etc.
8636 // The compiler emits BUILTIN_XTRACE_LINE only at those
8637 // construct boundaries (compile_arith / compile_cond /
8638 // compile_if / compile_while / compile_for / compile_case);
8639 // simple commands route to BUILTIN_XTRACE_ARGS instead. So
8640 // this handler always emits when xtrace is on — no prefix-
8641 // string heuristic.
8642 let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
8643 if on {
8644 let already = XTRACE_DONE_PS4.with(|f| f.get());
8645 if !already {
8646 printprompt4();
8647 }
8648 // c:exec.c:5240/5286 — `fprintf(xtrerr, "%s\n", expr)`. Buffer
8649 // the line + newline, flush once (single write).
8650 xtrerr_fputs(&cmd_val.to_str());
8651 xtrerr_fputs("\n");
8652 xtrerr_flush();
8653 XTRACE_DONE_PS4.with(|f| f.set(false));
8654 }
8655 Value::Status(0)
8656 });
8657
8658 // BUILTIN_XTRACE_ARRAY_LINE — xtrace line for an `arr=(...)` / `arr+=(...)`
8659 // assignment. Stack on entry: [array, prefix] (argc = 2); pops prefix
8660 // ("name=( " / "name+=( "), then the whole assembled Value::Array. Direct
8661 // port of c:Src/exec.c::addvars:2624-2632, guarded on the live xtrace
8662 // state like C's `if (xtr)`: prints `prefix qz(e0) qz(e1) … ) ` with each
8663 // element shell-quoted (quotedzputs). Replaces the former one-VM-slot-per-
8664 // element trace, which overflowed next_slot (u16) on large literals.
8665 vm.register_builtin(BUILTIN_XTRACE_ARRAY_LINE, |vm, _argc| {
8666 let prefix = vm.pop().to_str();
8667 let arr = vm.pop();
8668 let live = vm.last_status;
8669 with_executor(|exec| {
8670 exec.set_last_status(live);
8671 });
8672 let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
8673 if on {
8674 let already = XTRACE_DONE_PS4.with(|f| f.get());
8675 if !already {
8676 printprompt4();
8677 }
8678 let mut line = String::with_capacity(prefix.len() + 16);
8679 line.push_str(&prefix);
8680 if let Value::Array(items) = arr {
8681 for it in items.iter() {
8682 line.push_str(&crate::ported::utils::quotedzputs(&it.to_str()));
8683 line.push(' ');
8684 }
8685 }
8686 line.push_str(") ");
8687 line.push('\n');
8688 xtrerr_fputs(&line);
8689 xtrerr_flush();
8690 XTRACE_DONE_PS4.with(|f| f.set(false));
8691 }
8692 Value::Status(0)
8693 });
8694
8695 // BUILTIN_MAKE_ARRAY_COUNTED — pop a count Int (top), then pop that many
8696 // values below it, and push them as one Value::Array (bottom-of-group
8697 // first). Same result as Op::MakeArray(N) but N comes from the stack as an
8698 // i64, dodging MakeArray's u16 operand cap. The compiler emits this only
8699 // when a literal `arr=(...)` has more than u16::MAX elements.
8700 vm.register_builtin(BUILTIN_MAKE_ARRAY_COUNTED, |vm, _argc| {
8701 let count = vm.pop().to_int().max(0) as usize;
8702 let mut items: Vec<Value> = Vec::with_capacity(count);
8703 for _ in 0..count {
8704 items.push(vm.pop());
8705 }
8706 items.reverse();
8707 Value::array(items)
8708 });
8709
8710 // BUILTIN_ARGV_RFLATTEN — recursively flatten a MakeArray-packed argv
8711 // bundle so a >255-arg Call/CallFunction/CallBuiltin (dispatched with
8712 // argc=1 over the single packed Array) recovers every positional arg. The
8713 // call ops flatten only one level; a brace/glob/`$arr` word contributes a
8714 // nested Array that would otherwise stringify. See the const doc.
8715 vm.register_builtin(BUILTIN_ARGV_RFLATTEN, |vm, _argc| {
8716 let v = vm.pop();
8717 let mut out: Vec<String> = Vec::new();
8718 flatten_array_value(v, &mut out);
8719 Value::array(out.into_iter().map(Value::str).collect())
8720 });
8721
8722 // Like XTRACE_LINE but reads the top `argc - 1` values from the
8723 // VM stack WITHOUT consuming them (peek), then pops a prefix
8724 // string at the top. Joins prefix + peeked args with spaces using
8725 // zsh's quotedzputs-equivalent quoting. Direct port of
8726 // Src/exec.c:2055-2066 — emit AFTER expansion, with each arg
8727 // shell-quoted, so `for i in a b; echo for $i` traces as
8728 // `echo for a` / `echo for b`, not `echo for $i`.
8729 //
8730 // Stack contract on entry: [arg1, arg2, ..., argN, prefix].
8731 // Pops prefix; peeks argN..arg1 below. argc = N + 1.
8732 vm.register_builtin(BUILTIN_XTRACE_ARGS, |vm, argc| {
8733 let prefix = vm.pop().to_str();
8734 let live = vm.last_status;
8735 with_executor(|exec| {
8736 exec.set_last_status(live);
8737 });
8738 let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
8739 if on {
8740 let n_args = argc.saturating_sub(1) as usize;
8741 let len = vm.stack.len();
8742 // c:Src/exec.c:2055 — argv is the POST-expansion word
8743 // list, so an arg that expanded to multiple words splats
8744 // into multiple trace tokens AND an arg that expanded to
8745 // zero words (empty unquoted `${UNSET}`) emits nothing.
8746 // pop_args (line 6243) already does this splat for the
8747 // real handler; mirror the same Array → splat / empty →
8748 // drop logic here so xtrace renders `echo ${UNSET}` as
8749 // `echo` (zsh) instead of `echo ''` (the previous
8750 // single-arg stringify path returned "" and then
8751 // quotedzputs wrapped it in `''`).
8752 let arg_strs: Vec<String> = if n_args > 0 && len >= n_args {
8753 let mut out = Vec::new();
8754 for v in &vm.stack[len - n_args..] {
8755 match v {
8756 Value::Array(items) => {
8757 for item in items.iter() {
8758 out.push(quotedzputs(&item.to_str()));
8759 }
8760 }
8761 other => out.push(quotedzputs(&other.to_str())),
8762 }
8763 }
8764 out
8765 } else {
8766 Vec::new()
8767 };
8768 // Builtins dispatch through `execbuiltin` (Src/builtin.c:442)
8769 // which emits its own PS4 + name + args xtrace. To avoid
8770 // double-emission, skip our emission here when the first
8771 // arg is a known builtin with a registered HandlerFunc —
8772 // those go through execbuiltin and will trace themselves.
8773 // Externals + builtins-not-yet-routed-through-execbuiltin
8774 // keep our emission as a stand-in.
8775 let goes_through_execbuiltin = crate::ported::builtin::BUILTINS
8776 .iter()
8777 .any(|b| b.node.nam == prefix && b.handlerfunc.is_some());
8778 if !goes_through_execbuiltin {
8779 let line = if arg_strs.is_empty() {
8780 prefix
8781 } else {
8782 format!("{} {}", prefix, arg_strs.join(" "))
8783 };
8784 // Mirrors Src/exec.c:2055 xtrace emission. C does:
8785 // if (!doneps4) printprompt4();
8786 // ... emit args + spaces ...
8787 // fputc('\n', xtrerr); fflush(xtrerr);
8788 // printprompt4 + the args + `\n` all land in the xtrerr
8789 // buffer; the single fflush below writes the whole line in
8790 // one syscall so concurrent pipeline stages never
8791 // interleave (c:makecline:2122-2123).
8792 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
8793 if !already_ps4 {
8794 printprompt4();
8795 }
8796 xtrerr_fputs(&line);
8797 xtrerr_fputs("\n"); // c:2122 fputc('\n', xtrerr)
8798 xtrerr_flush(); // c:2123 fflush(xtrerr)
8799 }
8800 XTRACE_DONE_PS4.with(|f| f.set(false));
8801 }
8802 Value::Status(0)
8803 });
8804
8805 // BUILTIN_XTRACE_ASSIGN — direct port of the per-assignment
8806 // trace block at Src/exec.c:2517-2582. C body excerpt:
8807 // xtr = isset(XTRACE);
8808 // if (xtr) { printprompt4(); doneps4 = 1; }
8809 // while (assign) {
8810 // if (xtr) fprintf(xtrerr, "%s+=" or "%s=", name);
8811 // ... eval value into `val` ...
8812 // if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
8813 // ...
8814 // }
8815 //
8816 // Stack on entry: [..., name, value]. PEEKS both (they're left
8817 // on stack for SET_VAR to pop). Emits `name=<quoted-val> ` with
8818 // no newline; trailing `\n` comes from XTRACE_ARGS (cmd path)
8819 // or XTRACE_NEWLINE (assignment-only path).
8820 vm.register_builtin(BUILTIN_XTRACE_ASSIGN, |vm, _argc| {
8821 let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
8822 if on {
8823 // PEEK [..., name, value] — argc==2 by contract.
8824 let len = vm.stack.len();
8825 if len >= 2 {
8826 let name = vm.stack[len - 2].to_str();
8827 let value = vm.stack[len - 1].to_str();
8828 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
8829 if !already_ps4 {
8830 printprompt4();
8831 XTRACE_DONE_PS4.with(|f| f.set(true));
8832 }
8833 // C: `fprintf(xtrerr, "%s=", name)` then `quotedzputs
8834 // (val); fputc(' ', xtrerr);`. Append to the xtrerr buffer
8835 // (no newline / no flush — the line continues with the
8836 // command via XTRACE_ARGS, or ends at XTRACE_NEWLINE).
8837 xtrerr_fputs(&format!("{}={} ", name, quotedzputs(&value)));
8838 }
8839 }
8840 Value::Status(0)
8841 });
8842
8843 // BUILTIN_XTRACE_NEWLINE — emit trailing `\n` + flush iff a
8844 // prior XTRACE_ASSIGN this line already emitted PS4. Mirrors
8845 // C's `fputc('\n', xtrerr); fflush(xtrerr);` at exec.c:3398
8846 // (the assignment-only path through execcmd_exec).
8847 vm.register_builtin(BUILTIN_XTRACE_NEWLINE, |_vm, _argc| {
8848 let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
8849 if on {
8850 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
8851 if already_ps4 {
8852 xtrerr_fputs("\n"); // c:3398 fputc('\n', xtrerr)
8853 xtrerr_flush(); // c:3398 fflush(xtrerr)
8854 XTRACE_DONE_PS4.with(|f| f.set(false));
8855 }
8856 }
8857 Value::Status(0)
8858 });
8859
8860 // c:Src/exec.c WC_TRYBLOCK — post-always re-jump probes. Each
8861 // returns 1 + consumes the atomic when the corresponding
8862 // escape flag is set; the try-block compile pairs each with
8863 // a JumpIfFalse + Jump → outer scope's return / break /
8864 // continue patches.
8865 vm.register_builtin(BUILTIN_RETFLAG_CHECK, |_vm, _argc| {
8866 use std::sync::atomic::Ordering;
8867 let r = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
8868 if r != 0 {
8869 // Don't clear here — doshfunc owns the clear at c:6047
8870 // when the function unwinds. Leaving it set propagates
8871 // through nested `eval`/`source` callers correctly.
8872 Value::Int(1)
8873 } else {
8874 Value::Int(0)
8875 }
8876 });
8877 vm.register_builtin(BUILTIN_BREAKS_CHECK, |_vm, _argc| {
8878 use std::sync::atomic::Ordering;
8879 let b = crate::ported::builtin::BREAKS.load(Ordering::Relaxed);
8880 let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
8881 // `break` sets BREAKS but NOT CONTFLAG; `continue` sets both.
8882 // Filter out the continue path here so the two checks are
8883 // mutually exclusive.
8884 if b != 0 && c == 0 {
8885 // Consume BREAKS so the outer loop's break_patches
8886 // landing doesn't double-decrement.
8887 crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
8888 Value::Int(1)
8889 } else {
8890 Value::Int(0)
8891 }
8892 });
8893 vm.register_builtin(BUILTIN_CONTFLAG_CHECK, |_vm, _argc| {
8894 use std::sync::atomic::Ordering;
8895 let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
8896 if c != 0 {
8897 crate::ported::builtin::CONTFLAG.store(0, Ordering::Relaxed);
8898 crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
8899 Value::Int(1)
8900 } else {
8901 Value::Int(0)
8902 }
8903 });
8904 // c:Src/loop.c — `loops++` / `loops--` bracket every iterative
8905 // construct: execfor c:114/188, execwhile c:427/491, execrepeat
8906 // c:523/546. `loops` is a GLOBAL, not a per-frame counter, and
8907 // `bin_break` reads it (`if (!loops)`) to decide whether `break` /
8908 // `continue` is legal. Because doshfunc does NOT reset it (only
8909 // restores it under LOCAL_LOOPS, c:6104-6112), a function called
8910 // from inside a loop sees the CALLER's count and its `break` ends
8911 // the caller's loop. zshrs's compiled for/while/until/repeat lower
8912 // to raw jumps, so without these two ops the counter stayed 0 and
8913 // every such `break` errored out instead.
8914 vm.register_builtin(BUILTIN_LOOP_ENTER, |_vm, _argc| {
8915 crate::ported::builtin::LOOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); // c:114
8916 Value::Int(0)
8917 });
8918 // c:Src/loop.c:141-145 + :199-203 (execfor), :478-481 (execwhile),
8919 // :534-537 (execrepeat) — every loop that abandons its body because
8920 // `errflag` is set FORCES the escaping status first:
8921 // if (errflag) { if (breaks) breaks--; lastval = 1; break; }
8922 // so a fatal error inside a loop leaves 1, not whatever the failing
8923 // command set. `setopt extendedglob; for i in 1 2; do [[ abc == [ ]]; done`
8924 // exits 1 in zsh while the bare `[[ abc == [ ]]` exits 2; zshrs's compiled
8925 // loops jumped straight to the chunk-end landing and carried the cond's 2
8926 // out. `execselect` has no such assignment (c:217+), which is why
8927 // `compile_select` does not bump `open_loop_depth` and never emits this.
8928 vm.register_builtin(BUILTIN_LOOP_ERRFLAG_STATUS, |vm, _argc| {
8929 // The `if (errflag)` half of the C guard is re-tested HERE, not at
8930 // compile time: the same abort edge also carries an ERREXIT
8931 // (`set -e`) exit, which in C leaves execlist via `zexit(lastval)`
8932 // and never reaches the loop's `if (errflag)` arm — so that status
8933 // must survive untouched.
8934 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
8935 & crate::ported::zsh_h::ERRFLAG_ERROR)
8936 != 0
8937 {
8938 vm.last_status = 1; // c:144/201/480/536
8939 with_executor(|exec| exec.set_last_status(1)); // c:144/201/480/536
8940 }
8941 Value::Int(0)
8942 });
8943 vm.register_builtin(BUILTIN_LOOP_EXIT, |_vm, _argc| {
8944 use std::sync::atomic::Ordering::SeqCst;
8945 // Saturating: a chunk aborted mid-loop (errflag, `return`)
8946 // unwinds through `run_chunk`'s restore rather than this op, so
8947 // never let a stray decrement drive the count negative.
8948 let _ = crate::ported::builtin::LOOPS
8949 .fetch_update(SeqCst, SeqCst, |n| Some(if n > 0 { n - 1 } else { 0 })); // c:188
8950 Value::Int(0)
8951 });
8952
8953 // c:Src/loop.c:529-534 (execwhile), :180-185 (execfor), :540-545
8954 // (execrepeat) — the identical post-body drain every loop runs:
8955 // if (breaks) {
8956 // breaks--;
8957 // if (breaks || !contflag) break;
8958 // contflag = 0;
8959 // }
8960 // Returns Int(1) when this loop must terminate, Int(0) when it
8961 // should proceed to the next iteration. Only a `break`/`continue`
8962 // executed in a DIFFERENT chunk (a called function, `eval`, a
8963 // sourced file) reaches here — an in-chunk `break` compiles to a
8964 // direct jump and never touches the counter.
8965 vm.register_builtin(BUILTIN_LOOP_BREAK_DRAIN, |_vm, _argc| {
8966 use std::sync::atomic::Ordering::SeqCst;
8967 let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
8968 if breaks == 0 {
8969 return Value::Int(0);
8970 }
8971 let remaining = breaks - 1;
8972 crate::ported::builtin::BREAKS.store(remaining, SeqCst); // c:530
8973 let contflag = crate::ported::builtin::CONTFLAG.load(SeqCst);
8974 if remaining != 0 || contflag == 0 {
8975 return Value::Int(1); // c:532 — `break`
8976 }
8977 crate::ported::builtin::CONTFLAG.store(0, SeqCst); // c:533
8978 Value::Int(0)
8979 });
8980
8981 // c:Src/exec.c:1370 execlist — `while (wc_code(code) == WC_LIST &&
8982 // !breaks && !retflag && !errflag)`. A pending `breaks` stops the
8983 // CURRENT list at the next statement boundary WITHOUT consuming it,
8984 // so the flag keeps travelling outward until a loop's drain eats it.
8985 // Non-consuming by design: the drain above is the only consumer.
8986 vm.register_builtin(BUILTIN_BREAKS_PENDING, |_vm, _argc| {
8987 let b = crate::ported::builtin::BREAKS.load(std::sync::atomic::Ordering::SeqCst);
8988 Value::Int(if b != 0 { 1 } else { 0 })
8989 });
8990
8991 vm.register_builtin(BUILTIN_NOEXEC_CHECK, |_vm, _argc| {
8992 // c:Src/exec.c:1390 — `set -n` / `noexec` option: parse but
8993 // don't execute. Returns Int(1) when noexec is set so the
8994 // emit-side JumpIfTrue skips the statement body.
8995 if opt_state_get("noexec").unwrap_or(false) {
8996 return Value::Int(1);
8997 }
8998 // c:Src/exec.c:1390 — execlist's list-loop gate:
8999 // `while (wc_code(code) == WC_LIST && !breaks && !retflag
9000 // && !errflag)`
9001 // — once errflag is set, the NEXT sublist never starts, so
9002 // lastval survives untouched to the shell exit. Without this
9003 // prologue gate the follow-up statement RAN, its dispatch
9004 // saw errflag, returned 1, and SetStatus clobbered lastval —
9005 // `[[ x == [a- ]]; print rc=$?` exited 1 instead of zsh's 2
9006 // (the cond syntax error set lastval=2 per exec.c:5216-5221).
9007 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
9008 & crate::ported::zsh_h::ERRFLAG_ERROR)
9009 != 0
9010 {
9011 return Value::Int(1);
9012 }
9013 Value::Int(0)
9014 });
9015 // c:Src/exec.c:1536-1538 —
9016 // /* suppress errexit for commands before && and || and after ! */
9017 // if (isandor || isnot)
9018 // noerrexit |= NOERREXIT_EXIT | NOERREXIT_RETURN;
9019 // The bits live on the PROCESS-GLOBAL `noerrexit`, so they are still
9020 // in force inside a shell function called from that position (doshfunc
9021 // clears only NOERREXIT_RETURN, c:5930). zshrs suppressed the check
9022 // purely at COMPILE time (`errexit_suppress_depth`), which cannot
9023 // reach a separately-compiled function body — so
9024 // TRAPZERR(){ print E }; f(){ print f; false; }; f && t
9025 // fired the ZERR trap from inside `f` where zsh stays silent
9026 // (C03traps:14, E01options:18,19,21).
9027 vm.register_builtin(BUILTIN_NOERREXIT_SUPPRESS, |_vm, _argc| {
9028 use std::sync::atomic::Ordering;
9029 let old = crate::ported::exec::noerrexit.load(Ordering::Relaxed); // c:1417
9030 NOERREXIT_SAVES.with(|st| st.borrow_mut().push(old));
9031 crate::ported::exec::noerrexit.store(
9032 old | crate::ported::zsh_h::NOERREXIT_EXIT | crate::ported::zsh_h::NOERREXIT_RETURN,
9033 Ordering::Relaxed,
9034 ); // c:1538
9035 Value::Int(0)
9036 });
9037 // c:Src/exec.c:1621 / c:1626 — `noerrexit = oldnoerrexit;`
9038 vm.register_builtin(BUILTIN_NOERREXIT_RESTORE, |_vm, _argc| {
9039 use std::sync::atomic::Ordering;
9040 if let Some(old) = NOERREXIT_SAVES.with(|st| st.borrow_mut().pop()) {
9041 crate::ported::exec::noerrexit.store(old, Ordering::Relaxed); // c:1621
9042 }
9043 Value::Int(0)
9044 });
9045 vm.register_builtin(BUILTIN_DONETRAP_RESET, |_vm, _argc| {
9046 // c:Src/exec.c:1455 — `donetrap = 0;` at sublist start.
9047 // Reset before each top-level statement so the next
9048 // sublist's ERREXIT_CHECK fires the ZERR trap on its FIRST
9049 // non-zero command. Carries the "already fired" state
9050 // across function-call returns within the SAME outer
9051 // sublist (per C semantics — donetrap is process-global).
9052 // Bug #303 in docs/BUGS.md.
9053 crate::ported::exec::DONETRAP.store(0, std::sync::atomic::Ordering::Relaxed);
9054 // `${~spec}` carrier: C's `globsubst` is a paramsubst-LOCAL
9055 // int (c:Src/subst.c:1671 `int globsubst = isset(GLOBSUBST);`,
9056 // set to 2 by `${~}` at c:2597-2603) whose only effect is the
9057 // `shtokenize()` of THAT substitution's own result
9058 // (c:4419-4420). It can therefore never be observed by a later
9059 // statement. zshrs carries the flag on the global option table
9060 // (subst.rs:5125-5136) so the compile-emitted glob ops in the
9061 // same word pipeline can see it, and restores it at
9062 // command-dispatch boundaries — but a `${~}` sitting in a word
9063 // that dispatches NO command (a `for`/`select` word list, a
9064 // loop/`case` header) had no such boundary before the NEXT
9065 // statement's words were expanded, so GLOB_SUBST leaked into
9066 // them. This op is emitted exactly once per sublist, in
9067 // compile_list's prologue (compile_zsh.rs:557) — i.e. BEFORE
9068 // the sublist's words expand — which is the same "state is
9069 // gone by the next statement" guarantee C gets for free.
9070 // Without it, `_parameters`' `for i in ${…:#${~pfilt}*}` loop
9071 // globbed its `ary+=($i:"$val")` body word and died with
9072 // "bad pattern: HISTCHARS:!^#", killing `-<TAB>` completion.
9073 consume_tilde_globsubst_carrier();
9074 Value::Status(0)
9075 });
9076
9077 vm.register_builtin(BUILTIN_SUBLIST_FINISH, |vm, _argc| {
9078 // c:Src/jobs.c:1754 — `pipestats[0] = lastval;`. C has ONE
9079 // `lastval` global (c:Src/exec.c:120), so `waitonejob` reads
9080 // exactly the status the finished sublist just produced.
9081 //
9082 // zshrs splits that global in two: the fusevm status cell that
9083 // `Op::SetStatus`/`Op::GetStatus` and therefore `$?` use, and
9084 // the `builtin::LASTVAL` mirror that the ported `waitonejob`
9085 // reads. The compound-command compilers (compile_if,
9086 // compile_while, compile_for, compile_case, …) settle their
9087 // result with `Op::SetStatus` alone, so LASTVAL still holds
9088 // whatever the last dispatched BUILTIN returned — the loop
9089 // condition, typically. `if [[ -z x ]]; then :; fi` and
9090 // `while false; do :; done` both end with `$? == 0` and a
9091 // stale LASTVAL of 1.
9092 //
9093 // compile_sublist pushes `Op::GetStatus` ahead of this call so
9094 // the authoritative status arrives as an argument; republish it
9095 // through LASTVAL to reunify the two before the ported
9096 // waitonejob reads it, exactly as the single-command dispatch
9097 // sites at c:Src/exec.c:4367 do.
9098 let status = vm.pop().to_int() as i32;
9099 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
9100 // c:Src/jobs.c:1750-1756 — `compile_sublist` only emits this
9101 // marker for a cmplx sublist element that is not a multi-stage
9102 // pipeline, i.e. exactly the case where C's job carries no
9103 // procs, so drive the canonical port with a procs-less job the
9104 // same way the single-command sites do.
9105 let mut synth = crate::ported::zsh_h::job::default();
9106 crate::ported::jobs::waitonejob(&mut synth);
9107 Value::Status(0)
9108 });
9109
9110 // `[[ -z X ]]` / `[[ -n X ]]` — pop one Value, route through
9111 // canonical `src/ported/cond.rs::evalcond` so the actual
9112 // empty/non-empty test reuses the C-port at `cond.rs:270-271`
9113 // (`'n' => !arg.is_empty()`, `'z' => arg.is_empty()`).
9114 //
9115 // The Array→args conversion lives at the bridge because cond.rs
9116 // expects `&[&str]` (C `cond_str` signature equivalent). For
9117 // `"${arr[@]}"` in DQ context the splice yields `Value::Array`
9118 // — an empty array still expands to one implicit empty word
9119 // (per zsh's "${arr[@]}" splat preserving at least one slot
9120 // in cond context), so:
9121 // - Array(0) → ["-z", ""] → evalcond → 0 (true)
9122 // - Array(1) → ["-z", word] → evalcond → 0/1
9123 // - Array(2+) → ["-z", w1, w2, ...] → evalcond → 2 (parse
9124 // error: too many ops)
9125 // → coerced to false
9126 // - Str(s) → ["-z", s] → evalcond → 0/1
9127 //
9128 // Bug #185 in docs/BUGS.md.
9129 fn run_cond_str_empty(v: Value, op: &str) -> Value {
9130 let words: Vec<String> = match v {
9131 Value::Array(arr) => arr.iter().map(|x| x.to_str()).collect(),
9132 Value::Str(s) => vec![s.to_string()],
9133 other => vec![other.to_str()],
9134 };
9135 let mut args: Vec<&str> = vec![op];
9136 if words.is_empty() {
9137 args.push("");
9138 } else {
9139 args.extend(words.iter().map(|s| s.as_str()));
9140 }
9141 let opts: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
9142 let vars: std::collections::HashMap<String, String> = std::collections::HashMap::new();
9143 // c:Src/cond.c:62-66 — `evalcond` returns 0=true, 1=false,
9144 // 2=syntax-error. Coerce error to false (observable behavior
9145 // in zsh: `[[ -z a b ]]` errors and the test as a whole
9146 // returns non-zero).
9147 // `[[ ]]` dispatch — C's `evalcond(state, NULL)` calling convention.
9148 // `None` for from_test → mathevali integer-compare coercion path.
9149 let ret = crate::ported::cond::evalcond(&args, &opts, &vars, false, None);
9150 Value::Int(if ret == 0 { 1 } else { 0 })
9151 }
9152 vm.register_builtin(BUILTIN_COND_STR_EMPTY, |vm, _argc| {
9153 let v = vm.pop();
9154 run_cond_str_empty(v, "-z")
9155 });
9156 vm.register_builtin(BUILTIN_COND_STR_NONEMPTY, |vm, _argc| {
9157 let v = vm.pop();
9158 run_cond_str_empty(v, "-n")
9159 });
9160
9161 // `exec N<<<"str"` — herestring redirect to explicit fd, applied
9162 // permanently. Direct port of `Src/exec.c:4655 getherestr` +
9163 // `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)` at c:3766-
9164 // 3780 for the nullexec=1 bare-exec-redir path. Bug #205 in
9165 // docs/BUGS.md.
9166 vm.register_builtin(BUILTIN_EXEC_HERESTR_FD, |vm, _argc| {
9167 let fd = vm.pop().to_int() as i32;
9168 let content = vm.pop().to_str();
9169 // c:4671-4672 — append `\n` for "real" herestrings (not
9170 // heredoc-derived). zshrs's bare-exec path only fires for
9171 // the `<<<` syntax (REDIR_HERESTR), so always append.
9172 let body = format!("{}\n", content);
9173 // c:4673-4679 — gettempfile → write_loop → close → reopen
9174 // read-only → unlink. Rust equivalent via tempfile crate or
9175 // explicit O_TMPFILE; use mkstemp + unlink-immediately to
9176 // mirror C exactly.
9177 use std::ffi::CString;
9178 let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
9179 let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
9180 if write_fd < 0 {
9181 crate::ported::utils::zwarn(&format!(
9182 "can't create temp file for here document: {}",
9183 std::io::Error::last_os_error()
9184 ));
9185 return Value::Status(1);
9186 }
9187 // c:4675 — write_loop(fd, t, len)
9188 let bytes = body.as_bytes();
9189 let mut off = 0;
9190 while off < bytes.len() {
9191 let n = unsafe {
9192 libc::write(
9193 write_fd,
9194 bytes[off..].as_ptr() as *const libc::c_void,
9195 bytes.len() - off,
9196 )
9197 };
9198 if n <= 0 {
9199 unsafe { libc::close(write_fd) };
9200 return Value::Status(1);
9201 }
9202 off += n as usize;
9203 }
9204 unsafe { libc::close(write_fd) }; // c:4676
9205 // Path null-terminated by mkstemp; reopen for reading.
9206 let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
9207 // c:4678 — unlink immediately so the file disappears on
9208 // close, leaving only the fd reference.
9209 unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
9210 if read_fd < 0 {
9211 return Value::Status(1);
9212 }
9213 // c:3779 addfd → dup2 to target fd, close intermediate.
9214 let r = unsafe { libc::dup2(read_fd, fd) };
9215 unsafe { libc::close(read_fd) };
9216 if r < 0 {
9217 return Value::Status(1);
9218 }
9219 Value::Status(0)
9220 });
9221 // c:Src/exec.c:2418 + addfd splice — MULTIOS fan-out. Stack
9222 // layout pushed by compile_zsh's coalescing pass:
9223 // [target_1, op_byte_1, target_2, op_byte_2, …, target_N,
9224 // op_byte_N, fd]
9225 // argc = 2N + 1. Pops, opens every target, sets up a pipe +
9226 // splitter thread that reads pipe → writes every chunk to
9227 // every opened target, dup2's pipe-write-end onto fd. The
9228 // splitter is closed + joined by host_redirect_scope_end.
9229 // Bug #36 in docs/BUGS.md.
9230 vm.register_builtin(BUILTIN_MULTIOS_REDIRECT, |vm, argc| {
9231 if argc < 3 || argc % 2 == 0 {
9232 // Bad shape — bail.
9233 return Value::Status(1);
9234 }
9235 // Pop fd first (top of stack).
9236 let fd = vm.pop().to_int() as i32;
9237 // Then pop (op, target) pairs in reverse compile order. Keep
9238 // targets as Values — a glob-bearing target arrives as a
9239 // Value::Array of matches.
9240 let n_targets = ((argc - 1) / 2) as usize;
9241 let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_targets);
9242 for _ in 0..n_targets {
9243 let op_byte = vm.pop().to_int() as u8;
9244 let target = vm.pop();
9245 pairs.push((op_byte, target));
9246 }
9247 // Restore compile order (target_1 first).
9248 pairs.reverse();
9249
9250 // c:Src/glob.c:2195-2203 xpandredir — "Loop over matches,
9251 // duplicating the redirection for each file found": a glob
9252 // target with N matches becomes N members of the same multio
9253 // (`echo hi > *.txt` with two matches writes both files).
9254 let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
9255 for (op_byte, target) in pairs {
9256 match target {
9257 Value::Array(items) => {
9258 for item in items.iter() {
9259 entries.push((op_byte, item.to_str()));
9260 }
9261 }
9262 other => entries.push((op_byte, other.to_str())),
9263 }
9264 }
9265 if entries.is_empty() {
9266 return Value::Status(1);
9267 }
9268
9269 // c:Src/exec.c:2418 — `else if (!mfds[fd1] || unset(MULTIOS))`:
9270 // with MULTIOS unset every redirect takes the REPLACE path in
9271 // script order — each target is still opened (created /
9272 // truncated) and dup2'd over the fd, so the LAST one wins and
9273 // earlier files end up empty (`unsetopt multios; print x > a
9274 // > b` leaves `a` empty, `x` in `b`). host_apply_redirect is
9275 // exactly one replace step, noclobber gate included.
9276 let multios_on = opt_state_get("multios").unwrap_or(true);
9277 if !multios_on {
9278 with_executor(|exec| {
9279 for (op_byte, target) in &entries {
9280 exec.host_apply_redirect(fd as u8, *op_byte, target);
9281 if exec.redirect_failed {
9282 // c:Src/exec.c execerr — abort the remaining
9283 // redirect list on failure.
9284 break;
9285 }
9286 }
9287 });
9288 return Value::Status(0);
9289 }
9290
9291 if entries.len() == 1 {
9292 // Single member after splicing — a plain replace
9293 // (c:2418 new-multio arm). Route through
9294 // host_apply_redirect so the noclobber gate, the
9295 // pipeline-output split partial, and error handling all
9296 // apply exactly as for an un-bagged redirect.
9297 let (op_byte, target) = &entries[0];
9298 with_executor(|exec| {
9299 exec.host_apply_redirect(fd as u8, *op_byte, target);
9300 });
9301 return Value::Status(0);
9302 }
9303
9304 // c:Src/exec.c:3722-3724 — when this command's stdout IS the
9305 // pipeline output, C seeds mfds[1] with the pipe BEFORE
9306 // walking the redirect list, so the pipe is the multio's
9307 // first member (`print x >&1 > f | cat` sends `x` down the
9308 // pipe TWICE: once for the seed, once for the `>&1` dup).
9309 let pipe_seed = fd == 1
9310 && with_executor(|exec| {
9311 exec.pipe_output_scope
9312 .is_some_and(|d| d + 1 == exec.redirect_scope_stack.len())
9313 });
9314
9315 // Save current fd state for scope-end restoration — BEFORE
9316 // the first member's replace dup2 below.
9317 // c:Src/exec.c:2425 — `int fdN = movefd(fd1); save[fd1] = fdN;`. A SAVED
9318 // descriptor is shell state and must live above the script's fd range:
9319 // plain dup() returns the LOWEST free fd, which parked the saved stdout
9320 // on fd 3, so `print -u 3 -r -- X 2>/dev/null` wrote into the shell's own
9321 // saved descriptor and reported success where zsh says `bad file number`.
9322 // F_DUPFD with a floor of 10 is exactly what movefd does.
9323 let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
9324 if saved >= 0 {
9325 with_executor(|exec| {
9326 if let Some(top) = exec.redirect_scope_stack.last_mut() {
9327 top.push((fd, saved));
9328 } else {
9329 unsafe { libc::close(saved) };
9330 }
9331 });
9332 }
9333
9334 // Accumulate member fds in redirect order. c:Src/exec.c:
9335 // 2447-2480 addfd — the FIRST member REPLACES the fd
9336 // (c:2448-2450 `mfds[fd1]->ct=1; mfds[fd1]->fds[0]=fd1;`), so
9337 // a later numeric `>&N` self-dup resolves against the fd's
9338 // value at that point in the sequence: `print x > f >&1`
9339 // writes f TWICE; `print x >&1 > f` writes the ORIGINAL
9340 // stdout + f.
9341 let mut target_fds: Vec<i32> = Vec::with_capacity(entries.len() + 1);
9342 if pipe_seed {
9343 let p = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
9344 if p >= 0 {
9345 target_fds.push(p);
9346 }
9347 }
9348 let noclobber = opt_state_get("noclobber").unwrap_or(false)
9349 || !opt_state_get("clobber").unwrap_or(true);
9350 for (i, (op_byte, target)) in entries.iter().enumerate() {
9351 let open_result: std::io::Result<i32> = match *op_byte {
9352 r::DUP_WRITE | r::DUP_READ => {
9353 // Numeric `>&N` — dup the LIVE fd N (after any
9354 // earlier member's replace).
9355 match target.trim_start_matches('&').parse::<i32>() {
9356 Ok(src) => {
9357 let d = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
9358 if d >= 0 {
9359 Ok(d)
9360 } else {
9361 Err(std::io::Error::last_os_error())
9362 }
9363 }
9364 Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
9365 }
9366 }
9367 r::WRITE => {
9368 // c:Src/exec.c clobber_open — noclobber applies
9369 // to multio file targets too; failure aborts the
9370 // remaining redirect list (execerr), so `setopt
9371 // noclobber; touch a; print x > a > b` errors on
9372 // `a` and never creates `b`.
9373 let target_meta = std::fs::metadata(target).ok();
9374 let target_is_regular_file = target_meta
9375 .as_ref()
9376 .map(|m| m.file_type().is_file())
9377 .unwrap_or(false);
9378 // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY re-uses
9379 // an empty regular file under noclobber (same allowance
9380 // as the single-redirect path).
9381 let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
9382 && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
9383 if noclobber && target_is_regular_file && !clobber_empty_ok {
9384 eprintln!(
9385 "{}:{}: file exists: {}",
9386 shname(),
9387 crate::ported::lex::lineno(),
9388 target
9389 );
9390 for prev in &target_fds {
9391 unsafe {
9392 libc::close(*prev);
9393 }
9394 }
9395 with_executor(|exec| {
9396 exec.redirect_failed = true;
9397 });
9398 // Sink the upcoming command's output (mirrors
9399 // the single-redirect noclobber arm in
9400 // host_apply_redirect).
9401 if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
9402 let new_fd = file.into_raw_fd();
9403 unsafe {
9404 libc::dup2(new_fd, fd);
9405 libc::close(new_fd);
9406 }
9407 }
9408 return Value::Status(1);
9409 }
9410 fs::OpenOptions::new()
9411 .write(true)
9412 .create(true)
9413 .truncate(true)
9414 .open(target)
9415 .map(|f| f.into_raw_fd())
9416 }
9417 r::APPEND => fs::OpenOptions::new()
9418 .write(true)
9419 .create(true)
9420 .append(true)
9421 .open(target)
9422 .map(|f| f.into_raw_fd()),
9423 _ => fs::OpenOptions::new()
9424 .write(true)
9425 .create(true)
9426 .truncate(true)
9427 .open(target)
9428 .map(|f| f.into_raw_fd()),
9429 };
9430 match open_result {
9431 Ok(tfd) => {
9432 if i == 0 && !pipe_seed {
9433 // c:2448-2450 — first member replaces the fd.
9434 unsafe {
9435 libc::dup2(tfd, fd);
9436 }
9437 }
9438 target_fds.push(tfd);
9439 }
9440 Err(e) => {
9441 // c:Src/exec.c:3741 — `zwarn("%e: %s", errno, fname)`:
9442 // zwarning supplies the `name:LINE:` prefix with the
9443 // REAL current lineno; redir_errno_msg builds the `%e`
9444 // errno message (was a hardcoded ErrorKind match that
9445 // showed generic "redirect failed" for EROFS/etc.).
9446 let msg = redir_errno_msg(&e);
9447 crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
9448 // Close already-opened fds to avoid leaks.
9449 for prev in &target_fds {
9450 unsafe {
9451 libc::close(*prev);
9452 }
9453 }
9454 with_executor(|exec| {
9455 exec.redirect_failed = true;
9456 });
9457 return Value::Status(1);
9458 }
9459 }
9460 }
9461
9462 // Create the splitter pipe.
9463 let (read_end, write_end) = match os_pipe::pipe() {
9464 Ok(p) => p,
9465 Err(_) => {
9466 for f in &target_fds {
9467 unsafe {
9468 libc::close(*f);
9469 }
9470 }
9471 return Value::Status(1);
9472 }
9473 };
9474 // c:Src/exec.c:5222 — `pp[0] = movefd(pp[0]);` in `mpipe()`.
9475 // c:Src/utils.c:1990-2012 movefd — "if(fd != -1 && fd < 10)"
9476 // dup into the >=10 range and zclose the low copy, then mark
9477 // the result FDT_INTERNAL. Every shell-internal fd goes
9478 // through this so it can never share a number with the
9479 // user-visible `>&N` range that the redirect bookkeeping
9480 // opens/dups/closes. This splitter kept the raw (low) pipe
9481 // read end, so an unrelated close of that number shut it
9482 // under the splitter thread and dropping the owned
9483 // PipeReader aborted the process with std's "IO Safety
9484 // violation: owned file descriptor already closed"
9485 // (E01options.ztst:46 `( echo hello ) >a >b`, ~2 runs in 3).
9486 let read_end = unsafe {
9487 <os_pipe::PipeReader as std::os::unix::io::FromRawFd>::from_raw_fd(
9488 crate::extensions::fds::movefd(read_end.into_raw_fd()),
9489 )
9490 };
9491 let pipe_write_raw = AsRawFd::as_raw_fd(&write_end);
9492 // Spawn the splitter thread: read pipe → write every chunk
9493 // to every target fd. Each write inside the thread uses
9494 // libc::write directly on the raw fd (no Rust File ownership
9495 // so the splitter can close after EOF without racing main).
9496 let target_fds_for_thread = target_fds.clone();
9497 let handle = std::thread::spawn(move || {
9498 let mut r = read_end;
9499 let mut buf = [0u8; 8192];
9500 loop {
9501 match std::io::Read::read(&mut r, &mut buf) {
9502 Ok(0) => break,
9503 Ok(n) => {
9504 for &tfd in &target_fds_for_thread {
9505 let mut off = 0;
9506 while off < n {
9507 let w = unsafe {
9508 libc::write(
9509 tfd,
9510 buf[off..n].as_ptr() as *const libc::c_void,
9511 n - off,
9512 )
9513 };
9514 if w <= 0 {
9515 break;
9516 }
9517 off += w as usize;
9518 }
9519 }
9520 }
9521 Err(_) => break,
9522 }
9523 }
9524 // Close every target so file contents flush.
9525 for tfd in target_fds_for_thread {
9526 unsafe {
9527 libc::close(tfd);
9528 }
9529 }
9530 });
9531
9532 // Dup the pipe write-end onto the target fd; close the
9533 // original write_end so EOF arrives when host_redirect_scope_end
9534 // closes our tracked pipe_write_fd.
9535 let write_dup = unsafe { libc::fcntl(pipe_write_raw, libc::F_DUPFD, 10) };
9536 drop(write_end);
9537 if write_dup < 0 {
9538 return Value::Status(1);
9539 }
9540 unsafe {
9541 libc::dup2(write_dup, fd);
9542 libc::close(write_dup);
9543 }
9544 // Track the running splitter so scope-end can drain + join.
9545 // The "write_fd" we store is the user-visible fd (e.g. 1).
9546 // Closing that fd at scope-end isn't quite right; we need a
9547 // way to send EOF. Solution: track the write_dup we just
9548 // closed; instead keep a second dup for the close-on-end.
9549 // Shell-internal bookkeeping fd — above the script's range (movefd).
9550 let close_on_end = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
9551 with_executor(|exec| {
9552 if let Some(top) = exec.multios_scope_stack.last_mut() {
9553 top.push((close_on_end, handle));
9554 } else {
9555 // No scope — leak the dup; thread will keep running
9556 // until process exit. Should not happen because
9557 // host_redirect_scope_begin pushed a frame.
9558 unsafe { libc::close(close_on_end) };
9559 }
9560 });
9561 Value::Status(0)
9562 });
9563 // c:Src/exec.c:2418 input-arm — MULTIOS read fan-in. Stack
9564 // layout pushed by compile_zsh (mirrors the write side):
9565 // [source_1, op_1, source_2, op_2, …, source_N, op_N, fd]
9566 // argc = 2N + 1; op distinguishes file opens (READ) from numeric
9567 // `<&N` dups (DUP_READ); a glob source arrives as Value::Array
9568 // and splices into one member per match (c:Src/glob.c:2195-2203).
9569 // Opens every source, sets up a pipe + producer thread that
9570 // reads each source in order and writes to the pipe write-end,
9571 // then closes its write-end so the consumer gets EOF. dup2 the
9572 // pipe read-end onto fd. Bug #36 input side in docs/BUGS.md.
9573 vm.register_builtin(BUILTIN_MULTIOS_READ, |vm, argc| {
9574 if argc < 3 || argc % 2 == 0 {
9575 return Value::Status(1);
9576 }
9577 let fd = vm.pop().to_int() as i32;
9578 let n_sources = ((argc - 1) / 2) as usize;
9579 let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_sources);
9580 for _ in 0..n_sources {
9581 let op_byte = vm.pop().to_int() as u8;
9582 let source = vm.pop();
9583 pairs.push((op_byte, source));
9584 }
9585 pairs.reverse();
9586
9587 // Splice glob match arrays (c:Src/glob.c:2195-2203).
9588 let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
9589 for (op_byte, source) in pairs {
9590 match source {
9591 Value::Array(items) => {
9592 for item in items.iter() {
9593 entries.push((op_byte, item.to_str()));
9594 }
9595 }
9596 other => entries.push((op_byte, other.to_str())),
9597 }
9598 }
9599 if entries.is_empty() {
9600 return Value::Status(1);
9601 }
9602
9603 // c:Src/exec.c:2418 — `unset(MULTIOS)`: sequential replace,
9604 // last source wins (`unsetopt multios; cat < a < b` reads
9605 // only b; a is still opened — and errors still surface).
9606 let multios_on = opt_state_get("multios").unwrap_or(true);
9607 if !multios_on {
9608 with_executor(|exec| {
9609 for (op_byte, source) in &entries {
9610 exec.host_apply_redirect(fd as u8, *op_byte, source);
9611 if exec.redirect_failed {
9612 break;
9613 }
9614 }
9615 });
9616 return Value::Status(0);
9617 }
9618
9619 if entries.len() == 1 {
9620 // Single member after splicing — plain replace.
9621 let (op_byte, source) = &entries[0];
9622 with_executor(|exec| {
9623 exec.host_apply_redirect(fd as u8, *op_byte, source);
9624 });
9625 return Value::Status(0);
9626 }
9627
9628 // Save current fd state for scope-end restoration — BEFORE
9629 // the first member's replace dup2 below.
9630 // c:Src/exec.c:2425 — `int fdN = movefd(fd1); save[fd1] = fdN;`. A SAVED
9631 // descriptor is shell state and must live above the script's fd range:
9632 // plain dup() returns the LOWEST free fd, which parked the saved stdout
9633 // on fd 3, so `print -u 3 -r -- X 2>/dev/null` wrote into the shell's own
9634 // saved descriptor and reported success where zsh says `bad file number`.
9635 // F_DUPFD with a floor of 10 is exactly what movefd does.
9636 let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
9637 if saved >= 0 {
9638 with_executor(|exec| {
9639 if let Some(top) = exec.redirect_scope_stack.last_mut() {
9640 top.push((fd, saved));
9641 } else {
9642 unsafe { libc::close(saved) };
9643 }
9644 });
9645 }
9646
9647 // Open every source in redirect order; numeric `<&N` dups
9648 // resolve against the LIVE fd table. First member replaces
9649 // the fd (c:2448-2450) so later self-dups see it.
9650 let mut source_fds: Vec<i32> = Vec::with_capacity(entries.len());
9651 for (i, (op_byte, source)) in entries.iter().enumerate() {
9652 let open_result: std::io::Result<i32> = match *op_byte {
9653 r::DUP_READ | r::DUP_WRITE => match source.trim_start_matches('&').parse::<i32>() {
9654 Ok(src) => {
9655 let d = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
9656 if d >= 0 {
9657 Ok(d)
9658 } else {
9659 Err(std::io::Error::last_os_error())
9660 }
9661 }
9662 Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
9663 },
9664 _ => fs::File::open(source).map(|f| f.into_raw_fd()),
9665 };
9666 match open_result {
9667 Ok(tfd) => {
9668 if i == 0 {
9669 unsafe {
9670 libc::dup2(tfd, fd);
9671 }
9672 }
9673 source_fds.push(tfd);
9674 }
9675 Err(e) => {
9676 let msg = match e.kind() {
9677 std::io::ErrorKind::PermissionDenied => "permission denied",
9678 std::io::ErrorKind::NotFound => "no such file or directory",
9679 _ => "open failed",
9680 };
9681 // c:Src/exec.c:3741 — zwarn with real lineno prefix.
9682 crate::ported::utils::zwarn(&format!("{}: {}", msg, source));
9683 for prev in &source_fds {
9684 unsafe {
9685 libc::close(*prev);
9686 }
9687 }
9688 with_executor(|exec| {
9689 exec.redirect_failed = true;
9690 });
9691 return Value::Status(1);
9692 }
9693 }
9694 }
9695
9696 // Create the concatenator pipe.
9697 let (read_end, write_end) = match os_pipe::pipe() {
9698 Ok(p) => p,
9699 Err(_) => {
9700 for f in &source_fds {
9701 unsafe {
9702 libc::close(*f);
9703 }
9704 }
9705 return Value::Status(1);
9706 }
9707 };
9708 // dup the pipe read-end onto fd before spawning the
9709 // producer; close the original read_end so the consumer
9710 // (reading via fd) is the sole reference until scope-end.
9711 let read_dup = unsafe { libc::dup(AsRawFd::as_raw_fd(&read_end)) };
9712 drop(read_end);
9713 if read_dup < 0 {
9714 for f in &source_fds {
9715 unsafe {
9716 libc::close(*f);
9717 }
9718 }
9719 return Value::Status(1);
9720 }
9721 unsafe {
9722 libc::dup2(read_dup, fd);
9723 libc::close(read_dup);
9724 }
9725 // Spawn the producer.
9726 let source_fds_for_thread = source_fds.clone();
9727 let handle = std::thread::spawn(move || {
9728 let mut w = write_end;
9729 let mut buf = [0u8; 8192];
9730 for sfd in source_fds_for_thread {
9731 loop {
9732 let n = unsafe {
9733 libc::read(sfd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
9734 };
9735 if n <= 0 {
9736 break;
9737 }
9738 let n = n as usize;
9739 if std::io::Write::write_all(&mut w, &buf[..n]).is_err() {
9740 break;
9741 }
9742 }
9743 unsafe {
9744 libc::close(sfd);
9745 }
9746 }
9747 // Closing w (the write_end) at scope drop signals EOF
9748 // to the consumer.
9749 });
9750 with_executor(|exec| {
9751 // Track using a closed-write sentinel — the producer
9752 // owns write_end so we just need to join. Use -1 fd
9753 // marker meaning "no fd to close".
9754 if let Some(top) = exec.multios_scope_stack.last_mut() {
9755 top.push((-1, handle));
9756 } else {
9757 let _ = handle.join();
9758 }
9759 });
9760 Value::Status(0)
9761 });
9762 // c:Src/exec.c:3978-3986 — nullexec==1 marker. See the const's
9763 // doc block. Arg: 1 = entering a bare-exec redirect, 0 = leaving.
9764 vm.register_builtin(BUILTIN_EXEC_PERM_REDIRS, |vm, _argc| {
9765 let on = vm.pop().to_int() != 0;
9766 with_executor(|exec| exec.exec_redirs_permanent = on);
9767 Value::Status(0)
9768 });
9769 // Bare-exec redirect epilogue — see the const's doc block.
9770 // c:Src/exec.c:252-259 (execerr) + c:4367-4386 (done: POSIX gate).
9771 vm.register_builtin(BUILTIN_EXEC_REDIR_DONE, |vm, _argc| {
9772 use std::sync::atomic::Ordering;
9773 let failed = with_executor(|exec| {
9774 let f = exec.redirect_failed;
9775 exec.redirect_failed = false;
9776 f
9777 });
9778 if !failed {
9779 return Value::Status(0);
9780 }
9781 // c:255 — `redir_err = lastval = 1`.
9782 vm.last_status = 1;
9783 if isset(crate::ported::zsh_h::POSIXBUILTINS) && !isset(crate::ported::zsh_h::INTERACTIVE) {
9784 // c:4379-4383 — non-interactive POSIX fatal: exit(1).
9785 // In-process equivalent: arm EXIT_PENDING/EXIT_VAL so the
9786 // next BUILTIN_ERREXIT_CHECK (trigger 2) unwinds the
9787 // script with status 1 — same deferred-exit shape the
9788 // `exit` builtin uses inside subshell contexts.
9789 crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
9790 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
9791 }
9792 Value::Status(1)
9793 });
9794 // c:Src/exec.c:3722-3724 — see the const's doc block. No args.
9795 vm.register_builtin(BUILTIN_PIPE_OUTPUT_MARK, |_vm, _argc| {
9796 with_executor(|exec| exec.pipe_output_pending = true);
9797 Value::Status(0)
9798 });
9799 // c:Src/exec.c:3710-3724 — install this pipeline stage's fds.
9800 // /* Make a copy of stderr for xtrace output before redirecting */
9801 // fflush(xtrerr);
9802 // ...
9803 // /* Add pipeline input/output to mnodes */
9804 // if (input) addfd(forked, save, mfds, 0, input, 0, NULL);
9805 // if (output) addfd(forked, save, mfds, 1, output, 1, NULL);
9806 // Emitted into the stage chunk by compile_zsh.rs (after the arg
9807 // words' expansion ops, before the redirect scope), and fed by
9808 // BUILTIN_RUN_PIPELINE via `stage_fds_park`. Doing the dup2 HERE
9809 // rather than before the chunk runs is what makes a `$(...)` in a
9810 // stage's arguments see the shell's fd 0 instead of the pipe.
9811 vm.register_builtin(BUILTIN_PIPE_FDS_INSTALL, |vm, argc| {
9812 // Arg: `|&` merge-stderr flag (compile_zsh always passes it).
9813 let merge_stderr = pop_args(vm, argc)
9814 .first()
9815 .map(|s| s != "0" && !s.is_empty())
9816 .unwrap_or(false);
9817 let (in_fd, out_fd) = stage_fds_take();
9818 if in_fd < 0 && out_fd < 0 {
9819 return Value::Status(0);
9820 }
9821 // c:3711 `fflush(xtrerr)` — flush before the fds move, so
9822 // anything buffered from the expansion phase lands on the
9823 // ORIGINAL fd, not on the pipe.
9824 let _ = std::io::stdout().flush();
9825 let _ = std::io::stderr().flush();
9826 unsafe {
9827 if in_fd >= 0 {
9828 libc::dup2(in_fd, libc::STDIN_FILENO);
9829 if in_fd != libc::STDIN_FILENO {
9830 libc::close(in_fd);
9831 }
9832 }
9833 if out_fd >= 0 {
9834 libc::dup2(out_fd, libc::STDOUT_FILENO);
9835 if out_fd != libc::STDOUT_FILENO {
9836 libc::close(out_fd);
9837 }
9838 // `cmd |& next`: the `2>&1` C appends to cmd's redirect
9839 // list (walked at c:3730+, i.e. after this addfd), so
9840 // stderr follows the pipe, not the shell's stdout.
9841 if merge_stderr {
9842 libc::dup2(libc::STDOUT_FILENO, libc::STDERR_FILENO);
9843 }
9844 }
9845 }
9846 Value::Status(0)
9847 });
9848 // c:Src/exec.c — block-level redirect-failure gate. When a
9849 // compound command (`{ … } < file`, `( … ) > file`, etc.) has a
9850 // failing redirect (e.g. `< /nonexistent`), zsh skips the entire
9851 // body AND sets lastval to 1. The simple-command path's
9852 // redirect_failed check (line 215-221 above) only catches the
9853 // failure when a builtin dispatches and is consumed by that
9854 // single builtin call — so a multi-statement block kept running
9855 // its remaining statements after the redir error. Emit-side at
9856 // compile_zsh.rs::compile_command's Redirected arm pairs this
9857 // with a JumpIfTrue → WithRedirectsEnd to abandon the body.
9858 vm.register_builtin(BUILTIN_REDIRECT_FAILED_CHECK, |vm, _argc| {
9859 let failed = with_executor(|exec| {
9860 let f = exec.redirect_failed;
9861 exec.redirect_failed = false;
9862 f
9863 });
9864 if failed {
9865 vm.last_status = 1;
9866 Value::Int(1)
9867 } else {
9868 Value::Int(0)
9869 }
9870 });
9871 // c:Src/exec.c — drop-in replacement for fusevm's Op::Exec used by
9872 // the dynamic-first-word path (`$cmd`, `$(cmd)`, glob-named cmds).
9873 // fusevm's Op::Exec returns Value::Status(0) when post-expansion
9874 // argv is empty (vm.rs:1722) — that clobbers \$? for the
9875 // `\$(exit 1); echo \$?` case where the cmd-subst left
9876 // last_status = 1 but the empty expansion gets exec'd to 0.
9877 // Mirror C zsh: when the word list is empty after expansion,
9878 // \$? becomes whatever the inner cmd-subst's last_status is
9879 // (preserved here by returning Value::Status(last_status)).
9880 // c:Src/cond.c:308-316 — `if (!(pprog = patcompile(right, ...)))
9881 // { zwarnnam(fromtest, "bad pattern: %s", right); return 2; }`.
9882 // The cond path must NOT use str_match/glob_match_static: the
9883 // case-statement consumer of those follows Src/loop.c:667 zerr
9884 // semantics (errflag abort), while cond is a zwarn + status-2
9885 // soft failure. COND_BAD_PATTERN carries the 2 across the
9886 // Bool-shaped stack contract (so `!=`'s LogNot can't lose it).
9887 thread_local! {
9888 static COND_BAD_PATTERN: std::cell::Cell<bool> =
9889 const { std::cell::Cell::new(false) };
9890 }
9891 vm.register_builtin(BUILTIN_COND_STRMATCH, |vm, _argc| {
9892 let pat = pattern_filesub(&vm.pop().to_str());
9893 let s = vm.pop().to_str();
9894 // bash `shopt -s nocasematch` → case-insensitive `[[ == ]]` / `[[ != ]]`.
9895 // Lowercase BOTH sides for the match decision (glob metacharacters are
9896 // not letters, so the pattern's `*`/`?`/`[…]` structure is preserved).
9897 // No-op unless the bash shopt is active. --zsh unaffected.
9898 let (s, pat) = if crate::dash_mode::nocasematch() {
9899 (s.to_lowercase(), pat.to_lowercase())
9900 } else {
9901 (s, pat)
9902 };
9903 let mut pat_tok = pat.clone();
9904 crate::ported::glob::tokenize(&mut pat_tok);
9905 if crate::ported::pattern::patcompile(
9906 &pat_tok,
9907 crate::ported::zsh_h::PAT_STATIC as i32,
9908 None,
9909 )
9910 .is_none()
9911 {
9912 // c:314 — zwarnnam(fromtest, "bad pattern: %s", right).
9913 crate::ported::utils::zwarn(&format!("bad pattern: {}", pat));
9914 COND_BAD_PATTERN.with(|c| c.set(true));
9915 return Value::Bool(false);
9916 }
9917 // Match via the shared engine so `(#b)`/`(#m)` backref and
9918 // MATCH-variable population stays in one place.
9919 Value::Bool(crate::vm_helper::glob_match_static(&s, &pat))
9920 });
9921 vm.register_builtin(BUILTIN_COND_UNKNOWN, |vm, _argc| {
9922 // c:Src/cond.c:150-188 — `zwarnnam(fromtest, "unknown condition: %s",
9923 // name)` for a `-X` op with no matching cond module. Like a cond
9924 // syntax error it yields status 2 and aborts: arm COND_BAD_PATTERN so
9925 // the downstream BUILTIN_COND_STATUS_FROM_BOOL carries the 2 across the
9926 // Bool-shaped stack and runs the shared errflag+set_last_status(2)+abort
9927 // path (c:Src/exec.c:5216-5221). Returns Bool(false) as the operand.
9928 let op = vm.pop().to_str();
9929 crate::ported::utils::zerr(&format!("unknown condition: {}", op));
9930 COND_BAD_PATTERN.with(|c| c.set(true));
9931 Value::Bool(false)
9932 });
9933 vm.register_builtin(BUILTIN_COND_STATUS_FROM_BOOL, |vm, _argc| {
9934 // `${~pat}` / `${(P)~pat}` inside a `[[ … ]]` operand flips
9935 // GLOB_SUBST on via the tilde carrier so the pattern match sees
9936 // active metacharacters. In C that flag is prefork-scoped and
9937 // gone once the operand is consumed; zshrs restores it at the
9938 // next command-dispatch boundary, but a bare `[[ … ]]` has no
9939 // trailing assignment to trigger that — so globsubst leaked ON
9940 // into the NEXT command's word expansion, filename-generating a
9941 // scalar value it should not (p10k `_p9k_set_prompt`: line 45
9942 // `[[ … != ${(P)~disabled} ]]` leaked into line 46's
9943 // `local val=$arr[idx]`, whose glob-char-laden value then hit
9944 // "no matches found" and aborted the whole prompt build →
9945 // garbled 25-line prompt / interactive hang). Consume the
9946 // carrier here: this builtin ends EVERY `[[ … ]]`, and runs
9947 // after the operands (and their pattern match) are done.
9948 consume_tilde_globsubst_carrier();
9949 let ok = vm.pop().to_int() != 0;
9950 let bad = COND_BAD_PATTERN.with(|c| {
9951 let b = c.get();
9952 c.set(false);
9953 b
9954 });
9955 if bad {
9956 // c:Src/exec.c:5216-5221 — `stat = evalcond(...);
9957 // /* 2 indicates a syntax error. For compatibility,
9958 // turn this into a shell error. */
9959 // if (stat == 2) errflag |= ERRFLAG_ERROR;`
9960 // The errflag abort exits the script with lastval (2),
9961 // matching `zsh -fc '[[ x == [a- ]]; print rc=$?'`
9962 // printing nothing after the diagnostic and exiting 2.
9963 crate::ported::utils::errflag.fetch_or(
9964 crate::ported::zsh_h::ERRFLAG_ERROR,
9965 std::sync::atomic::Ordering::Relaxed,
9966 );
9967 with_executor(|exec| exec.set_last_status(2));
9968 return Value::Int(2); // c:Src/cond.c:316 `return 2;`
9969 }
9970 let status: i32 = if ok { 0 } else { 1 };
9971 // c:Src/exec.c:5216 — `lastval = evalcond(...)`: the conditional's
9972 // result IS the command's lastval, and c:Src/cond.c's evalcond
9973 // never inspects errflag while evaluating. So when a `[[ … ]]`
9974 // operand raised errflag (e.g. a nounset "parameter not set" zerr
9975 // on `${arr[99]}` under NO_UNSET), zsh STILL completes the test and
9976 // exits with the cond result; the errflag only aborts the FOLLOWING
9977 // commands. Sync the result to the executor's live lastval HERE —
9978 // the nounset site left it at a transient 1, and the next
9979 // BUILTIN_ERREXIT_CHECK reads the executor (not vm.last_status), so
9980 // without this sync `setopt NO_UNSET; [[ -z ${arr[99]} ]]` exited 1
9981 // instead of 0. The Op::SetStatus that follows sets vm.last_status;
9982 // this keeps the executor coherent with it before the abort check.
9983 with_executor(|exec| exec.set_last_status(status));
9984 Value::Int(status as i64)
9985 });
9986 vm.register_builtin(BUILTIN_USE_CMDOUTVAL_RESET, |_vm, _argc| {
9987 crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
9988 Value::Status(0)
9989 });
9990
9991 vm.register_builtin(BUILTIN_EXEC_DYNAMIC, |vm, argc| {
9992 let raw = pop_args(vm, argc);
9993 // Flatten Array entries into argv slots (matches fusevm
9994 // Op::Exec's flatten at vm.rs:1660-1665) so `${arr[@]}` /
9995 // splice expansions produce one argv slot per element.
9996 let args: Vec<String> = raw.into_iter().collect();
9997 // c:Src/subst.c paramsubst — when `${var:?msg}` or
9998 // `${var?msg}` set errflag, the expansion may produce empty
9999 // argv[0] which would fall into the EACCES/permission-denied
10000 // path below, masking the real paramsubst diagnostic with a
10001 // spurious "permission denied:" line and rc=126. Honour
10002 // errflag so the simple command ends with the paramsubst
10003 // error as the sole diagnostic, rc=1. Bug #86.
10004 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
10005 & crate::ported::zsh_h::ERRFLAG_ERROR)
10006 != 0
10007 {
10008 return Value::Status(1);
10009 }
10010 if args.is_empty() {
10011 // c:Src/exec.c:3442 — a command whose words expand to ZERO
10012 // words is a NULL command: `cmdoutval = use_cmdoutval ?
10013 // lastval : 0`. `use_cmdoutval` is set (below, in
10014 // BUILTIN_CMD_SUBST_TEXT) only when a command substitution
10015 // ran during this command's word expansion, so:
10016 // `false; $(exit 5)` → keep the subst status (5)
10017 // `false; $nonexistent` → reset to 0 (null command).
10018 // The previous port unconditionally kept `$?`, so
10019 // `false; $unset` wrongly stayed 1 (A01grammar.ztst:5).
10020 let keep =
10021 crate::ported::exec::use_cmdoutval.load(std::sync::atomic::Ordering::Relaxed) != 0;
10022 let status = if keep { vm.last_status } else { 0 };
10023 crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
10024 return Value::Status(status);
10025 }
10026 if args[0].is_empty() {
10027 // Explicit empty command word — exec returns EACCES.
10028 let script_name =
10029 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
10030 let lineno: u64 = with_executor(|exec| {
10031 exec.scalar("LINENO")
10032 .and_then(|s| s.parse::<u64>().ok())
10033 .unwrap_or(1)
10034 });
10035 eprintln!("{}:{}: permission denied: ", script_name, lineno);
10036 return Value::Status(126);
10037 }
10038 // AOP intercepts (zshrs extension, no C counterpart) — same
10039 // gate as host_exec_external (the static-head path): dynamic
10040 // command names (`cmd=/bin/echo; $cmd payload`) must consult
10041 // registered intercepts before dispatch, else `intercept
10042 // before /bin/echo ...` fires for the literal spelling but
10043 // not the variable one. run_intercepts runs before-advice
10044 // in-place and returns None to continue; Some(status) means
10045 // an around/after advice fully handled the command.
10046 let intercepted = with_executor(|exec| {
10047 if exec.intercepts.is_empty() {
10048 return None;
10049 }
10050 let full_cmd = if args.len() == 1 {
10051 args[0].clone()
10052 } else {
10053 args.join(" ")
10054 };
10055 let rest: Vec<String> = args[1..].to_vec();
10056 exec.run_intercepts(&args[0], &full_cmd, &rest)
10057 });
10058 if let Some(result) = intercepted {
10059 return Value::Status(result.unwrap_or(127));
10060 }
10061 // zshrs-original opcode builtins (async, doctor, peach, …) reached
10062 // via a run-time-resolved head (`$var`): they are absent from the
10063 // static BUILTINS port table / builtintab, so execcmd_exec below would
10064 // treat the head as external and report "command not found" — even
10065 // though `whence` calls it a builtin and a literal head runs it via
10066 // CallBuiltin. Dispatch by name here, but ONLY when the head is neither
10067 // a user function nor a ported builtin, so the shell's
10068 // function -> builtin -> external order is preserved.
10069 if let Some(head) = args.first() {
10070 let is_fn = with_executor(|e| e.function_exists(head));
10071 let is_ported =
10072 crate::ported::builtin::createbuiltintable().contains_key(head.as_str());
10073 if !is_fn && !is_ported {
10074 if let Some(status) = try_run_registered_builtin(head, &args[1..]) {
10075 crate::ported::builtin::LASTVAL
10076 .store(status, std::sync::atomic::Ordering::Relaxed);
10077 return Value::Status(status);
10078 }
10079 }
10080 }
10081 // c:Src/exec.c:2900 execcmd_exec — canonical simple-command
10082 // dispatcher. Runs precmd-modifier walk (c:3013-3091), then
10083 // dispatches to execbuiltin (c:4233) / runshfunc (c:3431+) /
10084 // execute (c:4314) per the resolved head. zshrs's bytecode VM
10085 // expanded the args before reaching here; we feed them in via
10086 // eparams.args and let execcmd_exec do the rest exactly as C
10087 // does for static heads. Without this, `c=builtin; $c source X`
10088 // skipped the precmd walk and emitted "command not found:
10089 // builtin".
10090 let mut state = crate::ported::zsh_h::estate {
10091 prog: Box::<crate::ported::zsh_h::eprog>::default(),
10092 pc: 0,
10093 strs: None,
10094 strs_offset: 0,
10095 };
10096 let mut eparams = crate::ported::zsh_h::execcmd_params {
10097 args: Some(args),
10098 redir: None,
10099 beg: 0,
10100 varspc: None,
10101 assignspc: None,
10102 typ: crate::ported::zsh_h::WC_SIMPLE as i32,
10103 postassigns: 0,
10104 htok: 0,
10105 };
10106 // input/output=0 → no pipe redirection (use shell stdio
10107 // directly); `output != 0` at c:2988 forks immediately. last1=2
10108 // (c:Src/exec.c:2014 `last1 ? 1 : 2`): terminal pipe stage but
10109 // the shell IS needed afterward — the VM keeps executing
10110 // bytecode after this op. last1=1 would arm the fake-exec
10111 // optimization (c:3646-3651, gate at c:3662 `last1 != 1`),
10112 // making `execute()` execve THIS process for external heads:
10113 // `p=/bin/echo; $p hi; echo after` replaced the shell and
10114 // `after` never ran (D04parameter chunk 11 shell-killer).
10115 // c:Src/exec.c:1690-1700 — execpline's job frame: save thisjob
10116 // (`pj = thisjob`) and allocate the jobtab slot that
10117 // execcmd_fork's addproc (c:2853) hangs the child pid off.
10118 // Without a live thisjob, the fork at c:3662 (last1 != 1 →
10119 // external must fork) registers no proc, nothing waits, and
10120 // the child races the rest of the script.
10121 let pj = {
10122 use crate::ported::jobs;
10123 *jobs::THISJOB
10124 .get_or_init(|| std::sync::Mutex::new(-1))
10125 .lock()
10126 .unwrap_or_else(|e| e.into_inner())
10127 };
10128 let newjob = {
10129 use crate::ported::jobs;
10130 let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
10131 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
10132 jobs::initjob(&mut tab) // c:1700 `thisjob = newjob = initjob()`
10133 };
10134 {
10135 use crate::ported::jobs;
10136 *jobs::THISJOB
10137 .get_or_init(|| std::sync::Mutex::new(-1))
10138 .lock()
10139 .unwrap_or_else(|e| e.into_inner()) = newjob as i32;
10140 }
10141 crate::ported::exec::execcmd_exec(
10142 &mut state,
10143 &mut eparams,
10144 0, // input (c:2989)
10145 0, // output (c:2988)
10146 crate::ported::zsh_h::Z_SYNC as i32, // how
10147 2, // last1=2 — shell continues (c:2014)
10148 -1, // close_if_forked
10149 );
10150 // c:Src/exec.c:1828-1835 — execpline's Z_SYNC tail: waitjobs()
10151 // reaps the forked external. c:Src/jobs.c:487-495 + 551-552 —
10152 // the job's LAST proc sets lastval (0200|sig when signalled,
10153 // else WEXITSTATUS). Builtin/shfunc heads never forked (job
10154 // has no procs) — LASTVAL was already set by execbuiltin /
10155 // doshfunc inside execcmd_exec; skip the wait.
10156 {
10157 use crate::ported::jobs;
10158 let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
10159 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
10160 if jobs::hasprocs(&tab, newjob) {
10161 jobs::waitjobs(&mut tab, newjob); // c:1835
10162 if let Some(p) = tab[newjob].procs.last() {
10163 let val = if p.is_signaled() {
10164 0o200 | p.term_sig() // c:Src/jobs.c:489-490
10165 } else {
10166 p.exit_status() // c:Src/jobs.c:494
10167 };
10168 crate::ported::builtin::LASTVAL
10169 .store(val, std::sync::atomic::Ordering::Relaxed);
10170 }
10171 }
10172 // c:1977-1979 — `deletejob(jn, 0)` once done; c:1981
10173 // `thisjob = pj` restores the caller's job.
10174 if newjob < tab.len() {
10175 jobs::deletejob(&mut tab[newjob], false);
10176 }
10177 *jobs::THISJOB
10178 .get_or_init(|| std::sync::Mutex::new(-1))
10179 .lock()
10180 .unwrap_or_else(|e| e.into_inner()) = pj;
10181 }
10182 let status = crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
10183 let mut synth = crate::ported::zsh_h::job::default();
10184 crate::ported::jobs::waitonejob(&mut synth);
10185 Value::Status(status)
10186 });
10187 // c:Src/exec.c:3386-3419 — `< file` / `> file` with no command
10188 // word. Resolves NULLCMD/READNULLCMD at runtime, then dispatches the
10189 // resulting word the way execcmd's fall-through does (shell function →
10190 // builtin → external). Redirects are already applied by the surrounding
10191 // WithRedirectsBegin scope.
10192 vm.register_builtin(BUILTIN_NULLCMD_EXEC, |vm, argc| {
10193 let args = pop_args(vm, argc);
10194 let is_single_read = args
10195 .first()
10196 .map(|s| s != "0" && !s.is_empty())
10197 .unwrap_or(false);
10198 // c:Src/exec.c — when the surrounding redir-open failed
10199 // (e.g. `< /nonexistent`), zerr already printed the diag
10200 // and set redirect_failed. Don't invoke NULLCMD — return
10201 // status 1 like the wordcode path does.
10202 let redir_failed = with_executor(|exec| {
10203 let f = exec.redirect_failed;
10204 exec.redirect_failed = false;
10205 f
10206 });
10207 if redir_failed {
10208 crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
10209 return Value::Status(1);
10210 }
10211 let nullcmd = crate::ported::params::getsparam("NULLCMD");
10212 let nc_str = nullcmd.as_deref().unwrap_or("");
10213 let nc_empty = nc_str.is_empty();
10214 // c:3340-3344 — CSHNULLCMD or no NULLCMD set → diagnostic.
10215 if nc_empty || crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLCMD) {
10216 let script_name =
10217 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
10218 let lineno: u64 = with_executor(|exec| {
10219 exec.scalar("LINENO")
10220 .and_then(|s| s.parse::<u64>().ok())
10221 .unwrap_or(1)
10222 });
10223 eprintln!("{}:{}: redirection with no command", script_name, lineno);
10224 return Value::Status(1);
10225 }
10226 // c:3350 — SHNULLCMD → run `:`.
10227 let cmd: String = if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHNULLCMD) {
10228 ":".to_string()
10229 } else if is_single_read {
10230 // c:3354-3359 — single REDIR_READ + READNULLCMD set → readnullcmd.
10231 let rnc = crate::ported::params::getsparam("READNULLCMD");
10232 let rnc_str = rnc.as_deref().unwrap_or("");
10233 if !rnc_str.is_empty() {
10234 rnc_str.to_string()
10235 } else {
10236 nc_str.to_string() // c:3360-3363 fallback
10237 }
10238 } else {
10239 nc_str.to_string() // c:3360-3363
10240 };
10241 // c:Src/exec.c:3408/3414/3418 — C does not "run NULLCMD" as a special
10242 // case: it APPENDS the word to the command's arg list
10243 // (`addlinknode(args, dupstring(nullcmd))`) and falls through to
10244 // execcmd's ORDINARY dispatch, which resolves that word in this order:
10245 // c:3484-3487 `shfunctab->getnode(shfunctab, cmdarg)` → shell function
10246 // c:3489 `builtintab->getnode(builtintab, cmdarg)` → builtin
10247 // otherwise → external command (PATH lookup).
10248 // `host_exec_external` already implements the shell-function arm and
10249 // the external arm (plus the AOP intercepts and the module-builtin
10250 // name arms), so only the builtintab arm has to be decided here.
10251 //
10252 // The builtintab question must be asked of the TABLE.
10253 // `builtin_in_builtintab` alone is NOT a membership test — it is the
10254 // module *gate*, and `builtin_owning_module` returns None for any name
10255 // it does not know, whose `None => true` arm
10256 // (src/extensions/ext_builtins.rs:179-182) then reports EVERY string as
10257 // an available builtin. With that as the only predicate, the default
10258 // `READNULLCMD=more` (config.h DEFAULT_READNULLCMD), a user's
10259 // `READNULLCMD=less`, `NULLCMD=/bin/cat` and every other external name
10260 // were classified as core builtins and handed to
10261 // `dispatch_builtin_raw("more", vec![])`, which cannot work: plain
10262 // `< file` printed NOTHING and returned 1, and a missing NULLCMD
10263 // returned a silent 1 instead of `command not found` / 127.
10264 //
10265 // Membership first, gate second. The zshrs-original coreutils-shaped
10266 // builtins (`cat`, `basename`, … EXT_BUILTIN_NAMES) are not entries of
10267 // `createbuiltintable()` at all, so the documented `NULLCMD=cat` /
10268 // `READNULLCMD=cat` idioms keep reaching the real `/bin/cat` the way
10269 // `zsh -f` does without needing an explicit exclusion.
10270 //
10271 // `dispatch_builtin` (not `dispatch_builtin_raw`) is the correct entry:
10272 // C's `builtintab->getnode` filters DISABLED nodes, so `disable :;
10273 // NULLCMD=:; > f` must fall through to PATH — the raw dispatcher
10274 // deliberately bypasses that set (it is what `builtin NAME` uses).
10275 let is_shfunc = with_executor(|exec| exec.function_exists(&cmd)); // c:3485
10276 let is_builtin = crate::ported::builtin::createbuiltintable().contains_key(&cmd)
10277 && crate::extensions::ext_builtins::builtin_in_builtintab(&cmd); // c:3489
10278 let status = if !is_shfunc && is_builtin {
10279 dispatch_builtin(&cmd, Vec::new()) // c:3489-3504
10280 } else {
10281 with_executor(|exec| exec.host_exec_external(&[cmd]))
10282 };
10283 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
10284 Value::Status(status)
10285 });
10286 // c:Src/exec.c:3342 — `zerr("redirection with no command")`.
10287 // Bare prefix-keyword (`builtin`, `command`, `exec`, `noglob`,
10288 // `nocorrect`) with a redirect but no command word. Emits the
10289 // canonical diagnostic via zerr (which sets errflag) and
10290 // returns Status(1). Bug #534.
10291 vm.register_builtin(BUILTIN_REDIR_NO_CMD, |_vm, _argc| {
10292 crate::ported::utils::zerr("redirection with no command");
10293 Value::Status(1)
10294 });
10295 vm.register_builtin(BUILTIN_DEBUG_TRAP, |vm, _argc| {
10296 // c:Src/signals.c:1245 dotrap(SIGDEBUG) — fires the DEBUG
10297 // trap body once per statement. The body sees the parent
10298 // shell's $? (LASTVAL). Guard against re-entry: commands
10299 // inside the DEBUG trap body would otherwise trigger
10300 // DEBUG_TRAP recursively → stack overflow. zsh guards via
10301 // its in_trap counter; we mirror with a thread-local Cell.
10302 //
10303 // c:Src/exec.c::trapcmd — before dotrap, the C source sets
10304 // `ZSH_DEBUG_CMD` to the about-to-run command text via
10305 // `dupstring(text)`. The trap body reads the parameter;
10306 // C unsets it after the trap returns. compile_list emits
10307 // the rendered statement text as the single arg here so the
10308 // shell-visible parameter reflects the command. Bug #263 in
10309 // docs/BUGS.md.
10310 // Return value: `Int(1)` tells the emit-side JumpIfTrue to SKIP the
10311 // statement this trap ran in front of — C's `donedebug == 2`
10312 // (c:Src/exec.c:1499/1511/1519-1529) plus the forced-return case
10313 // where C's list loop (`while (… && !retflag …)`, c:1443) never
10314 // reaches the command. `Int(0)` = run it.
10315 //
10316 // Two call sites, distinguished by the `mode` operand:
10317 // 0 — c:1476-1502, the DEBUG_BEFORE_CMD block that runs BEFORE the
10318 // sublist (only when the option is set),
10319 // 1 — c:1628-1644, the `sublist_done:` block that runs AFTER it
10320 // (only when the option is NOT set).
10321 // Firing the pre-sublist arm in both modes made the default
10322 // (post-command) DEBUG trap observe the NEXT statement's `$LINENO`
10323 // — A05execution:19 saw "Line 2 / Line 3" for a trap zsh reports as
10324 // "Line 1 / Line 2".
10325 let mode = vm.pop().to_int();
10326 let cmd_text = vm.pop().to_str();
10327 let before = mode == 0;
10328 DEBUG_TRAP_REENTRY.with(|c| {
10329 if c.get() {
10330 return Value::Int(0);
10331 }
10332 // c:1476 `isset(DEBUGBEFORECMD)` / c:1628 `!isset(DEBUGBEFORECMD)`.
10333 if before != isset(crate::ported::zsh_h::DEBUGBEFORECMD) {
10334 return Value::Int(0);
10335 }
10336 // c:Src/exec.c:1423 — `if (sigtrapped[SIGDEBUG] &&
10337 // isset(DEBUGBEFORECMD) && !intrap)`. Bug #573: without
10338 // this gate, every sublist boundary called
10339 // setsparam("ZSH_DEBUG_CMD", ...) even when no DEBUG trap
10340 // was set, polluting the param table and (under
10341 // WARN_CREATE_GLOBAL) emitting a spurious
10342 // `scalar parameter ZSH_DEBUG_CMD created globally`
10343 // warning at every function call.
10344 //
10345 // Two trap registries exist (per signals.rs:1481-1511 dotrap):
10346 // - settrap path → sigtrapped[SIGDEBUG] bits set
10347 // - bin_trap path → traps_table["DEBUG"] populated, sigtrapped untouched
10348 // Mirror the dotrap dispatch decision: skip only when BOTH
10349 // are absent.
10350 let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
10351 let debug_trapped = crate::ported::signals::sigtrapped
10352 .lock()
10353 .map(|v| v.get(sig_debug).copied().unwrap_or(0))
10354 .unwrap_or(0);
10355 let debug_in_table = crate::ported::builtin::traps_table()
10356 .lock()
10357 .map(|t| t.contains_key("DEBUG"))
10358 .unwrap_or(false);
10359 if debug_trapped == 0 && !debug_in_table {
10360 return Value::Int(0);
10361 }
10362 c.set(true);
10363 // c:1478-1481 — `int oerrexit_opt = opts[ERREXIT]; Param pm;
10364 // opts[ERREXIT] = 0; noerrexit |= NOERREXIT_EXIT |
10365 // NOERREXIT_RETURN;`. ERREXIT is forced OFF across the trap
10366 // body so the option can be used as the "skip this command"
10367 // signal (c:1499) without the body's own failing commands
10368 // exiting the shell.
10369 let oerrexit_opt = isset(crate::ported::zsh_h::ERREXIT); // c:1478
10370 crate::ported::options::opt_state_set("errexit", false); // c:1480
10371 let oldnoerrexit =
10372 crate::ported::exec::noerrexit.load(std::sync::atomic::Ordering::Relaxed);
10373 crate::ported::exec::noerrexit.store(
10374 oldnoerrexit
10375 | crate::ported::zsh_h::NOERREXIT_EXIT
10376 | crate::ported::zsh_h::NOERREXIT_RETURN,
10377 std::sync::atomic::Ordering::Relaxed,
10378 ); // c:1481
10379 // c:Src/exec.c:1484 — set ZSH_DEBUG_CMD scalar (PM_READONLY
10380 // is NOT set on ZSH_DEBUG_CMD, so the canonical
10381 // setsparam path is fine here — no direct paramtab
10382 // mutation needed).
10383 // c:1636 — the post-sublist arm has no ZSH_DEBUG_CMD assignment;
10384 // the parameter is a DEBUG_BEFORE_CMD feature only.
10385 if before {
10386 crate::ported::params::setsparam("ZSH_DEBUG_CMD", &cmd_text);
10387 }
10388 // c:1488/1636 — `exiting = donetrap;` … c:1493/1641 `donetrap = exiting;`
10389 let exiting = crate::ported::exec::DONETRAP.load(std::sync::atomic::Ordering::Relaxed);
10390 let ret = crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed); // c:1489
10391 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGDEBUG);
10392 // c:1491-1492 — `if (!retflag) lastval = ret;`. A trap that
10393 // ran `return N` keeps the forced status; anything else
10394 // leaves the pre-trap `$?` alone.
10395 let retflag =
10396 crate::ported::builtin::RETFLAG.load(std::sync::atomic::Ordering::Relaxed) != 0;
10397 if !retflag {
10398 crate::ported::builtin::LASTVAL.store(ret, std::sync::atomic::Ordering::Relaxed);
10399 // c:1492
10400 }
10401 crate::ported::exec::noerrexit
10402 .store(oldnoerrexit, std::sync::atomic::Ordering::Relaxed); // c:1494
10403 // c:1499 — `donedebug = isset(ERREXIT) ? 2 : 1;`. The trap
10404 // setting ERREXIT is zsh's documented "skip this command"
10405 // signal (zshmisc(1), DEBUG trap).
10406 let donedebug2 = before && isset(crate::ported::zsh_h::ERREXIT); // c:1499
10407 crate::ported::options::opt_state_set("errexit", oerrexit_opt); // c:1500/1643
10408 crate::ported::exec::DONETRAP.store(exiting, std::sync::atomic::Ordering::Relaxed); // c:1493/1641
10409 // c:Src/exec.c:1501-1502 — `if (pm) unsetparam_pm(pm, 0, 1);`
10410 if before {
10411 crate::ported::params::unsetparam("ZSH_DEBUG_CMD");
10412 }
10413 c.set(false);
10414 if retflag {
10415 // c:1443 — the enclosing list loop stops on retflag, so the
10416 // command never runs. Mirror the forced status into the VM's
10417 // own counter (the ported LASTVAL atomic is a separate store)
10418 // and report "skip" so the emit-side jump lands past the
10419 // statement, where the RETFLAG escape unwinds the function.
10420 let forced =
10421 crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
10422 vm.last_status = forced;
10423 with_executor(|exec| exec.set_last_status(forced));
10424 return Value::Int(1);
10425 }
10426 if donedebug2 {
10427 // c:1511 — `if (donedebug != 2) execsimple(state);` and
10428 // c:1519-1529 — the compound form skips the whole sublist and
10429 // sets `donetrap = 1`.
10430 crate::ported::exec::DONETRAP.store(1, std::sync::atomic::Ordering::Relaxed); // c:1527
10431 return Value::Int(1);
10432 }
10433 Value::Int(0)
10434 })
10435 });
10436
10437 // Fatal-only abort check emitted between the pipes of an `&&` / `||`
10438 // chain, where the full errexit check is suppressed. Mirrors ONLY the
10439 // errflag arm of BUILTIN_ERREXIT_CHECK below: an errflag abandons the
10440 // list in zsh, and no connector can consume it.
10441 vm.register_builtin(BUILTIN_FATAL_ABORT_CHECK, |vm, _argc| {
10442 use std::sync::atomic::Ordering;
10443 // c:Src/exec.c:1390 — `while (wc_code(code) == WC_LIST && !breaks &&
10444 // !retflag && !errflag)`: the enclosing list loops test the WHOLE
10445 // errflag. A user interrupt sets ERRFLAG_INT and never ERRFLAG_ERROR
10446 // (signals.c:457), so masking here let the rest of the list run after
10447 // an interrupt:
10448 // TRAPINT() { print T; return 1 }
10449 // f() { print A; kill -INT $$; print C }; f; print B
10450 // zsh: A T zshrs: A T B
10451 let errflag_set = crate::ported::utils::errflag.load(Ordering::Relaxed) != 0;
10452 if !errflag_set || isset(crate::ported::zsh_h::INTERACTIVE) {
10453 return Value::Int(0);
10454 }
10455 // CONTINUE_ON_ERROR: clear and keep going, as the full check does.
10456 if isset(crate::ported::zsh_h::CONTINUEONERROR) {
10457 crate::ported::utils::errflag
10458 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
10459 return Value::Int(0);
10460 }
10461 // Abort the chain with the failing command's own status intact —
10462 // a cond syntax error left lastval=2 (c:Src/exec.c:5216-5221), and
10463 // that 2 is what zsh exits with. Reading the executor's live
10464 // lastval (not forcing 1) is the same rule the full check uses.
10465 vm.last_status = with_executor(|exec| exec.last_status());
10466 Value::Int(1)
10467 });
10468 vm.register_builtin(BUILTIN_PRINT_EXIT_VALUE, |vm, argc| {
10469 // c:Src/exec.c:5498-5505 — the ANONYMOUS-FUNCTION report in
10470 // execfuncdef:
10471 // execshfunc(shf, args);
10472 // ret = lastval;
10473 // if (isset(PRINTEXITVALUE) && isset(SHINSTDIN) && lastval) {
10474 // fprintf(stderr, "zsh: exit %lld\n", lastval);
10475 // It has NO `!subsh` term, unlike execcmd_exec's site at
10476 // c:4308-4309. `argc == 1` marks that call site (the compiler
10477 // pushes a 1 before it); `argc == 0` is the per-command site.
10478 let anon_site = argc >= 1 && vm.pop().to_int() != 0;
10479 // c:Src/exec.c:4308-4316 — `if (isset(PRINTEXITVALUE) &&
10480 // isset(SHINSTDIN) && lastval && !subsh) fprintf(stderr,
10481 // "zsh: exit %lld\n", lastval);`
10482 //
10483 // SHINSTDIN keeps this to a shell reading its program from stdin
10484 // (`zsh -f < script`, the interactive shell) — `-c` and script-file
10485 // runs never report. `subsh` keeps it out of forked pipeline stages
10486 // and `(...)` subshells, which is why zsh prints nothing for
10487 // `false | true` or `(exit 3)`. A function BODY is silent for a
10488 // different reason: c:Src/exec.c:6037 `opts[PRINTEXITVALUE] = 0`
10489 // in doshfunc (ported at exec.rs), restored at c:6158.
10490 let lastval = vm.last_status; // c:4309 lastval
10491 if crate::ported::zsh_h::isset(crate::ported::zsh_h::PRINTEXITVALUE) // c:4308
10492 && crate::ported::zsh_h::isset(crate::ported::zsh_h::SHINSTDIN) // c:4308
10493 && lastval != 0 // c:4309
10494 && (anon_site
10495 || crate::ported::exec::subsh.load(std::sync::atomic::Ordering::Relaxed) == 0)
10496 // c:4309 (`!subsh`; absent at the c:5498 anon-function site)
10497 {
10498 eprintln!("zsh: exit {lastval}"); // c:4311/4313
10499 let _ = std::io::Write::flush(&mut std::io::stderr()); // c:4315 fflush(stderr)
10500 }
10501 Value::Status(0)
10502 });
10503 vm.register_builtin(BUILTIN_ERREXIT_CHECK, |vm, _argc| {
10504 // Returns Value::Int(1) when the caller should jump to the
10505 // current scope's return-patch landing (subshell-end / func-
10506 // end / chunk-end). Returns Value::Int(0) otherwise. Emit
10507 // side at `emit_errexit_check` pairs this with a JumpIfTrue
10508 // → return_patches pattern so the caller can short-circuit.
10509 //
10510 // Four triggers:
10511 // 1. RETFLAG set by a nested `return` / `exit` (eval,
10512 // sourced file, called function). Unwind THIS scope so
10513 // the flag propagates outward until something clears it.
10514 // 2. EXIT_PENDING set (mostly subshell-context exits). Same
10515 // propagation logic.
10516 // 3. `set -e` + nonzero status — the classic errexit path.
10517 // 4. errflag set in non-interactive mode — readonly
10518 // reassign, bad redirect, parse error mid-expansion etc.
10519 // Aborts the script (c:Src/init.c loop()).
10520 use std::sync::atomic::Ordering;
10521 let retflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
10522 // c:Src/exec.c:6198-6201 — "If we are in an exit trap, finish it
10523 // first... we wouldn't set exit_pending if we were already in one."
10524 // C's list loop (c:1443) never consults exit_pending at all;
10525 // EXIT_PENDING is zshrs's own deferred-exit channel, and leaving it
10526 // armed while the EXIT trap body runs made every command after the
10527 // trap's FIRST one get skipped:
10528 // h(){ echo a; echo b }; trap h EXIT; f(){ exit }; f
10529 // zsh: a b zshrs: a
10530 // `in_exit_trap` is the same counter C tests at c:6201.
10531 let exit_pending = if crate::ported::signals::in_exit_trap.load(Ordering::Relaxed) != 0 {
10532 0
10533 } else {
10534 crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed)
10535 };
10536 // c:Src/exec.c:1571-1603 — `sublist_done:` runs the ZERR trap for
10537 // the sublist that just failed. It is NOT gated on retflag: C only
10538 // consults retflag at the TOP of the list loop (c:1370 `while
10539 // (wc_code(code) == WC_LIST && !breaks && !retflag && !errflag)`),
10540 // which stops the NEXT sublist — the current one still completes
10541 // its sublist_done. So `return 5` fires the ERR trap on its way out.
10542 //
10543 // zshrs's escape short-circuit below returns before ever reaching
10544 // the ZERR fire, so a `return N` inside a try-list skipped the trap:
10545 // f() { { return 5 } always { print fin } }; f
10546 // printed `fin / err=5` where zsh prints `err=5 / fin / err=5`.
10547 // (Plain `f() { return 5 }` matched by luck — the inner fire was
10548 // missing but the OUTER sublist fired instead, since doshfunc had
10549 // cleared retflag by then and DONETRAP was still 0.)
10550 //
10551 // `exit` is deliberately excluded: C's `exit` goes zexit() →
10552 // realexit(), leaving the process without ever reaching
10553 // sublist_done. Verified: `zsh -fc 'trap "print err" ERR; f(){ exit
10554 // 5 }; f'` prints nothing.
10555 if retflag != 0 && exit_pending == 0 {
10556 let last = vm.last_status;
10557 // c:1598-1603 — same DONETRAP gate as the non-escape path below.
10558 if last != 0 && crate::ported::exec::DONETRAP.load(Ordering::Relaxed) == 0 {
10559 // c:Src/signals.c:1085-1087 — `int obreaks = breaks; int
10560 // oretflag = retflag; int olastval = lastval;` and c:1220-1222
10561 // — `breaks += obreaks; retflag = oretflag;`. dotrapargs
10562 // brackets EVERY trap dispatch with this save/restore because
10563 // the trap body runs as a normal list and would otherwise
10564 // consume the caller's control-flow flags. That matters
10565 // exactly here: we are firing ZERR while retflag is SET, and a
10566 // FUNCTION-form trap (`TRAPZERR() { … }`) goes through
10567 // doshfunc, whose epilogue eats retflag outright
10568 // (c:Src/exec.c:6047-6052 `if (retflag) { retflag = 0; breaks
10569 // = funcsave->breaks; }`). Without the bracket the pending
10570 // `return 5` was swallowed by its own ERR trap and the
10571 // function ran on:
10572 // TRAPZERR() { print z }; f() { { return 2 } always { : }
10573 // print after }; f
10574 // printed `after`, where zsh returns from f.
10575 //
10576 // zshrs's `dotrap` inlines the dispatch and does not carry
10577 // dotrapargs' save/restore, so the bracket lives at this call
10578 // site. lastval is restored too (c:1087 / c:1213 `lastval =
10579 // olastval`) — the trap body's own commands must not become
10580 // the caller's `$?`.
10581 let obreaks = crate::ported::builtin::BREAKS.load(Ordering::Relaxed); // c:1085
10582 let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed); // c:1086
10583 let olastval = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1087
10584 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1601
10585 crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed); // c:1602
10586 crate::ported::builtin::BREAKS.store(obreaks, Ordering::Relaxed); // c:1220
10587 crate::ported::builtin::RETFLAG.store(oretflag, Ordering::Relaxed); // c:1222
10588 crate::ported::builtin::LASTVAL.store(olastval, Ordering::Relaxed);
10589 // c:1213
10590 }
10591 }
10592 if retflag != 0 || exit_pending != 0 {
10593 if exit_pending != 0 {
10594 // c:Src/builtin.c zexit — the deferred exit carries its
10595 // status in EXIT_VAL; sync it into the VM counter so
10596 // the top-level unwind reports it as the script's exit
10597 // (run_chunk returns vm.last_status). Without this, a
10598 // POSIX-fatal `.` failure exited 127 (bin_dot's return)
10599 // instead of C's exit(1) at Src/exec.c:4383.
10600 vm.last_status = crate::ported::builtin::EXIT_VAL.load(Ordering::Relaxed) & 0xFF;
10601 }
10602 return Value::Int(1);
10603 }
10604 let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
10605 & crate::ported::zsh_h::ERRFLAG_ERROR)
10606 != 0;
10607 // c:Src/init.c:1931 — `if (errflag && !interact &&
10608 // !isset(CONTINUEONERROR)) { errexit = 1; break; }` — with
10609 // CONTINUE_ON_ERROR set, the top-level do-while re-enters
10610 // loop() and the NEXT list runs instead of the shell exiting.
10611 // Clear the flag so the next statement starts clean (the
10612 // failed statement's lastval is already in place).
10613 if errflag_set
10614 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
10615 && crate::ported::zsh_h::isset(crate::ported::zsh_h::CONTINUEONERROR)
10616 {
10617 crate::ported::utils::errflag
10618 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
10619 return Value::Int(0);
10620 }
10621 if errflag_set && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE) {
10622 // c:Src/exec.c execlist — every enclosing list loop runs
10623 // `while (... && !errflag)`, so a set errflag breaks the
10624 // CURRENT scope and the check in the enclosing scope
10625 // breaks THAT one, all the way out. Leave errflag SET —
10626 // do NOT convert it to EXIT_PENDING: a process-exit
10627 // signal tunnels through the containment boundaries C
10628 // has, namely eval (Src/builtin.c:6221 `errflag &=
10629 // ~ERRFLAG_ERROR`), source (Src/init.c:1663 same), fork
10630 // boundaries (subshell/cmdsubst — child's errflag dies
10631 // with the child), and the interactive toplevel
10632 // (Src/init.c:139). Those boundaries clear errflag
10633 // themselves and execution continues past them; with
10634 // EXIT_PENDING armed here, `eval 'assoc=(odd)'; echo
10635 // after` aborted the whole script where zsh 5.9 prints
10636 // `after` (eval status 1). Bug #74's function case
10637 // (`f() { local -r x=5; x=10; }; f; echo after`) still
10638 // aborts: the function scope unwinds on THIS check, and
10639 // the caller's next ERREXIT_CHECK sees the still-set
10640 // errflag and unwinds too — exactly C's propagation.
10641 //
10642 // c:Src/init.c:234 — loop() BREAKS on errflag and
10643 // zsh_main exits with the UNTOUCHED lastval, NOT a
10644 // forced 1: `typeset -i x=3#8` (math error during the
10645 // assignment, before typeset sets a status) exits 0 in
10646 // zsh; a cond syntax error set lastval=2 (exec.c:5216-
10647 // 5221) and zsh exits 2; the readonly-reassign case
10648 // exits 1 because ITS lastval is 1. Sync the VM counter
10649 // from the executor's live lastval instead of
10650 // overwriting.
10651 vm.last_status = with_executor(|exec| exec.last_status());
10652 // c:Src/exec.c:1598-1603 — `sublist_done:` runs the ZERR trap
10653 // for the failed sublist BEFORE the enclosing list loop breaks
10654 // on errflag (`while (... && !errflag)` at c:1370). So an
10655 // errflag-setting command (readonly reassign, bad redirect)
10656 // must fire ZERR on its way out, exactly like the retflag
10657 // escape above and the non-escape fall-through below. Without
10658 // this the errflag early-return pre-empted the ZERR block
10659 // further down, so `TRAPZERR() { … }; typeset -r ro=1; ro=2`
10660 // aborted the script (correct) but never fired the trap. Same
10661 // DONETRAP gate + dotrapargs save/restore bracket
10662 // (c:signals.c:1085-1087 / 1213-1222) as the retflag branch:
10663 // a function-form TRAPZERR runs through doshfunc and would
10664 // otherwise consume the caller's breaks/retflag/lastval.
10665 let last = with_executor(|exec| exec.last_status());
10666 if last != 0 && crate::ported::exec::DONETRAP.load(Ordering::Relaxed) == 0 {
10667 let obreaks = crate::ported::builtin::BREAKS.load(Ordering::Relaxed); // c:1085
10668 let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed); // c:1086
10669 let olastval = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1087
10670 // c:Src/signals.c:1101 — dotrapargs returns early if errflag
10671 // is set, and c:1174/1205-1218 brackets the dispatch with
10672 // `traperr = errflag` … restore. The failing assignment left
10673 // errflag SET, so the trap body (`print zerr`) would itself
10674 // bail on the first op. Clear errflag across the dispatch so
10675 // the body runs, then restore it so the script still aborts.
10676 let oerrflag = crate::ported::utils::errflag.load(Ordering::Relaxed); // c:1174
10677 crate::ported::utils::errflag.store(0, Ordering::Relaxed);
10678 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1601
10679 crate::ported::utils::errflag.store(oerrflag, Ordering::Relaxed); // c:1216
10680 crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed); // c:1602
10681 crate::ported::builtin::BREAKS.store(obreaks, Ordering::Relaxed); // c:1220
10682 crate::ported::builtin::RETFLAG.store(oretflag, Ordering::Relaxed); // c:1222
10683 crate::ported::builtin::LASTVAL.store(olastval, Ordering::Relaxed);
10684 // c:1213
10685 }
10686 return Value::Int(1);
10687 }
10688 let last = vm.last_status;
10689 if last == 0 {
10690 return Value::Int(0);
10691 }
10692 // c:Src/exec.c:1598 `if (!this_noerrexit && !donetrap &&
10693 // !this_donetrap)` — gate the ZERR trap fire on DONETRAP so
10694 // an inner sublist (e.g. `false` inside a function) that
10695 // already fired ZERR doesn't fire it AGAIN at the outer
10696 // sublist's post-command check (after the function
10697 // returned non-zero). Bug #303 in docs/BUGS.md. DONETRAP
10698 // is reset at top-level statement boundaries via
10699 // BUILTIN_DONETRAP_RESET (compile_list emit at
10700 // compile_zsh.rs).
10701 let already_done = crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
10702 // c:Src/exec.c:1652-1653 —
10703 // if (sigtrapped[SIGZERR] && lastval &&
10704 // !(noerrexit & NOERREXIT_EXIT)) {
10705 // The ZERR half of the check is gated on the SAME runtime bit as
10706 // the errexit half below. Without this an `&&`/`||` operand — or
10707 // anything it calls — still fired ZERR, so
10708 // TRAPZERR(){ print E }; f(){ print f; false; }; f && t
10709 // printed E where zsh is silent (C03traps:14, E01options:18).
10710 let zerr_suppressed = (crate::ported::exec::noerrexit.load(Ordering::Relaxed)
10711 & crate::ported::zsh_h::NOERREXIT_EXIT)
10712 != 0; // c:1653
10713 if !already_done && !zerr_suppressed {
10714 // c:Src/signals.c:1245 dotrap(SIGZERR) — canonical ZERR
10715 // trap dispatch. Fires whenever a command exits
10716 // non-zero.
10717 let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
10718 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR);
10719 // c:1602 — `donetrap = 1;` after firing.
10720 crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed);
10721 // c:Src/signals.c:1201-1203 — a trap body that ran `return N`
10722 // comes back with `lastval = new_trap_return` and `retflag =
10723 // 1`; C's enclosing `while (wc_code(code) == WC_LIST &&
10724 // !breaks && !retflag && !errflag)` (c:Src/exec.c:1443) then
10725 // abandons the rest of the list and the containing function
10726 // returns that status. zshrs's list loop is the VM, which
10727 // reads `vm.last_status` / the executor's counter rather than
10728 // the ported LASTVAL atomic — so mirror the forced status
10729 // across and report "abort" to the caller. Without this
10730 // fn(){ trap 'print t; return 42' ZERR; false; print B }
10731 // ran `print B` and returned 0.
10732 if oretflag == 0 && crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) != 0 {
10733 let forced = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1201
10734 vm.last_status = forced;
10735 with_executor(|exec| exec.set_last_status(forced));
10736 return Value::Int(1); // c:1443 — list loop stops on retflag
10737 }
10738 }
10739 // c:Src/exec.c:1605-1610 — compute errreturn / errexit.
10740 // errreturn = ERRRETURN && (INTERACTIVE || locallevel || sourcelevel)
10741 // && !(noerrexit & NOERREXIT_RETURN)
10742 // errexit = (ERREXIT || (ERRRETURN && !errreturn))
10743 // && !(noerrexit & NOERREXIT_EXIT)
10744 let no_err = crate::ported::exec::noerrexit.load(Ordering::Relaxed);
10745 let locallvl = crate::ported::params::locallevel.load(Ordering::Relaxed);
10746 let sourcelvl = crate::ported::init::sourcelevel.load(Ordering::Relaxed);
10747 let errreturn_opt = isset(crate::ported::zsh_h::ERRRETURN);
10748 let in_unwindable_scope =
10749 isset(crate::ported::zsh_h::INTERACTIVE) || locallvl != 0 || sourcelvl != 0;
10750 let errreturn = errreturn_opt
10751 && in_unwindable_scope
10752 && (no_err & crate::ported::zsh_h::NOERREXIT_RETURN) == 0;
10753 if errreturn {
10754 // c:1620-1623 — `retflag = 1; breaks = loops;` — unwind to
10755 // function boundary without exiting the shell.
10756 crate::ported::builtin::RETFLAG.store(1, Ordering::Relaxed);
10757 let loops = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
10758 crate::ported::builtin::BREAKS.store(loops, Ordering::Relaxed);
10759 return Value::Int(1);
10760 }
10761 let (errexit_on, in_subshell) = with_executor(|exec| {
10762 let on_canonical = isset(ERREXIT) || (errreturn_opt && !errreturn); // c:1608-1609
10763 let on_legacy = opt_state_get("errexit").unwrap_or(false);
10764 (
10765 (on_canonical || on_legacy) && (no_err & crate::ported::zsh_h::NOERREXIT_EXIT) == 0,
10766 !exec.subshell_snapshots.is_empty(),
10767 )
10768 });
10769 if !errexit_on {
10770 return Value::Int(0);
10771 }
10772 // c:Src/exec.c:1611-1618 — under ERR_EXIT a failing command exits the
10773 // whole shell via realexit() FROM THE POINT OF FAILURE, before any
10774 // enclosing `always` arm can run. zsh 5.9.2 (the reference) has no
10775 // `this_noerrexit` deferral, so at top-level / function scope the
10776 // faithful behavior is to process-exit here (zexit fires the SIGEXIT
10777 // trap and exits). This bypasses the always arm, fixing
10778 // `setopt errexit; { false } always { print A }` which wrongly ran the
10779 // always body: the deferred EXIT_PENDING routed the unwind through
10780 // always_entry (compile_zsh.rs re-points it there) and
10781 // SET_TRY_BLOCK_ERROR then cleared the pending exit so the body ran.
10782 if crate::ported::builtin::SUBSHELL_DEPTH.load(Ordering::Relaxed) == 0 {
10783 crate::ported::builtin::zexit(last, crate::ported::zsh_h::ZEXIT_NORMAL);
10784 // c:1618 realexit
10785 }
10786 // Subshell: zshrs runs subshells in-process, so it cannot process-exit
10787 // the whole shell here — defer to the subshell-end unwind.
10788 crate::ported::builtin::EXIT_VAL.store(last, Ordering::Relaxed);
10789 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
10790 let _ = in_subshell;
10791 Value::Int(1)
10792 });
10793
10794 // BUILTIN_ASSIGN_ONLY_STATUS — status of an assignment-only
10795 // simple command. c:Src/exec.c:3393-3396 (execcmd_exec, no
10796 // command word + varspc): `if (errflag) lastval = 1; else
10797 // lastval = cmdoutval;`; same shape at c:1322 (execsimple
10798 // WC_ASSIGN: `lv = (errflag ? errflag : cmdoutval)`) and
10799 // c:3977 (nullexec=2 redir variant). cmdoutval is the exit of
10800 // a `$()` that ran in an RHS (already in vm.last_status via
10801 // compile_assign's per-assign SetStatus), 0 otherwise. The
10802 // store goes to the canonical LASTVAL too — that IS C's single
10803 // `lastval` global; without it the errflag-abort path
10804 // (BUILTIN_ERREXIT_CHECK trigger 4) syncs vm.last_status from
10805 // a stale LASTVAL and `readonly r=1; r=2` exited 0, not 1.
10806 vm.register_builtin(BUILTIN_ASSIGN_ONLY_STATUS, |vm, _argc| {
10807 use std::sync::atomic::Ordering;
10808 let had_cmd_subst = vm.pop().to_int() != 0;
10809 let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
10810 & crate::ported::zsh_h::ERRFLAG_ERROR)
10811 != 0;
10812 // c:Src/exec.c addvars — `if (!pm) { lastval = 1; if
10813 // (!cmdoutval) cmdoutval = 1; }` (assignment-failed cheat).
10814 let assign_failed = ASSIGN_FAILED_FLAG.swap(false, std::sync::atomic::Ordering::Relaxed);
10815 let status = if errflag_set || assign_failed {
10816 1 // c:Src/exec.c:3394 `lastval = 1` / addvars cmdoutval=1
10817 } else if had_cmd_subst {
10818 vm.last_status // c:3396 `lastval = cmdoutval` (subst exit)
10819 } else {
10820 0 // c:3396 `lastval = cmdoutval` (cmdoutval = 0)
10821 };
10822 with_executor(|exec| exec.set_last_status(status));
10823 // c:Src/jobs.c deletefilelist — a `=(cmd)` temp file is bound to the
10824 // JOB of the command that created it and unlinked when that command
10825 // completes (Src/exec.c:5588 for the shfunc case; the simple-command
10826 // job's filelist likewise). An assignment-only command like
10827 // `f==(cmd)` has no consuming builtin/exec, so the PsubFdGuard that
10828 // cleans consuming commands never fires — the temp leaked and a later
10829 // `$(<$f)` / `[[ -f $f ]]` still saw it, where zsh deletes it at the
10830 // end of the assignment (verified: even `f==(x) && cat $f` fails).
10831 // Clean here so the assignment command is the temp's job boundary.
10832 close_pending_psub_fds();
10833 Value::Status(status)
10834 });
10835
10836 // `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
10837 // Pops [name, op_byte, rhs] (rhs popped first). Returns the modified
10838 // value as Value::Str. Handles unset/empty distinction (`:-` etc.
10839 // treat empty same as unset, matching POSIX).
10840 // BUILTIN_PARAM_DEFAULT_FAMILY — `${var-x}` / `${var:-x}` / `${var=x}` /
10841 // `${var:=x}` / `${var?x}` / `${var:?x}` / `${var+x}` / `${var:+x}`.
10842 // PURE PASSTHRU: pop name + op + rhs, reconstruct the canonical
10843 // brace expression, hand to `subst::paramsubst` (C port of
10844 // `Src/subst.c::paramsubst`). All "missing vs empty" gating,
10845 // nounset suppression, default-evaluation, and elide-empty-words
10846 // semantics live inside paramsubst.
10847 vm.register_builtin(BUILTIN_PARAM_DEFAULT_FAMILY, |vm, _argc| {
10848 let rhs = vm.pop().to_str();
10849 let op = vm.pop().to_int() as u8;
10850 let name = vm.pop().to_str();
10851 // op=8 is the `${+name}` set-test prefix form (distinct from the
10852 // `${name+rhs}` substitute-if-set suffix form which is op=7).
10853 // Per compile_zsh.rs::parse_param_modifier: the `+` is emitted as
10854 // a leading sigil and `rhs` is empty.
10855 let body = if op == 8 {
10856 format!("${{+{}}}", name)
10857 } else {
10858 let op_str = match op {
10859 0 => ":-",
10860 1 => ":=",
10861 2 => ":?",
10862 3 => ":+",
10863 4 => "-",
10864 5 => "=",
10865 6 => "?",
10866 7 => "+",
10867 _ => "-",
10868 };
10869 format!("${{{}{}{}}}", name, op_str, rhs)
10870 };
10871 paramsubst_to_value(&body)
10872 });
10873
10874 // `${var:offset[:length]}` — substring. Pops [name, offset, length].
10875 // length == -1 means "rest of string". Negative offset counts from end.
10876 // BUILTIN_PARAM_SUBSTRING — `${var:offset:length}` literal-int form.
10877 // PURE PASSTHRU: reconstruct `${name:offset:length}` and route
10878 // through `subst::paramsubst`. Length sentinel `i64::MIN` =
10879 // "no length given" (omit the `:length` portion).
10880 //
10881 // c:Src/subst.c:1571,3781 — `${name:-N}` is the colon-default
10882 // operator, NOT a substring with negative offset. zsh's lexical
10883 // rule disambiguates via a literal space: `${name: -N}` (space
10884 // before `-`) is the substring form. The reconstructed body MUST
10885 // preserve that space when offset < 0; otherwise paramsubst's
10886 // `:-` dispatch fires on the synthesized `${name:-N}` body and
10887 // returns N as the unset-default instead of slicing the last N
10888 // chars. Length-form `${name:-N:M}` has the same trap.
10889 vm.register_builtin(BUILTIN_PARAM_SUBSTRING, |vm, _argc| {
10890 let length = vm.pop().to_int();
10891 let offset = vm.pop().to_int();
10892 let name = vm.pop().to_str();
10893 // !!! DASH-STRICT GATE !!! dash/ash have no `${var:offset:length}`
10894 // substring expansion (it is a "Bad substitution"); bash/ksh/sh do.
10895 if crate::dash_mode::dash_strict() {
10896 crate::ported::utils::zerr("bad substitution");
10897 crate::ported::utils::errflag.fetch_or(
10898 crate::ported::zsh_h::ERRFLAG_ERROR,
10899 std::sync::atomic::Ordering::Relaxed,
10900 );
10901 with_executor(|exec| exec.set_last_status(1));
10902 return Value::str("");
10903 }
10904 let off_sep = if offset < 0 { " " } else { "" };
10905 let body = if length == i64::MIN {
10906 format!("${{{}:{}{}}}", name, off_sep, offset)
10907 } else {
10908 format!("${{{}:{}{}:{}}}", name, off_sep, offset, length)
10909 };
10910 paramsubst_to_value(&body)
10911 });
10912
10913 // BUILTIN_PARAM_SUBSTRING_EXPR — `${var:offset_expr[:length_expr]}` form.
10914 // PURE PASSTHRU: rebuild `${name:offset:length}` using the
10915 // expression text verbatim (paramsubst's offset/length
10916 // parser evaluates arith / param refs itself).
10917 //
10918 // c:Src/subst.c:1571,3781 — same `:-` disambiguation trap as
10919 // BUILTIN_PARAM_SUBSTRING. The expression text may itself start
10920 // with `-` (e.g. `${VAR:$((-1))}` arith resolves at the body-
10921 // assembly layer in some upstream paths, leaving `-1` in
10922 // off_expr). Insert a leading space when off_expr starts with
10923 // `-` so paramsubst's check_colon_subscript (subst.c:1571)
10924 // accepts the operand as a math expression instead of the
10925 // `:-` operator catching it.
10926 vm.register_builtin(BUILTIN_PARAM_SUBSTRING_EXPR, |vm, _argc| {
10927 let has_len = vm.pop().to_int() != 0;
10928 let len_expr = vm.pop().to_str();
10929 let off_expr = vm.pop().to_str();
10930 let name = vm.pop().to_str();
10931 let off_sep = if off_expr.starts_with('-') { " " } else { "" };
10932 let body = if has_len {
10933 format!("${{{}:{}{}:{}}}", name, off_sep, off_expr, len_expr)
10934 } else {
10935 format!("${{{}:{}{}}}", name, off_sep, off_expr)
10936 };
10937 paramsubst_to_value(&body)
10938 });
10939
10940 // `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}`
10941 // Pops [name, pattern, op_byte]. op: 0=`#` short-prefix, 1=`##` long,
10942 // 2=`%` short-suffix, 3=`%%` long. Glob-pattern matching via the
10943 // existing glob_match_static helper.
10944 // BUILTIN_PARAM_STRIP — `${var#pat}` / `${var##pat}` / `${var%pat}` /
10945 // `${var%%pat}`. PURE PASSTHRU: reconstruct the brace expression
10946 // and route through `subst::paramsubst`. (M)/(S) flags arrive
10947 // through SUB_FLAGS (already inside paramsubst's scope), so we
10948 // just clear the bridge-side cached read.
10949 vm.register_builtin(BUILTIN_PARAM_STRIP, |vm, _argc| {
10950 let _dq_flag = vm.pop().to_int() != 0;
10951 let op = vm.pop().to_int() as u8;
10952 let pattern = vm.pop().to_str();
10953 let name = vm.pop().to_str();
10954 let op_str = match op {
10955 0 => "#",
10956 1 => "##",
10957 2 => "%",
10958 3 => "%%",
10959 _ => "#",
10960 };
10961 let body = format!("${{{}{}{}}}", name, op_str, pattern);
10962 paramsubst_to_value(&body)
10963 });
10964
10965 // `$((expr))` — pops [expr_string], evaluates via MathEval which
10966 // honors integer-vs-float distinction (zsh-compatible). Returns
10967 // the result as Value::Str so it can be Concat'd into surrounding
10968 // word context.
10969 vm.register_builtin(BUILTIN_ARITH_EVAL, |vm, _argc| {
10970 // Pure path: evaluate expr, return string. errflag may be
10971 // set by arithsubst on math error; the caller decides
10972 // whether to clear it. For `(( ... ))` (math command) the
10973 // compile_arith path clears via BUILTIN_ARITH_CMD_FINISH;
10974 // for `$((... ))` (substitution inside another command)
10975 // errflag stays set so the surrounding command aborts —
10976 // matches c:Src/math.c "math errors propagate as errflag
10977 // through the containing word expansion".
10978 let expr = vm.pop().to_str();
10979 let result = crate::ported::subst::arithsubst(&expr, "", "");
10980 let _ = vm; // silence unused warning when no math error path mutates
10981 Value::str(result)
10982 });
10983
10984 // After-call hook used by compile_arith's `(( ... ))` path: when
10985 // arithsubst set errflag (math error), clear it and signal
10986 // status=2 in vm.last_status — matches zsh's c:exec.c arith-
10987 // failure: the math command exits 2 and the script continues.
10988 vm.register_builtin(BUILTIN_ARITH_CMD_FINISH, |vm, _argc| {
10989 use std::sync::atomic::Ordering;
10990 let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
10991 let err = live & crate::ported::zsh_h::ERRFLAG_ERROR;
10992 let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
10993 if err != 0 {
10994 // c:Src/subst.c:3344 — when `${var:?msg}` fires, errflag
10995 // is OR'd with ERRFLAG_HARD to signal a script-abort
10996 // error (vs a recoverable math error like `$((1/0))`).
10997 // Clear only the ERRFLAG_ERROR bit; preserve
10998 // ERRFLAG_HARD so the next ERREXIT_CHECK aborts the
10999 // script. Bug #193 in docs/BUGS.md.
11000 if hard != 0 {
11001 // Keep ERRFLAG_HARD AND ERRFLAG_ERROR set so the
11002 // script-abort gate downstream still fires.
11003 vm.last_status = 2;
11004 Value::Status(2)
11005 } else {
11006 crate::ported::utils::errflag
11007 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
11008 vm.last_status = 2;
11009 Value::Status(2)
11010 }
11011 } else {
11012 Value::Status(vm.last_status)
11013 }
11014 });
11015
11016 // `$(cmd)` — pops [cmd_string], routes through
11017 // run_command_substitution which performs an in-process pipe-capture.
11018 // Avoids the Op::CmdSubst sub-chunk word-emit bug
11019 // (`printf "a\nb"` produced "anb" via that path). Returns trimmed
11020 // output (trailing newlines stripped per POSIX cmd-sub semantics).
11021 vm.register_builtin(BUILTIN_CMD_SUBST_TEXT, |vm, _argc| {
11022 let cmd = vm.pop().to_str();
11023 // Inherit live $? into the inner shell so cmd-subst sees the
11024 // parent's most recent exit. Same rationale as the mode-3
11025 // backtick path above.
11026 let live_status = vm.last_status;
11027 let result = with_executor(|exec| {
11028 exec.set_last_status(live_status);
11029 exec.run_command_substitution(&cmd)
11030 });
11031 // Mirror run_command_substitution's exec.last_status side
11032 // effect into the VM's live counter so a containing
11033 // assignment's BUILTIN_SET_VAR — which reads vm.last_status
11034 // — sees the cmd-subst's exit. Without this, `a=$(false);
11035 // echo $?` reads stale 0 (vm.last_status was zeroed by
11036 // compile_assign's prelude SetStatus, and run_cmd_subst only
11037 // updated exec.last_status). Pull the value back through
11038 // exec since it owns the canonical post-subst record.
11039 let cs_status = with_executor(|exec| exec.last_status());
11040 vm.last_status = cs_status;
11041 // c:Src/exec.c — a command substitution running during a
11042 // command's word expansion makes its exit the status of an
11043 // otherwise-empty command (`$(exit 5)` → 5). Flag it so
11044 // BUILTIN_EXEC_DYNAMIC's null-command branch keeps `$?` instead
11045 // of resetting to 0.
11046 crate::ported::exec::use_cmdoutval.store(1, std::sync::atomic::Ordering::Relaxed);
11047 Value::str(result)
11048 });
11049
11050 // Text-based word expansion. Pops [preserved_text, mode_byte].
11051 // mode_byte:
11052 // 0 = Default — expand_string + xpandbraces + expand_glob
11053 // 1 = DoubleQuoted — strip outer `"…"`, expand_string only
11054 // (no brace, no glob — DQ semantics)
11055 // 2 = SingleQuoted — strip outer `'…'`, no expansion
11056 // (kept for symmetry; Snull early-return covers most SQ)
11057 // 3 = AltBackquote — strip backticks, run as cmd-sub
11058 // 7 = RedirTarget — same as Default but glob gated on MULTIOS
11059 // (c:Src/glob.c:2161-2167 xpandredir)
11060 // 8 = unquoted assignment VALUE — same as 6 plus PREFORK_SINGLE
11061 // (c:Src/exec.c:2603 / :4239-4241)
11062 // Single result → Value::str; multi → Value::Array.
11063 vm.register_builtin(BUILTIN_EXPAND_TEXT, |vm, _argc| {
11064 let mode = vm.pop().to_int() as u8;
11065 let text = vm.pop().to_str();
11066 // Sync vm.last_status → exec.last_status so cmd-subst (mode 3)
11067 // and any nested $? reads inside singsub see the live `$?`
11068 // from the most recent VM op. Without this, cmd-subst inside
11069 // arg-eval saw a stale exec.last_status that was zeroed at
11070 // the start of the current statement. Direct port of zsh's
11071 // pre-cmdsubst lastval propagation per Src/exec.c:4770.
11072 let live_status = vm.last_status;
11073 with_executor(|exec| exec.set_last_status(live_status));
11074 let result_value = with_executor(|exec| match mode {
11075 // Mode 1 = DoubleQuoted (argument context).
11076 // Mode 5 = DoubleQuoted in scalar-assignment context.
11077 // Both share the same DQ unescape pre-processing; mode 5
11078 // additionally bumps `in_scalar_assign` so subst_port's
11079 // paramsubst sees ssub=true and suppresses split flags
11080 // `(f)` / `(s:STR:)` / `(0)` per Src/subst.c:1759 +
11081 // Src/exec.c::addvars line 2546 (the PREFORK_SINGLE bit
11082 // C zsh sets when prefork-ing the assignment RHS).
11083 1 | 5 => {
11084 // DoubleQuoted: strip outer `"…"` if present. In DQ
11085 // context, `\` escapes the DQ-special chars `$`, `` ` ``,
11086 // `"`, `\`. zsh's expand_string expects the lexer's
11087 // `\0X` literal-marker for an already-escaped char, so
11088 // we pre-process: `\$` → `\0$`, `\\` → `\0\`, etc. Then
11089 // expand_string handles the rest.
11090 let inner = if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
11091 &text[1..text.len() - 1]
11092 } else {
11093 text.as_str()
11094 };
11095 // The lexer's dquote_parse (Src/lex.c) already tokenized
11096 // DQ contents: `$` → Qstring (\u{8c}), `\$`/`\\`/`\"`/
11097 // `` \` `` → Bnull (\u{9f}) + literal. Stringsubst /
11098 // multsub recognize these markers natively. We pass
11099 // `inner` through verbatim — no re-tokenization needed.
11100 let prepped: String = inner.to_string();
11101 // Tell parameter-flag application that we're inside
11102 // double quotes — array-only flags ((o), (O), (n),
11103 // (i), (M), (u)) must be no-ops here per zsh.
11104 exec.in_dq_context += 1;
11105 if mode == 5 {
11106 exec.in_scalar_assign += 1;
11107 }
11108 // Mode 1 = argv DQ word; mode 5 = scalar-assign RHS.
11109 // In C zsh, the corresponding prefork-on-list paths
11110 // are: argv → `prefork(argv_list, 0)` returns multi-
11111 // word LinkList (Src/exec.c::execcmd), assignment →
11112 // `prefork(rhs_list, PREFORK_SINGLE|PREFORK_ASSIGN)`
11113 // returns single-word (Src/exec.c::addvars line
11114 // 2546). zshrs's `multsub` (Src/subst.c:544) is the
11115 // multi-result variant; `singsub` (Src/subst.c:514)
11116 // asserts ≤1 node. Mode 5 keeps singsub; mode 1
11117 // switches to multsub so `"${(@)arr}"`/`"$@"`/
11118 // `"${arr[@]}"` in argv context emit multiple words
11119 // as the C path would.
11120 // c:Src/lex.c untokenize — the final argv pass C runs
11121 // on every expanded word (glob.c:1862 / exec.c) drops
11122 // the Nularg empty-word sentinel remnulargs left in
11123 // place and folds any remaining token chars. Without
11124 // it, quoted splits with empty pieces
11125 // ("${(s:|:)x}" on "|a|b|") leak U+00A1 into argv.
11126 let result_value = if mode == 5 {
11127 let out = crate::ported::subst::singsub(&prepped);
11128 Value::str(crate::ported::lex::untokenize(&out))
11129 } else {
11130 let (_first, nodes, _ms_ws, _ret) = crate::ported::subst::multsub(&prepped, 0);
11131 // c:Src/subst.c:655 — multsub returns Vec::new()
11132 // for zero-word results (quoted array splat that
11133 // resolved to empty array). Surface as
11134 // Value::Array(vec![]) so the downstream array
11135 // assignment / argv flattening sees ZERO args.
11136 // Previous Rust port returned Value::str("") which
11137 // surfaced as ONE empty arg. Bug #120 in
11138 // docs/BUGS.md.
11139 if nodes.is_empty() {
11140 Value::array(Vec::new())
11141 } else if nodes.len() == 1 {
11142 Value::str(crate::ported::lex::untokenize(
11143 &nodes.into_iter().next().unwrap(),
11144 ))
11145 } else {
11146 Value::array(
11147 nodes
11148 .into_iter()
11149 .map(|n| Value::str(crate::ported::lex::untokenize(&n)))
11150 .collect(),
11151 )
11152 }
11153 };
11154 if mode == 5 {
11155 exec.in_scalar_assign -= 1;
11156 }
11157 exec.in_dq_context -= 1;
11158 result_value
11159 }
11160 2 => {
11161 // SingleQuoted: pure literal, strip outer `'…'`.
11162 let inner = if text.len() >= 2 && text.starts_with('\'') && text.ends_with('\'') {
11163 &text[1..text.len() - 1]
11164 } else {
11165 text.as_str()
11166 };
11167 Value::str(inner.to_string())
11168 }
11169 3 => {
11170 // Backquote command sub: strip outer backticks.
11171 // Word-split the result on IFS when the surrounding
11172 // word is unquoted — zsh: `print -l \`echo a b c\``
11173 // emits one arg per word. The $(…) path applies the
11174 // same split via BUILTIN_WORD_SPLIT after capture; do
11175 // the equivalent here for the `…` form.
11176 let inner = if text.len() >= 2 && text.starts_with('`') && text.ends_with('`') {
11177 &text[1..text.len() - 1]
11178 } else {
11179 text.as_str()
11180 };
11181 // Apply the live VM status before running the inner
11182 // shell so the inherited $? matches zsh's lastval
11183 // propagation.
11184 exec.set_last_status(live_status);
11185 let captured = exec.run_command_substitution(inner);
11186 let trimmed = captured.trim_end_matches('\n');
11187 if exec.in_dq_context > 0 {
11188 Value::str(trimmed.to_string())
11189 } else {
11190 let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
11191 let parts: Vec<Value> = trimmed
11192 .split(|c: char| ifs.contains(c))
11193 .filter(|s| !s.is_empty())
11194 .map(|s| Value::str(s.to_string()))
11195 .collect();
11196 if parts.is_empty() {
11197 Value::str(String::new())
11198 } else if parts.len() == 1 {
11199 parts.into_iter().next().unwrap()
11200 } else {
11201 Value::array(parts)
11202 }
11203 }
11204 }
11205 4 => {
11206 // HeredocBody: expand variables / command-subst / arith
11207 // but NOT glob or brace. Heredoc lines like `[42]` must
11208 // pass through verbatim — running them through the
11209 // default pipeline triggers NOMATCH on the literal.
11210 Value::str(crate::ported::subst::singsub(&text))
11211 }
11212 _ => {
11213 // Default (unquoted): the lexer's gettokstr already
11214 // tokenized backslash-escapes (`\$` → Bnull+$, etc).
11215 // Pass `text` through verbatim — multsub/stringsubst
11216 // recognize the markers natively. No bridge-side
11217 // re-tokenization needed.
11218 //
11219 // Mode 6 = unquoted RHS in scalar-assign context.
11220 // Pass PREFORK_ASSIGN so prefork's filesub colon-walk
11221 // fires per c:Src/exec.c:2546.
11222 let prepped: String = text.clone();
11223 if std::env::var("ZSHRS_TRACE_DEFP").is_ok() {
11224 eprintln!(
11225 "[TRACE_DEFP] text={:?} prepped={:?} mode={}",
11226 text, prepped, mode
11227 );
11228 }
11229 // Mode 8 = the unquoted VALUE of a `NAME=VALUE` assignment
11230 // (bare statement or typeset-family argument). C preforks
11231 // exactly that with `PREFORK_SINGLE|PREFORK_ASSIGN`
11232 // (c:Src/exec.c:2603 and c:Src/exec.c:4239-4241); the
11233 // PREFORK_SINGLE half is paramsubst's `ssub`
11234 // (c:Src/subst.c:1761), which gates off the forced split at
11235 // c:Src/subst.c:3913.
11236 let pf_flags = if mode == 8 {
11237 crate::ported::zsh_h::PREFORK_SINGLE | crate::ported::zsh_h::PREFORK_ASSIGN
11238 } else if mode == 6 {
11239 crate::ported::zsh_h::PREFORK_ASSIGN
11240 } else {
11241 0
11242 };
11243 // c:Src/subst.c:544+ — `multsub(&prepped, 0)` is the
11244 // unquoted-argv equivalent of zsh's `prefork(list,
11245 // 0, NULL)` for a single-element list. Returns the
11246 // post-expansion node list (Vec<String>) so array-
11247 // shape results (e.g. `${a:e}`, `${a[@]}`,
11248 // `${(s::)str}`) splat into multiple argv words.
11249 // singsub() collapses to one string and discards the
11250 // splat — parity bug #28 (whole-array modifier).
11251 // c:Src/subst.c:3929-3932 — `if (isarr) l->list.flags |=
11252 // LF_ARRAY; else l->list.flags &= ~LF_ARRAY;`. C's paramsubst
11253 // holds the LinkList and stamps its `isarr` on it directly;
11254 // the Rust port hands the same bit back through the
11255 // `PARAMSUBST_LF_ARRAY` thread-local (subst.rs:20511) — set at
11256 // subst.rs:17796 (`isarr != 0 && !forced_split_to_one`) and
11257 // reset to false at the top of EVERY paramsubst
11258 // (subst.rs:3891 / subst.rs:17445).
11259 //
11260 // Clear it BEFORE multsub so a segment that runs no paramsubst
11261 // at all reads false instead of some earlier expansion's
11262 // value. `multsub`'s own `isarr` return cannot be used for
11263 // this: an unquoted `$(cmd)` / `` `cmd` `` sets LF_ARRAY
11264 // unconditionally (subst.rs:897 / :1159, c:Src/subst.c:285-286
11265 // `if (!qt) list->list.flags |= LF_ARRAY;` and c:331), so
11266 // `x$(true)y` would look array-shaped when zsh keeps that
11267 // word (verified: it is the single word `xy`).
11268 //
11269 // Every paramsubst re-initialises the cell on entry, so
11270 // clearing it here cannot disturb subst.rs's own readers
11271 // (stringsubst reads it immediately after each paramsubst
11272 // call, subst.rs:1094).
11273 crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.set(false));
11274 let (_first, nodes, _ms_ws, _ret) =
11275 crate::ported::subst::multsub(&prepped, pf_flags);
11276 // Read immediately: brace expansion / filesub / glob below can
11277 // re-enter paramsubst and overwrite the cell. `seg_is_array` is
11278 // the array-ness of the OUTERMOST paramsubst in this segment —
11279 // C's c:4245 `if (isarr)` for the same expansion. Paired with
11280 // `seg_zero_words` it separates the two empty shapes for the
11281 // empty-result arm further down (c:4362 vs c:4464).
11282 let seg_is_array = crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.get());
11283 let seg_zero_words = nodes.is_empty();
11284 if std::env::var("ZSHRS_TRACE_MULTSUB").is_ok() {
11285 eprintln!("[TRACE_MULTSUB] prepped={:?} nodes={:?}", prepped, nodes);
11286 }
11287 // c:Src/subst.c:166 — xpandbraces runs AFTER prefork's
11288 // substitution pass and BEFORE untokenize/glob. Per
11289 // word, scan for Inbrace TOKEN and expand. Words that
11290 // don't contain Inbrace TOKEN pass through unchanged.
11291 // Brace expansion is done here (inside the bridge
11292 // default arm) instead of via a post-EXPAND_TEXT
11293 // BRACE_EXPAND emit because untokenize (line below)
11294 // strips TOKEN bytes, after which the strict-TOKEN
11295 // xpandbraces gate would no longer match.
11296 let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
11297 // c:Src/options.c — `no_brace_expand` (negated
11298 // `braceexpand`) gates brace expansion entirely.
11299 // When off, `{a,b}` stays literal.
11300 // c:Src/subst.c:170 — `if (unset(IGNOREBRACES) && !(flags &
11301 // PREFORK_SINGLE))` guards the `xpandbraces` loop, so a word
11302 // preforked as a scalar (assignment VALUE, mode 8) is NEVER
11303 // brace-expanded: `local x={a,b}` stores the five literal
11304 // characters. This pass stands in for prefork's loop, so it
11305 // owes the same guard.
11306 let brace_expand = opt_state_get("braceexpand").unwrap_or(true)
11307 && (pf_flags & crate::ported::zsh_h::PREFORK_SINGLE) == 0; // c:170
11308 let pre_brace: Vec<String> = if nodes.is_empty() {
11309 vec![String::new()]
11310 } else {
11311 nodes
11312 };
11313 let brace_expanded: Vec<String> = pre_brace
11314 .into_iter()
11315 .flat_map(|w| {
11316 if brace_expand && w.contains('\u{8f}') {
11317 crate::ported::glob::xpandbraces(&w, brace_ccl)
11318 } else {
11319 vec![w]
11320 }
11321 })
11322 .collect();
11323 // zsh stores the option as `glob` (default ON);
11324 // `setopt noglob` writes `glob=false`. Honor either
11325 // form so the dispatcher behaves the same as zsh.
11326 // Mode 7 = redirect-target word: glob only under
11327 // MULTIOS (c:Src/glob.c:2161-2167 xpandredir,
11328 // "Globbing is only done for multios.").
11329 let noglob = opt_state_get("noglob").unwrap_or(false)
11330 || opt_state_get("GLOB").map(|v| !v).unwrap_or(false)
11331 || !opt_state_get("glob").unwrap_or(true)
11332 || (mode == 7 && !opt_state_get("multios").unwrap_or(true));
11333 let parts: Vec<String> = brace_expanded
11334 .into_iter()
11335 .flat_map(|s| {
11336 // The lexer leaves glob metacharacters in their
11337 // META-encoded form: `*` → `\u{87}`, `?` →
11338 // `\u{86}`, `[` → `\u{91}`, etc. expand_string
11339 // doesn't untokenize them, so the literal-char
11340 // checks below (`s.contains('*')`) would miss
11341 // every real glob and skip expand_glob — that
11342 // bug let `echo *.toml` print the literal
11343 // `*.toml` because the META `\u{87}` never
11344 // matched the literal `*`. Untokenize once so
11345 // the metacharacter checks see the canonical
11346 // form. zsh's pattern.c expects `*` etc. as
11347 // bare chars at the glob layer.
11348 // c:Src/pattern.c:4306 haswilds on the still-
11349 // TOKENIZED word (pre-untokenize), matching C's
11350 // zglob entry gate (Src/glob.c:1230) which runs
11351 // on the lexer-tokenized string. haswilds
11352 // matches ONLY token codes: source-level
11353 // `*.toml` carries Star and fires; bare literal
11354 // `[`/`*`/`?` from `$'...'` decode, `:-`
11355 // default values, or nested-substitution
11356 // results were never shtokenize'd (C
11357 // subst.c:3231 sets globsubst=0 in the `:-`
11358 // arm) and stay literal — bug #625. Plain
11359 // multibyte text (`↔`) never matches a token
11360 // codepoint — bug #627.
11361 let is_glob_pre = !noglob && crate::ported::pattern::haswilds(&s);
11362 // c:Src/glob.c:1230 — zglob receives the word in
11363 // LEXER-TOKENIZED form and only untokenizes it when
11364 // it declines to glob (c:1232) or falls back to the
11365 // literal (c:1884). The token form is what makes a
11366 // QUOTED metachar distinguishable from an active one:
11367 // in `*(.e['[[ $REPLY == a* ]]'])` the body's `]`
11368 // bytes are raw ASCII while the qualifier's real
11369 // closer is `Outbrack`, which is exactly how
11370 // checkglobqual (c:1163/1170, testing Outpar/Inpar)
11371 // and get_strarg's tokenized delimiter half
11372 // (Src/subst.c:1379-1390) find the true end. zshrs
11373 // untokenized here, one line before the glob layer,
11374 // so every quoted metachar became indistinguishable
11375 // from an active one and the qualifier parser closed
11376 // on the first quoted `]`. Keep the tokenized word
11377 // for the glob call; the untokenized form still
11378 // drives the non-glob arms below.
11379 let s_tok = s.clone();
11380 let s = crate::lex::untokenize(&s);
11381 // Skip glob expansion for assignment-shaped
11382 // words (`NAME=value`). zsh doesn't expand the
11383 // RHS of an assignment as a path glob unless
11384 // `setopt globassign` is set, and feeding such
11385 // words through expand_glob makes NOMATCH
11386 // (default ON) fire spuriously on
11387 // `integer i=2*3+1`, `path=*.rs`, etc.
11388 let is_assignment_shape = {
11389 let bytes = s.as_bytes();
11390 let mut i = 0;
11391 if !bytes.is_empty()
11392 && (bytes[0] == b'_' || bytes[0].is_ascii_alphabetic())
11393 {
11394 i += 1;
11395 while i < bytes.len()
11396 && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric())
11397 {
11398 i += 1;
11399 }
11400 i < bytes.len() && bytes[i] == b'='
11401 } else {
11402 false
11403 }
11404 };
11405 // Glob-trigger decision: pre-untokenize
11406 // haswilds_tokens_only result (computed above
11407 // before the untokenize that collapses META
11408 // tokens to their ASCII forms). The TOKEN-only
11409 // gate matches C `Src/pattern.c:4306-4376`
11410 // exactly — only Inbrack/Star/Quest/Inpar/Bar/
11411 // Inang/Pound/Hat token codes count as wild,
11412 // not their literal ASCII counterparts. Source-
11413 // level `*.toml` carries Star token so globs;
11414 // `$'…'`-decoded `[abc]` carries bare `[` so
11415 // stays literal. Bug #625.
11416 if is_glob_pre && !is_assignment_shape {
11417 exec.expand_glob(&s_tok)
11418 } else if is_assignment_shape
11419 && crate::ported::zsh_h::isset(crate::ported::zsh_h::MAGICEQUALSUBST)
11420 {
11421 // c:Src/exec.c:3353 — when MAGIC_EQUAL_SUBST is set
11422 // on a non-typeset command, esprefork = PREFORK_TYPESET,
11423 // so every NAME=value arg runs through
11424 // filesub(PREFORK_TYPESET): the `~`/`=` after the
11425 // first `=` (and after each `:`) undergo filename
11426 // expansion. `print foo=~/bar` → `foo=$HOME/bar`.
11427 // filesubstr (subst.c:741) keys on the Tilde TOKEN,
11428 // not literal `~`; this `s` was already untokenized
11429 // above, so re-tokenize (as BUILTIN_MAGIC_EQUALS_PREFORK
11430 // does) before filesub, then untokenize the result.
11431 let mut tokd = s.clone();
11432 crate::ported::glob::shtokenize(&mut tokd);
11433 let exp = crate::ported::subst::filesub(
11434 &tokd,
11435 crate::ported::zsh_h::PREFORK_TYPESET,
11436 );
11437 vec![crate::lex::untokenize(&exp).to_string()]
11438 } else {
11439 vec![s]
11440 }
11441 })
11442 .collect();
11443 if parts.len() == 1 {
11444 let only = parts.into_iter().next().unwrap_or_default();
11445 // Empty unquoted expansion → drop the arg entirely
11446 // (zsh "remove empty unquoted words" rule). Returning
11447 // an empty Value::Array makes pop_args contribute zero
11448 // items. Direct port of subst.c's empty-elide pass at
11449 // the end of multsub which removes empty linknodes
11450 // from unquoted contexts. Quoted DQ/SQ paths (modes
11451 // 1/2/5) take separate arms above and always emit
11452 // Value::Str so the empty arg survives.
11453 //
11454 // c:Src/subst.c:4437 + 1650-1656 — a word CONTAINING a
11455 // quoted span never drops: `x"${v[-1]}"y` (v empty)
11456 // is the scalar "xy", and a standalone `"${v[-1]}"`
11457 // is ONE empty arg. The lexer marks DQ/SQ spans with
11458 // Dnull(\u{9e})/Snull(\u{9d})/Qstring(\u{8c})/
11459 // Bnull(\u{9f}); their presence in the SOURCE word
11460 // means qt semantics apply. Without this gate, zpwr's
11461 // global `setopt rc_expand_param` turned autopair's
11462 // `local lchar="${LBUFFER[-1]}"` (empty prompt +
11463 // backspace) into an ARGLESS `local` — the full
11464 // parameter-table dump the user saw per keystroke.
11465 if only.is_empty() {
11466 // A quote span that WRAPS the expansion keeps the
11467 // empty arg (`"${v[-1]}"` → one empty arg). But a
11468 // quote INSIDE the `${…}` braces — e.g. the alternate
11469 // of `${x:+'q'}` or `${x:-'d'}` — does NOT: when that
11470 // branch isn't taken the result is a plain unquoted
11471 // empty and must ELIDE, matching zsh (`a=(A ${x:+'q'}
11472 // C)` → 2 elements, not 3). So only count quote
11473 // markers at brace-depth 0 (outside `${…}`). Inbrace
11474 // = \u{8f}, Outbrace = \u{90}.
11475 let mut depth = 0i32;
11476 let mut word_has_quoted_span = false;
11477 for c in text.chars() {
11478 match c {
11479 '\u{8f}' => depth += 1,
11480 '\u{90}' => depth -= 1,
11481 '\u{9e}' | '\u{9d}' | '\u{8c}' | '\u{9f}' | '"' | '\''
11482 if depth <= 0 =>
11483 {
11484 word_has_quoted_span = true;
11485 break;
11486 }
11487 _ => {}
11488 }
11489 }
11490 if word_has_quoted_span {
11491 // Returns a SCALAR Value, so the empty-Array
11492 // shape bit does not describe it — leave the
11493 // cell alone. Overwriting it here would let a
11494 // trailing quoted-empty segment resurrect a word
11495 // an EARLIER empty array already deleted:
11496 // `setopt rcexpandparam; a=(); x${a}"${P}"y` is
11497 // ZERO words in zsh, and concat_plan9's
11498 // `(Array(empty), scalar)` arm returns the scalar
11499 // when the bit says "scalar".
11500 Value::str(String::new())
11501 } else {
11502 // The empty `Value::Array` below stands for TWO
11503 // different C shapes, and under RC_EXPAND_PARAM
11504 // they behave OPPOSITELY:
11505 //
11506 // c:Src/subst.c:4362-4365 (plan9, empty ARRAY)
11507 // if (plan9) { uremnode(l, n); return n; }
11508 // → the whole word is deleted:
11509 // `a=(); x${a}y` and `x${${a}}y` are 0 words.
11510 //
11511 // c:Src/subst.c:4438-4467 (scalar arm, empty
11512 // SCALAR) — c:4464
11513 // *str = strcatsub(&y, ostr, aptr, x, xlen,
11514 // fstr, globsubst, copied);
11515 // then c:4467 `setdata(n, (void *) y);`
11516 // → the surrounding text survives:
11517 // `unset P; x${P}y` and `x${${P}}y` are the
11518 // single word `xy`.
11519 //
11520 // Nesting is NOT the discriminator — shape is.
11521 // The word is deleted only when the expansion was
11522 // ARRAY-shaped (c:4245 `if (isarr)`) AND produced
11523 // zero words, i.e. C's `while ((x = *aval++))`
11524 // loop at c:4327 never ran and left `plan9`
11525 // non-zero at c:4362. Anything else empty — an
11526 // unset/empty scalar, a nested subexp that
11527 // resolved to a scalar, a command substitution
11528 // with no output — takes c:4438's scalar arm and
11529 // keeps the word.
11530 note_empty_is_scalar(!(seg_zero_words && seg_is_array));
11531 Value::array(Vec::new())
11532 }
11533 } else {
11534 Value::str(only)
11535 }
11536 } else {
11537 Value::array(parts.into_iter().map(Value::str).collect())
11538 }
11539 }
11540 });
11541 // Pull any inner cmd-subst (`` `cmd` `` via mode 3 or via
11542 // mode 0/6 multsub → getoutput, `$(cmd)` via the default
11543 // arm's multsub path, nested `$()`s reached through
11544 // stringsubst) back into vm.last_status so a containing
11545 // assignment's BUILTIN_SET_VAR — which reads vm.last_status —
11546 // sees the cmd-subst's exit. Without this, backtick
11547 // assignments (`a=\`false\`; echo $?`) reported 0 because the
11548 // ported LASTVAL update never reached the VM-side counter.
11549 let cs_status = with_executor(|exec| exec.last_status());
11550 vm.last_status = cs_status;
11551 result_value
11552 });
11553
11554 // `${#name}` — pops [name]. Returns the value's element count for
11555 // arrays (indexed and assoc) or character length for scalars.
11556 // BUILTIN_PARAM_LENGTH — `${#name}`. PURE PASSTHRU.
11557 vm.register_builtin(BUILTIN_PARAM_LENGTH, |vm, _argc| {
11558 let name = vm.pop().to_str();
11559 // PARAM_LENGTH's empty-result semantics differ from
11560 // paramsubst_to_value: 0 nodes → "0" (numeric length), not
11561 // empty array. paramsubst on `${#X}` always returns at least
11562 // one node in practice (the length string); the empty case
11563 // is defensive.
11564 let mut ret_flags: i32 = 0;
11565 let (_full, _pos, nodes) = crate::ported::subst::paramsubst(
11566 &format!("${{#{}}}", name),
11567 0,
11568 false,
11569 0i32,
11570 &mut ret_flags,
11571 );
11572 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
11573 with_executor(|exec| exec.set_last_status(1));
11574 }
11575 if nodes.is_empty() {
11576 Value::str("0")
11577 } else {
11578 nodes_to_value(nodes)
11579 }
11580 });
11581
11582 // `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
11583 // `${var/%pat/repl}` — Pops [name, pattern, replacement, op_byte].
11584 // op: 0=first, 1=all, 2=anchor-prefix (`/#`), 3=anchor-suffix (`/%`).
11585 // BUILTIN_PARAM_REPLACE — `${var/pat/repl}` / `${var//pat/repl}` /
11586 // `${var/#pat/repl}` / `${var/%pat/repl}`. PURE PASSTHRU.
11587 vm.register_builtin(BUILTIN_PARAM_REPLACE, |vm, _argc| {
11588 let dq_flag = vm.pop().to_int() != 0;
11589 let op = vm.pop().to_int() as u8;
11590 let repl = vm.pop().to_str();
11591 let pattern = vm.pop().to_str();
11592 let name = vm.pop().to_str();
11593 // !!! DASH-STRICT GATE !!! dash / ash have no `${var/pat/repl}`
11594 // pattern-replacement expansion — it is a "Bad substitution" error
11595 // (bash/ksh/POSIX-sh support it, so those modes fall through). Raise
11596 // the canonical zsh diagnostic + exit 1 to match /bin/dash's failure.
11597 if crate::dash_mode::dash_strict() {
11598 crate::ported::utils::zerr("bad substitution");
11599 crate::ported::utils::errflag.fetch_or(
11600 crate::ported::zsh_h::ERRFLAG_ERROR,
11601 std::sync::atomic::Ordering::Relaxed,
11602 );
11603 with_executor(|exec| exec.set_last_status(1));
11604 return Value::str("");
11605 }
11606 // DQ context: C's lexer marks every `$` inside double quotes
11607 // as the Qstring token (Src/lex.c dquote_parse) and keeps `'`
11608 // a plain char — so a DQ replacement's `$'…'` is LITERAL in
11609 // C (Src/subst.c:301 decodes only the tokenized Snull form;
11610 // `"${a/X/$'\0'}"` keeps the five chars `$'\0'`). The body
11611 // rebuilt below re-enters stringsubst as raw text, which
11612 // would mis-decode `$'…'` as ANSI-C; stamp the Qstring
11613 // marker on the repl's `$` so stringsubst sees the same DQ
11614 // signal C's tokens carry. The PATTERN side keeps decoding
11615 // (matches observed zsh: the pattern's `$'\0'` matches a
11616 // real NUL while the repl's stays literal).
11617 let repl = if dq_flag {
11618 repl.replace('$', "\u{8c}")
11619 } else {
11620 repl
11621 };
11622 // op encoding: 0 = first `/`, 1 = all `//`, 2 = anchor-prefix
11623 // `/#`, 3 = anchor-suffix `/%`. The brace form distinguishes
11624 // first-vs-all by single vs doubled slash, and anchored by
11625 // a `#` or `%` immediately after the slash(es).
11626 let body = match op {
11627 0 => format!("${{{}/{}/{}}}", name, pattern, repl),
11628 1 => format!("${{{}//{}/{}}}", name, pattern, repl),
11629 2 => format!("${{{}/#{}/{}}}", name, pattern, repl),
11630 3 => format!("${{{}/%{}/{}}}", name, pattern, repl),
11631 _ => format!("${{{}/{}/{}}}", name, pattern, repl),
11632 };
11633 // c:Src/subst.c:1625 — paramsubst's qt flag. The compiler
11634 // threads the word's DQ context onto the stack; dropping it
11635 // (the old `let _dq_flag`) ran the rebuilt body with qt=false
11636 // whenever the opcode fired outside an EXPAND_TEXT scope, so
11637 // DQ-only semantics inside the replacement (e.g. `$'` staying
11638 // literal per Src/subst.c:301 — `"${a/x/$'\t'q}"`) were lost.
11639 // Bump in_dq_context exactly like EXPAND_TEXT mode 1 so
11640 // paramsubst_to_value's qt probe sees the right context.
11641 if dq_flag {
11642 with_executor(|exec| exec.in_dq_context += 1);
11643 }
11644 let ret = paramsubst_to_value(&body);
11645 if dq_flag {
11646 with_executor(|exec| exec.in_dq_context -= 1);
11647 }
11648 ret
11649 });
11650
11651 vm.register_builtin(BUILTIN_REGISTER_COMPILED_FN, |vm, argc| {
11652 let args = pop_args(vm, argc);
11653 let mut iter = args.into_iter();
11654 let name = iter.next().unwrap_or_default();
11655 let body_b64 = iter.next().unwrap_or_default();
11656 let body_source = iter.next().unwrap_or_default();
11657 let line_base_str = iter.next().unwrap_or_default();
11658 let line_base: i64 = line_base_str.parse().unwrap_or(0);
11659 // c:Src/exec.c:5382 `do_tracing = *state->pc++;` — the `-T` of
11660 // `function -T name { … }`, carried across from compile_funcdef.
11661 let do_tracing = iter.next().map(|s| s == "1").unwrap_or(false); // c:5382
11662 // c:Src/exec.c:5451-5456 — `shf->redir = <redir_prog>`: the rendered
11663 // text of the definition's trailing redirections (empty when there
11664 // were none). See `shfunc::redir_text`.
11665 let redir_text = iter.next().unwrap_or_default(); // c:5453
11666 // c:5387 — `tracing_flags = do_tracing ? PM_TAGGED_LOCAL : 0;`
11667 let tracing_flags: u32 = if do_tracing {
11668 crate::ported::zsh_h::PM_TAGGED_LOCAL
11669 } else {
11670 0
11671 }; // c:5387
11672 let bytes = base64_decode(&body_b64);
11673 let status = match bincode::deserialize::<fusevm::Chunk>(&bytes) {
11674 Ok(chunk) => with_executor(|exec| {
11675 // c:Src/exec.c:5383 — `shf->filename =
11676 // ztrdup(scriptfilename);` — the function's
11677 // definition-file is read from the canonical
11678 // file-scope `scriptfilename` global at compile
11679 // time, NOT from a per-executor struct field.
11680 // exec.scriptfilename is seeded once at
11681 // bins/zshrs.rs:1717 to the bin basename ("zsh")
11682 // and never updates on source/dot, so reading from
11683 // it left every user function's def_file as "zsh".
11684 // Route through scriptfilename_get() so source /
11685 // dot's set_scriptfilename calls propagate.
11686 let def_file = crate::ported::utils::scriptfilename_get()
11687 .or_else(|| exec.scriptfilename.clone());
11688 let def_file_for_prov = def_file.clone();
11689 if !body_source.is_empty() {
11690 exec.function_source
11691 .insert(name.clone(), body_source.clone());
11692 }
11693 exec.function_line_base.insert(name.clone(), line_base);
11694 exec.function_def_file.insert(name.clone(), def_file);
11695 // PFA-SMR aspect: every `name() {}` / `function name { }`
11696 // funnels through here at compile time. Emit one record
11697 // with the function name + raw body source.
11698 #[cfg(feature = "recorder")]
11699 if crate::recorder::is_enabled() {
11700 let ctx = exec.recorder_ctx();
11701 let body = if body_source.is_empty() {
11702 None
11703 } else {
11704 Some(body_source.as_str())
11705 };
11706 crate::recorder::emit_function(&name, body, ctx);
11707 }
11708 // c:Src/exec.c:5516-5531 — `TRAP<SIG>() { ... }` is the
11709 // function-named trap install. zsh detects the `TRAP`
11710 // prefix at func-def time and calls
11711 // `settrap(signum, NULL, ZSIG_FUNC)` so the next
11712 // dispatch of that signal routes to the named shfunc.
11713 // Bug #157 in docs/BUGS.md — fusevm_bridge's funcdef
11714 // opcode skipped this dispatch entirely, so TRAPEXIT /
11715 // TRAPUSR1 / TRAPZERR / TRAPDEBUG never fired.
11716 //
11717 // ORDER MATTERS. C runs settrap at c:5518 and
11718 // `shfunctab->addnode` only at c:5539, and dosavetrap
11719 // says why (c:634-637): "Get the old function: this
11720 // assumes we haven't added the new one yet." Running
11721 // the install first made settrap→unsettrap→removetrap→
11722 // dosavetrap snapshot the NEW body, so endtrapscope
11723 // "restored" the inner definition and a nested
11724 // `TRAPEXIT() { … }` permanently clobbered the outer one.
11725 if name.len() > 4 && name.starts_with("TRAP") {
11726 if let Some(sn) = crate::ported::jobs::getsigidx(&name[4..]) {
11727 let _ = crate::ported::signals::settrap(
11728 sn,
11729 None,
11730 crate::ported::zsh_h::ZSIG_FUNC as i32,
11731 );
11732 // c:5530 — `removetrapnode(signum);` "Remove the
11733 // old node explicitly in case it has an
11734 // alternative name". NOT mirrored here: zshrs's
11735 // `jobs::removetrapnode` routes through
11736 // `hashtable::removeshfuncnode`, which calls back
11737 // into `unsettrap` (C explicitly avoids that path
11738 // — see the comment at Src/signals.c:836-838) and
11739 // would tear down the trap `settrap` just armed.
11740 // The `tab.add` below overwrites the canonical
11741 // `TRAP<SIG>` node anyway; only the alt-name case
11742 // (TRAPCLD vs TRAPCHLD) is left unhandled.
11743 }
11744 }
11745 // Mirror into canonical shfunctab so scanfunctions /
11746 // ${(k)functions} / functions builtin see user defs.
11747 // C: exec.c:funcdef → shfunctab->addnode(ztrdup(name),shf).
11748 if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
11749 let mut shf = crate::ported::hashtable::shfunc_with_body(&name, &body_source);
11750 // c:Src/exec.c:5453/5455 — `shf->redir = …`. Stored as
11751 // rendered text (see `shfunc::redir_text`); `None` is C's
11752 // `shf->redir == NULL`.
11753 shf.redir_text = if redir_text.is_empty() {
11754 None
11755 } else {
11756 Some(redir_text.clone())
11757 };
11758 // c:Src/exec.c:5437 — `shf->node.flags = tracing_flags;`
11759 shf.node.flags |= tracing_flags as i32; // c:5437
11760 // c:5532-5538 — /* Is this function traced and redefining
11761 // itself? */
11762 // if (funcstack && funcstack->tp == FS_FUNC &&
11763 // !strcmp(s, funcstack->name)) {
11764 // Shfunc old = ((Shfunc)shfunctab->getnode(shfunctab, s));
11765 // if (old)
11766 // shf->node.flags |= old->node.flags &
11767 // (PM_TAGGED|PM_TAGGED_LOCAL);
11768 // }
11769 // The ported walker does this at exec.rs:7387, but fusevm —
11770 // not that walker — is what registers a `name() { … }`, so a
11771 // `functions -T f`-tagged function that redefined itself
11772 // came back untraced (E02xtrace:6).
11773 if let Ok(stk) = crate::ported::modules::parameter::FUNCSTACK.lock() {
11774 if let Some(top) = stk.last() {
11775 // c:5533
11776 if top.tp == crate::ported::zsh_h::FS_FUNC && top.name == name {
11777 // c:5535 — `Shfunc old = shfunctab->getnode(shfunctab, s);`
11778 // Read through the write guard already held
11779 // above: `shfunctab_lock()` is a std RwLock and
11780 // is NOT reentrant, so re-acquiring it here
11781 // self-deadlocked on exactly the
11782 // self-redefinition case (C04funcdef:30 hung).
11783 if let Some(old) = tab.get(&name) {
11784 // c:5537
11785 shf.node.flags |= old.node.flags
11786 & (crate::ported::zsh_h::PM_TAGGED as i32
11787 | crate::ported::zsh_h::PM_TAGGED_LOCAL as i32);
11788 }
11789 }
11790 }
11791 }
11792 // `shfunc_with_body` stamps the AMBIENT `scriptfilename`
11793 // (c:Src/exec.c:5383). That is right for an ordinary
11794 // definition but wrong twice over for an autoloaded one:
11795 //
11796 // * c:Src/exec.c:5622-5630 — while an autoload file's text
11797 // runs, C sets `scriptfilename = getshfuncfile(shf)`, so a
11798 // `name() { … }` INSIDE it records the fpath file. zshrs
11799 // runs that body on the normal VM path with the CALLER's
11800 // scriptfilename still in place, so a ksh-style autoload
11801 // file — one that defines the function and then calls it —
11802 // got attributed to whoever triggered the load.
11803 // * the function is then re-registered UNCHANGED when its
11804 // chunk is compiled at call time, and that second stamp
11805 // would overwrite the first even if the first were right.
11806 //
11807 // Result before this: `whence -v f` said "from ./caller.zsh"
11808 // where zsh says "from dir/f", and `funcsourcetrace[1]`
11809 // pointed at the caller — which compsys reads directly
11810 // (`_git` locates git-completion.bash through
11811 // `"$(dirname ${funcsourcetrace[1]%:*})"`).
11812 if let Some(f) = crate::vm_helper::autoload_def_file(&name) {
11813 shf.filename = Some(f); // c:5625 getshfuncfile(shf)
11814 } else if let Some(prev) = tab.get(&name) {
11815 // Re-registration of an unchanged body relabels nothing.
11816 if prev.body.as_deref() == Some(body_source.as_str()) {
11817 shf.filename = prev.filename.clone();
11818 shf.node.flags |=
11819 prev.node.flags & crate::ported::zsh_h::PM_LOADDIR as i32;
11820 }
11821 }
11822 // c:Src/exec.c:5409 — `shf->lineno = lineno;`. Use
11823 // the same max(1, line_base) clamp as the synth_shf
11824 // in vm_helper::dispatch_function_call. Bug #396.
11825 shf.lineno = std::cmp::max(1, line_base);
11826 // c:Src/exec.c:5402 — `shfunc_set_sticky(shf);`
11827 // stamps the definition-time `sticky` emulation
11828 // snapshot onto the function so a later call can
11829 // re-enter that emulation (doshfunc c:5978). The
11830 // ported execfuncdef does this at exec.rs:7141, but
11831 // fusevm — not that walker — is what actually
11832 // registers a `name() { … }`, so `emulate sh -c
11833 // 'f() { … }'` produced a function with no sticky
11834 // emulation at all (B07emulate.ztst:6,7,8,12,13,14).
11835 crate::ported::exec::shfunc_set_sticky(&mut shf);
11836 // zshrs re-runs this registration when a function's
11837 // chunk is (re)compiled at call time — see the
11838 // filename note above. That second pass happens with
11839 // the AMBIENT sticky (normally none), which would
11840 // erase the definition-time stamp. An unchanged body
11841 // is not a redefinition, so keep what it had.
11842 if shf.sticky.is_none() {
11843 if let Some(prev) = tab.get(&name) {
11844 if prev.body.as_deref() == Some(body_source.as_str()) {
11845 shf.sticky = prev
11846 .sticky
11847 .as_deref()
11848 .map(|b| crate::ported::exec::sticky_emulation_dup(b, 0));
11849 }
11850 }
11851 }
11852 tab.add(shf);
11853 }
11854 // Lineage tap: this is where a `name() { … }` actually
11855 // lands in the VM path — `execfuncdef`'s shfunctab
11856 // install only runs for the interpreter path. The first
11857 // definition is the function's origin; a later one is a
11858 // `redefine` op on the same chain.
11859 if crate::provenance::active() {
11860 crate::provenance::on_func_define(
11861 &name,
11862 Some(body_source.as_str()),
11863 def_file_for_prov.as_deref(),
11864 std::cmp::max(1, line_base),
11865 );
11866 }
11867 exec.functions_compiled.insert(name, chunk);
11868 0
11869 }),
11870 Err(_) => 1,
11871 };
11872 Value::Status(status)
11873 });
11874
11875 // Wire the ShellHost so direct shell ops (Op::Glob, Op::TildeExpand,
11876 // Op::ExpandParam, Op::CmdSubst, Op::CallFunction, etc.) route through
11877 // ZshrsHost back into the executor.
11878 vm.set_shell_host(Box::new(ZshrsHost));
11879}
11880
11881impl ZshrsHost {
11882 /// True iff `c` can be a `(j:…:)` / `(s:…:)` delimiter — non-alphanumeric,
11883 /// non-underscore. Restricting to punctuation avoids `(jL)` consuming `L`
11884 /// as a delim instead of as the next flag.
11885 fn is_zsh_flag_delim(c: char) -> bool {
11886 !c.is_ascii_alphanumeric() && c != '_'
11887 }
11888}
11889
11890/// Shared `${name[idx]}` subscript dispatch for BUILTIN_ARRAY_INDEX
11891/// and the KSHARRAYS-unset arm of BUILTIN_ARRAY_INDEX_UNBRACED.
11892///
11893/// c:Src/subst.c subscript parsing — when paramsubst re-parses the
11894/// synthesized `${name[idx]}` body, characters like `'` `"` `\` `$`
11895/// etc. are LEXER-active inside the `[…]` and get reinterpreted
11896/// (quote-strip, paramsubst recursion, …). For PRE-EVALUATED key
11897/// strings (the dynamic-key fast path at compile_zsh.rs:3234 already
11898/// expanded `$k` via EXPAND_TEXT), the idx is a literal string that
11899/// must match the stored key byte-for-byte — no further
11900/// reinterpretation. Direct assoc lookup bypasses the lexer for this
11901/// case, avoiding the quote-strip bug where `h[a'b]` failed to
11902/// resolve because paramsubst's subscript lexer treated the `'` as a
11903/// quote. Bug #338. Only fires for simple assoc-name + non-flag idx
11904/// (no outer-flag sentinels, no `(…)` flag prefix on idx, no splat
11905/// operator). Other paths (slice, splat, flag-based search,
11906/// magic-assoc) still flow through paramsubst.
11907fn array_index_lookup(name: &str, idx: &str) -> Value {
11908 let idx_is_simple = !idx.starts_with('(') && idx != "@" && idx != "*" && !idx.contains(',');
11909 if idx_is_simple {
11910 // assoc_key_hit: single-lock O(1) probe — exec.assoc() clones
11911 // the WHOLE map per lookup (O(n), quadratic in shell loops).
11912 // When `name` IS an assoc, exact-key semantics apply to EVERY
11913 // plain key: hit → value, miss → empty (C `${assoc[missing]}`).
11914 // Never fall through to the textual `${name[key]}` rebuild —
11915 // keys carrying `{`/`}`/`[`/`]` (zsh-autopair probes
11916 // `${AUTOPAIR_LBOUNDS[$pair]}` with pair='{') re-parse as
11917 // broken syntax there ("failed to compile regex: repetition
11918 // quantifier…" + a `}` appended per keystroke).
11919 if let Some((_, v)) = crate::vm_helper::assoc_key_hit(name, idx) {
11920 return Value::str(v.unwrap_or_default());
11921 }
11922 }
11923 // c:Src/params.c:1449-1450 getindex — a leading `(e)`/`(E)` flag
11924 // group makes the subscript LITERAL (group consumed, exact key).
11925 // The textual rebuild below re-parses a FLAT `${name[(e)KEY]}`
11926 // string, so a `]` / `}` that arrived via `$key` expansion
11927 // terminates the subscript / brace early — "bad substitution" or
11928 // spilled-junk values (zpwr expandstats iterates alias keys
11929 // containing brackets). C never re-parses: getarg scans the
11930 // TOKENIZED source where expanded data brackets are inert. Do the
11931 // exact-match lookup directly against the assoc (plain or magic
11932 // alias tables); search groups ((r)/(i)/(k)/…) and other targets
11933 // keep the textual path.
11934 if let Some(rest) = idx.strip_prefix('(') {
11935 if let Some(close) = rest.find(')') {
11936 let grp = &rest[..close];
11937 if !grp.is_empty() && grp.chars().all(|ch| ch == 'e' || ch == 'E') {
11938 let key = &rest[close + 1..];
11939 if let Some(hit) = direct_assoc_key_get(name, key) {
11940 return Value::str(hit.unwrap_or_default());
11941 }
11942 }
11943 }
11944 }
11945 // Plain assoc key that the flat rebuild would mangle (`]` closes
11946 // the subscript, `}` closes the brace): direct lookup. On a miss
11947 // return empty — the textual fallback cannot represent the key.
11948 if (idx.contains(']') || idx.contains('}')) && !idx.starts_with('(') {
11949 if let Some(hit) = direct_assoc_key_get(name, idx) {
11950 return Value::str(hit.unwrap_or_default());
11951 }
11952 }
11953 let body = format!("${{{}[{}]}}", name, idx);
11954 paramsubst_to_value(&body)
11955}
11956
11957/// Exact-key read against an assoc-like target WITHOUT the textual
11958/// `${name[key]}` reparse (see array_index_lookup — expanded `]`/`}`
11959/// in keys break the flat form). `Some(hit)` when `name` is a target
11960/// this helper understands (plain assoc, or the alias magic assocs of
11961/// zsh/parameter — Src/Modules/parameter.c getpmalias family);
11962/// `None` = not direct-capable, caller keeps the textual path.
11963fn direct_assoc_key_get(name: &str, key: &str) -> Option<Option<String>> {
11964 use crate::ported::zsh_h::{ALIAS_GLOBAL, DISABLED};
11965 // c:Src/Modules/parameter.c:1247+ getpmalias / getpmgalias /
11966 // getpmsalias — each view filters its table by flags.
11967 let alias_view = |global: bool, suffix: bool, disabled: bool| -> Option<String> {
11968 let tab = if suffix {
11969 crate::ported::hashtable::sufaliastab_lock()
11970 } else {
11971 crate::ported::hashtable::aliastab_lock()
11972 };
11973 tab.read().ok().and_then(|t| {
11974 t.iter().find_map(|(k, a)| {
11975 let f = a.node.flags as u32;
11976 if k == key
11977 && ((f & ALIAS_GLOBAL as u32 != 0) == global || suffix)
11978 && ((f & DISABLED as u32 != 0) == disabled)
11979 {
11980 Some(a.text.clone())
11981 } else {
11982 None
11983 }
11984 })
11985 })
11986 };
11987 match name {
11988 "aliases" => Some(alias_view(false, false, false)),
11989 "galiases" => Some(alias_view(true, false, false)),
11990 "saliases" => Some(alias_view(false, true, false)),
11991 "dis_aliases" => Some(alias_view(false, false, true)),
11992 "dis_galiases" => Some(alias_view(true, false, true)),
11993 "dis_saliases" => Some(alias_view(false, true, true)),
11994 _ => {
11995 // Single-lock O(1) probe (see assoc_key_hit) — the previous
11996 // double exec.assoc() cloned the whole map twice per lookup.
11997 crate::vm_helper::assoc_key_hit(name, key).map(|(_, v)| v)
11998 }
11999 }
12000}
12001
12002/// KSHARRAYS bare-`$name` expansion words for the unbraced
12003/// no-subscript form (BUILTIN_ARRAY_INDEX_UNBRACED's KSHARRAYS arm).
12004///
12005/// - `@` / `*` stay the full positional list (the c:Src/params.c:
12006/// 2293-2296 first-element collapse is gated on
12007/// `itype_end(t, IIDENT, 1) != t` — an identifier-shaped name —
12008/// which `@`/`*` are not). The literal `[idx]` then joins the LAST
12009/// word, matching zsh 5.9: `setopt ksharrays; set -- p q;
12010/// print -- $@[0]` → `zsh:1: no matches found: q[0]`.
12011/// - Identifier-named arrays collapse to the FIRST element
12012/// (c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`).
12013/// - Assocs collapse to the first value in scan order; the `options`
12014/// magic assoc's scan order is `OPTIONTAB` bucket order (first key
12015/// `posixargzero`), matching zsh 5.9: `emulate sh -L;
12016/// print $options[posixargzero]` → `off[posixargzero]`.
12017/// - Scalars / unset names expand to their value / empty (zsh 5.9:
12018/// `setopt ksharrays; print -- $unsetvar[0]` →
12019/// `zsh:1: no matches found: [0]`).
12020fn ksharrays_bare_words(name: &str) -> Vec<String> {
12021 if name == "@" || name == "*" {
12022 return with_executor(|exec| exec.pparams());
12023 }
12024 // Magic special-parameter lookups first — mirrors the
12025 // BUILTIN_GET_VAR precedence (partab before executor tables).
12026 if let Some(vals) = crate::vm_helper::partab_array_get(name) {
12027 return vec![vals.into_iter().next().unwrap_or_default()];
12028 }
12029 if let Some(keys) = crate::vm_helper::partab_scan_keys(name) {
12030 let v = keys
12031 .first()
12032 .and_then(|k| crate::vm_helper::partab_get(name, k))
12033 .unwrap_or_default();
12034 return vec![v];
12035 }
12036 let arr_or_assoc = with_executor(|exec| {
12037 if let Some(arr) = exec.array(name) {
12038 // c:Src/params.c:2293-2296 — first element only.
12039 return Some(arr.first().cloned().unwrap_or_default());
12040 }
12041 if let Some(map) = exec.assoc(name) {
12042 // c:Src/params.c:2351-2358 — under KSH EMULATION a bare
12043 // `$assoc` is `${assoc[0]}` (KEY-"0" lookup), EMPTY unless the
12044 // hash has a key "0": `emulate -L ksh; typeset -A h=(a 1 b 2);
12045 // print $h` is empty. Every other mode (`setopt ksharrays`,
12046 // `emulate sh`) falls through to the first bucket value below.
12047 if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
12048 return Some(map.get("0").cloned().unwrap_or_default());
12049 }
12050 // Mirrors BUILTIN_GET_VAR's bare-assoc ordering
12051 // (sorted keys, first value).
12052 let mut keys: Vec<&String> = map.keys().collect();
12053 keys.sort();
12054 return Some(
12055 keys.first()
12056 .and_then(|k| map.get(*k).cloned())
12057 .unwrap_or_default(),
12058 );
12059 }
12060 None
12061 });
12062 if let Some(v) = arr_or_assoc {
12063 return vec![v];
12064 }
12065 vec![with_executor(|exec| exec.get_variable(name))]
12066}
12067
12068/// Run `body` through `crate::ported::subst::paramsubst` and convert
12069/// the resulting node list into a fusevm `Value`. Centralises the
12070/// pattern duplicated across ~10 BUILTIN_* handlers:
12071/// - build a `${...}` body string from opcode operands
12072/// - paramsubst the body
12073/// - propagate errflag to `exec.last_status`
12074/// - delegate the LinkList → Value conversion to `nodes_to_value`
12075///
12076/// **Extension** — Rust-only helper. No direct C analog because C
12077/// zsh uses LinkList everywhere; the conversion happens at the
12078/// boundary back into the VM's stack.
12079fn paramsubst_to_value(body: &str) -> Value {
12080 paramsubst_to_value_pf(body, 0)
12081}
12082
12083/// `paramsubst_to_value` with an explicit `pf_flags` (`PREFORK_*`) set.
12084///
12085/// c:Src/subst.c:1627 — `paramsubst(l, n, str, qt, pf_flags, ret_flags)`.
12086/// The only caller that needs a non-zero set today is the `${(flags)NAME}`
12087/// fast path when the word is a scalar-assignment VALUE: C preforks that with
12088/// `PREFORK_SINGLE|PREFORK_ASSIGN` (c:Src/exec.c:2603 for `x=…`,
12089/// c:Src/exec.c:4239-4241 for the typeset-family `NAME=…` argument), and
12090/// `PREFORK_SINGLE` is the `ssub` that turns off c:3913's forced split.
12091fn paramsubst_to_value_pf(body: &str, pf_flags: i32) -> Value {
12092 // c:Src/subst.c:1625 paramsubst's `qt` flag is the C signal that
12093 // the current expansion is inside `"…"`. The fast-path bridges
12094 // (BUILTIN_PARAM_*, BUILTIN_BRIDGE_BRACE_ARRAY) used to hardcode
12095 // qt=false, which silently broke DQ-only semantics inside
12096 // `${arr:^other}` / `${arr:^^other}` (Src/subst.c:3456-3520).
12097 // The executor's `in_dq_context` counter is bumped by EXPAND_TEXT
12098 // mode 1 / mode 5 before the bridge fires, so reading it here
12099 // propagates the DQ flag without changing every bridge call site.
12100 let qt = with_executor(|exec| exec.in_dq_context > 0);
12101 let mut ret_flags: i32 = 0;
12102 let (_full, _pos, nodes) =
12103 crate::ported::subst::paramsubst(body, 0, qt, pf_flags, &mut ret_flags);
12104 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
12105 with_executor(|exec| exec.set_last_status(1));
12106 }
12107 // c:Src/lex.c untokenize — the final argv pass C runs on every
12108 // expanded word (glob.c:1862 / exec.c) DROPS the Nularg
12109 // empty-word sentinel (c:2089 `if (c != Nularg)`) that
12110 // remnulargs faithfully leaves in place (glob.c:3673 re-adds it
12111 // for all-empty results). These fast-path bridges are terminal —
12112 // their output lands directly in argv slots — so apply it here.
12113 // Without it, quoted splits with empty pieces ("${(s:|:)x}" on
12114 // "|a|b|") leak U+00A1 into argv.
12115 let nodes: Vec<String> = nodes
12116 .into_iter()
12117 .map(|n| crate::ported::lex::untokenize(&n))
12118 .collect();
12119 let value = nodes_to_value(nodes);
12120 // Provenance: this is the funnel every `${...}` bytecode fast path
12121 // reaches, so it is where a tracked parameter's chain is handed to
12122 // the value the expansion produced.
12123 if crate::provenance::active() {
12124 if let Some(name) = prov_subst_name(body) {
12125 crate::provenance::on_param_read(&name, &value);
12126 }
12127 }
12128 value
12129}
12130
12131/// Parameter name inside a `${...}` expansion body, for the provenance
12132/// tap in `paramsubst_to_value_pf`. Skips a leading `(flags)` group and
12133/// the `#`/`+`/`^`/`=`/`~` prefix sigils, then takes the identifier —
12134/// `${(k)assoc[key]}` yields `assoc`, `${#F}` yields `F`. Returns `None`
12135/// for the forms with no single named source (`$(...)`, `${(%)...}`).
12136fn prov_subst_name(body: &str) -> Option<String> {
12137 let mut rest = body.strip_prefix("${").or_else(|| body.strip_prefix('$'))?;
12138 rest = rest.trim_end_matches('}');
12139 // Skip one leading `(flags)` group.
12140 if let Some(after) = rest.strip_prefix('(') {
12141 rest = after.split_once(')').map(|(_, r)| r)?;
12142 }
12143 rest = rest.trim_start_matches(['#', '+', '^', '=', '~']);
12144 let name: String = rest
12145 .chars()
12146 .take_while(|c| c.is_alphanumeric() || *c == '_')
12147 .collect();
12148 if name.is_empty() {
12149 None
12150 } else {
12151 Some(name)
12152 }
12153}
12154
12155/// Wrap a `Vec<String>` (e.g. paramsubst nodes, multsub parts,
12156/// xpandbraces output) into a fusevm `Value`: 0 → empty Array, 1 →
12157/// Str, >1 → Array. Same unwrap idiom every handler that calls a
12158/// canonical Vec-returning fn does.
12159/// c:Src/subst.c:1663 — `int plan9 = isset(RCEXPANDPARAM);`
12160///
12161/// zsh calls the RC_EXPAND_PARAM word shape "plan9" after the rc(1)
12162/// shell it comes from. The option is read fresh on every expansion
12163/// (`setopt` mid-script changes the very next word), so the concat
12164/// builtins must consult it at RUNTIME, not bake it in at compile time.
12165fn plan9_active() -> bool {
12166 with_executor(|_exec| opt_state_get("rcexpandparam").unwrap_or(false))
12167}
12168
12169thread_local! {
12170 /// The `isarr` bit that `Value` cannot carry.
12171 ///
12172 /// c:Src/subst.c:4245 `if (isarr)` gates the whole array emit block,
12173 /// so plan9's word-removal rule (c:4362 `uremnode`) only ever applies
12174 /// to an ARRAY-valued expansion. An empty SCALAR has `isarr == 0`,
12175 /// takes the c:4437 scalar branch, and leaves the surrounding text
12176 /// intact — `setopt rcexpandparam; v=; print -rl -- x$v y` prints `x`
12177 /// and `y`, while the same line with an empty ARRAY prints only `y`.
12178 ///
12179 /// zshrs collapses BOTH shapes to `Value::Array(vec![])`: a real empty
12180 /// array, and an unquoted empty scalar (collapsed so a standalone `$v`
12181 /// contributes zero argv words, mirroring prefork's `uremnode` at
12182 /// c:Src/subst.c:184-187). The two are indistinguishable by the time
12183 /// they reach a concat builtin, so the expansion builtins record here
12184 /// whether the empty Array they just produced came from a scalar.
12185 ///
12186 /// Sticky until the next expansion overwrites it — a word folds its
12187 /// segments left-associatively (`concat(concat(x, $e), y)`), so the
12188 /// propagating second concat must still see the first one's bit.
12189 /// Consequence: only a builtin that RETURNS an empty `Value::Array` may
12190 /// write the cell. A builtin returning an empty SCALAR `Value` must leave
12191 /// it alone, or it resurrects a word an earlier empty array deleted
12192 /// (`a=(); x${a}"${P}"y` is zero words in zsh) — see the
12193 /// `word_has_quoted_span` arm of `BUILTIN_EXPAND_TEXT`.
12194 static EMPTY_EXPANSION_IS_SCALAR: std::cell::Cell<bool> =
12195 const { std::cell::Cell::new(false) };
12196}
12197
12198/// Record whether the empty expansion just produced was a scalar (`true`)
12199/// or a genuine array (`false`). See `EMPTY_EXPANSION_IS_SCALAR`.
12200fn note_empty_is_scalar(is_scalar: bool) {
12201 EMPTY_EXPANSION_IS_SCALAR.with(|c| c.set(is_scalar));
12202}
12203
12204/// True when the empty `Value::Array` about to be concatenated stands for
12205/// an empty SCALAR (c:4438-4467), not an empty array (c:4362).
12206fn empty_is_scalar() -> bool {
12207 EMPTY_EXPANSION_IS_SCALAR.with(|c| c.get())
12208}
12209
12210/// Re-assert `was_scalar` when `v` is an empty result.
12211///
12212/// For a pass-through stage — one that rewrites word TEXT but cannot turn a
12213/// scalar into an array or vice versa — the shape bit belongs to whichever
12214/// expansion produced the value, not to the stage. Such stages usually end in
12215/// `nodes_to_value`, which records "array" for an empty result, so without
12216/// this the producer's bit is lost before `concat_plan9` reads it.
12217/// Non-empty results carry their shape in the `Value` itself and are
12218/// returned untouched.
12219fn restore_empty_shape(v: Value, was_scalar: bool) -> Value {
12220 if matches!(&v, Value::Array(a) if a.is_empty()) {
12221 note_empty_is_scalar(was_scalar);
12222 }
12223 v
12224}
12225
12226/// `concat_splice` with the provenance tap applied to the result. Every
12227/// concat bytecode op routes through this (or `concat_plan9_prov`), so a
12228/// word built out of a tracked parameter keeps that parameter's lineage
12229/// even though the concatenated bytes are a fresh allocation.
12230fn concat_splice_prov(lhs: Value, rhs: Value) -> Value {
12231 if !crate::provenance::active() {
12232 return concat_splice(lhs, rhs);
12233 }
12234 let (l, r) = (lhs.clone(), rhs.clone());
12235 let out = concat_splice(lhs, rhs);
12236 crate::provenance::on_concat(&l, &r, &out);
12237 out
12238}
12239
12240/// `concat_plan9` with the provenance tap. See `concat_splice_prov`.
12241fn concat_plan9_prov(lhs: Value, rhs: Value) -> Value {
12242 if !crate::provenance::active() {
12243 return concat_plan9(lhs, rhs);
12244 }
12245 let (l, r) = (lhs.clone(), rhs.clone());
12246 let out = concat_plan9(lhs, rhs);
12247 crate::provenance::on_concat(&l, &r, &out);
12248 out
12249}
12250
12251/// c:Src/subst.c:4366-4437 — the NON-plan9 arm of paramsubst's array
12252/// emit block: "simply join the first and last values."
12253///
12254/// The word prefix (`ostr..aptr`) is concatenated onto element 0
12255/// (c:4386 `strcatsub(&y, ostr, aptr, x, xlen, NULL, …)`), the interior
12256/// elements are emitted bare (c:4393-4412), and the word suffix (`fstr`)
12257/// is concatenated onto the final element (c:4414-4429). Applied left-
12258/// associatively across a word's segments this reproduces zsh's
12259/// `pre${arr}post` → `prep` / `q` / `rpost`.
12260///
12261/// An EMPTY array never reaches this arm in C: c:4261
12262/// `if ((!aval[0] || !aval[1]) && !plan9)` collapses it to the scalar ""
12263/// first, so the surrounding text survives as one word (`x$e y` → `x`).
12264/// That is what the empty-Array arms below reproduce.
12265fn concat_splice(lhs: Value, rhs: Value) -> Value {
12266 match (lhs, rhs) {
12267 (Value::Array(la), Value::Array(ra)) => {
12268 if la.is_empty() {
12269 return Value::Array(ra);
12270 }
12271 if ra.is_empty() {
12272 return Value::Array(la);
12273 }
12274 // Last of la merges with first of ra; rest unchanged.
12275 let mut la = la.to_vec();
12276 let last_l = la.pop().unwrap();
12277 let mut ra_iter = ra.iter().cloned();
12278 let first_r = ra_iter.next().unwrap();
12279 let l_s = last_l.as_str_cow();
12280 let r_s = first_r.as_str_cow();
12281 let mut merged = String::with_capacity(l_s.len() + r_s.len());
12282 merged.push_str(&l_s);
12283 merged.push_str(&r_s);
12284 la.push(Value::str(merged));
12285 la.extend(ra_iter);
12286 Value::array(la)
12287 }
12288 (Value::Array(la), rhs_scalar) => {
12289 // c:4261 — empty array + empty surrounding text is zero
12290 // words, not one empty word. Bug #120 in docs/BUGS.md:
12291 // `b=("${a[@]:0:-1}")` gave len=1 instead of zsh's len=0.
12292 let rhs_s = rhs_scalar.as_str_cow();
12293 if la.is_empty() {
12294 if rhs_s.is_empty() {
12295 return Value::array(Vec::new());
12296 }
12297 return Value::str(rhs_s.to_string());
12298 }
12299 let mut la = la.to_vec();
12300 let last = la.pop().unwrap();
12301 let l_s = last.as_str_cow();
12302 let mut s = String::with_capacity(l_s.len() + rhs_s.len());
12303 s.push_str(&l_s);
12304 s.push_str(&rhs_s);
12305 la.push(Value::str(s));
12306 Value::array(la)
12307 }
12308 (lhs_scalar, Value::Array(ra)) => {
12309 let lhs_s = lhs_scalar.as_str_cow();
12310 if ra.is_empty() {
12311 // Symmetric c:4261 empty-array rule; see the arm above.
12312 if lhs_s.is_empty() {
12313 return Value::array(Vec::new());
12314 }
12315 return Value::str(lhs_s.to_string());
12316 }
12317 let mut ra = ra.to_vec();
12318 let first = ra.remove(0);
12319 let r_s = first.as_str_cow();
12320 let mut s = String::with_capacity(lhs_s.len() + r_s.len());
12321 s.push_str(&lhs_s);
12322 s.push_str(&r_s);
12323 let mut out = Vec::with_capacity(ra.len() + 1);
12324 out.push(Value::str(s));
12325 out.extend(ra);
12326 Value::array(out)
12327 }
12328 (lhs_s, rhs_s) => {
12329 let l = lhs_s.as_str_cow();
12330 let r = rhs_s.as_str_cow();
12331 let mut s = String::with_capacity(l.len() + r.len());
12332 s.push_str(&l);
12333 s.push_str(&r);
12334 Value::str(s)
12335 }
12336 }
12337}
12338
12339/// c:Src/subst.c:4316-4365 — the plan9 (RC_EXPAND_PARAM) arm of
12340/// paramsubst's array emit block.
12341///
12342/// Every element gets the FULL word prefix and suffix
12343/// (c:4341 `strcatsub(&y, ostr, aptr, x, xlen, y + 1, …)` inside the
12344/// per-element loop), giving the cross product with the surrounding
12345/// text: `pre${arr}post` → `preppost` / `preqpost` / `prerpost`.
12346///
12347/// An EMPTY array removes the WHOLE word: the c:4327
12348/// `while ((x = *aval++))` loop body never runs, so `plan9` is still
12349/// non-zero at c:4362 and the node is deleted —
12350/// `if (plan9) { uremnode(l, n); return n; }` (c:4362-4365). `e=();
12351/// setopt RC_EXPAND_PARAM; print -rl -- x$e y` prints only `y`. This is
12352/// the opposite of the non-plan9 rule at c:4261, which keeps `x`.
12353fn concat_plan9(lhs: Value, rhs: Value) -> Value {
12354 // c:4245 `if (isarr)` — an empty SCALAR never enters the array emit
12355 // block, so it contributes "" and the word survives (c:4437). Only a
12356 // real empty ARRAY reaches c:4362's `uremnode`. `Value` cannot tell
12357 // the two apart; EMPTY_EXPANSION_IS_SCALAR carries the missing bit.
12358 let scalar_empty = empty_is_scalar();
12359 match (lhs, rhs) {
12360 // c:4362-4365 — an empty array on either side deletes the word.
12361 // Propagated as an empty Array so a later concat in the same word
12362 // (`x${e[@]}y` folds twice) keeps the word deleted; pop_args
12363 // splats an empty Array into zero argv words.
12364 (Value::Array(la), rhs_v) if la.is_empty() => {
12365 if scalar_empty {
12366 // Empty scalar prefix: "" + rhs (c:4437 strcatsub).
12367 return match rhs_v {
12368 Value::Array(ra) if ra.is_empty() => Value::array(Vec::new()),
12369 other => other,
12370 };
12371 }
12372 Value::array(Vec::new())
12373 }
12374 (lhs_v, Value::Array(ra)) if ra.is_empty() => {
12375 if scalar_empty {
12376 // Empty scalar suffix: lhs + "" (c:4437 strcatsub).
12377 return lhs_v;
12378 }
12379 Value::array(Vec::new())
12380 }
12381 (Value::Array(la), Value::Array(ra)) => {
12382 let mut out = Vec::with_capacity(la.len() * ra.len());
12383 for a in la.iter() {
12384 let a_s = a.as_str_cow();
12385 for b in ra.iter() {
12386 let b_s = b.as_str_cow();
12387 let mut s = String::with_capacity(a_s.len() + b_s.len());
12388 s.push_str(&a_s);
12389 s.push_str(&b_s);
12390 out.push(Value::str(s));
12391 }
12392 }
12393 Value::array(out)
12394 }
12395 (Value::Array(la), rhs_scalar) => {
12396 let r = rhs_scalar.as_str_cow();
12397 let out: Vec<Value> = la
12398 .iter()
12399 .map(|a| {
12400 let a_s = a.as_str_cow();
12401 let mut s = String::with_capacity(a_s.len() + r.len());
12402 s.push_str(&a_s);
12403 s.push_str(&r);
12404 Value::str(s)
12405 })
12406 .collect();
12407 Value::array(out)
12408 }
12409 (lhs_scalar, Value::Array(ra)) => {
12410 let l = lhs_scalar.as_str_cow();
12411 let out: Vec<Value> = ra
12412 .iter()
12413 .map(|b| {
12414 let b_s = b.as_str_cow();
12415 let mut s = String::with_capacity(l.len() + b_s.len());
12416 s.push_str(&l);
12417 s.push_str(&b_s);
12418 Value::str(s)
12419 })
12420 .collect();
12421 Value::array(out)
12422 }
12423 (lhs_s, rhs_s) => {
12424 // Both scalar: nothing to distribute (c:4444 scalar branch).
12425 let l = lhs_s.as_str_cow();
12426 let r = rhs_s.as_str_cow();
12427 let mut s = String::with_capacity(l.len() + r.len());
12428 s.push_str(&l);
12429 s.push_str(&r);
12430 Value::str(s)
12431 }
12432 }
12433}
12434
12435/// Flatten one word-segment `Value` into its element strings: an Array splats
12436/// to its items, a scalar is a single element.
12437fn word_seg_elems(v: &Value) -> Vec<String> {
12438 match v {
12439 Value::Array(items) => items.iter().map(|i| i.as_str_cow().into_owned()).collect(),
12440 other => vec![other.as_str_cow().into_owned()],
12441 }
12442}
12443
12444/// Assemble a DQ word from its segments, mixing plan9 (`^`, cross-product) and
12445/// non-plan9 (splice) segments in one pass — see BUILTIN_WORD_ASSEMBLE_PLAN9.
12446///
12447/// c:Src/subst.c:4316-4437 — zsh threads a "growing edge" (`aptr`/`fstr`)
12448/// through the whole word: an element stays active until a splice freezes all
12449/// but the last. `active_lo` is the index where that active tail begins.
12450/// * plan9 segment → every active element crosses with EVERY new element;
12451/// all results stay active (c:4316-4350 cartesian). An empty plan9 array
12452/// deletes the word (c:4362 `uremnode`).
12453/// * splice segment → every active element takes the FIRST new element, the
12454/// remaining new elements append as fresh words; the last becomes the new
12455/// growing edge (c:4366-4437 first/last join). A single-element splice keeps
12456/// the whole active tail active (nothing frozen). An empty splice array
12457/// contributes nothing and leaves the word intact.
12458fn word_assemble_plan9(segments: &[Value], plan9_flags: &[bool]) -> Value {
12459 let mut words: Vec<String> = Vec::new();
12460 let mut active_lo: usize = 0;
12461 let mut started = false;
12462 for (i, seg) in segments.iter().enumerate() {
12463 let plan9 = plan9_flags.get(i).copied().unwrap_or(false);
12464 let elems = word_seg_elems(seg);
12465 if plan9 && elems.is_empty() {
12466 // c:4362-4365 — plan9 empty array deletes the whole word.
12467 return Value::array(Vec::new());
12468 }
12469 if !started {
12470 started = true;
12471 // c:Src/subst.c:4261 — `if ((!aval[0] || !aval[1]) && !plan9)`.
12472 // A NON-plan9 EMPTY expansion (empty array, or an empty scalar
12473 // that zshrs collapsed to the same empty `Value::Array`) is
12474 // folded into the word text as the empty string and the node
12475 // SURVIVES (c:4268-4274 `strcatsub` of prefix + "" + suffix).
12476 // Only the plan9 arm deletes the word, and that is the
12477 // `uremnode` case already returned above (c:4362-4365).
12478 //
12479 // Seeding `words` with that single empty element is what keeps a
12480 // growing edge alive for the segments that follow. Leaving
12481 // `words` empty instead made `words[active_lo..]` an empty slice
12482 // forever, so every later segment cross-multiplied against
12483 // nothing and the whole word vanished: `n=""; a=(x y z);
12484 // print -rl -- $n${^a}` printed nothing where zsh prints
12485 // `x`/`y`/`z`, and `$n$a${^a}` dropped the leading `x`. Only a
12486 // word whose FIRST segment was the empty one was affected —
12487 // `pre$n${^a}` already started from the literal and hit the
12488 // "contributes nothing" `continue` below.
12489 words = if elems.is_empty() {
12490 vec![String::new()]
12491 } else {
12492 elems
12493 };
12494 // plan9 → the whole first array is the growing edge; splice/scalar
12495 // → only its last element grows, earlier ones are finalized words.
12496 active_lo = if plan9 {
12497 0
12498 } else {
12499 words.len().saturating_sub(1)
12500 };
12501 continue;
12502 }
12503 if plan9 {
12504 let mut new_active = Vec::with_capacity(words[active_lo..].len() * elems.len());
12505 for a in &words[active_lo..] {
12506 for r in &elems {
12507 new_active.push(format!("{a}{r}"));
12508 }
12509 }
12510 let frozen_len = active_lo;
12511 words.truncate(frozen_len);
12512 words.extend(new_active);
12513 active_lo = frozen_len; // all cross-products stay active
12514 } else {
12515 if elems.is_empty() {
12516 // Non-plan9 empty array contributes nothing; word survives.
12517 continue;
12518 }
12519 let frozen_len = active_lo;
12520 let r0 = &elems[0];
12521 let head: Vec<String> = words[active_lo..]
12522 .iter()
12523 .map(|a| format!("{a}{r0}"))
12524 .collect();
12525 words.truncate(frozen_len);
12526 words.extend(head);
12527 words.extend(elems[1..].iter().cloned());
12528 active_lo = if elems.len() == 1 {
12529 frozen_len // single-element splice: head stays the growing edge
12530 } else {
12531 words.len() - 1 // multi: only the last appended word grows
12532 };
12533 }
12534 }
12535 match words.len() {
12536 0 => Value::array(Vec::new()),
12537 1 => Value::str(words.pop().unwrap()),
12538 _ => Value::array(words.into_iter().map(Value::str).collect()),
12539 }
12540}
12541
12542fn nodes_to_value(nodes: Vec<String>) -> Value {
12543 // c:Src/glob.c:3649 remnulargs — strip the Nularg (`\u{a1}`)
12544 // sentinel and other INULL bytes that paramsubst's splat block
12545 // emits for empty array elements (so prefork's empty-node-delete
12546 // pass doesn't drop them). Downstream consumers (cond `-z`/`-n`,
12547 // command args, etc.) must see the post-remnulargs strings. Bug
12548 // #185 in docs/BUGS.md: `[[ -z "${b[@]}" ]]` for b=("") returned
12549 // false because the leftover `\u{a1}` had StringLen=1.
12550 let stripped: Vec<String> = nodes
12551 .into_iter()
12552 .map(|mut s| {
12553 crate::ported::glob::remnulargs(&mut s);
12554 s
12555 })
12556 .collect();
12557 if stripped.is_empty() {
12558 // Zero nodes = an ARRAY-shaped expansion that produced no words
12559 // (empty array splat, empty slice). c:4245 `if (isarr)` holds, so
12560 // plan9 deletes the surrounding word (c:4362).
12561 note_empty_is_scalar(false);
12562 Value::array(Vec::new())
12563 } else if stripped.len() == 1 {
12564 let only = stripped.into_iter().next().unwrap();
12565 // c:Src/subst.c:183-186 — `else if (!(flags & PREFORK_SINGLE)
12566 // && !(*ret_flags & PREFORK_KEY_VALUE) && !keep)
12567 // uremnode(list, node);`
12568 // C zsh's prefork removes empty linknodes from the result
12569 // list when in non-SINGLE (argv-context) mode. The ported
12570 // prefork at subst.rs:388-396 honors the same delete-empty
12571 // pass, but some paramsubst paths land here with a single-
12572 // empty-string Vec instead of an empty Vec (paramsubst's
12573 // slice / substring / parameter-flag branches allocate a
12574 // result before checking emptiness). Mirror the prefork
12575 // drop at this layer: single-empty under !in_dq_context
12576 // collapses to Value::Array(empty), and pop_args (line 6243)
12577 // splats the empty Array → zero argv words. DQ context
12578 // (in_dq_context > 0) keeps the empty string so
12579 // `echo "${UNSET}"` still produces an empty arg per zsh's
12580 // quoting rules (c:Src/subst.c:1650-1656 isarr comment).
12581 if only.is_empty() {
12582 let in_dq = with_executor(|exec| exec.in_dq_context > 0);
12583 if !in_dq {
12584 // One empty node = a SCALAR-shaped empty result (c:4437),
12585 // not an empty array — see EMPTY_EXPANSION_IS_SCALAR.
12586 note_empty_is_scalar(true);
12587 return Value::array(Vec::new());
12588 }
12589 }
12590 Value::str(only)
12591 } else {
12592 Value::array(stripped.into_iter().map(Value::str).collect())
12593 }
12594}
12595
12596fn pop_args(vm: &mut fusevm::VM, argc: u8) -> Vec<String> {
12597 let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
12598 for _ in 0..argc {
12599 popped.push(vm.pop());
12600 }
12601 popped.reverse();
12602 let mut args: Vec<String> = Vec::with_capacity(popped.len());
12603 for v in popped {
12604 match v {
12605 Value::Array(items) => {
12606 for item in items.iter() {
12607 args.push(item.to_str());
12608 }
12609 }
12610 other => args.push(other.to_str()),
12611 }
12612 }
12613 // `expand_glob` set the glob-failed cell when a no-match glob
12614 // triggered nomatch (c:Src/glob.c:1877). Signal the failure via
12615 // last_status + the per-command glob_failed cell; the dispatcher
12616 // (`host_exec_external`) consumes + clears it and returns status 1
12617 // without running the command body.
12618 if with_executor(|exec| exec.current_command_glob_failed.get()) {
12619 with_executor(|exec| exec.set_last_status(1));
12620 }
12621 // c:Src/exec.c:2709 setunderscore / c:Src/params.c:252 underscore_gsu
12622 // — `$_` has exactly ONE store in zsh, the `zunderscore` global
12623 // (`Src/init.c:49`), read back through `underscoregetfn`. It is
12624 // never written into the parameter table: the `_` Param created by
12625 // `IPDEF2("_", underscore_gsu, PM_DONTIMPORT)` (c:Src/params.c:326)
12626 // carries `nullstrsetfn` as its setfn, so even `_=x` stores nothing.
12627 //
12628 // The deferred `pending_underscore` → `set_scalar("_")` promotion
12629 // that used to live here was a second, contradictory store: it wrote
12630 // the paramtab node, which (a) CLEARED the PM_UNSET that `unset _`
12631 // had just set — resurrecting the parameter, so `${+_}` flipped back
12632 // to 1 and `$_` kept reading a value where zsh reports empty — and
12633 // (b) shadowed the canonical zunderscore value. Every dispatch path
12634 // now calls `set_zunderscore` (the `setunderscore` equivalent) just
12635 // before running its command, which is where C sets it
12636 // (execcmd_exec, c:3545-3547), so the deferral is unnecessary as
12637 // well: argument expansion has already happened by then, exactly as
12638 // in C.
12639 args
12640}
12641
12642/// zsh dispatch order is alias → function → builtin → external. The
12643/// compiler emits direct CallBuiltin ops for known builtin names for
12644/// perf, which silently skips a user function that shadows the same
12645/// name (e.g. `echo() { ... }; echo hi` would run the C builtin
12646/// without this check). Returns Some(status) when the call is routed
12647/// to the user function; the builtin handler should fall through to
12648/// its native impl when None.
12649/// Fork+exec a system binary by name. Used by `reg_overridable!` as
12650/// the fall-through path when `[builtins].coreutils_shadows = off`
12651/// (the default) — runs the canonical `/bin/X` instead of zshrs's
12652/// in-process shadow so old scripts hit zero behavioral divergence.
12653///
12654/// Inherits stdin/stdout/stderr from the parent so pipelines work
12655/// transparently. Resolves the binary via PATH; mirrors what zsh's
12656/// own external-command dispatch would do. Returns the child's exit
12657/// status (or 127 if PATH lookup fails — the standard "command not
12658/// found" code).
12659/// RAII guard that queues signals for the lifetime of a synchronous
12660/// foreground `waitpid` (via `std::process::Command::status`/`wait`).
12661///
12662/// zshrs installs a process-wide SIGCHLD handler (`zhandler` →
12663/// `wait_for_processes` → `waitpid(-1, WNOHANG)`) that reaps EVERY
12664/// exited child to drive the job table. `std::process::Command` does
12665/// its own targeted `waitpid(pid)`; when the reaper fires on any
12666/// thread between the fork and that wait, it reaps the child first and
12667/// `Command::status()` fails with ECHILD ("No child processes (os
12668/// error 10)"). This surfaced as `zshrs: hostname: No child processes
12669/// (os error 10)` when a coreutils shadow (`coreutils_shadows = off`
12670/// default) fork-execs `/usr/bin/hostname` while a background prewarm
12671/// child exits at the same instant.
12672///
12673/// Holding this guard bumps `queueing_enabled` (a global SeqCst atomic
12674/// that `zhandler` honors on every thread), so a SIGCHLD arriving
12675/// during the wait is pushed onto the deferred queue instead of being
12676/// reaped — `Command::status()` reaps its own child and reads the real
12677/// status. On drop, `unqueue_signals()` drains the queue, so any
12678/// genuine background children that exited meanwhile still get reaped
12679/// and routed to the job table. This is the same queue_signals /
12680/// unqueue_signals fencing zsh uses around its own foreground waits
12681/// (Src/exec.c). Panic-safe via `Drop`.
12682/// Apply `entersubsh`'s trap reset to the CURRENT process state.
12683///
12684/// !!! WARNING: RUST-ONLY HELPER !!!
12685/// C does this inline inside `entersubsh` (c:Src/exec.c:1088-1092), which
12686/// every subshell — `( … )` AND each forked pipeline stage — funnels
12687/// through. zshrs has two separate places that enter a subshell context
12688/// (the in-process `subshell_begin` and the pipeline stage fork), so the
12689/// reset lives here to keep them from drifting apart.
12690///
12691/// ```c
12692/// if (!(flags & ESUB_KEEPTRAP))
12693/// for (sig = 0; sig <= SIGCOUNT; sig++)
12694/// if (!(sigtrapped[sig] & ZSIG_FUNC) &&
12695/// !(isset(POSIXTRAPS) && (sigtrapped[sig] & ZSIG_IGNORED)))
12696/// unsettrap(sig);
12697/// ```
12698///
12699/// `unsettrap` clears BOTH the body and the sigtrapped flags, so both
12700/// stores are reset here. Function-form traps (ZSIG_FUNC, kept in
12701/// shfunctab as TRAPxxx) survive by construction; under POSIX_TRAPS an
12702/// IGNORED trap survives too. The loop bound stops below the pseudo
12703/// signals (c:Src/signals.h:34-35), so ERR/ZERR and DEBUG survive while
12704/// SIGEXIT (sig 0) is cleared.
12705fn entersubsh_reset_traps() {
12706 let posixtraps = crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXTRAPS);
12707 if let Ok(mut tbl) = crate::ported::builtin::traps_table().lock() {
12708 tbl.retain(|name, body| {
12709 // Above SIGCOUNT — outside c:1088's loop entirely.
12710 if name == "ERR" || name == "ZERR" || name == "DEBUG" {
12711 return true;
12712 }
12713 // c:1090-1092 — otherwise keep ONLY (POSIXTRAPS && ignored).
12714 posixtraps && body.is_empty()
12715 });
12716 }
12717 if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
12718 let count = crate::ported::signals_h::SIGCOUNT as usize;
12719 for sig in 0..st.len().min(count + 1) {
12720 let state = st[sig];
12721 if state == 0 {
12722 continue;
12723 }
12724 if (state & crate::ported::zsh_h::ZSIG_FUNC) != 0 {
12725 continue; // c:1090
12726 }
12727 if posixtraps && (state & crate::ported::zsh_h::ZSIG_IGNORED) != 0 {
12728 continue; // c:1091
12729 }
12730 st[sig] = 0; // c:1092 unsettrap(sig)
12731 }
12732 }
12733}
12734
12735/// `waitpid(pid, &status, 0)` that retries on `EINTR`.
12736///
12737/// !!! WARNING: RUST-ONLY HELPER !!!
12738/// No C counterpart — C zsh reaches the same place by blocking signals
12739/// around its foreground waits (`queue_signals` / `unqueue_signals`
12740/// fencing in `Src/exec.c`), so its `waitpid` is never interrupted in
12741/// the first place.
12742///
12743/// zshrs installs a process-wide SIGCHLD handler (`zhandler` →
12744/// `wait_for_processes`). When it fires while the shell is blocked in
12745/// this wait, `waitpid` returns -1/`EINTR` WITHOUT touching `status`.
12746/// The pipeline reap loop ignored the return value and read the
12747/// still-zero `status`, so `WIFEXITED(0)` was true and every FORKED
12748/// stage reported exit 0: `false | true` published `$pipestatus` as
12749/// `0 0` instead of zsh's `1 0`, and `setopt pipefail` had no non-zero
12750/// entry left to promote (c:Src/jobs.c:434-435 `if (jpipestats[i])
12751/// pipefail = jpipestats[i];`, applied at c:451-454).
12752///
12753/// Returns the raw wait status, or `None` if the child could not be
12754/// reaped at all (e.g. `ECHILD` because the handler won the race).
12755fn waitpid_eintr(pid: libc::pid_t) -> Option<i32> {
12756 loop {
12757 let mut status: i32 = 0;
12758 let rc = unsafe { libc::waitpid(pid, &mut status, 0) };
12759 if rc >= 0 {
12760 return Some(status);
12761 }
12762 let err = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
12763 if err != libc::EINTR {
12764 return None;
12765 }
12766 }
12767}
12768
12769pub(crate) struct ForegroundWaitGuard;
12770
12771impl ForegroundWaitGuard {
12772 #[inline]
12773 pub(crate) fn enter() -> Self {
12774 crate::ported::signals_h::queue_signals();
12775 ForegroundWaitGuard
12776 }
12777}
12778
12779impl Drop for ForegroundWaitGuard {
12780 #[inline]
12781 fn drop(&mut self) {
12782 crate::ported::signals_h::unqueue_signals();
12783 }
12784}
12785
12786fn exec_system_command(name: &str, args: &[String]) -> i32 {
12787 // c:Src/jobs.c — count the fork so `time` reports for an
12788 // overridable coreutils shadow run as an external (`time sleep 0`,
12789 // `time cat …`). This is a distinct spawn path from
12790 // execute_external_bg; without the bump BUILTIN_TIME_SUBLIST saw no
12791 // job and stayed silent. (Builtins that don't reach a spawn never
12792 // hit this fn.)
12793 crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
12794 // Queue signals across the wait so the SIGCHLD reaper can't steal
12795 // this child out from under Command::status — see ForegroundWaitGuard.
12796 let status = {
12797 let _wait_guard = ForegroundWaitGuard::enter();
12798 std::process::Command::new(name)
12799 .args(args)
12800 .stdin(std::process::Stdio::inherit())
12801 .stdout(std::process::Stdio::inherit())
12802 .stderr(std::process::Stdio::inherit())
12803 .status()
12804 };
12805 match status {
12806 Ok(s) => s.code().unwrap_or(if s.success() { 0 } else { 1 }),
12807 Err(e) => {
12808 eprintln!("zshrs: {}: {}", name, e);
12809 127
12810 }
12811 }
12812}
12813
12814/// !!! WARNING: RUST-ONLY HELPER !!!
12815///
12816/// C has no counterpart: `fork()` gives a forked `(...)` subshell its own
12817/// copy of the fd table, so the flock fds a subshell opens (`Src/utils.c:2111`
12818/// `addlockfd` marks them `FDT_FLOCK` / `FDT_FLOCK_EXEC`) vanish with the
12819/// child and its `fcntl(F_SETLK)` locks are released. zshrs runs `(...)`
12820/// in-process, so it has to enumerate those slots at subshell entry and
12821/// close the new ones at subshell exit. Walks the same `fdtable` /
12822/// `max_zsh_fd` pair as `zcloselockfd` (`Src/utils.c:2155-2164`).
12823fn current_flock_fds() -> Vec<i32> {
12824 use crate::ported::zsh_h::{FDT_FLOCK, FDT_FLOCK_EXEC};
12825 let max_fd = crate::ported::utils::MAX_ZSH_FD.load(std::sync::atomic::Ordering::Relaxed);
12826 if max_fd < 0 {
12827 return Vec::new();
12828 }
12829 (0..=max_fd)
12830 .filter(|fd| {
12831 let slot = crate::ported::utils::fdtable_get(*fd);
12832 slot == FDT_FLOCK || slot == FDT_FLOCK_EXEC
12833 })
12834 .collect()
12835}
12836
12837fn try_user_fn_override(name: &str, args: &[String]) -> Option<i32> {
12838 let has_fn = with_executor(|exec| {
12839 exec.functions_compiled.contains_key(name) || exec.function_exists(name)
12840 });
12841 if !has_fn {
12842 return None;
12843 }
12844 Some(with_executor(|exec| {
12845 exec.dispatch_function_call(name, args).unwrap_or(127)
12846 }))
12847}
12848
12849/// Builtin ID for `${name}` reads — routes through canonical
12850/// `getsparam` (Src/params.c:3076) via paramtab + env walk so nested
12851/// VMs (function calls) see the same storage.
12852pub const BUILTIN_GET_VAR: u16 = 283;
12853
12854/// Like `BUILTIN_GET_VAR` but forces double-quoted (DQ) semantics on
12855/// the read regardless of the runtime `in_dq_context`. The compiler
12856/// emits this for a QUOTED simple-var read (`"$name"`) — those compile
12857/// to a direct GET_VAR with no EXPAND_TEXT wrapper, so `in_dq_context`
12858/// is 0 and the plain GET_VAR would wrongly word-elide an array's empty
12859/// elements (`a=(1 "" 3); "$a"` must keep the empty → `1 3`, not
12860/// `1 3`). With force_dq the array joins via sepjoin keeping empties and
12861/// a scalar is returned verbatim (no empty-drop, no SH_WORD_SPLIT).
12862pub const BUILTIN_GET_VAR_DQ: u16 = 639;
12863
12864/// Builtin ID for `name=value` assignments — pops [name, value] and
12865/// routes through canonical `setsparam` (Src/params.c:3350).
12866pub const BUILTIN_SET_VAR: u16 = 284;
12867
12868/// Builtin ID that sets the thread-local [`SET_VAR_GLOB_ELIGIBLE`] flag true.
12869/// Emitted by the compiler immediately before a `BUILTIN_SET_VAR` whose scalar
12870/// RHS carried an UNQUOTED glob token, so the runtime knows the RHS is a literal
12871/// glob pattern eligible for GLOB_ASSIGN. Takes no stack args, pushes nothing.
12872pub const BUILTIN_MARK_GLOB_ELIGIBLE: u16 = 640;
12873
12874/// Builtin ID for pipeline execution. Pops N sub-chunk indices from the stack;
12875/// each index points into `vm.chunk.sub_chunks` (compiled stage bodies). Forks
12876/// N children, wires stdin/stdout between them via pipes, runs each stage's
12877/// bytecode on a fresh VM in its child, parent waits for all and pushes the
12878/// last stage's exit status. This is bytecode-native pipeline execution —
12879/// no tree-walker delegation.
12880pub const BUILTIN_RUN_PIPELINE: u16 = 285;
12881
12882/// Builtin ID for `Array → String` joining. Pops one value: if it's an Array,
12883/// joins its string-coerced elements with a single space; otherwise passes
12884/// through. Used after `Op::Glob` to convert the pattern's matched paths into
12885/// the single argv-token form the bytecode word model expects (no per-word
12886/// splitting yet — that's a future phase).
12887pub const BUILTIN_ARRAY_JOIN: u16 = 286;
12888
12889/// Builtin ID for `cmd &` background execution. IDs 287/288/289 are reserved
12890/// for the planned array work in Phase G1 (SET_ARRAY/SET_ASSOC/ARRAY_INDEX),
12891/// so this lands at 290. Pops the sub-chunk index then the job text; forks;
12892/// child detaches (`setsid`), runs the sub-chunk on a fresh VM, exits with
12893/// last_status; parent registers the job in the canonical JOBTAB
12894/// (initjob/addproc/spawnjob per c:Src/exec.c:1700-1758) so `jobs` / `wait
12895/// %N` / `kill %N` / `disown` and the zsh/parameter assocs all see it, then
12896/// returns Status(0) immediately.
12897pub const BUILTIN_RUN_BG: u16 = 290;
12898
12899/// Indexed-array assignment: `arr=(a b c)`. Compile_simple emits N element
12900/// pushes followed by name push, then `CallBuiltin(BUILTIN_SET_ARRAY, N+1)`.
12901/// The handler pops args (last popped = name in our pushing order) and stores
12902/// `Vec<String>` into `executor.arrays`. Tree-walker callers see the same
12903/// storage. Any prior scalar binding in `executor.variables` for `name` is
12904/// removed so `${name}` (scalar context) consistently reflects the array's
12905/// first element via `get_variable`.
12906pub const BUILTIN_SET_ARRAY: u16 = 287;
12907
12908/// Single-key set on an associative array: `foo[key]=val`. Stack (top-down):
12909/// [name, key, value]. Stores `value` into `executor.assoc_arrays[name][key]`,
12910/// creating the outer entry if missing. compile_simple detects `var[...]=...`
12911/// in assignments and emits this builtin.
12912pub const BUILTIN_SET_ASSOC: u16 = 288;
12913
12914/// `${arr[idx]}` — single-element array index. Pops two args:
12915/// stack: [name, idx_str]
12916/// Returns the indexed element as Value::str. Indexing semantics: zsh is
12917/// 1-based by default; bash is 0-based. We follow zsh.
12918/// Special idx values: `@` and `*` return the whole array as Value::Array
12919/// (which fuses correctly via the Op::Exec splice for argv splice).
12920pub const BUILTIN_ARRAY_INDEX: u16 = 289;
12921
12922/// `${#arr[@]}` and `${#arr}` (when arr is an array name) — array length.
12923/// Pops one arg: name. Returns Value::str of len.
12924
12925/// `${arr[@]}` — splice all elements as a Value::Array. Pops one arg: name.
12926/// The Array gets flattened by Op::Exec/ExecBg/CallFunction into argv.
12927pub const BUILTIN_ARRAY_ALL: u16 = 292;
12928
12929/// Flatten one level of Value::Array nesting. Pops N values; for each, if it's
12930/// a Value::Array, its elements are appended directly; otherwise the value is
12931/// appended as-is. Pushes a single Value::Array of the flattened result. Used
12932/// by the for-loop word-list compile path: when a word like `${arr[@]}`
12933/// produces a nested Array, this lets `for i in ${arr[@]}` iterate over the
12934/// inner elements rather than the outer single-element array.
12935pub const BUILTIN_ARRAY_FLATTEN: u16 = 293;
12936
12937/// `coproc [name] { body }` — bidirectional pipe to async child. Pops a name
12938/// (optional, "" for default) and a sub-chunk index. Creates two pipes, forks,
12939/// child redirects its fd 0/1 to the inner ends and runs the body, parent
12940/// stores [write_fd, read_fd] into the named array (default `COPROC`). Caller
12941/// closes the fds and `wait`s when done. Job-table integration deferred to
12942/// Phase G6 alongside the bg `&` work.
12943pub const BUILTIN_RUN_COPROC: u16 = 294;
12944
12945/// `arr+=(d e f)` — append N elements to an existing indexed array. Compile
12946/// emits N element pushes + name push, then `CallBuiltin(295, N+1)`. Handler
12947/// drains args (last popped = name), extends `executor.arrays[name]` (creates
12948/// the entry if missing). Mirrors zsh's `+=` semantics for indexed arrays.
12949pub const BUILTIN_APPEND_ARRAY: u16 = 295;
12950
12951/// `name[@]=(...)` / `name[*]=(...)` whole-array SET. Identical to
12952/// BUILTIN_SET_ARRAY for an indexed array / scalar (whole replace), but
12953/// rejects an associative target with "attempt to set slice of
12954/// associative array" (c:Src/params.c:3324-3327).
12955pub const BUILTIN_SET_ARRAY_AT: u16 = 633;
12956
12957/// `name[@]+=(...)` / `name[*]+=(...)` whole-array APPEND. Indexed
12958/// append (push), assoc target → same slice-of-assoc error as 633.
12959pub const BUILTIN_APPEND_ARRAY_AT: u16 = 634;
12960
12961/// `select var in words; do body; done` — interactive numbered-menu loop.
12962/// Compile emits N word pushes + var-name push + sub-chunk index push, then
12963/// `CallBuiltin(296, N+2)`. Handler prints `1) word1\n2) word2\n...` to
12964/// stderr, prints `$PROMPT3` (default `?# `) to stderr, reads a line from
12965/// stdin. On EOF returns 0. On a valid 1-based number, sets `var` to the
12966/// chosen word, runs the sub-chunk, then redisplays the menu and loops. On
12967/// invalid input redraws the menu without running the body. `break` from
12968/// inside the body exits the loop (handled by the body's own bytecode).
12969pub const BUILTIN_RUN_SELECT: u16 = 296;
12970
12971/// `m[k]+=value` — append onto an existing assoc-array value (string concat).
12972/// If the key doesn't exist, behaves like SET_ASSOC. Stack: [name, key, value].
12973
12974/// `break` from inside a body that runs on a sub-VM (select, future
12975/// loop-via-builtin constructs). Writes the canonical
12976/// `crate::ported::builtin::BREAKS` atomic (port of `Src/loop.c:46
12977/// breaks`). Outer-loop builtins drain BREAKS/CONTFLAG after each
12978/// body run, matching the loop.c:529-534 drain pattern.
12979pub const BUILTIN_SET_BREAK: u16 = 299;
12980
12981/// `continue` from inside a sub-VM body. Sets CONTFLAG=1 + bumps
12982/// BREAKS, matching `bin_break`'s WC_CONTINUE arm at Src/builtin.c
12983/// c:5836 `contflag = 1; FALLTHROUGH; breaks++;`.
12984pub const BUILTIN_SET_CONTINUE: u16 = 300;
12985
12986/// Brace expansion: `{a,b,c}` → 3 values, `{1..5}` → 5 values, `{01..05}` →
12987/// zero-padded numerics, `{a..e}` → letter range. Pops one string, returns
12988/// Value::Array of expansions (empty array → original string preserved).
12989pub const BUILTIN_BRACE_EXPAND: u16 = 301;
12990
12991/// Glob qualifier filter: `*(qualifier)` filters glob results by predicate.
12992/// Pops [pattern, qualifier_string]. Returns Value::Array of matching paths.
12993
12994/// Re-export the regex_match host method as a builtin so `[[ s =~ pat ]]`
12995/// works even when fusevm's Op::RegexMatch isn't routed (compat fallback).
12996
12997/// Word-split a string on IFS (default: whitespace). Pops one string,
12998/// returns Value::Array of fields. Used in array-literal context where
12999/// `arr=($(cmd))` should expand cmd's stdout into multiple elements.
13000pub const BUILTIN_WORD_SPLIT: u16 = 304;
13001
13002/// `${=name}` / SH_WORD_SPLIT forced IFS split — c:Src/subst.c:3920-3928
13003/// `aval = sepsplit(val, spsep, 0, 1);`.
13004///
13005/// Unlike BUILTIN_WORD_SPLIT (which routes through `multsub`'s
13006/// PREFORK_SPLIT walker — the c:553-620 loop that COLLAPSES runs of
13007/// separators and never emits an empty field), this is the *other* zsh
13008/// splitter: `sepsplit` → `Src/utils.c:3711 spacesplit(s, allownull=0)`,
13009/// which distinguishes the two IFS classes and preserves empty fields.
13010///
13011/// Stack: \[value\]. argc selects the empty-field rule:
13012/// * argc == 0 — the expansion is a bare unquoted word (`${=v}`): the
13013/// leading/trailing `""` fields spacesplit emits for skipped
13014/// IFS-WHITESPACE are empty argv words and prefork deletes them
13015/// (c:Src/subst.c:184-187 `uremnode`).
13016/// * argc == 1 — the expansion is quoted (`"${=v}"`) or has adjacent
13017/// word segments (`x${=v}y`): those fields survive, because in C the
13018/// word's `Dnull` quote markers / literal prefix+suffix attach to the
13019/// first and last elements (c:4386 / c:4429 strcatsub) and the node is
13020/// no longer empty. `v=$' a:b '` → `""`, `a:b`, `""` quoted; `x`,
13021/// `a:b`, `y` with surrounding text.
13022///
13023/// Empty fields that come from an IFS-NON-whitespace separator are the
13024/// `nulstring` (`Nularg`, c:Src/subst.c:36) and survive in BOTH cases —
13025/// `IFS=x; v=xaxbx` splits to `""`, `a`, `b`, `""` quoted or not.
13026pub const BUILTIN_FORCE_SPLIT: u16 = 643;
13027
13028/// Register a pre-compiled fusevm chunk as a function. Stack: [name,
13029/// base64-bincode-of-Chunk]. Used by compile_zsh's compile_funcdef to
13030/// register functions parsed via parse_init+parse without going through the
13031/// ShellCommand JSON serialization path.
13032pub const BUILTIN_REGISTER_COMPILED_FN: u16 = 305;
13033/// `BUILTIN_VAR_EXISTS` constant.
13034pub const BUILTIN_VAR_EXISTS: u16 = 306;
13035/// Native param-modifier builtins. Each takes a fixed argv shape and
13036/// returns the modified value as Value::Str.
13037///
13038/// `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
13039/// — pop [name, op_byte, rhs]. op_byte: 0=`:-`, 1=`:=`, 2=`:?`, 3=`:+`.
13040pub const BUILTIN_PARAM_DEFAULT_FAMILY: u16 = 307;
13041/// `${var:offset[:length]}` — pop [name, offset, length] (length=-1 means
13042/// "rest of value"; negative offset counts from end).
13043pub const BUILTIN_PARAM_SUBSTRING: u16 = 308;
13044/// `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}` — pop
13045/// [name, pattern, op_byte]. op_byte: 0=`#`, 1=`##`, 2=`%`, 3=`%%`.
13046pub const BUILTIN_PARAM_STRIP: u16 = 309;
13047/// `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
13048/// `${var/%pat/repl}` — pop [name, pattern, replacement, op_byte].
13049/// op_byte: 0=first, 1=all, 2=anchor-prefix, 3=anchor-suffix.
13050pub const BUILTIN_PARAM_REPLACE: u16 = 310;
13051/// `${#name}` — character length of a scalar value, or element count
13052/// of an indexed/assoc array. Pops \[name\], returns count as Value::Str.
13053pub const BUILTIN_PARAM_LENGTH: u16 = 311;
13054/// `$((expr))` arithmetic substitution. Pops \[expr_string\], evaluates
13055/// via the executor's MathEval (integer-aware), returns result as
13056/// Value::Str. Bypasses ArithCompiler's float-only Op::Div path so
13057/// `$((10/3))` returns "3" not "3.333...".
13058pub const BUILTIN_ARITH_EVAL: u16 = 312;
13059/// `(( ... ))` math command post-eval status hook. Pops nothing,
13060/// pushes Value::Status. If errflag is set (math error in the
13061/// preceding BUILTIN_ARITH_EVAL call), clears it and emits status=2
13062/// matching c:Src/math.c arith-failure semantics. Otherwise emits
13063/// the current vm.last_status. Used by compile_arith's `(( ... ))`
13064/// path so the math command swallows errors without halting the
13065/// script — `$((... ))` substitutions skip this hook so their
13066/// errflag propagates up to the containing command.
13067pub const BUILTIN_ARITH_CMD_FINISH: u16 = 527;
13068/// `$(cmd)` command substitution. Pops \[cmd_string\], runs through
13069/// `run_command_substitution` which compiles via parse_init+parse + ZshCompiler
13070/// and captures stdout via an in-process pipe. Returns trimmed output
13071/// as Value::Str. Avoids the sub-chunk word-emit quoting bug in the
13072/// raw Op::CmdSubst path.
13073pub const BUILTIN_CMD_SUBST_TEXT: u16 = 313;
13074/// Text-based word expansion. Pops \[preserved_text\]: the word with
13075/// quotes preserved (Dnull→`"`, Snull→`'`, Bnull→`\`), runs
13076/// `expand_string` (variable + cmd-sub + arith) then `xpandbraces`
13077/// then `expand_glob`. Returns Value::str (single match) or
13078/// Value::Array (multi-match brace/glob).
13079pub const BUILTIN_EXPAND_TEXT: u16 = 314;
13080
13081/// `[[ a -ef b ]]` — same-inode test. Stack: [a, b]. Pushes Bool true iff
13082/// both paths resolve to the same `(dev, inode)` pair (zsh + bash semantics).
13083pub const BUILTIN_SAME_FILE: u16 = 315;
13084
13085/// `[[ a -nt b ]]` — file `a` newer than file `b` (mtime strict).
13086/// Stack: [path_a, path_b]. Pushes Bool. zsh-compatible "missing"
13087/// rules: if both exist, compare mtime; if only `a` exists → true;
13088/// otherwise false.
13089pub const BUILTIN_FILE_NEWER: u16 = 324;
13090
13091/// `[[ a -ot b ]]` — mirror of `-nt`. If both exist, compare mtime;
13092/// if only `b` exists → true; otherwise false.
13093pub const BUILTIN_FILE_OLDER: u16 = 325;
13094
13095/// `[[ -k path ]]` — sticky bit (S_ISVTX) set on path.
13096pub const BUILTIN_HAS_STICKY: u16 = 326;
13097/// `[[ -u path ]]` — setuid bit (S_ISUID).
13098pub const BUILTIN_HAS_SETUID: u16 = 327;
13099/// `[[ -g path ]]` — setgid bit (S_ISGID).
13100pub const BUILTIN_HAS_SETGID: u16 = 328;
13101/// `[[ -O path ]]` — owned by effective UID.
13102pub const BUILTIN_OWNED_BY_USER: u16 = 329;
13103/// `[[ -G path ]]` — owned by effective GID.
13104pub const BUILTIN_OWNED_BY_GROUP: u16 = 330;
13105/// `[[ -N path ]]` — file modified since last accessed (atime <= mtime).
13106pub const BUILTIN_FILE_MODIFIED_SINCE_ACCESS: u16 = 341;
13107
13108/// `name+=val` (no parens) — runtime-dispatched append.
13109/// If name is an indexed array → push val as element.
13110/// If name is an assoc array → error (zsh requires `(k v)` form).
13111/// Else → scalar concat (existing SET_VAR behavior).
13112pub const BUILTIN_APPEND_SCALAR_OR_PUSH: u16 = 331;
13113
13114/// `[[ -c path ]]` — character device.
13115pub const BUILTIN_IS_CHARDEV: u16 = 332;
13116/// `[[ -b path ]]` — block device.
13117pub const BUILTIN_IS_BLOCKDEV: u16 = 333;
13118/// `[[ -p path ]]` — FIFO / named pipe.
13119pub const BUILTIN_IS_FIFO: u16 = 334;
13120/// `[[ -S path ]]` — socket.
13121pub const BUILTIN_IS_SOCKET: u16 = 335;
13122/// `BUILTIN_ERREXIT_CHECK` constant.
13123pub const BUILTIN_ERREXIT_CHECK: u16 = 336;
13124/// Fatal-error-only abort check, for use INSIDE an `&&` / `||` chain.
13125///
13126/// A chain suppresses the errexit check (a non-zero status is "consumed"
13127/// by the connector — `false && x` must not fire ERREXIT or the ZERR
13128/// trap). But an errflag — a *fatal* error such as a `[[ ]]` bad pattern
13129/// — is not a status the connector can consume: zsh abandons the whole
13130/// list. Without this, zshrs ran the `||` right-hand side after the
13131/// error (`[[ x = [a- ]] || touch f` created `f`; zsh does not) and the
13132/// aborted builtin then overwrote the cond's status 2 with 1.
13133///
13134/// Same errflag arm as `BUILTIN_ERREXIT_CHECK`, with the errexit/ZERR
13135/// half omitted.
13136pub const BUILTIN_FATAL_ABORT_CHECK: u16 = 641;
13137/// Post-`always`-arm checks for the canonical RETFLAG / BREAKS /
13138/// CONTFLAG atomics that mark try-block escapes. Each returns
13139/// Value::Int(1) when the corresponding atomic is set (and consumes
13140/// it so the next escape doesn't re-fire) and Value::Int(0) otherwise.
13141/// Paired with JumpIfFalse + Jump to outer return_patches /
13142/// break_patches / continue_patches by compile_zsh's `Try` arm.
13143pub const BUILTIN_RETFLAG_CHECK: u16 = 600;
13144/// `BUILTIN_BREAKS_CHECK` constant.
13145pub const BUILTIN_BREAKS_CHECK: u16 = 601;
13146/// `BUILTIN_CONTFLAG_CHECK` constant.
13147pub const BUILTIN_CONTFLAG_CHECK: u16 = 602;
13148/// `loops++` on entry to a compiled for/while/until/repeat
13149/// (c:Src/loop.c:114/427/523).
13150pub const BUILTIN_LOOP_ENTER: u16 = 656;
13151/// `loops--` on exit from a compiled for/while/until/repeat
13152/// (c:Src/loop.c:188/491/546).
13153pub const BUILTIN_LOOP_EXIT: u16 = 657;
13154/// Post-body `if (breaks) { breaks--; … }` drain (c:Src/loop.c:529-534).
13155/// Int(1) = terminate this loop, Int(0) = next iteration.
13156pub const BUILTIN_LOOP_BREAK_DRAIN: u16 = 658;
13157/// Non-consuming `breaks != 0` probe for execlist's per-statement gate
13158/// (c:Src/exec.c:1370).
13159pub const BUILTIN_BREAKS_PENDING: u16 = 659;
13160/// `shtokenize` the top-of-stack string in place — c:Src/subst.c:4419-4420
13161/// `if (globsubst) shtokenize(y)`, the step that makes a `${~spec}` /
13162/// `$~spec` value's metachars PATTERN-ACTIVE.
13163///
13164/// zshrs expands a `[[ ]]` operand at the VM level and hands `cond_str`
13165/// (c:Src/cond.c:525) a finished string, so the token state C carries in
13166/// the word itself has to be re-applied at the point of use. Without it a
13167/// module condition compiles the value as a literal: `[[ -prefix $~pat ]]`
13168/// (Completion/Base/Utility/_numbers sh:65) is the one shipped completer
13169/// that depends on it.
13170pub const BUILTIN_COND_SHTOKENIZE: u16 = 660;
13171/// Fire the DEBUG trap (SIGDEBUG) before each statement.
13172/// c:Src/exec.c:1357-1500 DEBUGBEFORECMD — when a "DEBUG" entry is
13173/// installed via `trap '...' DEBUG`, run the body just before the
13174/// next command. Cheap when no DEBUG trap is set (one hashmap lookup
13175/// returns None and we early-out).
13176pub const BUILTIN_DEBUG_TRAP: u16 = 603;
13177/// `set -n` / `set -o noexec` — parse but don't execute. Returns
13178/// Value::Int(1) when the noexec option is set so the caller's
13179/// JumpIfTrue skips the statement body. c:Src/exec.c:1390 main loop
13180/// check.
13181pub const BUILTIN_NOEXEC_CHECK: u16 = 604;
13182/// Block-level redirect-failure gate. Reads exec.redirect_failed
13183/// (set by host.redirect when a redirect open fails); returns
13184/// Value::Int(1) AND clears the flag if set, else 0. Emit-side at
13185/// compile_zsh.rs::compile_command's Redirected arm pairs with a
13186/// JumpIfTrue → WithRedirectsEnd to abandon the body. Without this,
13187/// a multi-statement block after a failed redir kept running every
13188/// statement after the first (the first builtin consumed the flag,
13189/// subsequent statements ran unimpeded).
13190pub const BUILTIN_REDIRECT_FAILED_CHECK: u16 = 605;
13191/// Drop-in replacement for fusevm's Op::Exec for the dynamic-first-
13192/// word path (`$cmd`, `$(cmd)`, `~/bin/foo`). Returns
13193/// Value::Status(vm.last_status) when post-expansion argv is empty
13194/// (preserves the inner cmd-subst's exit), Value::Status(126) with
13195/// "permission denied" when `argv[0]` is empty, otherwise routes
13196/// through executor.host_exec_external like Op::Exec did.
13197pub const BUILTIN_EXEC_DYNAMIC: u16 = 606;
13198/// Reset `use_cmdoutval` to 0 at the START of a dynamic command (before
13199/// its words expand), so a command substitution from a PREVIOUS command
13200/// can't leak into this command's null-command status decision
13201/// (c:Src/exec.c:3009 `use_cmdoutval = !args`). See BUILTIN_EXEC_DYNAMIC.
13202pub const BUILTIN_USE_CMDOUTVAL_RESET: u16 = 637;
13203/// Tilde-expand a match pattern's leading `~`, the way `singsub` does
13204/// before the pattern reaches `patcompile`.
13205///
13206/// c:Src/cond.c:299-307 — `right = dupstring(ecrawstr(…)); singsub(&right);
13207/// … patcompile(right, …)`. `singsub` is `prefork(PREFORK_SINGLE)`
13208/// (c:Src/subst.c:520), and prefork runs `filesub` on every word, so an
13209/// unquoted `~` in a `[[ … == ~/* ]]` pattern — or one that arrives via
13210/// `${~var}` — is a home directory, not a literal character. `case`
13211/// patterns take the same route (c:Src/loop.c:610 `singsub(&pat)`).
13212/// zshrs untokenizes the pattern at compile time and re-tokenizes it in
13213/// the matcher, so the expansion has to happen here.
13214///
13215/// Only a LEADING `~` is considered, which is all `filesubstr`
13216/// (c:Src/subst.c:741) ever expands: a `~` elsewhere is EXTENDED_GLOB's
13217/// "except" operator and must survive untouched, and a quoted one has
13218/// already been backslash-escaped by `escape_quoted_glob_metas` so it
13219/// fails the leading-char test.
13220///
13221/// powerlevel10k's directory segment is the visible consumer: its
13222/// `_POWERLEVEL9K_DIR_CLASSES` walk matches `$PWD` against `~` and `~/*`
13223/// via `[[ $_p9k__cwd == ${~a} ]]` (internal/p10k.zsh:2029). Without the
13224/// expansion both classes missed, every path under `$HOME` fell through
13225/// to the `*` DEFAULT class, and the prompt showed the generic folder
13226/// icon in place of the home / home-subfolder one.
13227fn pattern_filesub(pattern: &str) -> String {
13228 let first = pattern.chars().next();
13229 if first != Some('~') && first != Some(crate::ported::zsh_h::Tilde) {
13230 return pattern.to_string();
13231 }
13232 // filesubstr keys on the Tilde TOKEN, so shtokenize first (a raw `~`
13233 // becomes Tilde; an already-tokenized one passes through), then
13234 // untokenize the surviving glob metas back for the matcher.
13235 let mut tok = pattern.to_string();
13236 crate::ported::glob::shtokenize(&mut tok);
13237 crate::ported::lex::untokenize(&crate::ported::subst::filesub(&tok, 0))
13238}
13239
13240/// `[[ lhs == pat ]]` / `!=` glob compare — cond-specific so the
13241/// bad-pattern diagnostic follows Src/cond.c:308-316: zwarnnam
13242/// "bad pattern: %s" WITHOUT errflag (the script continues) and the
13243/// cond statement exits 2. Stack: [lhs, pat] → Bool. On compile
13244/// failure pushes Bool(false) and arms COND_BAD_PATTERN so
13245/// BUILTIN_COND_STATUS_FROM_BOOL reports 2.
13246pub const BUILTIN_COND_STRMATCH: u16 = 624;
13247/// Pops the cond result Bool → Int status per Src/cond.c: true→0,
13248/// false→1, but 2 when COND_BAD_PATTERN was armed during this cond
13249/// (covers `!=` where LogNot flips the Bool before status time).
13250pub const BUILTIN_COND_STATUS_FROM_BOOL: u16 = 625;
13251/// `[[ ]]` unknown condition. Pops \[op_name\], emits `zerr("unknown
13252/// condition: %s")` and sets ERRFLAG_ERROR so the next BUILTIN_ERREXIT_CHECK
13253/// (trigger 4) aborts the input — matching zsh's COND_MODI "unknown condition"
13254/// path (Src/cond.c:150-188) for a `-X` op with no matching loadable module.
13255/// Returns Bool(false). Replaces a compile-time `eprintln!` hack that printed
13256/// the message but never set errflag (so the line didn't abort).
13257pub const BUILTIN_COND_UNKNOWN: u16 = 632;
13258/// Bare-`exec` redirect epilogue. Consumes `exec.redirect_failed` and
13259/// applies the C `done:` tail of execcmd_exec:
13260/// - c:Src/exec.c:252-259 execerr — `redir_err = lastval = 1` (the
13261/// failed redirect makes the exec statement exit 1, NOT fatal by
13262/// itself);
13263/// - c:Src/exec.c:4367-4386 — `if (isset(POSIXBUILTINS) && (cflags
13264/// & (BINF_PSPECIAL|BINF_EXEC)) ...) { if (redir_err || errflag)
13265/// { if (!isset(INTERACTIVE)) exit(1); } }` — POSIX_BUILTINS makes
13266/// a failed exec redirect fatal in a non-interactive shell.
13267/// Returns Value::Status(0|1) for the trailing SetStatus.
13268pub const BUILTIN_EXEC_REDIR_DONE: u16 = 626;
13269/// Assignment-prefix epilogue for bare `exec` redirects
13270/// (`x=$(cmd) exec >file`). c:Src/exec.c:3969-3976 — nullexec==1
13271/// runs addvars THEN, without POSIX_BUILTINS, restores the params
13272/// (`save_params` / `restore_params`): the RHS side effects fire but
13273/// the values don't persist. With POSIX_BUILTINS the assignments
13274/// stick. Pops the BEGIN_INLINE_ENV frame either way.
13275pub const BUILTIN_EXEC_INLINE_ENV_DONE: u16 = 627;
13276
13277/// `< file` / `> file` with no command word (NULLCMD path).
13278/// Resolves NULLCMD (default "cat") / READNULLCMD (default "more")
13279/// at runtime per Src/exec.c:3386-3419, then dispatches that word
13280/// exactly as execcmd's fall-through does: shell function (c:3485)
13281/// → builtin (c:3489) → external. Argc is 1: the int (0 or 1) on the
13282/// stack indicates whether this is a single REDIR_READ redirect
13283/// (selects READNULLCMD when set + non-empty).
13284pub const BUILTIN_NULLCMD_EXEC: u16 = 607;
13285/// `.` (dot) — alias of source/bin_dot but dispatches with the
13286/// literal name "." so the diagnostic prefix matches zsh's
13287/// (`zsh:.:1: …` vs source's `zsh:source:1: …`).
13288/// c:Src/builtin.c:9308 — `BUILTIN(".", BINF_PSPECIAL, bin_dot, …)`.
13289pub const BUILTIN_DOT: u16 = 608;
13290/// `logout` — fusevm maps this to BUILTIN_EXIT alongside `exit`/`bye`,
13291/// which drops the name and dispatches with BIN_EXIT funcid. zsh's
13292/// `logout` outside a login shell must emit "not login shell" + exit 1,
13293/// which only fires when bin_break sees BIN_LOGOUT funcid. Dedicated
13294/// opcode dispatches via BUILTINS table by literal name "logout".
13295pub const BUILTIN_LOGOUT: u16 = 610;
13296/// `BUILTIN_PARAM_SUBSTRING_EXPR` constant.
13297pub const BUILTIN_PARAM_SUBSTRING_EXPR: u16 = 337;
13298/// `BUILTIN_XTRACE_LINE` constant.
13299pub const BUILTIN_XTRACE_LINE: u16 = 338;
13300/// `BUILTIN_XTRACE_ARRAY_LINE` — xtrace an `arr=(...)` assignment from the
13301/// whole assembled `Value::Array` (see compile_zsh array-literal codegen).
13302pub const BUILTIN_XTRACE_ARRAY_LINE: u16 = 649;
13303/// `BUILTIN_MAKE_ARRAY_COUNTED` — like `Op::MakeArray(u16)` but the element
13304/// count is a runtime `Int` on the stack, so it is not capped at 65535. Used
13305/// by the array-literal codegen only when the literal has > u16::MAX elements
13306/// (e.g. a .zcompdump's ~103k-element `_comps=(...)`).
13307pub const BUILTIN_MAKE_ARRAY_COUNTED: u16 = 650;
13308/// `BUILTIN_ARGV_RFLATTEN` — pop one `Op::MakeArray`-packed argv bundle and
13309/// push it back as ONE recursively-flattened `Value::Array` of scalars. Emitted
13310/// by the simple-command codegen ONLY on the >255-arg overflow path: the
13311/// `Call`/`CallFunction`/`CallBuiltin` opcodes carry argc as a u8, so a command
13312/// with more than 255 args is packed into a single Array (dispatched with
13313/// argc=1) instead. But those call ops flatten their argv only ONE level, which
13314/// would stringify a nested Array (a brace/glob/`$arr` word contributes a
13315/// `Value::Array`). Pre-flattening here — same descent as
13316/// [`flatten_array_value`], the array-assignment path — makes the bundle flat
13317/// so the call op's single-level splat restores every positional arg. Bit
13318/// compsys: a completer's `_arguments <specs…>` with a large brace-form option
13319/// set (curl ships 59 `{-x,--long}` specs) dropped the long forms.
13320pub const BUILTIN_ARGV_RFLATTEN: u16 = 653;
13321/// `BUILTIN_ARRAY_JOIN_STAR` constant.
13322pub const BUILTIN_ARRAY_JOIN_STAR: u16 = 339;
13323/// `BUILTIN_SET_RAW_OPT` constant.
13324pub const BUILTIN_SET_RAW_OPT: u16 = 340;
13325
13326/// `time { compound; ... }` — wall-clock-time the sub-chunk and print
13327/// elapsed seconds. Stack: [sub_chunk_idx as Int]. Runs the sub-chunk
13328/// on the current VM (so positional/local state is shared) and prints
13329/// the timing summary to stderr in zsh's format. Pushes Status.
13330pub const BUILTIN_TIME_SUBLIST: u16 = 316;
13331
13332/// `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocation.
13333/// Stack: [path, varid, op_byte]. Opens `path` per `op_byte`, gets the
13334/// new fd (≥10 in zsh; we use libc::open with O_CLOEXEC bit cleared so
13335/// the inherited fd survives Command::new spawns), stores the fd number
13336/// as a string in `$varid`. Pushes Status (0 success, 1 error).
13337pub const BUILTIN_OPEN_NAMED_FD: u16 = 317;
13338
13339/// Word-segment concat that does cartesian-product distribution over
13340/// arrays. Stack: [lhs, rhs]. Used for RC_EXPAND_PARAM `${arr}` and
13341/// explicit-distribute forms (`${^arr}`, `${(@)…}`).
13342///
13343/// - both scalar: `Value::str(a + b)` (fast path, identical to Op::Concat)
13344/// - lhs Array, rhs scalar: `Value::Array([a + rhs for a in lhs])`
13345/// - lhs scalar, rhs Array: `Value::Array([lhs + b for b in rhs])`
13346/// - both Array: cartesian product `[a + b for a in lhs for b in rhs]`
13347pub const BUILTIN_CONCAT_DISTRIBUTE: u16 = 318;
13348
13349/// Forced-distribute concat — like `BUILTIN_CONCAT_DISTRIBUTE` but
13350/// always distributes cartesian regardless of the `rcexpandparam`
13351/// option. Emitted by the segments fast-path when an
13352/// `is_distribute_expansion` segment is present (`${^arr}`,
13353/// `${(@)arr}`, `${(s.…)arr}` etc.) per zsh: the source-level
13354/// distribution flag overrides the option default.
13355/// Direct port of Src/subst.c:1875 `case Hat: nojoin = 1` and the
13356/// `rcexpandparam` test bypass for the explicit-distribute flags.
13357pub const BUILTIN_CONCAT_DISTRIBUTE_FORCED: u16 = 522;
13358
13359/// Capture current `last_status` into the `TRY_BLOCK_ERROR` variable.
13360/// Emitted between the try block and the always block of `{ … } always
13361/// { … }` so the finally arm can read $TRY_BLOCK_ERROR.
13362pub const BUILTIN_SET_TRY_BLOCK_ERROR: u16 = 320;
13363/// `BUILTIN_RESTORE_TRY_BLOCK_STATUS` constant.
13364pub const BUILTIN_RESTORE_TRY_BLOCK_STATUS: u16 = 432;
13365/// `BUILTIN_BEGIN_INLINE_ENV` constant.
13366pub const BUILTIN_BEGIN_INLINE_ENV: u16 = 433;
13367/// `BUILTIN_END_INLINE_ENV` constant.
13368pub const BUILTIN_END_INLINE_ENV: u16 = 434;
13369/// Closes the current inline-env frame's save list. Emitted right
13370/// after the prefix assignments of `X=foo cmd` have committed and
13371/// before `cmd` dispatches, so assignments performed BY the command
13372/// are not recorded into (and therefore not reverted with) the frame.
13373/// c:Src/exec.c:4410 `save_params` snapshots only the parsed
13374/// WC_ASSIGN chain; the list is closed before the builtin/shell
13375/// function runs. Without the seal, `X=y . file` reverted every
13376/// global the sourced file assigned — which emptied git's
13377/// `git-completion.bash` option tables (`__git_log_common_options`
13378/// et al.) that `_git` sources via `GIT_SOURCING_ZSH_COMPLETION=y . …`.
13379pub const BUILTIN_SEAL_INLINE_ENV: u16 = 654;
13380
13381/// End-of-sublist `waitonejob` for a sublist that ran wholly in the
13382/// current shell. Emitted by `compile_zsh::compile_sublist` after each
13383/// element of the `&&`/`||` chain whose parse-time `cmplx` flag is set
13384/// (see `compile_zsh::sublist_elem_is_cmplx`) and whose top level is
13385/// NOT a multi-stage pipeline.
13386///
13387/// c:Src/exec.c:1489-1492 — `execlist` routes each sublist element on
13388/// the parse-time flag: `if (WC_SUBLIST_FLAGS(code) & WC_SUBLIST_SIMPLE)
13389/// execsimple(state); else execpline(state, code, ltype, ...);`. Only
13390/// the `execpline` arm builds a job, and `execpline` ends by calling
13391/// `waitonejob` on it.
13392///
13393/// c:Src/jobs.c:1748-1757 — `waitonejob(Job jn)`:
13394/// ```c
13395/// if (jn->procs || jn->auxprocs) zwaitjob(jn - jobtab, 0);
13396/// else { deletejob(jn, 0); pipestats[0] = lastval; numpipestats = 1; }
13397/// ```
13398/// A sublist that forked (a real multi-stage pipeline) takes the
13399/// `zwaitjob` arm, whose `storepipestats` (c:Src/jobs.c:420) publishes
13400/// the per-stage array — in zshrs that is `BUILTIN_RUN_PIPELINE`'s own
13401/// `set_array("pipestatus", ...)`. Every other cmplx sublist runs with
13402/// an empty proc list and takes the `else` arm, which is what this
13403/// builtin performs. Resolving which arm applies is a compile-time
13404/// decision in C (the parse-time flag) and is a compile-time decision
13405/// here too, so no marker is emitted for the pipeline case at all.
13406///
13407/// This is what makes a compound command publish `$pipestatus`:
13408/// `if ...; fi`, `for ...; done`, `case ... esac`, `while ...; done`,
13409/// `{ ... }`, `( ... )` and a bare command all reach `execpline` when
13410/// their body is cmplx, so zsh leaves `numpipestats == 1`. It also
13411/// makes the OUTER sublist win over an inner pipeline's array —
13412/// `if true; then true|false; fi` is `n=1 p=(1)`, not `(0 1)` — because
13413/// the outer procs-less job overwrites what the inner job stored.
13414///
13415/// Fires BEFORE the `!` negation (`emit_negate_status`): C applies
13416/// `WC_SUBLIST_NOT` inside `execpline` after the wait, so
13417/// `! [[ -z x ]]` records the PRE-negation status — `p=(1)` with `$?`
13418/// of 0.
13419pub const BUILTIN_SUBLIST_FINISH: u16 = 655;
13420
13421/// `[[ -o option ]]` — shell-option-set test. Stack: \[option_name\].
13422/// Normalizes the name (strip underscores, lowercase) and reads
13423/// `exec.options`. Pushes Bool.
13424pub const BUILTIN_OPTION_SET: u16 = 321;
13425/// Tri-state `[[ -o NAME ]]` — same lookup as BUILTIN_OPTION_SET
13426/// but returns a Value::Int (0=set, 1=unset, 3=invalid-name). The
13427/// 3-state code matches zsh's `[[ -o invalid ]]` exit (Src/cond.c
13428/// :502 `optison()`). Used by compile_cond's `-o` arm to skip the
13429/// generic bool→status conversion and preserve the invalid-name
13430/// signal in `$?`.
13431pub const BUILTIN_OPTION_CHECK_TRISTATE: u16 = 609;
13432
13433/// `${var:#pattern}` — array filter: remove elements matching `pattern`.
13434/// Stack: [name, pattern]. For scalar `var`, returns empty if it matches
13435/// the pattern, else the value. For array `var`, returns Array of
13436/// non-matching elements.
13437pub const BUILTIN_PARAM_FILTER: u16 = 322;
13438
13439/// `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()` —
13440/// subscripted-array assign with array-literal RHS. Stack:
13441/// [...elements, name, key]. Empty elements + single-int key `a[i]=()`
13442/// removes that element. Comma-key `a[i,j]=(...)` splices.
13443pub const BUILTIN_SET_SUBSCRIPT_RANGE: u16 = 323;
13444
13445/// `[[ -X file ]]` for unknown unary test op `-X`. Stack: \[op_name\].
13446/// Emits zsh's `unknown condition: -X` diagnostic to stderr and
13447/// pushes Bool(false). Without this, unknown conditions silently
13448/// returned false matching neither zsh's error format nor the
13449/// expected status code (zsh returns 2 for parse error).
13450
13451/// `[[ -t fd ]]` — fd-is-a-tty check. Stack: \[fd_string\].
13452/// Routes through libc::isatty. Pushes Bool.
13453///
13454/// ID 644 (unique, next free above the previous max of 643). This was
13455/// 325, which COLLIDED with BUILTIN_FILE_OLDER (also 325). The VM's
13456/// builtin table is last-registration-wins, and FILE_OLDER registered
13457/// after IS_TTY, so every `[[ -t fd ]]` silently dispatched to the
13458/// file-`-ot` handler: it compared the mtime of a file NAMED by the fd
13459/// string ("0", "1", …) — which never exists — so `[[ -t 0 ]]` was
13460/// always false. That broke interactive detection (`[[ -t 0 && -t 1 ]]`)
13461/// and any config gated on it. c:Src/cond.c:390 `return !isatty(...)`.
13462pub const BUILTIN_IS_TTY: u16 = 644;
13463/// Runtime rejection of a process substitution used inside a `[[ … ]]`
13464/// cond operand. c:Src/exec.c:4918/5040/5069 — `getoutputfile`/`getproc`
13465/// error `"process substitution %s cannot be used here"` when `thisjob ==
13466/// -1`, which is the case during cond evaluation. zshrs's THISJOB never
13467/// distinguishes that context at runtime, so the compiler emits this
13468/// builtin (gated on `in_cond_operand`) instead of the ProcessSubIn/Out
13469/// opcode. Pops the substitution text, zerrs, sets errflag (aborting the
13470/// statement → empty stdout, exit 1, matching zsh), returns empty.
13471pub const BUILTIN_PROCSUB_COND_ERROR: u16 = 645;
13472/// `${^arr}` cross-product concat — RC_EXPAND_PARAM forced ON by the `^` flag.
13473///
13474/// Distinct from BUILTIN_CONCAT_DISTRIBUTE_FORCED, which the other distribute
13475/// shapes (`${(@)a}`, `${(f)v}`, `${a[@]}`) share: those keep the word when the
13476/// array is EMPTY (`x${(@)a}y` → `xy`), but plan9 DELETES it
13477/// (c:Src/subst.c:4362-4365 `if (plan9) { uremnode(l, n); return n; }`), so
13478/// `x${^a}y` with `a=()` produces no word at all. One builtin cannot serve both
13479/// — the plan9-ness is known only at compile time, from the `^` flag itself.
13480/// Routes to `concat_plan9`, which already ports both c:4362's removal and the
13481/// c:4316-4350 cartesian emit, and is what the OPTION path
13482/// (`setopt rcexpandparam`) has always used.
13483pub const BUILTIN_CONCAT_PLAN9: u16 = 646;
13484/// `${^^arr}` concat — RC_EXPAND_PARAM forced OFF by the doubled flag
13485/// (c:Src/subst.c:2553-2555 `plan9 = 0`).
13486///
13487/// The mirror of BUILTIN_CONCAT_PLAN9. Needed because every other concat
13488/// builtin consults `plan9_active()` (the runtime OPTION) and so cross-products
13489/// anyway under `setopt rcexpandparam`, while `^^` must override the option:
13490/// setopt rcexpandparam; a=(a b c); print -rl -- ${^^a}.x
13491/// # zsh: `a`, `b`, `c.x` — spliced, NOT `a.x b.x c.x`
13492/// The override is computed in paramsubst but the distribution call is made
13493/// here, so — like the `^` flag — the only place that knows is the compiler.
13494/// Routes straight to `concat_splice`, C's non-plan9 join-first-and-last path
13495/// (c:4366-4437).
13496pub const BUILTIN_CONCAT_SPLICE_NOPLAN9: u16 = 647;
13497/// Atomic word assembler for a DQ word that MIXES a plan9 (`^`) segment with a
13498/// non-plan9 (splice/scalar) segment — e.g. `"${(@)^a}${(@)b}"`.
13499///
13500/// The per-pair concat fold (CONCAT_PLAN9 / CONCAT_SPLICE picked ONCE for the
13501/// whole word) cannot express a word where segment A distributes but segment B
13502/// splices: a single operator does full-cross OR first/last-splice, never both,
13503/// and it loses track of which trailing elements are still the "growing edge".
13504/// zsh (Src/subst.c:4316-4437) instead threads a growing edge through the whole
13505/// word — an element is "active" until a splice freezes all but the last.
13506///
13507/// This builtin ports that edge-tracking directly. Stack (bottom→top):
13508/// descriptor, seg0, seg1, …, seg(n-1) with argc = n + 1
13509/// where `descriptor` is an n-char string, one char per segment: `'1'` = plan9
13510/// (`^`), `'0'` = splice/scalar/literal. Each segment value is an Array (splat)
13511/// or scalar (1 element). Result is the assembled Array (or scalar / deleted).
13512pub const BUILTIN_WORD_ASSEMBLE_PLAN9: u16 = 652;
13513/// `break N`/`continue N` runtime-count validator (see registration).
13514pub const BUILTIN_BREAK_COUNT_VALIDATE: u16 = 648;
13515/// `[[ -r/-w/-x file ]]` via access(2) (doaccess) — see handler.
13516pub const BUILTIN_COND_ACCESS: u16 = 638;
13517
13518/// Evaluate a `[[ ]]` module/completion condition (`-prefix`/`-suffix`/
13519/// `-after`/`-between`). Stack (top-first): argc operand words, then the
13520/// operator word. Dispatches to `complete::eval_mod_cond`. Result pushed as
13521/// Bool (true = condition matched). Used by the `ZshCond::ModCond` compile arm.
13522pub const BUILTIN_COND_MOD: u16 = 651;
13523
13524/// `provenance` — report the lineage of a tracked parameter: where its
13525/// bytes entered the shell (command substitution, glob, heredoc, an
13526/// earlier assignment) and every bytecode-level op that touched them
13527/// since. Handler: `ShellExecutor::builtin_provenance`; engine:
13528/// `src/extensions/provenance.rs`. ID 661 is the first free slot above
13529/// the 653-660 block.
13530pub const BUILTIN_PROVENANCE: u16 = 661;
13531
13532/// PRINT_EXIT_VALUE report for one finished simple command. Direct port
13533/// of c:Src/exec.c:4308-4316 (`execcmd_exec`'s tail):
13534/// ```c
13535/// if (isset(PRINTEXITVALUE) && isset(SHINSTDIN) && lastval && !subsh)
13536/// fprintf(stderr, "zsh: exit %lld\n", lastval);
13537/// ```
13538/// The ported `execcmd_exec` carries the same code (exec.rs), but fusevm —
13539/// not that walker — is what actually runs a command, so the report never
13540/// fired. `compile_simple` emits this call right after the dispatch's
13541/// `Op::SetStatus` (both the builtin and the function/external arm), which
13542/// is exactly where the C line sits. Pushes Status(0), which the emit side
13543/// pops; `vm.last_status` is left untouched.
13544pub const BUILTIN_PRINT_EXIT_VALUE: u16 = 662;
13545
13546/// Update `$LINENO` to track the source line of the next statement.
13547/// Stack: \[n\] (the line number from `ZshPipe.lineno`). Direct port
13548/// of zsh's `lineno` global tracking (Src/input.c:330) — the
13549/// compiler emits one of these per top-level pipe so `$LINENO`
13550/// reflects the source position at runtime. ID 342 picked because
13551/// the previous `326` collided with `BUILTIN_HAS_STICKY` (the 325
13552/// collision between IS_TTY and FILE_OLDER has since been fixed by
13553/// moving IS_TTY to 644).
13554pub const BUILTIN_SET_LINENO: u16 = 342;
13555
13556/// Pop a scalar from the VM stack, run expand_glob on it, push the
13557/// result as Value::Array. Used by the segment-concat compile path
13558/// when var refs concatenate with glob meta literals (`$D/*`,
13559/// `${prefix}*`, etc.) — those skip the bridge's pathname-expansion
13560/// pass and would otherwise leak the glob meta to argv as a literal.
13561pub const BUILTIN_GLOB_EXPAND: u16 = 343;
13562
13563/// MULTIOS-gated glob expansion for redirect-target words
13564/// (c:Src/glob.c:2161-2167 xpandredir: "Globbing is only done for
13565/// multios."). Same stack shape as BUILTIN_GLOB_EXPAND; additionally
13566/// passes the word through literally when `unsetopt multios`.
13567// 624 is BUILTIN_COND_STRMATCH — the VM's builtin table is
13568// last-registration-wins, so a duplicate id silently shadows the
13569// earlier handler.
13570pub const BUILTIN_REDIR_GLOB_EXPAND: u16 = 628;
13571
13572/// Reset the default-word glob-pending carrier at the START of a word
13573/// whose source contains a glob metachar (so the flag never leaks from a
13574/// prior word/statement). Paired with BUILTIN_DEFAULT_WORD_GLOB.
13575pub const BUILTIN_DEFAULT_WORD_GLOB_RESET: u16 = 635;
13576
13577/// Filename-generate the ASSEMBLED word ONLY when the default/alternate
13578/// paramsubst arm took a SOURCE word carrying glob metachars
13579/// (subst::DEFAULT_WORD_GLOB_PENDING). A parameter VALUE never sets the
13580/// flag, so `x='*file'; ${x:-d}` stays literal while `${x:-*file}` /
13581/// `${x:-a*}bar` glob. Reads+clears the flag. c:Src/subst.c → globlist.
13582pub const BUILTIN_DEFAULT_WORD_GLOB: u16 = 636;
13583/// `BUILTIN_SET_LOOP_VAR` constant — for-loop variable binding via
13584/// `setloopvar` (Src/params.c:6362): a PM_NAMEREF loop var REBINDS
13585/// to each word (SETREFNAME + setscope) instead of assigning
13586/// through the chain. Returns Bool(false) when zerr fired
13587/// (read-only reference / invalid self reference) so the loop
13588/// driver aborts, mirroring C execfor's errflag check.
13589pub const BUILTIN_SET_LOOP_VAR: u16 = 629;
13590
13591/// EXTEND step of typeset paren-init packing. Pops `argc` values:
13592/// [base, e1, …, eN] — base is either the opener (`name=(` /
13593/// `name+=(`) or a previous EXTEND result. Pushes base with
13594/// `\u{1f}` + element appended per element. Array values SPLICE
13595/// their items as separate elements (`typeset b=( x $arr )` splat);
13596/// an empty Array contributes nothing (unquoted-empty elision).
13597/// CallBuiltin's argc is u8, so the compiler emits one EXTEND per
13598/// ≤200-element chunk — p10k's 408-element `__p9k_colors=( … )`
13599/// overflowed a single-shot pack (argc wrapped mod 256 and the
13600/// stack spilled into the arg list: "not an identifier: 173…").
13601/// BUILTIN_TYPESET_PAREN_CLOSE appends the final `\u{1f})`,
13602/// yielding the exact REJOIN_SEP-delimited one-arg form
13603/// bin_typeset's single-arg splitter consumes (builtin.rs ~4891,
13604/// empties preserved, leading/trailing sentinel-empties trimmed
13605/// once). One arg in → one arg out: bin_typeset's multi-arg rejoin
13606/// (paren-depth scan, unsafe on EXPANDED paren-literal elements
13607/// like p10k's `')' ''`) never runs.
13608pub const BUILTIN_TYPESET_PAREN_PACK: u16 = 630;
13609
13610/// CLOSE step — pops the EXTEND chain's result, pushes it with
13611/// `\u{1f})` appended. See BUILTIN_TYPESET_PAREN_PACK.
13612pub const BUILTIN_TYPESET_PAREN_CLOSE: u16 = 631;
13613
13614/// Shared body of BUILTIN_GLOB_EXPAND / BUILTIN_REDIR_GLOB_EXPAND.
13615/// c:Src/glob.c:1872 — `zglob` runs per-word in the argv pipeline.
13616/// When the upstream EXPAND_TEXT returned an array (e.g. `${a:e}`
13617/// splat → ["txt","md"]), glob each element separately, not a
13618/// sepjoin'd scalar. `skip_glob` short-circuits to a literal
13619/// pass-through (noglob, or a redirect target under
13620/// `unsetopt multios`).
13621fn glob_expand_word_value(raw: Value, skip_glob: bool) -> Value {
13622 let patterns: Vec<String> = match raw {
13623 Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
13624 other => vec![other.to_str()],
13625 };
13626 if skip_glob {
13627 return if patterns.is_empty() {
13628 Value::array(Vec::new())
13629 } else if patterns.len() == 1 {
13630 Value::str(patterns.into_iter().next().unwrap())
13631 } else {
13632 Value::array(patterns.into_iter().map(Value::str).collect())
13633 };
13634 }
13635 let mut out: Vec<String> = Vec::with_capacity(patterns.len());
13636 for pattern in &patterns {
13637 // c:Src/subst.c — filename generation runs `filesub` (tilde/`=`
13638 // expansion) BEFORE globbing. A `~`/`=` reaching this word-glob op
13639 // comes from `${~spec}` / GLOB_SUBST marking a substituted VALUE:
13640 // literal and quoted `~` words are filesub'd (or skip glob) upstream
13641 // and never arrive here. filesubstr matches the Tilde TOKEN, so
13642 // shtokenize first (raw `~`->Tilde; already-Tilde `${~a[@]}` results
13643 // pass through), run filesub, then untokenize surviving glob metas
13644 // for expand_glob. Gated on `~`/`=` (raw or token) so ordinary
13645 // substituted words skip the roundtrip. Fixes `${~x}` x="~/foo".
13646 let filesubbed = if pattern.contains('~')
13647 || pattern.contains('=')
13648 || pattern.contains(crate::ported::zsh_h::Tilde)
13649 || pattern.contains(crate::ported::zsh_h::Equals)
13650 {
13651 let mut tok = pattern.clone();
13652 crate::ported::glob::shtokenize(&mut tok);
13653 crate::ported::lex::untokenize(&crate::ported::subst::filesub(&tok, 0))
13654 } else {
13655 pattern.clone()
13656 };
13657 let matches = with_executor(|exec| exec.expand_glob(&filesubbed));
13658 if matches.is_empty() {
13659 // c:1872 nullglob — drop this word, don't emit a hole
13660 continue;
13661 }
13662 for m in matches {
13663 out.push(m);
13664 }
13665 }
13666 if out.is_empty() {
13667 return Value::array(Vec::new());
13668 }
13669 if patterns.len() == 1 && out.len() == 1 && out[0] == patterns[0] {
13670 // No real matches; expand_glob returned the literal. Pass
13671 // back as scalar so downstream ops don't re-flatten.
13672 return Value::str(out.into_iter().next().unwrap());
13673 }
13674 Value::array(out.into_iter().map(Value::str).collect())
13675}
13676
13677/// Push a `CmdState` token onto the command-context stack. Direct
13678/// port of zsh's `cmdpush(int cmdtok)` (Src/prompt.c:1623). The
13679/// stack is consulted by `%_` in PS4/prompt expansion to produce
13680/// the cumulative control-flow-context labels (`if`, `then`,
13681/// `cmdand`, `cmdor`, `cmdsubst`, …) that `zsh -x` xtrace shows
13682/// in the trace prefix. Compile_zsh emits push/pop pairs around
13683/// each compound command (if/while/[[…]]/((…))/$(…) etc.).
13684/// Token is a `CmdState as u8`.
13685pub const BUILTIN_CMD_PUSH: u16 = 344;
13686
13687/// Pop the top of the command-context stack. Direct port of zsh's
13688/// `cmdpop(void)` (Src/prompt.c:1631).
13689pub const BUILTIN_CMD_POP: u16 = 345;
13690
13691/// Emit an xtrace line built from the top `argc` values on the VM
13692/// stack, peeked WITHOUT consuming. Used to trace simple commands
13693/// AFTER expansion, so `echo for $i` shows as `echo for a` / `echo
13694/// for b`. Direct port of Src/exec.c:2055-2066.
13695pub const BUILTIN_XTRACE_ARGS: u16 = 346;
13696
13697/// Trace one assignment: emits `name=<quoted-value> ` (no newline)
13698/// to xtrerr if XTRACE is on. Coalesces with subsequent
13699/// XTRACE_ASSIGN / XTRACE_ARGS calls onto the SAME line via the
13700/// `XTRACE_DONE_PS4` flag so `a=1 b=2 echo $a $b` produces:
13701/// `<PS4>a=1 b=2 echo 1 2\n`
13702/// matching C zsh's `execcmd_exec` body (Src/exec.c:2517-2582):
13703/// xtr = isset(XTRACE);
13704/// if (xtr) { printprompt4(); doneps4 = 1; }
13705/// while (assign) {
13706/// if (xtr) fprintf(xtrerr, "%s=", name);
13707/// ... eval value ...
13708/// if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
13709/// }
13710///
13711/// Stack contract on entry: [..., name, value]. Both peeked, NOT
13712/// consumed (the matching SET_VAR call pops them after). argc = 2.
13713pub const BUILTIN_XTRACE_ASSIGN: u16 = 525;
13714
13715/// Emit a trailing `\n` + flush iff XTRACE is on AND PS4 was
13716/// emitted by an earlier XTRACE_ASSIGN this line. Used at the end
13717/// of compile_simple's assignment-only path so the trace line gets
13718/// terminated. Mirrors C's exec.c:3397-3399 (the assign-only return
13719/// path through execcmd_exec which does `fputc('\n', xtrerr);
13720/// fflush(xtrerr)`).
13721///
13722/// Stack: untouched. argc = 0.
13723pub const BUILTIN_XTRACE_NEWLINE: u16 = 526;
13724
13725/// Push the live `xtrace` opt-state as `Value::Int(1)` (on) or
13726/// `Value::Int(0)` (off). Used by `compile_cond` to gate the
13727/// trace-string-building block on xtrace state at runtime — without
13728/// this the trace path's `compile_word_str` on each operand re-
13729/// evaluates side-effectful expressions (`$((i++))`) once for the
13730/// trace string and once for the actual condition, doubling the
13731/// effective increment. Bug #159 in docs/BUGS.md.
13732///
13733/// Stack: pushes Int(0|1). argc = 0.
13734pub const BUILTIN_XTRACE_IS_ON: u16 = 611;
13735
13736/// Reset the `DONETRAP` flag at the start of each top-level statement
13737/// (sublist boundary). Mirrors C `Src/exec.c:1455` — `donetrap = 0`.
13738/// Stack: untouched. argc = 0. Bug #303 in docs/BUGS.md.
13739pub const BUILTIN_DONETRAP_RESET: u16 = 612;
13740
13741/// c:Src/exec.c:1417 (`int oldnoerrexit = noerrexit;`) + c:1536-1538
13742/// (`if (isandor || isnot) noerrexit |= NOERREXIT_EXIT|NOERREXIT_RETURN;`).
13743/// Saves the current `noerrexit` on a per-thread stack and ORs in the two
13744/// suppression bits for the duration of one `&&`/`||` chain operand (or a
13745/// `!`-negated command). Stack: untouched. argc = 0.
13746pub const BUILTIN_NOERREXIT_SUPPRESS: u16 = 665;
13747
13748/// c:Src/exec.c:1621 / c:1626 — `noerrexit = oldnoerrexit;`. Pops the
13749/// matching save pushed by [`BUILTIN_NOERREXIT_SUPPRESS`].
13750/// Stack: untouched. argc = 0.
13751pub const BUILTIN_NOERREXIT_RESTORE: u16 = 666;
13752
13753/// c:Src/loop.c:144 + :201 (execfor), :480 (execwhile), :536 (execrepeat) —
13754/// `lastval = 1;` on the `errflag` exit from a loop body. Emitted only on the
13755/// fatal-abort path of a compiled for/while/until/repeat; `execselect` has no
13756/// such assignment in C and never emits it.
13757/// Stack: pushes Int(0). argc = 0.
13758pub const BUILTIN_LOOP_ERRFLAG_STATUS: u16 = 667;
13759
13760thread_local! {
13761 /// c:Src/exec.c:1417 — C keeps `oldnoerrexit` as an execlist-local
13762 /// automatic, so the save/restore pairs nest with the C call stack.
13763 /// zshrs's compiler emits the two halves as separate ops, so the saved
13764 /// values need an explicit stack. Thread-local because `noerrexit`
13765 /// itself is per-shell state and worker threads run their own lists.
13766 static NOERREXIT_SAVES: std::cell::RefCell<Vec<i32>> =
13767 const { std::cell::RefCell::new(Vec::new()) };
13768}
13769
13770/// `[[ -z X ]]` / `[[ -n X ]]` operand-empty test that honours zsh's
13771/// array-splice semantics. C zsh evaluates `[[ -z X ]]` per
13772/// `Src/cond.c:347` (case 'z'): `s` is the SCALAR operand passed
13773/// through `cond_str`'s singsub. For `"${arr[@]}"` zsh expands per
13774/// `Src/subst.c:multsub` which yields each element as its own word
13775/// list node; cond.c then sees the joined-or-single-element form.
13776///
13777/// The compile-side `-z` shortcut at `compile_zsh.rs:5371` used
13778/// `Op::StringLen` which calls `Value::len` — for `Value::Array`
13779/// that returns ARRAY LENGTH, not string length. `b=("")` produced
13780/// `Value::Array([""])` → `len = 1` → `-z` returned false.
13781///
13782/// This builtin pops one `Value` and pushes `1` (empty) or `0`
13783/// (non-empty) per the cond context:
13784/// - `Value::Str(s)` → s.is_empty()
13785/// - `Value::Array([])` → true (zero words → vacuous-empty)
13786/// - `Value::Array([s])` → s.is_empty() (single-word case)
13787/// - `Value::Array([_; n>=2])` → false (multiple non-empty
13788/// words; zsh would raise "unknown condition" but the
13789/// observable test result is non-empty/false)
13790///
13791/// Companion to BUILTIN_COND_STR_NONEMPTY (#185 in docs/BUGS.md).
13792pub const BUILTIN_COND_STR_EMPTY: u16 = 613;
13793
13794/// `[[ -n X ]]` operand-non-empty test (logical complement of
13795/// BUILTIN_COND_STR_EMPTY).
13796pub const BUILTIN_COND_STR_NONEMPTY: u16 = 614;
13797
13798/// `exec N<<<"str"` — herestring redirect to explicit fd, applied
13799/// permanently to the shell (no scope restoration). Pops `[content,
13800/// fd]` from the stack; creates a temp file, writes
13801/// `content + "\n"`, reopens read-only, dup2's to `fd`, unlinks the
13802/// temp path so it disappears on close. Mirrors C `Src/exec.c:4655
13803/// getherestr` + `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)`
13804/// at c:3766-3780 for the bare-exec-redir code path (nullexec=1).
13805/// Bug #205 in docs/BUGS.md.
13806///
13807/// Stack: pushes `Value::Status(0)` on success, `Status(1)` on
13808/// failure. argc = 2.
13809pub const BUILTIN_EXEC_HERESTR_FD: u16 = 615;
13810
13811/// MULTIOS write/append fan-out for `cmd > a > b` / `cmd > a >> b`
13812/// style redirects (Bug #36 in docs/BUGS.md). zsh's MULTIOS option
13813/// (Src/exec.c:2418 `mfds[fd1]` check + addfd splice) creates a
13814/// pipe at fd1, spawns an internal "tee" process that copies
13815/// stdin → every collected target file. Without this, only the
13816/// LAST redirect target survives because each dup2 overwrites the
13817/// previous binding.
13818///
13819/// Stack layout (pushed by compile_zsh's compile_redirs coalescing
13820/// pass): `[target_1, op_byte_1, target_2, op_byte_2, …, target_N,
13821/// op_byte_N, fd]`. Pops 2N+1 elements; `argc = 2*N + 1`. A target
13822/// may be a Value::Array of glob matches (spliced into one member
13823/// per match, c:Src/glob.c:2195-2203); an op may be DUP_WRITE for a
13824/// numeric `>&N` member (c:Src/exec.c:3895-3917).
13825///
13826/// Runtime (MULTIOS set):
13827/// 1. Seed the member list with `dup(1)` when this command's
13828/// stdout is the pipeline output (c:Src/exec.c:3722-3724).
13829/// 2. Open/dup all targets per their op_byte in redirect order
13830/// (WRITE truncate + noclobber gate / APPEND / DUP_WRITE live
13831/// dup); the first member replaces the fd (c:2448-2450).
13832/// 3. Save `dup(fd)` onto the active redirect_scope_stack so
13833/// `host_redirect_scope_end` restores the original fd.
13834/// 4. Create a pipe; spawn a thread that reads from the pipe
13835/// read-end and writes every chunk to every opened target.
13836/// 5. dup2 the pipe write-end onto `fd` so the command's writes
13837/// go through the splitter.
13838/// 6. Track `(pipe_write_fd, JoinHandle)` so scope-end can close
13839/// the pipe (draining the thread) and join before restoring.
13840///
13841/// MULTIOS unset (c:2418 `unset(MULTIOS)` replace arm): each entry
13842/// is applied as a plain sequential replace via host_apply_redirect
13843/// — every file still opened/truncated, last one wins.
13844pub const BUILTIN_MULTIOS_REDIRECT: u16 = 617;
13845
13846/// MULTIOS input-side concatenation for `cmd < a < b` shapes
13847/// (Bug #36 input arm). C zsh's `Src/exec.c:2418` mfds dispatch
13848/// also covers the read direction — when multiple `<` redirects
13849/// target the same fd, mfds[fd] grows and addfd splices a
13850/// concatenating cat into the pipe.
13851///
13852/// Stack layout (mirrors the write side): `[source_1, op_1,
13853/// source_2, op_2, …, source_N, op_N, fd]`. Pops 2N + 1 elements
13854/// (argc = 2N + 1). op is READ for file sources, DUP_READ for
13855/// numeric `<&N` members; a source may be a Value::Array of glob
13856/// matches (spliced, c:Src/glob.c:2195-2203).
13857///
13858/// Runtime (MULTIOS set):
13859/// 1. Open/dup every source in redirect order; first member
13860/// replaces the fd (c:Src/exec.c:2448-2450).
13861/// 2. Save `dup(fd)` onto the redirect_scope_stack.
13862/// 3. Create a pipe; spawn a thread that reads each source in
13863/// order and writes every chunk to the pipe write-end. Close
13864/// write-end when done so the consumer sees EOF.
13865/// 4. dup2 the pipe read-end onto `fd`.
13866/// 5. Track the JoinHandle so scope-end joins (no fd-close needed
13867/// here — the producer thread closes its own pipe write-end
13868/// on exit).
13869///
13870/// MULTIOS unset: sequential replace via host_apply_redirect — last
13871/// source wins (c:2418).
13872pub const BUILTIN_MULTIOS_READ: u16 = 618;
13873
13874/// Toggle `ShellExecutor::exec_redirs_permanent`. Emitted by
13875/// compile_zsh's bare-`exec`-with-redirects arm tightly around each
13876/// `Op::Redirect`: `LoadInt(1); CallBuiltin; …Redirect…; LoadInt(0);
13877/// CallBuiltin`. While set, `host_apply_redirect` skips pushing the
13878/// saved fd into the enclosing redirect scope, making the fd change
13879/// permanent.
13880///
13881/// c:Src/exec.c:3978-3986 — nullexec==1 (`exec` carrying only
13882/// redirections): "If nullexec is 1 we specifically *don't* restore
13883/// the original fd's before returning" — the per-execcmd `save[]`
13884/// dups are closed unrestored. An ENCLOSING group's own saves are a
13885/// different execcmd's `save[]` and still restore (verified:
13886/// `{ exec 2>/dev/null; } 2>&1; ls /nope` prints the ls error in zsh).
13887pub const BUILTIN_EXEC_PERM_REDIRS: u16 = 619;
13888
13889/// Set `ShellExecutor::pipe_output_pending`. Emitted by compile_pipe
13890/// at the head of a NON-LAST pipeline-stage sub-chunk when that
13891/// stage's top-level command carries redirects (`Simple` with redirs
13892/// or `Redirected` compound). The forked stage child runs the chunk
13893/// with stdout already dup2'd onto the pipe; the first
13894/// `host_redirect_scope_begin` (the stage command's own redirect
13895/// list) consumes the flag into `pipe_output_scope`, enabling the
13896/// MULTIOS stream-split for fd-1 write redirects in that list.
13897///
13898/// c:Src/exec.c:3722-3724 — `addfd(forked, save, mfds, 1, output, 1,
13899/// NULL)` registers the pipe in mfds[1] in the SAME execcmd that
13900/// walks the stage command's redirect list; mfds is per-execcmd, so
13901/// nested body commands (`{ echo a > f; } | cat`) never see it.
13902pub const BUILTIN_PIPE_OUTPUT_MARK: u16 = 620;
13903
13904/// Install the pipeline stage's parked fds onto 0/1.
13905///
13906/// c:Src/exec.c:3720-3724 — `addfd(forked, save, mfds, 0, input, 0,
13907/// NULL)` / `addfd(..., 1, output, 1, NULL)`. Runs after prefork
13908/// (c:3304) and globlist (c:3702) have expanded the stage's argument
13909/// words, which is why a `$(...)` in those words reads the shell's
13910/// original stdin rather than the pipe. Emitted by
13911/// `compile_zsh.rs::emit_stage_fds_install`; the fds themselves are
13912/// parked by `BUILTIN_RUN_PIPELINE`.
13913pub const BUILTIN_PIPE_FDS_INSTALL: u16 = 642;
13914
13915/// Magic-equals prefork for a single arg word of a
13916/// `BINF_MAGICEQUALS` builtin head (`alias`). Direct port of
13917/// c:Src/exec.c:3298-3304 — `esprefork = PREFORK_TYPESET;
13918/// prefork(args, esprefork, NULL)` runs on the argv BEFORE the addfd
13919/// redirect loop at c:3720, so an expansion zerr (`alias bad===` →
13920/// equalsubstr "= not found" at Src/subst.c:726) prints to the
13921/// command's UN-redirected stderr. argc=1: pops the just-pushed
13922/// word value, runs shtokenize → prefork(PREFORK_TYPESET) →
13923/// untokenize on it (each element for Array splices), pushes the
13924/// result back. Emitted by compile_simple per arg word when the
13925/// dispatch head is `alias`; BUILTIN_ALIAS itself no longer
13926/// preforks (it would double-fire the diagnostic).
13927pub const BUILTIN_MAGIC_EQUALS_PREFORK: u16 = 621;
13928
13929/// Bare (unbraced) `$name[idx]` subscript — same dispatch as
13930/// `BUILTIN_ARRAY_INDEX` while KSHARRAYS is unset, but under
13931/// KSHARRAYS the unbraced form does NOT subscript (c:Src/subst.c:
13932/// 2800-2802 + 2867): `$name` expands bare and `[idx]` stays literal
13933/// trailing text that undergoes filename generation. Operands:
13934/// [name, idx, suffix, quoted].
13935pub const BUILTIN_ARRAY_INDEX_UNBRACED: u16 = 622;
13936
13937/// Assignment-only simple-command exit status. Direct port of
13938/// `lv = (errflag ? errflag : cmdoutval)` (c:Src/exec.c:1322,
13939/// execsimple's WC_ASSIGN arm) / `if (errflag) lastval = 1; else
13940/// lastval = cmdoutval;` (c:Src/exec.c:3393-3396, execcmd_exec's
13941/// no-command-word varspc path; redir variant at c:3977). Pops
13942/// [had_cmd_subst]; cmdoutval is the live vm.last_status when a
13943/// `$()` ran in any RHS of the chain, 0 otherwise. Writes the
13944/// canonical LASTVAL (C's single `lastval` global) so the
13945/// non-interactive errflag abort exits with this value per
13946/// Src/init.c:234. Caller pairs with SetStatus.
13947pub const BUILTIN_ASSIGN_ONLY_STATUS: u16 = 623;
13948
13949/// c:Src/exec.c addvars — `if (!pm) { lastval = 1; if (!cmdoutval)
13950/// cmdoutval = 1; }`. Set by BUILTIN_SET_VAR on assignsparam
13951/// failure, consumed by BUILTIN_ASSIGN_ONLY_STATUS so the
13952/// assignment-only command reports status 1. Process-global like
13953/// C's `cmdoutval` (function bodies may run on a different thread
13954/// than the opcode that reads the status back).
13955pub static ASSIGN_FAILED_FLAG: std::sync::atomic::AtomicBool =
13956 std::sync::atomic::AtomicBool::new(false);
13957
13958/// `redirection with no command` parse-time error for bare
13959/// `builtin 2>&1` / `command < file` / `exec >&-` precmd-keyword
13960/// shapes with a redirect but no following command. Direct port
13961/// of `Src/exec.c:3342 zerr("redirection with no command")`.
13962/// argc=0; pushes Value::Status(1).
13963pub const BUILTIN_REDIR_NO_CMD: u16 = 616;
13964
13965/// GLOB_SUBST guard for `[[ x == $pat ]]` pattern RHS coming from
13966/// parameter / command substitution. C-zsh's `[[ == ]]` semantics
13967/// (Src/options.c GLOB_SUBST default OFF + Src/cond.c:552
13968/// `cond_match` + Src/pattern.c patcompile tokenization-based
13969/// meta detection) treat chars from substitution as LITERAL
13970/// unless GLOB_SUBST is on. The Rust patcompile accepts both
13971/// tokenized and raw-ASCII meta chars, losing the distinction,
13972/// so `pat="h*"; [[ hello == $pat ]]` matched in zshrs but not
13973/// in zsh. Bug #116 in docs/BUGS.md.
13974///
13975/// Compile-time signal: emitted by `compile_cond_expr` ONLY when
13976/// the RHS contains `$` or backtick. Runtime checks the live
13977/// option state. If GLOB_SUBST is OFF, the popped string has
13978/// its glob metachars escaped with `\` so the downstream StrMatch
13979/// → patcompile treats them as literals. If GLOB_SUBST is ON,
13980/// the value passes through unchanged so `setopt glob_subst`
13981/// restores zsh's pattern-on-expansion behavior.
13982///
13983/// Stack: pops one string, pushes the (possibly escaped) result.
13984/// argc = 1.
13985pub const BUILTIN_GLOB_SUBST_GUARD: u16 = 528;
13986
13987/// Coerce a string parameter value to a math number (Int or Float)
13988/// for arithmetic-context reads, mirroring C-zsh's `getmathparam`
13989/// (Src/math.c:337). When the variable holds a string like "hello"
13990/// that isn't numeric, C falls back to recursively evaluating the
13991/// raw string as an arith expression; if that fails too, returns 0.
13992///
13993/// Used by the ArithCompiler pre-load path so `(( y = x ))` with
13994/// `x="hello"` reads `x` as integer 0, then assigns y as integer 0
13995/// — matching zsh's behaviour. The previous Rust port used
13996/// BUILTIN_GET_VAR which returned the raw string "hello"; the
13997/// ArithCompiler stored it verbatim in y's slot, and the post-sync
13998/// BUILTIN_SET_VAR wrote y="hello" as scalar instead of y=0 as
13999/// integer. Bug #118 in docs/BUGS.md.
14000///
14001/// Stack: pops `name` (string), pushes coerced numeric Value.
14002/// argc = 1.
14003pub const BUILTIN_GET_MATH_VAR: u16 = 529;
14004
14005/// GLOB_SUBST runtime gate for words containing parameter / command
14006/// substitution. C-zsh's `prefork` (Src/subst.c) runs `shtokenize`
14007/// on the substituted value when `GLOB_SUBST` is set, making the
14008/// substituted chars eligible for filename generation. With the
14009/// option off, substituted chars stay literal.
14010///
14011/// The Rust port's compile_zsh emits `compile_word_str` for words
14012/// like `/tmp/X/$pat`, which returns the post-expansion string but
14013/// never runs glob expansion (no path here triggers
14014/// BUILTIN_GLOB_EXPAND). Bug #119 in docs/BUGS.md: with `setopt
14015/// glob_subst`, `for f in /tmp/X/$pat` (pat="*.txt") never matched
14016/// `*.txt` files.
14017///
14018/// This opcode wraps the substitution result and dispatches at
14019/// runtime: when GLOB_SUBST is OFF, return unchanged; when ON,
14020/// pass the value through `expand_glob` so glob metas become
14021/// active. Emitted by `compile_for_words` (and similar sites)
14022/// after WORD_SPLIT for words with unquoted expansion.
14023///
14024/// Stack: pops a Value (Str or Array of Str), pushes the glob-
14025/// expanded result (still Str or Array depending on input shape).
14026/// argc = 1.
14027pub const BUILTIN_GLOB_SUBST_EXPAND: u16 = 530;
14028/// `BUILTIN_ASSOC_HAS_KEY` constant — `${(k)assoc[name]}` key-existence
14029/// query. Returns the key text on hit, empty string on miss. Bug #145.
14030pub const BUILTIN_ASSOC_HAS_KEY: u16 = 531;
14031/// `BUILTIN_ARRAY_DROP_EMPTY` constant — filter empty elements from
14032/// an Array on the stack. Used by `for x in $@` / `for x in $*`
14033/// unquoted forms. Bug #166.
14034pub const BUILTIN_ARRAY_DROP_EMPTY: u16 = 532;
14035/// `BUILTIN_QUOTED_STAR_ONE_WORD` — normalize the result of a QUOTED
14036/// `"$*"` / `"${*}"` expansion to EXACTLY ONE word.
14037///
14038/// c:Src/subst.c:3032 — the quoted (`qt`) branch of paramsubst ends in
14039/// `val = sepjoin(aval, sep, 1)`, a plain string join. Joining the
14040/// EMPTY positional list yields `""`, so `"$*"` with no positionals is
14041/// one empty word, exactly like `"$empty"` — which is why
14042/// `set --; printf '%d|%s|%d\n' $# "$*" 7` prints `0||7` in zsh, bash,
14043/// dash and ksh alike.
14044///
14045/// zshrs routes `"$*"` through `BUILTIN_EXPAND_TEXT` mode 1, whose
14046/// `multsub` returns a ZERO-node list for the empty case (correct for
14047/// `"$@"`, which really does vanish) and the bridge turns that into
14048/// `Value::Array(vec![])` — so the word was elided and printf saw one
14049/// argument fewer. This op restores the join's single-word shape at the
14050/// one call site that knows the splat was `*` and not `@`.
14051///
14052/// Stack: pops the expansion result, pushes `Value::str("")` when it
14053/// was an empty Array, and the value unchanged otherwise. argc = 1.
14054pub const BUILTIN_QUOTED_STAR_ONE_WORD: u16 = 663;
14055/// `BUILTIN_KSH_FUNSUB` — zsh NOFORK command substitution
14056/// (`Src/subst.c:1904-2100`), which also covers the ksh93 funsub
14057/// `${ list; }` and mksh valsub `${| list; }`: a command substitution that
14058/// runs in the CURRENT shell environment rather than a subshell.
14059///
14060/// Three forms, selected by the character right after `${`
14061/// (c:Src/subst.c:1924/1930):
14062/// * blank → `${ cmd }`, value is the body's STDOUT. c:2026-2029 scopes
14063/// it under `.zsh.cmdsubst`; c:2035-2044 captures via a temp-file
14064/// redirect so the body still runs in the current shell.
14065/// * `|` → `${| cmd }`, value is `$REPLY`, which is LOCAL to the body
14066/// (c:2018-2024 `createparam("REPLY", PM_LOCAL|PM_UNSET|PM_HIDE)`).
14067/// * `{VAR}` → `${{VAR} cmd }`, value is `$VAR`, NOT localised, and an
14068/// array stays an array (c:2082-2083 re-enters the parameter path).
14069///
14070/// ksh(1), Command Substitution: "${ command;} … the command is executed
14071/// in the current shell environment", and the value is the standard output
14072/// with trailing newlines removed (zsh strips ONE newline unquoted and
14073/// none quoted — c:1908 `trim = (!EMULATION(EMULATE_ZSH)) ? 2 : !qt`).
14074/// mksh(1)'s valsub matches zsh's `${| … }` exactly.
14075///
14076/// Stack (bottom→top): body, rplyvar, kind, qt — kind 0 = stdout capture,
14077/// 1 = REPLY form, 2 = named-variable form; qt = 1 when the word was
14078/// double-quoted. argc = 4. Pushes the resulting string, or an Array when
14079/// the named variable holds one / when the unquoted result is word-split.
14080pub const BUILTIN_KSH_FUNSUB: u16 = 664;
14081/// `BUILTIN_QUOTEDZPUTS` constant — run top-of-stack value through
14082/// `crate::ported::utils::quotedzputs` and push the quoted result.
14083/// Used by the cond xtrace path so non-printable bytes (e.g.
14084/// `$'\C-[OP'` expanded ESC+OP) get re-wrapped in `$'…'` form for
14085/// the trace prefix line, matching zsh's `Src/exec.c` cond trace
14086/// which calls `quotedzputs(operand, xtrerr)` on each side. Bug
14087/// surfaced when `[[ -n $'\C-[OP' ]]` traced as `[[ -n OP ]]`
14088/// (raw bytes leaked through the terminal) vs zsh's
14089/// `[[ -n $'\C-[OP' ]]` source-form preservation.
14090pub const BUILTIN_QUOTEDZPUTS: u16 = 533;
14091/// `BUILTIN_QUOTE_TOKENIZED_OUTPUT` — port of
14092/// `crate::ported::exec::quote_tokenized_output` (Src/exec.c:2114)
14093/// applied to top-of-stack scalar. Used by cond xtrace for the RHS
14094/// of pattern-context comparisons (`=` / `==` / `!=`) where C zsh
14095/// emits the SOURCE form: untokenize lexer tokens (Star → `*`,
14096/// Inpar → `(`, …) and backslash-escape special chars, but
14097/// preserve literal ASCII unchanged. Distinct from quotedzputs
14098/// which wraps the whole string in `'…'` / `$'…'` based on
14099/// non-printability — that's wrong for `[[ x = a* ]]` which must
14100/// render as `[[ x = a* ]]`, not `'a*'`.
14101pub const BUILTIN_QUOTE_TOKENIZED_OUTPUT: u16 = 534;
14102
14103/// Bridge into subst_port::substitute_brace_array for nested forms
14104/// that need to PRESERVE array shape across the expand_string
14105/// boundary. Stack: `[content_string]`. Returns Value::Array of the
14106/// per-element words. Used by the compile path for
14107/// `${(@)<nested>...##pat}` shapes — the standard substitute_brace
14108/// returns String which collapses array→scalar; this builtin
14109/// preserves the multi-word output via paramsubst's third return
14110/// (`nodes` vec, the C source's `aval` thread).
14111pub const BUILTIN_BRIDGE_BRACE_ARRAY: u16 = 347;
14112
14113/// Word-segment concat with FIRST/LAST sticking. Stack: [lhs, rhs].
14114/// Used for default unquoted splice forms (`${arr[@]}`, `$@`, `$*`)
14115/// where prefix sticks to first element only and suffix to last only.
14116///
14117/// Distribution table:
14118/// - both scalar: `Value::str(a + b)` (fast path)
14119/// - lhs scalar, rhs Array(b₀..bₙ): `Value::Array([lhs+b₀, b₁, …, bₙ])`
14120/// - lhs Array(a₀..aₙ), rhs scalar: `Value::Array([a₀, …, aₙ₋₁, aₙ+rhs])`
14121/// - both Array: `Value::Array([a₀, …, aₙ₋₁, aₙ+b₀, b₁, …, bₙ])`
14122/// (last of lhs merges with first of rhs; the rest stay separate)
14123///
14124/// This is the default zsh semantics for `print -l X${arr[@]}Y` →
14125/// "Xa", "b", "cY" — three distinct args, surrounding text only on ends.
14126pub const BUILTIN_CONCAT_SPLICE: u16 = 319;
14127
14128/// `${(flags)name}` — zsh parameter expansion flags. Stack: [name, flags].
14129/// Flags applied left-to-right. Supported subset (high-value, used by zpwr):
14130///
14131/// `L` — lowercase the value (scalar; or each element if array)
14132/// `U` — uppercase
14133/// `j:sep:` — join array with `sep` (delim is the char after `j`)
14134/// `s:sep:` — split scalar on `sep` (returns Value::Array)
14135/// `f` — split on newlines (shorthand for `s.\n.`)
14136/// `o` — sort array ascending
14137/// `O` — sort array descending
14138/// `P` — indirect: read name's value as another var name, return that's value
14139/// `@` — keep as array (returns Value::Array — useful before `j` etc.)
14140/// `k` — keys of assoc array
14141/// `v` — values of assoc array
14142/// `#` — word count (array length as scalar)
14143///
14144/// Flags can stack: `(jL)` joins then lowercases; `(s.,.U)` splits on `,`
14145/// then uppercases each element. The long-tail flags (`q`, `qq`, `qqq` for
14146/// quoting, `A` for assoc, `%` for prompt expansion, `e`/`g` for re-eval,
14147/// `n`/`p` for numeric, `t` for type, etc.) are deferred — they hit the
14148/// runtime fallback via the catch-all expansion path.
14149pub const BUILTIN_PARAM_FLAG: u16 = 297;
14150
14151/// `ShellHost` implementation that delegates to the current `ShellExecutor`
14152/// via the `with_executor` thread-local.
14153///
14154/// Construct fresh on each VM run (it carries no state itself). The VM
14155/// dispatches host method calls during `vm.run()`, and `with_executor`
14156/// resolves to the executor pointer set by `ExecutorContext::enter`.
14157/// fusevm-host implementation tying bytecode ops to the
14158/// shell executor.
14159/// zshrs-original — no C counterpart. C zsh has no bytecode VM
14160/// to host; everything runs through `execlist()`/`execpline()`
14161/// directly (Src/exec.c lines 1349/1668).
14162pub struct ZshrsHost;
14163
14164/// Short label for a sub-chunk, used as a provenance origin. A `Chunk`
14165/// keeps no original source text (only ops, constants and a source
14166/// *file* name), so the readable handle is reconstructed from the
14167/// leading string constants — for `$(date +%s)` that is `date +%s`.
14168fn prov_chunk_label(sub: &fusevm::Chunk) -> String {
14169 sub.constants
14170 .iter()
14171 .filter_map(|c| match c {
14172 Value::Str(s) if !s.is_empty() => Some(s.as_str()),
14173 _ => None,
14174 })
14175 .take(3)
14176 .collect::<Vec<_>>()
14177 .join(" ")
14178}
14179
14180impl fusevm::ShellHost for ZshrsHost {
14181 fn glob(&mut self, pattern: &str, _recursive: bool) -> Vec<String> {
14182 let matches = with_executor(|exec| exec.expand_glob(pattern));
14183 if crate::provenance::active() {
14184 crate::provenance::on_glob(pattern, &matches);
14185 }
14186 matches
14187 }
14188
14189 fn tilde_expand(&mut self, s: &str) -> String {
14190 with_executor(|exec| s.to_string())
14191 }
14192
14193 fn brace_expand(&mut self, s: &str) -> Vec<String> {
14194 // Direct call to the canonical brace expander
14195 // (Src/glob.c::xpandbraces port at glob.rs:1678). Was
14196 // routing through singsub which uses PREFORK_SINGLE — that
14197 // flag explicitly suppresses brace expansion in subst.c:166,
14198 // so `print X{1,2,3}Y` returned the literal string.
14199 //
14200 // brace_ccl: respect the BRACE_CCL option which the bracket-
14201 // class form `{a-z}` requires. Pull from executor options.
14202 let brace_ccl = with_executor(|exec| opt_state_get("braceccl").unwrap_or(false));
14203 crate::ported::glob::xpandbraces(s, brace_ccl)
14204 }
14205
14206 fn str_match(&mut self, s: &str, pattern: &str) -> bool {
14207 let pattern: &str = &pattern_filesub(pattern);
14208 // Shell glob match — `*`, `?`, `[...]`, alternation. After the
14209 // cond path moved to BUILTIN_COND_STRMATCH, the consumer here
14210 // is the `case` arm dispatch, whose bad-pattern semantics are
14211 // Src/loop.c:663-667: `if (!(pprog = patcompile(pat, ...)))
14212 // zerr("bad pattern: %s", pat);` — errflag set, the arm
14213 // doesn't match, and the script aborts at the next command
14214 // boundary (matching `zsh -fc 'case x in [a-) ...'` printing
14215 // the diagnostic with exit 0 = untouched lastval).
14216 let mut pat_tok = pattern.to_string();
14217 crate::ported::glob::tokenize(&mut pat_tok);
14218 if crate::ported::pattern::patcompile(
14219 &pat_tok,
14220 crate::ported::zsh_h::PAT_STATIC as i32,
14221 None,
14222 )
14223 .is_none()
14224 {
14225 crate::ported::utils::zerr(&format!("bad pattern: {}", pattern)); // c:667
14226 return false;
14227 }
14228 glob_match_static(s, pattern)
14229 }
14230
14231 fn expand_param(&mut self, name: &str, _modifier: u8, _args: &[Value]) -> Value {
14232 // Sole funnel: route through `getsparam` matching C zsh's
14233 // `getsparam(name)` → `getvalue` → `getstrvalue` →
14234 // `Param.gsu->getfn` dispatch (Src/params.c:3076 / 2335).
14235 //
14236 // The lookup chain (GSU dispatch + variables + env + array-
14237 // join) lives in `params::getsparam`; subst.rs and this
14238 // bridge both call into it so the logic is in exactly one
14239 // place — mirroring C's "every read goes through getsparam"
14240 // architecture. fuseVM bytecode triggers this bridge when
14241 // the VM hits a PARAM opcode, equivalent to C's wordcode VM
14242 // resolving a parameter read during `exec.c` execution.
14243 //
14244 // Modifier handling: the `_modifier` / `_args` parameters
14245 // are populated by the bytecode compiler but applied by
14246 // separate VM opcodes (LENGTH/STRIP/SUBST/etc.) downstream
14247 // of this fetch — matching C's split between getsparam
14248 // (value fetch) and paramsubst's modifier-walk loop. This
14249 // bridge is the value-fetch step only.
14250 let val_str = crate::ported::params::getsparam(name).unwrap_or_default();
14251 let value = Value::str(val_str);
14252 // Provenance: a read of a tracked parameter hands the
14253 // parameter's chain to the produced value, by `Arc` identity
14254 // for the rest of this chunk and by content for the host
14255 // boundaries downstream that only see `String`.
14256 if crate::provenance::active() {
14257 crate::provenance::on_param_read(name, &value);
14258 }
14259 value
14260 }
14261
14262 fn process_sub_in(&mut self, sub: &fusevm::Chunk) -> String {
14263 // c:Src/exec.c:4906 getoutputfile — `=(cmd)` (marked "equalsubst" by the
14264 // compiler) is the TEMP-FILE flavor: create a real regular file, fork a
14265 // writer whose stdout is the file, WAIT for it (so the file is complete
14266 // and seekable before the consumer runs), and return the file path. It
14267 // is unlinked at job end. This differs from `<(cmd)` below, which is a
14268 // /dev/fd pipe that is never waited on.
14269 if sub.source == "equalsubst" {
14270 let nam = crate::ported::utils::gettempname(None, true)
14271 .unwrap_or_else(|| format!("/tmp/zshrs_eqsub_{}", std::process::id()));
14272 let cpath = match std::ffi::CString::new(nam.as_str()) {
14273 Ok(c) => c,
14274 Err(_) => return String::from("/dev/null"),
14275 };
14276 // c:4945 — O_WRONLY|O_CREAT|O_EXCL|O_NOCTTY, 0600.
14277 let fd = unsafe {
14278 libc::open(
14279 cpath.as_ptr(),
14280 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOCTTY,
14281 0o600,
14282 )
14283 };
14284 if fd < 0 {
14285 return String::from("/dev/null");
14286 }
14287 let sub_for_child = sub.clone();
14288 match unsafe { libc::fork() } {
14289 -1 => {
14290 unsafe { libc::close(fd) };
14291 let _ = fs::remove_file(&nam);
14292 return String::from("/dev/null");
14293 }
14294 0 => {
14295 // c:4985 — child: stdout → the temp file, run the body, exit.
14296 // Clear the inherited pending-file list so this child never
14297 // unlinks the PARENT's =() temp files when its own commands
14298 // dispatch (fork copies the list; unlink hits the shared fs).
14299 PSUB_PENDING_FILES.with(|v| v.borrow_mut().clear());
14300 unsafe {
14301 libc::dup2(fd, libc::STDOUT_FILENO);
14302 libc::close(fd);
14303 }
14304 let mut vm = fusevm::VM::new(sub_for_child);
14305 register_builtins(&mut vm);
14306 vm.set_shell_host(Box::new(ZshrsHost));
14307 let _ = vm.run();
14308 let _ = std::io::stdout().flush();
14309 unsafe { libc::_exit(0) };
14310 }
14311 child_pid => {
14312 // c:4976-4980 — parent: close the write fd and WAIT so the
14313 // file is fully written before the consumer opens it.
14314 unsafe {
14315 libc::close(fd);
14316 let mut status: libc::c_int = 0;
14317 libc::waitpid(child_pid, &mut status, 0);
14318 }
14319 let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
14320 PSUB_PENDING_FILES.with(|v| v.borrow_mut().push((depth, nam.clone())));
14321 return nam;
14322 }
14323 }
14324 }
14325 // c:Src/exec.c::getproc — `<(cmd)` uses pipe + fork + the
14326 // `/dev/fd/N` filesystem entry (where N is the read end of
14327 // the pipe held open in the parent). Consumer opens
14328 // `/dev/fd/N`, reads the cmd's stdout through the pipe.
14329 // Both macOS and Linux expose `/dev/fd` for held-open file
14330 // descriptors. Previous Rust port captured stdout into
14331 // `/tmp/zshrs_psub_*` tempfiles synchronously — works for
14332 // `diff <(a) <(b)` style readers that scan once but diverges
14333 // from zsh's observable path string and breaks any consumer
14334 // that introspects the path or expects a non-seekable pipe.
14335 let mut fds: [libc::c_int; 2] = [-1, -1];
14336 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
14337 // Pipe creation failed — fall back to tempfile so we at
14338 // least return SOMETHING.
14339 let fifo_path = format!(
14340 "/tmp/zshrs_psub_fallback_{}_{}",
14341 std::process::id(),
14342 with_executor(|e| {
14343 let n = e.process_sub_counter;
14344 e.process_sub_counter += 1;
14345 n
14346 })
14347 );
14348 let _ = fs::remove_file(&fifo_path);
14349 return fifo_path;
14350 }
14351 let (read_end, write_end) = (fds[0], fds[1]);
14352 let sub_for_child = sub.clone();
14353 match unsafe { libc::fork() } {
14354 -1 => {
14355 unsafe {
14356 libc::close(read_end);
14357 libc::close(write_end);
14358 }
14359 return String::from("/dev/null");
14360 }
14361 0 => {
14362 // Child: close read end, dup write end to stdout,
14363 // run the sub-chunk, exit. The exit closes the
14364 // write end automatically, so the parent's reader
14365 // gets EOF when the cmd finishes.
14366 PSUB_PENDING_FILES.with(|v| v.borrow_mut().clear());
14367 unsafe {
14368 libc::close(read_end);
14369 libc::dup2(write_end, libc::STDOUT_FILENO);
14370 libc::close(write_end);
14371 }
14372 // c:Src/exec.c:5101/5150 — `execode(prog, 0, 1, out ?
14373 // "outsubst" : "insubst");`. execode (c:1245-1266) APPENDS its
14374 // context for the duration of the body, so `<(cmd)` — whose child WRITES (out=1) — runs as `…:outsubst`.
14375 // zshrs's ported getproc carries these citations but is NOT the
14376 // live path (established in #1062) — the VM forks here instead,
14377 // so the push belongs in this child. No pop needed: this runs
14378 // INSIDE the forked child and dies with it. Bug #1069 (procsub
14379 // legs).
14380 if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
14381 ctx.push("outsubst".to_string());
14382 let joined = ctx.join(":");
14383 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
14384 if let Some(pm) = tab.get_mut("zsh_eval_context") {
14385 pm.u_arr = Some(ctx.clone());
14386 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
14387 }
14388 if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
14389 pm.u_str = Some(joined);
14390 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
14391 }
14392 }
14393 }
14394 crate::fusevm_disasm::maybe_print_stdout("process_subst_in", &sub_for_child);
14395 let mut vm = fusevm::VM::new(sub_for_child);
14396 register_builtins(&mut vm);
14397 vm.set_shell_host(Box::new(ZshrsHost));
14398 let _ = vm.run();
14399 let _ = std::io::stdout().flush();
14400 unsafe { libc::_exit(0) };
14401 }
14402 child_pid => {
14403 // c:Src/exec.c:5092 `procsubstpid = pid;` — record the
14404 // forked child's PID so `${sysparams[procsubstpid]}`
14405 // returns it (was reading the never-updated atomic, so it
14406 // always came back 0). p10k's gitstatus daemon reads
14407 // `sysparams[procsubstpid]` right after `sysopen <(cmd)`
14408 // to track its worker PID; with 0 the daemon's self-check
14409 // failed and gitstatus fell back to re-downloading
14410 // gitstatusd — surfacing as "no prebuilt gitstatusd".
14411 crate::ported::exec::procsubstpid
14412 .store(child_pid, std::sync::atomic::Ordering::Relaxed);
14413 // Parent: close write end, keep read end open under
14414 // the same fd value so `/dev/fd/N` resolves to the
14415 // pipe's read side. NOTE: FD_CLOEXEC must STAY clear
14416 // — consumers like `cat <(cmd)` and `diff <(a) <(b)`
14417 // discover the fd via exec inheritance, so closing
14418 // on exec defeats the whole point. C zsh's getproc
14419 // (Src/exec.c:5045+) leaves the fd open across exec.
14420 unsafe {
14421 libc::close(write_end);
14422 }
14423 // Park read_end for close-after-consuming-command,
14424 // exactly like process_sub_out does for its write_end
14425 // (c:Src/exec.c addfilelist(NULL, fd) → deletefilelist).
14426 // WITHOUT this the parent's read_end stayed open for
14427 // the whole shell lifetime: p10k's async worker /
14428 // realtime clock do `exec {fd}< <(cmd)` on every prompt,
14429 // so each keystroke/redraw leaked a pipe fd until the
14430 // ~256-fd limit was hit and the shell locked up
14431 // (107 leaked pipes + 107 unreaped children observed).
14432 let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
14433 PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, read_end)));
14434 // Reap the forked child so it doesn't linger as a
14435 // zombie. `<(cmd)` children are fire-and-forget (their
14436 // output flows through the pipe); C reaps them via the
14437 // job machinery. A non-blocking reap here is scheduled;
14438 // do a best-effort WNOHANG now and the rest drain on
14439 // subsequent proc-subs / prompt cycles.
14440 crate::fusevm_bridge::note_psub_child(child_pid);
14441 }
14442 }
14443 let path = format!("/dev/fd/{}", read_end);
14444 if crate::provenance::active() {
14445 crate::provenance::on_process_subst(&prov_chunk_label(sub), &path);
14446 }
14447 path
14448 }
14449
14450 fn process_sub_out(&mut self, sub: &fusevm::Chunk) -> String {
14451 // c:Src/exec.c:5025 getproc, PATH_DEV_FD branch — `>(cmd)`
14452 // (out == 0): `mpipe(pipes)`, fork; the CHILD `redup(pipes[0],
14453 // 0)` (pipe read end onto stdin) and `closem` drops the write
14454 // end; the PARENT closes pipes[0] and hands the consumer
14455 // `/dev/fd/<pipes[1]>` (the write end). The previous Rust port
14456 // used mkfifo + a child that BLOCKED in open(FIFO, O_RDONLY)
14457 // before running cmd — with no writer the child never started,
14458 // never exited, and kept its inherited stdout (e.g. a `$()`
14459 // capture pipe) open forever: `a=$(print -r -- >(true))` hung.
14460 // With the pipe shape the child runs immediately and exits,
14461 // releasing inherited fds exactly like zsh (verified: zsh
14462 // blocks ~2s on `a=$(print -r -- >(sleep 2))`, then EOFs).
14463 let mut fds: [libc::c_int; 2] = [-1, -1];
14464 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
14465 // Pipe creation failed — fall back to a plain temp file so
14466 // the consumer at least has a writable path.
14467 let fallback = format!(
14468 "/tmp/zshrs_psub_out_{}_{}",
14469 std::process::id(),
14470 with_executor(|e| {
14471 let n = e.process_sub_counter;
14472 e.process_sub_counter += 1;
14473 n
14474 })
14475 );
14476 let _ = fs::write(&fallback, "");
14477 return fallback;
14478 }
14479 let (read_end, write_end) = (fds[0], fds[1]);
14480 let sub_for_child = sub.clone();
14481 match unsafe { libc::fork() } {
14482 -1 => {
14483 unsafe {
14484 libc::close(read_end);
14485 libc::close(write_end);
14486 }
14487 String::from("/dev/null")
14488 }
14489 0 => {
14490 // Child: close the write end (c: closem after redup),
14491 // dup the read end onto stdin (c: redup(pipes[0], 0)),
14492 // run the sub-chunk, exit. Other std fds stay
14493 // inherited — zsh's child keeps the surrounding
14494 // command's stdout/stderr.
14495 unsafe {
14496 libc::close(write_end);
14497 libc::dup2(read_end, libc::STDIN_FILENO);
14498 libc::close(read_end);
14499 }
14500 // c:Src/exec.c:5101/5150 — `execode(prog, 0, 1, out ?
14501 // "outsubst" : "insubst");`. execode (c:1245-1266) APPENDS its
14502 // context for the duration of the body, so `>(cmd)` — whose child READS (out=0) — runs as `…:insubst`.
14503 // zshrs's ported getproc carries these citations but is NOT the
14504 // live path (established in #1062) — the VM forks here instead,
14505 // so the push belongs in this child. No pop needed: this runs
14506 // INSIDE the forked child and dies with it. Bug #1069 (procsub
14507 // legs).
14508 if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
14509 ctx.push("insubst".to_string());
14510 let joined = ctx.join(":");
14511 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
14512 if let Some(pm) = tab.get_mut("zsh_eval_context") {
14513 pm.u_arr = Some(ctx.clone());
14514 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
14515 }
14516 if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
14517 pm.u_str = Some(joined);
14518 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
14519 }
14520 }
14521 }
14522 crate::fusevm_disasm::maybe_print_stdout("process_subst_out:child", &sub_for_child);
14523 let mut vm = fusevm::VM::new(sub_for_child);
14524 register_builtins(&mut vm);
14525 vm.set_shell_host(Box::new(ZshrsHost));
14526 let _ = vm.run();
14527 unsafe { libc::_exit(0) };
14528 }
14529 child_pid => {
14530 // c:Src/exec.c:5143 `procsubstpid = pid;` — same fix as
14531 // the `<(cmd)` in-path above: record the forked child's
14532 // PID for `${sysparams[procsubstpid]}` (was always 0).
14533 crate::ported::exec::procsubstpid
14534 .store(child_pid, std::sync::atomic::Ordering::Relaxed);
14535 // Parent: close the read end, keep the write end open
14536 // under its fd value so `/dev/fd/N` resolves to the
14537 // pipe's write side. FD_CLOEXEC must STAY clear —
14538 // consumers (`tee >(cmd)`) discover the fd via exec
14539 // inheritance, matching process_sub_in above and C's
14540 // fdtable[fd] = FDT_PROC_SUBST bookkeeping. Park the
14541 // fd for close-after-consuming-command (c: addfilelist
14542 // (NULL, fd) → deletefilelist) so the child's reader
14543 // EOFs — without this `tee >(wc -c) </dev/null` left
14544 // wc blocked until shell exit.
14545 unsafe {
14546 libc::close(read_end);
14547 }
14548 let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
14549 PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, write_end)));
14550 let path = format!("/dev/fd/{}", write_end);
14551 if crate::provenance::active() {
14552 crate::provenance::on_process_subst(&prov_chunk_label(sub), &path);
14553 }
14554 path
14555 }
14556 }
14557 }
14558
14559 fn subshell_begin(&mut self) {
14560 with_executor(|exec| {
14561 // Special parameters whose value lives in a process GLOBAL behind a
14562 // GSU (`Src/params.c`'s `ifs`, `wordchars`, `home`, `histsiz`, …)
14563 // rather than in the param table. Mirrors the getfn dispatch list at
14564 // params.rs:12548. C isolates these for free by forking `(...)`;
14565 // zshrs's in-process subshell has to snapshot them by hand, or a
14566 // subshell-local `IFS=,` rewrites the parent's word-splitting.
14567 const SUBSHELL_SPECIAL_GLOBALS: &[&str] = &[
14568 "IFS",
14569 "HOME",
14570 "TERM",
14571 "USERNAME",
14572 "WORDCHARS",
14573 "TERMINFO",
14574 "TERMINFO_DIRS",
14575 "KEYBOARD_HACK",
14576 "histchars",
14577 "HISTSIZE",
14578 "SAVEHIST",
14579 ];
14580 // An UNSET special yields None and is skipped: the paramtab snapshot
14581 // restores its PM_UNSET flag, and the getfn dispatch refuses to read
14582 // the (stale) global while PM_UNSET is set (params.rs:12552).
14583 let special_globals_snap: Vec<(String, String)> = SUBSHELL_SPECIAL_GLOBALS
14584 .iter()
14585 .filter_map(|n| crate::ported::params::getsparam(n).map(|v| ((*n).to_string(), v)))
14586 .collect();
14587 // libc::umask returns the previous mask AND sets the new
14588 // one; call with current value to read without changing.
14589 let cur_umask = unsafe {
14590 let m = libc::umask(0o022);
14591 libc::umask(m);
14592 m as u32
14593 };
14594 // Snapshot paramtab + hashed-storage too (step 1 of the
14595 // store unification mirrors writes there; restoring only
14596 // the HashMaps leaks subshell-scoped writes to the parent
14597 // via paramtab readers like `paramsubst → vars_get`).
14598 let paramtab_snap = crate::ported::params::paramtab()
14599 .read()
14600 .ok()
14601 .map(|t| t.clone())
14602 // c:Src/params.c:854 — a fresh table is 151 buckets, not
14603 // the 17-bucket `Default`.
14604 .unwrap_or_else(|| crate::ported::hashtable::hashtable_nodes::newhashtable(151));
14605 let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
14606 .lock()
14607 .ok()
14608 .map(|m| m.clone())
14609 .unwrap_or_default();
14610 let loop_flags_snap = {
14611 use std::sync::atomic::Ordering::SeqCst;
14612 (
14613 crate::ported::builtin::LOOPS.load(SeqCst),
14614 crate::ported::builtin::BREAKS.load(SeqCst),
14615 crate::ported::builtin::CONTFLAG.load(SeqCst),
14616 )
14617 };
14618 exec.subshell_snapshots.push(SubshellSnapshot {
14619 // c:Src/Modules/zutil.c:106 `static HashTable zstyletab` —
14620 // fork-copied for `(...)` in C. See SubshellSnapshot::zstyles.
14621 zstyles: crate::ported::modules::zutil::zstyletab
14622 .lock()
14623 .map(|t| t.clone())
14624 .unwrap_or_default(),
14625 // c:Src/utils.c:2111 `addlockfd` — the fds carrying
14626 // `zsystem flock` locks. Recorded so subshell_end can close
14627 // the ones the subshell itself opened (C's fork does it for
14628 // free). See SubshellSnapshot::flock_fds.
14629 flock_fds: current_flock_fds(),
14630 loop_flags: loop_flags_snap,
14631 paramtab: paramtab_snap,
14632 paramtab_hashed_storage: paramtab_hashed_snap,
14633 special_globals: special_globals_snap,
14634 positional_params: exec.pparams(),
14635 env_vars: env::vars().collect(),
14636 // Save the LOGICAL pwd ($PWD env), not `current_dir()`'s
14637 // symlink-resolved path. zsh's subshell isolation per
14638 // Src/exec.c at the `entersubsh` path treats `pwd` (the
14639 // shell-tracked logical PWD) as the carrier — see
14640 // `Src/builtin.c:1239-1242` where cd writes the logical
14641 // dest into `pwd`. Falling back to current_dir() only
14642 // when PWD is unset matches `setupvals` at
14643 // `Src/init.c:1100+`.
14644 cwd: env::var("PWD")
14645 .ok()
14646 .map(PathBuf::from)
14647 .or_else(|| env::current_dir().ok()),
14648 umask: cur_umask,
14649 // Snapshot canonical `traps_table` — bin_trap writes
14650 // there (`Src/builtin.c`).
14651 traps: crate::ported::builtin::traps_table()
14652 .lock()
14653 .map(|t| t.clone())
14654 .unwrap_or_default(),
14655 // Snapshot option store so `(set -e)` /
14656 // `(setopt extendedglob)` don't leak to parent.
14657 opts: crate::ported::options::opt_state_snapshot(),
14658 // c:Src/exec.c — fork() copies the alias table to
14659 // the subshell. `(alias x=y)` inside the subshell
14660 // dies with the child; the parent doesn't see x.
14661 // Snapshot here so subshell_end can restore.
14662 // Bug #209 in docs/BUGS.md.
14663 aliases: crate::ported::hashtable::aliastab_lock()
14664 .read()
14665 .ok()
14666 .map(|t| {
14667 t.iter()
14668 .map(|(k, v)| (k.clone(), v.text.clone(), v.node.flags))
14669 .collect()
14670 })
14671 .unwrap_or_default(),
14672 // c:Src/exec.c::entersubsh — same fork-copy
14673 // semantics for shfunctab. `(f() { ... })` defined
14674 // inside the subshell dies with the child; parent's
14675 // `type f` reports "not found". Bug #208 in
14676 // docs/BUGS.md.
14677 shfuncs: crate::ported::hashtable::shfunctab_lock()
14678 .read()
14679 .ok()
14680 .map(|t| t.snapshot())
14681 .unwrap_or_default(),
14682 functions_compiled: exec.functions_compiled.clone(),
14683 function_source: exec.function_source.clone(),
14684 // c:Src/exec.c::entersubsh — subshell forks its own
14685 // modulestab. A `(zmodload zsh/X)` inside the
14686 // subshell flips MOD_INIT_B on the CHILD's
14687 // modulestab; when the child exits the change
14688 // dies with it. zshrs's in-process subshell would
14689 // otherwise leak the load to the parent.
14690 // Bug #210 in docs/BUGS.md. Snapshot just the
14691 // (name → flags) pairs since the only mutating
14692 // field is the flags bitmask (MOD_INIT_B for
14693 // loaded, MOD_UNLOAD for unloaded).
14694 modules: crate::ported::module::MODULESTAB
14695 .lock()
14696 .ok()
14697 .map(|t| {
14698 t.modules
14699 .iter()
14700 .map(|(k, v)| (k.clone(), v.node.flags))
14701 .collect()
14702 })
14703 .unwrap_or_default(),
14704 // c:Src/exec.c::entersubsh — fork-copy semantics for
14705 // THINGYTAB (ZLE widget registry). A subshell `zle -N`
14706 // / `zle -D` mutation dies with the child in C zsh;
14707 // mirror via in-process snapshot. Bug #453.
14708 thingytab: crate::ported::zle::zle_thingy::thingytab()
14709 .lock()
14710 .ok()
14711 .map(|t| t.clone())
14712 .unwrap_or_default(),
14713 // c:Src/exec.c::entersubsh — same fork-copy for the
14714 // KEYMAPNAMTAB (named keymap registry). `bindkey -N km`
14715 // / `bindkey -D km` inside a subshell dies with the
14716 // child. Bug #454.
14717 keymapnamtab: crate::ported::zle::zle_keymap::keymapnamtab()
14718 .lock()
14719 .ok()
14720 .map(|t| t.clone())
14721 .unwrap_or_default(),
14722 // c:Src/exec.c::entersubsh fork semantics — `$!`
14723 // (clone::lastpid) set by a `&` INSIDE the subshell
14724 // dies with the child: `( : & ); echo $!` -> 0.
14725 lastpid: crate::ported::modules::clone::lastpid
14726 .load(std::sync::atomic::Ordering::Relaxed),
14727 // c:Src/exec.c::entersubsh fork semantics — the
14728 // subshell gets a COPY of the job table; its disown/
14729 // wait/`&` mutations die with it. Bug #462.
14730 jobtab: crate::ported::jobs::JOBTAB
14731 .get_or_init(|| std::sync::Mutex::new(Vec::new()))
14732 .lock()
14733 .map(|t| t.clone())
14734 .unwrap_or_default(),
14735 curjob: *crate::ported::jobs::CURJOB
14736 .get_or_init(|| std::sync::Mutex::new(-1))
14737 .lock()
14738 .unwrap(),
14739 prevjob: *crate::ported::jobs::PREVJOB
14740 .get_or_init(|| std::sync::Mutex::new(-1))
14741 .lock()
14742 .unwrap(),
14743 maxjob: *crate::ported::jobs::MAXJOB
14744 .get_or_init(|| std::sync::Mutex::new(0))
14745 .lock()
14746 .unwrap(),
14747 thisjob: *crate::ported::jobs::THISJOB
14748 .get_or_init(|| std::sync::Mutex::new(-1))
14749 .lock()
14750 .unwrap(),
14751 // c:Src/exec.c entersubsh — fork copies the fd table;
14752 // the child's `exec >file` / `exec N<&-` mutations die
14753 // with it. Dup each user-range fd to >= 10 so
14754 // subshell_end can restore the parent's exact table.
14755 saved_fds: (0..10)
14756 .map(|fd| {
14757 let dup = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
14758 (fd, dup)
14759 })
14760 .collect(),
14761 // c:Src/signals.c:39 `sigtrapped` — saved so End restores the
14762 // parent's per-signal trap flags (see the field docs).
14763 sigtrapped: crate::ported::signals::sigtrapped
14764 .lock()
14765 .map(|g| g.clone())
14766 .unwrap_or_default(),
14767 // c:Src/exec.c:160 `int subsh;` — saved so End can put the
14768 // parent's value back (subshells nest).
14769 subsh: crate::ported::exec::subsh.load(std::sync::atomic::Ordering::Relaxed),
14770 // c:Src/builtin.c:541-547 — `enable`/`disable` flip the
14771 // DISABLED bit on the `builtintab` node; c's fork for
14772 // `( … )` gives the child a private copy of the table.
14773 // See SubshellSnapshot::builtins_disabled.
14774 builtins_disabled: crate::ported::builtin::BUILTINS_DISABLED
14775 .lock()
14776 .map(|s| s.clone())
14777 .unwrap_or_default(),
14778 // c:Src/builtin.c:541-547 — same for `disable -r` on the
14779 // `reswdtab` node. See SubshellSnapshot::reswds_disabled.
14780 reswds_disabled: crate::ported::hashtable::reswdtab_lock()
14781 .read()
14782 .map(|t| {
14783 t.iter()
14784 .filter(|(_, r)| {
14785 (r.node.flags & crate::ported::zsh_h::DISABLED as i32) != 0
14786 })
14787 .map(|(n, _)| n.clone())
14788 .collect()
14789 })
14790 .unwrap_or_default(),
14791 });
14792 // c:Src/exec.c:1192-1193 — `if (!(flags & ESUB_FAKE)) subsh = 1;`
14793 // A `( … )` is a real subshell, so the body runs with subsh set.
14794 // The forked child carries it in C; the in-process body needs it
14795 // set explicitly or per-command checks that read it — notably
14796 // PRINT_EXIT_VALUE (c:4309 `&& !subsh`) — behave as if the
14797 // command ran in the parent.
14798 crate::ported::exec::subsh.store(1, std::sync::atomic::Ordering::Relaxed);
14799 // C forks for `(...)` — count the fork-equivalent so
14800 // `time (builtin)` reports like zsh (see FORK_EVENTS).
14801 crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
14802 // c:Src/exec.c:1088-1092 — entersubsh resets traps in the child:
14803 // if (!(flags & ESUB_KEEPTRAP))
14804 // for (sig = 0; sig <= SIGCOUNT; sig++)
14805 // if (!(sigtrapped[sig] & ZSIG_FUNC) &&
14806 // !(isset(POSIXTRAPS) && (sigtrapped[sig] & ZSIG_IGNORED)))
14807 // unsettrap(sig);
14808 //
14809 // A subshell does NOT inherit string-form traps. Two exemptions:
14810 // FUNCTION-form traps (`TRAPUSR1() { … }`, ZSIG_FUNC) survive, and
14811 // under POSIX_TRAPS an IGNORED trap (`trap '' SIG`) survives.
14812 //
14813 // The ZSIG_FUNC exemption is structural here rather than a flag
14814 // test: zshrs keeps string-form bodies in traps_table and
14815 // function-form ones in shfunctab as TRAPxxx, so filtering only
14816 // traps_table leaves the function form untouched by construction.
14817 // ZSIG_IGNORED is `trap '' SIG`, which stores an empty body.
14818 //
14819 // The LOOP BOUND is also part of the spec, not an implementation
14820 // detail: `sig <= SIGCOUNT` never reaches the PSEUDO-signals,
14821 // which zsh numbers above the real ones —
14822 // #define SIGZERR (SIGCOUNT+1)
14823 // #define SIGDEBUG (SIGCOUNT+2) (c:Src/signals.h:34-35)
14824 // so ERR/ZERR and DEBUG traps SURVIVE a subshell, while SIGEXIT
14825 // (sig 0) is inside the loop and is cleared. Verified against the
14826 // oracle: `trap 'print e' ERR; (trap)` lists the ERR trap;
14827 // `trap 'print u' USR1; (trap)` lists nothing.
14828 //
14829 // Without this a subshell kept the parent's traps: `(trap)` listed
14830 // them where zsh lists nothing, and — the part that isn't
14831 // cosmetic — an inherited trap FIRED inside the child, so
14832 // `trap 'print p' USR1; (kill -USR1 $$; print after)` printed
14833 // p before after instead of after…p (the signal is meant to reach
14834 // the parent, whose trap runs there).
14835 //
14836 // The snapshot pushed above restores the parent's table at
14837 // subshell_end, which is what makes clearing safe for zshrs's
14838 // in-process subshell.
14839 {
14840 entersubsh_reset_traps();
14841 }
14842 // c:Src/exec.c:2862 — subshell fork flags carry ESUB_PGRP,
14843 // so entersubsh runs `clearjobtab(monitor)` (c:1219): the
14844 // child gets an EMPTY job table plus the procless control
14845 // job grabbed at Src/jobs.c:1828 (`thisjob = initjob()`).
14846 // That's why zsh's `(jobs)` prints nothing and `(kill %1)`
14847 // hits the empty control job instead of the parent's job 1.
14848 // The snapshot pushed above restores the parent's table at
14849 // subshell_end. Bug #462.
14850 let monitor = crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
14851 crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
14852 // clearjobtab left THISJOB on the control job (Src/jobs.c:
14853 // 1828). In C the very next pipeline's execpline reassigns
14854 // thisjob (Src/exec.c:1700 `thisjob = newjob = initjob()`),
14855 // so by the time any builtin runs, thisjob never aliases
14856 // the control job. zshrs has no per-pipeline job slot —
14857 // model the between-pipelines state (-1) so getjob's
14858 // `jobnum != thisjob` (c:jobs.c:2107) doesn't reject %1 and
14859 // setcurjob doesn't demote an inherited curjob that
14860 // collides with the control slot.
14861 *crate::ported::jobs::THISJOB
14862 .get_or_init(|| std::sync::Mutex::new(-1))
14863 .lock()
14864 .unwrap() = -1;
14865 // Subshell starts with EXIT trap cleared so the parent's
14866 // EXIT handler doesn't fire when the subshell ends. zsh:
14867 // each subshell has its own trap context. Other signals
14868 // are inherited (well, parent's are still in place — but
14869 // a trap set INSIDE the subshell shouldn't leak out).
14870 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
14871 t.remove("EXIT");
14872 }
14873 let level = exec
14874 .scalar("ZSH_SUBSHELL")
14875 .and_then(|s| s.parse::<i32>().ok())
14876 .unwrap_or(0);
14877 // c:Src/exec.c — ZSH_SUBSHELL carries PM_READONLY (declared
14878 // in params.rs special_params); setsparam would be rejected
14879 // by assignstrvalue's PM_READONLY guard. Write u_val
14880 // directly — same bypass pattern as BUILTIN_SET_LINENO at
14881 // line 2784. C zsh's PM_SPECIAL GSU vtable handles this
14882 // implicitly via the setfn callback.
14883 let new_level = (level + 1) as i64;
14884 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
14885 if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
14886 pm.u_val = new_level;
14887 pm.u_str = Some(new_level.to_string());
14888 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
14889 }
14890 }
14891 });
14892 // Bump SUBSHELL_DEPTH so zexit defers process::exit (see
14893 // SUBSHELL_DEPTH declaration in src/ported/builtin.rs for
14894 // rationale).
14895 crate::ported::builtin::SUBSHELL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
14896 // c:Src/exec.c::entersubsh — C zsh's subshell is a forked
14897 // child process: signals sent to the parent (via `kill $$`
14898 // inside the subshell, where `$$` is the parent's pid)
14899 // never reach the child's signal handlers. zshrs's
14900 // in-process subshell shares the process pid with the
14901 // parent, so without queueing the subshell's trap handler
14902 // fires for signals that zsh would deliver only to the
14903 // parent. Queue signals across the subshell body so the
14904 // parent's restored trap table sees them after
14905 // subshell_end's unqueue drain. Bug #450.
14906 crate::ported::signals_h::queue_signals();
14907 }
14908
14909 fn subshell_end(&mut self) -> Option<i32> {
14910 // Fire subshell's EXIT trap BEFORE restoring parent state so
14911 // the trap body sees the subshell's vars and exit status. zsh
14912 // forks for `(...)` so the trap runs in the child process,
14913 // before exit. We mirror by running it here, just before the
14914 // pop+restore. REMOVE the trap before firing so the inner
14915 // execute_script doesn't fire it again at its own end.
14916 let exit_trap_body = crate::ported::builtin::traps_table()
14917 .lock()
14918 .ok()
14919 .and_then(|mut t| t.remove("EXIT"));
14920 if let Some(body) = exit_trap_body {
14921 // Execute the trap body. Errors during trap execution
14922 // don't bubble — zsh ignores trap-body errors.
14923 with_executor(|exec| {
14924 let _ = exec.execute_script(&body);
14925 });
14926 }
14927 with_executor(|exec| {
14928 if let Some(snap) = exec.subshell_snapshots.pop() {
14929 // c:Src/exec.c::entersubsh fork semantics — `loops` /
14930 // `breaks` / `contflag` are process globals the child
14931 // owns a private copy of, so `(break)` inside a loop
14932 // cannot end the PARENT's loop. See
14933 // SubshellSnapshot::loop_flags.
14934 {
14935 use std::sync::atomic::Ordering::SeqCst;
14936 let (loops, breaks, contflag) = snap.loop_flags;
14937 crate::ported::builtin::LOOPS.store(loops, SeqCst);
14938 crate::ported::builtin::BREAKS.store(breaks, SeqCst);
14939 crate::ported::builtin::CONTFLAG.store(contflag, SeqCst);
14940 }
14941 // c:Src/Modules/zutil.c:106 — restore the fork-copied
14942 // zstyle table. See SubshellSnapshot::zstyles.
14943 if let Ok(mut t) = crate::ported::modules::zutil::zstyletab.lock() {
14944 *t = snap.zstyles;
14945 }
14946 // c:Src/utils.c:2155-2164 `zcloselockfd` — release the
14947 // `zsystem flock` locks the subshell itself took. Under C
14948 // the forked child's fds close on exit; here we close the
14949 // fds that appeared while the subshell was running.
14950 // See SubshellSnapshot::flock_fds.
14951 for fd in current_flock_fds() {
14952 if !snap.flock_fds.contains(&fd) {
14953 crate::ported::utils::zcloselockfd(fd);
14954 }
14955 }
14956 // c:Src/exec.c:160 / :1192-1193 — the child's `subsh = 1`
14957 // dies with the fork in C; restore the parent's value here.
14958 crate::ported::exec::subsh.store(snap.subsh, std::sync::atomic::Ordering::Relaxed);
14959 // c:Src/builtin.c:541-547 — the child's `enable`/`disable`
14960 // only touched its forked copy of `builtintab` /
14961 // `reswdtab`. Put the parent's DISABLED sets back.
14962 // See SubshellSnapshot::builtins_disabled.
14963 if let Ok(mut s) = crate::ported::builtin::BUILTINS_DISABLED.lock() {
14964 *s = snap.builtins_disabled;
14965 }
14966 if let Ok(mut t) = crate::ported::hashtable::reswdtab_lock().write() {
14967 let names: Vec<String> = t.iter().map(|(n, _)| n.clone()).collect();
14968 for n in names {
14969 if snap.reswds_disabled.contains(&n) {
14970 t.disable(&n);
14971 } else {
14972 t.enable(&n);
14973 }
14974 }
14975 }
14976 // c:Src/signals.c:39 — same fork-copy reasoning for the
14977 // per-signal trap flags cleared at subshell entry.
14978 if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
14979 *st = snap.sigtrapped.clone();
14980 }
14981 // c:Src/exec.c::entersubsh — restore parent's
14982 // modulestab so a subshell `(zmodload zsh/X)` doesn't
14983 // leak to the parent. Bug #210 in docs/BUGS.md.
14984 // Restore via per-module flag write since the
14985 // snapshot is `(name → flags)` only.
14986 if let Ok(mut t) = crate::ported::module::MODULESTAB.lock() {
14987 // A `zmodload zsh/X` for a module with no modulestab
14988 // node yet takes load_module's allocate-on-miss branch
14989 // (c:Src/module.c:2223-2251) and CREATES the node. In C
14990 // that node is allocated in the forked child and dies
14991 // with it; here it survives, and the flag-only restore
14992 // below never touched it because the parent's snapshot
14993 // has no entry for that name. So `(zmodload zsh/datetime)`
14994 // left the parent with a MOD_INIT_B node and
14995 // `zmodload -e zsh/datetime` answered 0 where zsh
14996 // answers 1 (V04features.ztst %prep loads the module in
14997 // exactly that shape). Drop nodes the parent didn't have
14998 // FIRST, then restore the flags of the ones it did.
14999 // Rolling the node out is not enough on its own: a
15000 // module's feature-enable state lives in ITS OWN
15001 // statics (C: the `bintab[]` BINF_ADDED bits and
15002 // `patab[]` `d->pm` slots the load flipped —
15003 // `setfeatureenables`, c:Src/module.c:3445), which the
15004 // fork made private to the child. Run the same rollback
15005 // C runs on a real unload — `cleanup_module` (c:1918) →
15006 // `finish_module` (c:1926) — so the parent's view of
15007 // those tables matches the "module was never loaded"
15008 // state it had before the subshell.
15009 let strays: Vec<String> = t
15010 .modules
15011 .keys()
15012 .filter(|n| !snap.modules.contains_key(*n))
15013 .cloned()
15014 .collect();
15015 for name in &strays {
15016 let loaded = t
15017 .modules
15018 .get(name)
15019 .map(|m| (m.node.flags & crate::ported::zsh_h::MOD_INIT_B) != 0)
15020 .unwrap_or(false);
15021 if loaded {
15022 let _ = crate::ported::module::cleanup_module(&mut t, name);
15023 let _ = crate::ported::module::finish_module(&mut t, name);
15024 }
15025 }
15026 t.modules.retain(|name, _| snap.modules.contains_key(name));
15027 for (name, saved_flags) in &snap.modules {
15028 if let Some(m) = t.modules.get_mut(name) {
15029 m.node.flags = *saved_flags;
15030 }
15031 }
15032 }
15033 // NOTE: this runs BEFORE the paramtab restore below.
15034 // `cleanup_module` -> `setfeatureenables(m, f, NULL)`
15035 // (c:Src/module.c:3445) -> `deleteparamdef` (c:1128) looks
15036 // its parameter up in the LIVE paramtab, so rolling the
15037 // module back after the parent's paramtab was reinstated
15038 // found nothing and left the module's `patab[]` slots
15039 // marked enabled forever.
15040 // Restore paramtab + hashed storage so subshell-scoped
15041 // writes via setsparam/setaparam/sethparam don't leak
15042 // to the parent via paramtab readers.
15043 if let Some(tab) = crate::ported::params::paramtab()
15044 .write()
15045 .ok()
15046 .as_deref_mut()
15047 {
15048 *tab = snap.paramtab;
15049 }
15050 // Restore the global-backed specials (see
15051 // SubshellSnapshot::special_globals). MUST run after the
15052 // paramtab restore above: setsparam writes through the GSU setfn
15053 // to BOTH the process global and the param node, so the paramtab
15054 // overwrite would otherwise clobber the node half of it.
15055 for (name, val) in &snap.special_globals {
15056 crate::ported::params::setsparam(name, val);
15057 }
15058 // c:Src/exec.c::entersubsh fork semantics — restore
15059 // the parent's `$!`; a background job inside `(...)`
15060 // dies with the child in C zsh.
15061 crate::ported::modules::clone::lastpid
15062 .store(snap.lastpid, std::sync::atomic::Ordering::Relaxed);
15063 // c:Src/exec.c::entersubsh fork semantics — restore the
15064 // parent's job table + curjob/prevjob/maxjob/thisjob.
15065 // The subshell mutated only its own copy. Bug #462.
15066 if let Ok(mut t) = crate::ported::jobs::JOBTAB
15067 .get_or_init(|| std::sync::Mutex::new(Vec::new()))
15068 .lock()
15069 {
15070 *t = snap.jobtab;
15071 }
15072 *crate::ported::jobs::CURJOB
15073 .get_or_init(|| std::sync::Mutex::new(-1))
15074 .lock()
15075 .unwrap() = snap.curjob;
15076 *crate::ported::jobs::PREVJOB
15077 .get_or_init(|| std::sync::Mutex::new(-1))
15078 .lock()
15079 .unwrap() = snap.prevjob;
15080 *crate::ported::jobs::MAXJOB
15081 .get_or_init(|| std::sync::Mutex::new(0))
15082 .lock()
15083 .unwrap() = snap.maxjob;
15084 *crate::ported::jobs::THISJOB
15085 .get_or_init(|| std::sync::Mutex::new(-1))
15086 .lock()
15087 .unwrap() = snap.thisjob;
15088 if let Some(m) = crate::ported::params::paramtab_hashed_storage()
15089 .lock()
15090 .ok()
15091 .as_deref_mut()
15092 {
15093 *m = snap.paramtab_hashed_storage;
15094 }
15095 exec.set_pparams(snap.positional_params);
15096 // Restore the OS env to its pre-subshell state.
15097 // Removes any `export` writes the subshell made, and
15098 // restores any vars the subshell unset. Without this
15099 // `(export y=sub)` would leak `y` to the parent shell.
15100 let current: HashMap<String, String> = env::vars().collect();
15101 for k in current.keys() {
15102 if !snap.env_vars.contains_key(k) {
15103 env::remove_var(k);
15104 }
15105 }
15106 for (k, v) in &snap.env_vars {
15107 if current.get(k) != Some(v) {
15108 env::set_var(k, v);
15109 }
15110 }
15111 if let Some(cwd) = snap.cwd {
15112 let _ = env::set_current_dir(&cwd);
15113 // Resync $PWD env so a parent `pwd` doesn't read
15114 // the cwd the subshell `cd`'d into.
15115 env::set_var("PWD", &cwd);
15116 }
15117 // Restore umask. zsh's `(umask 077)` doesn't leak to
15118 // parent because the subshell forks; we run in-process
15119 // so we manually reset.
15120 unsafe {
15121 libc::umask(snap.umask as libc::mode_t);
15122 }
15123 // Restore parent's traps (the subshell's own traps die
15124 // with it). zsh: `(trap "X" USR1)` doesn't leak the
15125 // USR1 trap out of the subshell. Write back to the
15126 // canonical `traps_table` (bin_trap writes there).
15127 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
15128 *t = snap.traps;
15129 }
15130 // Restore parent's option store so `(set -e)` /
15131 // `(setopt extendedglob)` don't leak. zsh forks
15132 // subshells so child option changes die with the
15133 // child; we run in-process and must restore.
15134 crate::ported::options::opt_state_restore(snap.opts);
15135 // c:Src/exec.c — fork() means alias mutations in a
15136 // subshell die with the child. Restore parent's
15137 // alias table from snapshot. Clear current entries
15138 // then re-add parent's. Bug #209 in docs/BUGS.md.
15139 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
15140 tab.clear();
15141 for (name, text, flags) in snap.aliases {
15142 tab.add(crate::ported::zsh_h::alias {
15143 node: crate::ported::zsh_h::hashnode {
15144 next: None,
15145 nam: name,
15146 // ALIAS_GLOBAL / DISABLED must survive the
15147 // round-trip — flags:0 turned every global
15148 // alias regular on ANY subshell exit.
15149 flags,
15150 },
15151 text,
15152 inuse: 0,
15153 });
15154 }
15155 }
15156 // c:Src/exec.c::entersubsh — same fork-copy
15157 // semantics for shfunctab. Restore parent's function
15158 // table from snapshot so `(f() { ... })` definitions
15159 // inside the subshell don't leak to the parent.
15160 // Bug #208 in docs/BUGS.md.
15161 if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
15162 tab.restore(snap.shfuncs);
15163 }
15164 // Restore the runtime dispatch tables (compiled chunks
15165 // + source). Without these, a subshell-defined
15166 // override leaves its bytecode in place even after
15167 // shfunctab is restored — `g` after the subshell would
15168 // still run the override.
15169 exec.functions_compiled = snap.functions_compiled;
15170 exec.function_source = snap.function_source;
15171 // c:Src/exec.c::entersubsh — restore parent's THINGYTAB
15172 // so a subshell's `zle -N w f` / `zle -D w` doesn't
15173 // affect the parent's widget registry. Bug #453.
15174 if let Ok(mut t) = crate::ported::zle::zle_thingy::thingytab().lock() {
15175 *t = snap.thingytab;
15176 }
15177 // Same for KEYMAPNAMTAB. Bug #454.
15178 if let Ok(mut t) = crate::ported::zle::zle_keymap::keymapnamtab().lock() {
15179 *t = snap.keymapnamtab;
15180 }
15181 // c:Src/exec.c entersubsh fork semantics — restore the
15182 // parent's user-range fd table. A bare `exec >file` /
15183 // `exec N>&-` inside `(...)` died with the C child;
15184 // the in-process subshell must undo it here. Flush
15185 // Rust's stdout buffer FIRST so bytes the subshell
15186 // printed drain to the SUBSHELL's fd 1, not the
15187 // restored parent fd.
15188 {
15189 use std::io::Write;
15190 let _ = std::io::stdout().flush();
15191 }
15192 for (fd, saved) in snap.saved_fds {
15193 unsafe {
15194 if saved >= 0 {
15195 libc::dup2(saved, fd);
15196 libc::close(saved);
15197 } else {
15198 // fd was closed at entry; close whatever
15199 // the subshell opened on that slot.
15200 libc::close(fd);
15201 }
15202 }
15203 }
15204 }
15205 });
15206 // Decrement SUBSHELL_DEPTH. If a deferred subshell exit
15207 // landed inside (EXIT_PENDING set with depth > 0), promote
15208 // the deferred status into the subshell's exit status now
15209 // that we're at the boundary, then clear so the parent
15210 // continues. Matches C zsh's "subshell-exit-via-fork"
15211 // boundary where the child's process::exit(N) becomes
15212 // $WAITSTATUS / $? in the parent.
15213 crate::ported::builtin::SUBSHELL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
15214 // c:Src/exec.c — drain the signal queue against the now-
15215 // restored parent trap table. Pairs with the
15216 // queue_signals() call at the end of subshell_begin.
15217 // Any `kill $$` from inside the subshell is processed
15218 // here against OUTER's trap, matching C zsh's
15219 // signal-delivery-to-parent semantics. Bug #450.
15220 crate::ported::signals_h::unqueue_signals();
15221 // c:Src/exec.c — a `( … )` subshell is a FORK in C: an errflag
15222 // abort inside the child ends the child with its lastval as
15223 // the exit status, and the flag dies with the child process.
15224 // The parent's $? picks up the status and the parent's lists
15225 // keep running. zsh 5.9: `(readonly r=1; r=2); echo "after
15226 // $?"` prints `after 1`. zshrs runs the subshell in-process,
15227 // so mirror the fork isolation by clearing ERRFLAG_ERROR at
15228 // the subshell boundary — exec.last_status() already carries
15229 // the child's lastval (synced by ERREXIT_CHECK trigger 4).
15230 //
15231 // ERRFLAG_HARD must die at this boundary too: `${u:?msg}` sets
15232 // errflag |= ERRFLAG_HARD (c:Src/subst.c:3344) and then, in a
15233 // C forked subshell, `_exit(1)` (c:3353) — the parent never
15234 // sees ANY errflag bit. A leaked HARD bit here made every
15235 // subsequent zerr() take the silent arm (c:Src/utils.c:175-177
15236 // `if (errflag || noerrs) { errflag |= ERRFLAG_ERROR; return; }`),
15237 // so the next eval/source's parse silently "failed" and the
15238 // D04 harness shell wedged after chunk 10's
15239 // `(print ${unset1:?exiting1})`.
15240 //
15241 // !!! DASH-FAMILY GATE — see dash_mode::fatal_error_status !!!
15242 // dash's `sh_error()` unwinds via `exraise(EXERROR)`, which sets
15243 // `exitstatus = 2` before `exitshell()`; the `( … )` boundary IS
15244 // one of the two places that unwind lands, so the subshell reports
15245 // 2 rather than zsh's `lastval == ERRFLAG_ERROR == 1`. Read the
15246 // flag BEFORE the clear below; the status is published at the
15247 // deferred-`exit` arm uses (run_chunk otherwise restores
15248 // `vm.last_status` over any write made here).
15249 let dash_fatal_status = {
15250 let ef = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
15251 let fatal = crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD;
15252 if ef & fatal != 0 {
15253 crate::extensions::dash_mode::fatal_error_status()
15254 } else {
15255 None
15256 }
15257 };
15258 crate::ported::utils::errflag.fetch_and(
15259 !(crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
15260 std::sync::atomic::Ordering::Relaxed,
15261 );
15262 // c:Src/builtin.c:5834 / Src/exec.c:1443 — `retflag` dies at the
15263 // fork boundary for the same reason `errflag` above does. In C a
15264 // `return` inside `( … )` sets retflag in the CHILD; the child's
15265 // execlist unwinds, the child _exit()s, and the PARENT's retflag
15266 // was never touched. zshrs runs subshells in-process, so the flag
15267 // survived and returned from the enclosing FUNCTION:
15268 // f() { ( return 1 ); print IN }; f
15269 // printed nothing where zsh prints `IN`. Storing 0 is exactly a
15270 // restore-to-entry: a non-zero retflag unwinds its list
15271 // immediately (c:1443's `!retflag` gate), so the parent can never
15272 // be sitting at a subshell with the flag already set. Twin of the
15273 // save/restore `$( … )` already does in vm_helper.rs (the
15274 // `saved_retflag` pair around the cmd-subst sub-VM), and of the
15275 // loops/breaks/contflag restore in SubshellSnapshot::loop_flags.
15276 crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
15277 let exit_pending =
15278 crate::ported::builtin::EXIT_PENDING.load(std::sync::atomic::Ordering::Relaxed);
15279 if exit_pending != 0 {
15280 // c:Src/builtin.c — `exit N` masks N to 8 bits because
15281 // POSIX _exit takes the low byte as status. `(exit 256)`
15282 // and `(exit 0)` are indistinguishable to the parent;
15283 // `(exit 257)` exits with 1. Without the mask zshrs's
15284 // in-process subshell propagated the full i32 (256) into
15285 // the parent's $?, diverging from zsh.
15286 let raw = crate::ported::builtin::EXIT_VAL.load(std::sync::atomic::Ordering::Relaxed);
15287 // dash's `exraise(EXERROR)` assigns `exitstatus = 2` at the
15288 // RAISE, so a fatal error wins over whatever deferred-exit
15289 // value the unwind happened to carry. Only an errflag-driven
15290 // unwind reaches this — a real `(exit 5)` never sets the flag,
15291 // so `(exit 5)` still reports 5.
15292 let val = dash_fatal_status.unwrap_or(raw & 0xFF);
15293 with_executor(|exec| exec.set_last_status(val));
15294 crate::ported::builtin::EXIT_PENDING.store(0, std::sync::atomic::Ordering::Relaxed);
15295 crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
15296 crate::ported::builtin::BREAKS.store(0, std::sync::atomic::Ordering::Relaxed);
15297 // Return the deferred-exit status so the VM updates its
15298 // own `last_status`. Otherwise run_chunk's post-script
15299 // `set_last_status(vm.last_status)` would clobber LASTVAL
15300 // back to the stale pre-subshell value.
15301 return Some(val);
15302 }
15303 // Same publication path for a fatal error that did NOT arm a
15304 // deferred exit (e.g. the failed-assignment unwind).
15305 if let Some(st) = dash_fatal_status {
15306 with_executor(|exec| exec.set_last_status(st));
15307 return Some(st);
15308 }
15309 None
15310 }
15311
15312 fn redirect(&mut self, fd: u8, op: u8, target: &str) {
15313 // Apply a redirection at the OS level for the next command/builtin.
15314 // The host tracks saved fds in a per-executor stack so a future
15315 // `with_redirects_end` can restore. For now, this is a thin wrapper
15316 // that performs the dup2; pairing with explicit save/restore is
15317 // delivered by `with_redirects_begin/end`.
15318 with_executor(|exec| exec.host_apply_redirect(fd, op, target));
15319 }
15320
15321 fn with_redirects_begin(&mut self, count: u8) {
15322 with_executor(|exec| exec.host_redirect_scope_begin(count));
15323 }
15324
15325 fn regex_match(&mut self, s: &str, regex: &str) -> bool {
15326 // c:Src/Modules/regex.c:54 `zcond_regex_match` — POSIX ERE
15327 // matching + populate `$MATCH` / `$MBEGIN` / `$MEND` /
15328 // `$match[]` / `$mbegin[]` / `$mend[]` (or `$BASH_REMATCH`
15329 // under BASHREMATCH). Direct delegation to the canonical
15330 // port at src/ported/modules/regex.rs:58.
15331 //
15332 // The bridge passthru path delivers TOKEN-form bytes here
15333 // (Inbrack \u{91}, Outbrack \u{92}, Star \u{87}, Quest
15334 // \u{86}, etc.) since the lexer tokenizes regex meta chars
15335 // inside `[[ ]]`. The host regex engine expects ASCII, so
15336 // untokenize the pattern (and subject, for safety) once at
15337 // this boundary. zsh C reaches its POSIX-ERE engine through
15338 // the same untokenize path inside zcond_regex_match.
15339 let s_clean = crate::lex::untokenize(s);
15340 let regex_clean = crate::lex::untokenize(regex);
15341 // c:Src/cond.c:113-119 — WHICH engine `=~` uses is an option:
15342 //
15343 // char *modname = isset(REMATCHPCRE) ? "zsh/pcre" : "zsh/regex";
15344 //
15345 // and the two speak different languages (POSIX ERE vs PCRE), so the
15346 // option decides whether `\d` is a digit class or a literal `d`, and
15347 // whether `(?<name>…)` compiles at all. This dispatch was missing:
15348 // `=~` always used the regex module, so `setopt rematchpcre` silently
15349 // did nothing.
15350 if crate::ported::zsh_h::isset(crate::ported::zsh_h::REMATCHPCRE) {
15351 // c:115 — "zsh/pcre" → the `-pcre-match` cond.
15352 crate::ported::modules::pcre::cond_pcre_match(
15353 &[s_clean, regex_clean],
15354 crate::ported::modules::pcre::CPCRE_PLAIN,
15355 ) != 0
15356 } else {
15357 // c:115 — "zsh/regex" → the `-regex-match` cond.
15358 crate::ported::modules::regex::zcond_regex_match(
15359 &[s_clean.as_str(), regex_clean.as_str()],
15360 crate::ported::modules::regex::ZREGEX_EXTENDED,
15361 ) != 0
15362 }
15363 }
15364
15365 fn with_redirects_end(&mut self) {
15366 with_executor(|exec| exec.host_redirect_scope_end());
15367 // c:Src/exec.c:5172 — if any redirect in this scope failed
15368 // (noclobber-blocked, ENOENT for read, etc.), the command's
15369 // exit status is forced to 1 regardless of what the (still-
15370 // executed) command's own exit was. C zsh prevents the
15371 // command from running at all when a redirect fails; the
15372 // Rust port still runs it (sinking output to /dev/null in
15373 // the noclobber arm at host_apply_redirect:5481) and then
15374 // overrides $? here. Same observable effect for the common
15375 // pattern `echo x > existing-file` under noclobber.
15376 let failed = with_executor(|exec| {
15377 let f = exec.redirect_failed;
15378 exec.redirect_failed = false;
15379 f
15380 });
15381 if failed {
15382 with_executor(|exec| exec.set_last_status(1));
15383 }
15384 }
15385
15386 fn heredoc(&mut self, content: &str) {
15387 // C `Src/exec.c:4641` — `parsestr(&buf)` runs parameter +
15388 // command substitution on the heredoc body. The lexer's
15389 // quoted-delimiter detection (`<<'EOF'`) routes through the
15390 // `Op::HereDoc` path in `compile_zsh.rs` which short-circuits
15391 // before reaching here; unquoted forms route through the
15392 // BUILTIN_EXPAND_TEXT mode-4 emit path that calls singsub.
15393 // This handler covers the verbatim/quoted case.
15394 if crate::provenance::active() {
15395 crate::provenance::on_heredoc("heredoc", content);
15396 }
15397 with_executor(|exec| exec.host_set_pending_stdin(content.to_string()));
15398 }
15399
15400 fn herestring(&mut self, content: &str) {
15401 // Shell semantics: herestring appends a newline. `<<<` body
15402 // substitution (`Src/exec.c:4655 getherestr` calls
15403 // `quotesubst` + `untokenize`) lands here verbatim; the
15404 // upstream compiler routes through `Op::HereString` after
15405 // BUILTIN_EXPAND_TEXT for the substitution pass, so callers
15406 // of `host.herestring` see the already-expanded form.
15407 let mut s = content.to_string();
15408 s.push('\n');
15409 if crate::provenance::active() {
15410 crate::provenance::on_heredoc("herestring", &s);
15411 }
15412 with_executor(|exec| exec.host_set_pending_stdin(s));
15413 }
15414
15415 fn exec(&mut self, args: Vec<String>) -> i32 {
15416 // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close
15417 // any `>(cmd)` write ends owned by this command once it
15418 // finishes (drops on every return path below).
15419 let _psub_fds = PsubFdGuard;
15420 // c:Src/subst.c paramsubst — when `${var:?msg}` or `${var?msg}`
15421 // triggered the "parameter null or not set" error, errflag
15422 // is raised and zsh aborts the simple command without
15423 // attempting exec. The expansion may have produced empty
15424 // argv[0] which falls into the c:?/permission-denied path
15425 // below, masking the real diagnostic with a spurious
15426 // "permission denied:" line and rc=126 instead of rc=1.
15427 // Honour errflag here so the script ends with the
15428 // paramsubst error as the sole diagnostic. Bug #86.
15429 //
15430 // c:Src/exec.c — C's execlist loop clears ERRFLAG_ERROR
15431 // between sublists when the error came from a NOMATCH-style
15432 // command failure (glob no-match, etc.) so subsequent
15433 // sublists run. zshrs's vm dispatch handles this at the
15434 // post-command-boundary HERE: if THIS command has its
15435 // `current_command_glob_failed` cell set (meaning the glob
15436 // NOMATCH happened during this command's argv prep), surface
15437 // status 1 and clear BOTH the cell AND ERRFLAG_ERROR so the
15438 // NEXT exec call sees a clean state. The errflag from
15439 // genuine script-fatal errors (parse, redirect, paramsubst
15440 // `${:?msg}`) does NOT come paired with glob_failed, so
15441 // those still short-circuit + propagate.
15442 consume_tilde_globsubst_carrier();
15443 let glob_failed = with_executor(|exec| {
15444 let f = exec.current_command_glob_failed.get();
15445 exec.current_command_glob_failed.set(false);
15446 f
15447 });
15448 if glob_failed {
15449 crate::ported::utils::errflag.fetch_and(
15450 !crate::ported::zsh_h::ERRFLAG_ERROR,
15451 std::sync::atomic::Ordering::Relaxed,
15452 );
15453 with_executor(|exec| exec.set_last_status(1));
15454 return 1;
15455 }
15456 // c:Src/subst.c:505-507 — CSH_NULL_GLOB external-path
15457 // boundary: command skipped with `no match` but the NEXT
15458 // sublist runs (zsh -fc 'setopt cshnullglob; ls *nope*;
15459 // print after' prints the error then `after` — verified
15460 // zsh 5.9.1), so clear ERRFLAG like the glob_failed arm.
15461 if consume_badcshglob() {
15462 crate::ported::utils::errflag.fetch_and(
15463 !crate::ported::zsh_h::ERRFLAG_ERROR,
15464 std::sync::atomic::Ordering::Relaxed,
15465 );
15466 with_executor(|exec| exec.set_last_status(1));
15467 return 1;
15468 }
15469 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
15470 & crate::ported::zsh_h::ERRFLAG_ERROR)
15471 != 0
15472 {
15473 return 1;
15474 }
15475 // c:Src/exec.c — two distinct empty-command cases:
15476 //
15477 // 1. args=[""] — an explicit empty-string command word
15478 // (`""`, `"\$unset"`, `\$'\$x'`). zsh attempts exec(2)
15479 // on the empty path → EACCES → "permission denied", \$?
15480 // = 126.
15481 //
15482 // 2. args=[] — the WORD LIST is empty (unquoted \$(\$cmd)
15483 // that produced empty, or an unquoted unset \$var that
15484 // elided). zsh: no exec is attempted; \$? becomes the
15485 // last cmd-subst's exit status (the inner sub-VM
15486 // already set last_status), and the line completes
15487 // silently. Critically NOT 126.
15488 if args.is_empty() {
15489 // c:Src/exec.c — empty word list passes through to a
15490 // no-op; preserve whatever the inner cmd-subst's exit
15491 // is. Return last_status so the caller's SetStatus
15492 // round-trips correctly.
15493 return with_executor(|exec| exec.last_status());
15494 }
15495 if args[0].is_empty() {
15496 let script_name =
15497 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
15498 let lineno: u64 = with_executor(|exec| {
15499 exec.scalar("LINENO")
15500 .and_then(|s| s.parse::<u64>().ok())
15501 .unwrap_or(1)
15502 });
15503 eprintln!("{}:{}: permission denied: ", script_name, lineno);
15504 return 126;
15505 }
15506 // c:Src/exec.c — when any redirect in the current scope
15507 // failed (e.g. noclobber blocked a `>` overwrite), zsh
15508 // refuses to execute the command and exits with status 1.
15509 // The Rust port still applied the command (writing to the
15510 // /dev/null sink installed by host_apply_redirect's
15511 // noclobber arm), but the success status overwrote the
15512 // intended `1`. Short-circuit here so the exec returns 1
15513 // without running the body.
15514 let redir_failed = with_executor(|exec| {
15515 let f = exec.redirect_failed;
15516 exec.redirect_failed = false;
15517 f
15518 });
15519 if redir_failed {
15520 return 1;
15521 }
15522 // c:Src/exec.c:3545-3547 — `setunderscore(lastnode(args))` for the
15523 // command about to run. Write the canonical `zunderscore` global,
15524 // NOT the paramtab node: `_`'s setfn is `nullstrsetfn`
15525 // (c:Src/params.c:252-253), so a table write has no counterpart in
15526 // C and clobbers the PM_UNSET bit that `unset _` relies on.
15527 if let Some(last) = args.last() {
15528 crate::ported::params::set_zunderscore(std::slice::from_ref(last)); // c:3546
15529 }
15530 // Provenance: record which argv slot each tracked value landed
15531 // in, before the command consumes it.
15532 if crate::provenance::active() {
15533 crate::provenance::on_exec("exec", &args);
15534 }
15535 // Route external command spawning through `executor.execute_external`
15536 // so intercepts (AOP before/after/around), command_hash lookups,
15537 // pre/postexec hooks, and zsh-specific fork-then-exec all apply.
15538 // Without this override, fusevm's default `host.exec` calls
15539 // `Command::new` directly, bypassing zshrs's dispatch logic.
15540 let status = with_executor(|exec| exec.host_exec_external(&args));
15541 // c:Src/jobs.c:1748 waitonejob (no-procs else-branch). zshrs's
15542 // exec model routes external commands through host_exec_external
15543 // (which already waitpid'd in-line); the canonical waitonejob
15544 // expects a Job to derive lastval, but here we already know
15545 // it. Synthesize a procs-less job so waitonejob's no-procs
15546 // branch fires the `pipestats[0]=lastval; numpipestats=1;`
15547 // update via the canonical port.
15548 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
15549 let mut synth = crate::ported::zsh_h::job::default();
15550 crate::ported::jobs::waitonejob(&mut synth);
15551 status
15552 }
15553
15554 fn cmd_subst(&mut self, sub: &fusevm::Chunk) -> String {
15555 // Run the sub-chunk on a nested VM with the same host wired up,
15556 // capturing stdout. The current executor remains active via the
15557 // thread-local — the nested VM uses CallBuiltin to dispatch shell
15558 // ops back through `with_executor`.
15559 let (read_end, write_end) = match os_pipe::pipe() {
15560 Ok(p) => p,
15561 Err(_) => return String::new(),
15562 };
15563 let saved_stdout = unsafe { libc::fcntl(libc::STDOUT_FILENO, libc::F_DUPFD, 10) };
15564 if saved_stdout < 0 {
15565 return String::new();
15566 }
15567 let saved_stderr = unsafe { libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD, 10) };
15568 let write_fd = AsRawFd::as_raw_fd(&write_end);
15569 unsafe {
15570 libc::dup2(write_fd, libc::STDOUT_FILENO);
15571 }
15572 drop(write_end);
15573
15574 // c:Bug #56 — publish the saved outer fds so a trap firing
15575 // during the nested VM run can route its body output to the
15576 // PARENT's stdout instead of the cmdsub's pipe-bound fd 1.
15577 // zsh's forked cmdsub gets this for free (trap runs in the
15578 // parent process whose fd 1 is untouched). zshrs's
15579 // in-process cmdsub needs this thread-local stack so the
15580 // trap dispatcher can find the right destination fd.
15581 CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().push((saved_stdout, saved_stderr)));
15582
15583 // Nested scope for `>(cmd)` fd ownership — commands inside
15584 // the cmdsub must not drain the enclosing command's pending
15585 // psub fds (see PSUB_SCOPE_DEPTH).
15586 let _psub_scope = PsubScope::enter();
15587
15588 // c:Src/exec.c:1161 — forked cmdsub child runs entersubsh()
15589 // which does `zsh_subshell++`; in-process equivalent.
15590 let _subshell_bump = CmdSubstSubshellBump::enter();
15591
15592 crate::fusevm_disasm::maybe_print_stdout("host.cmd_subst", sub);
15593 let mut vm = fusevm::VM::new(sub.clone());
15594 register_builtins(&mut vm);
15595 vm.set_shell_host(Box::new(ZshrsHost));
15596 let _ = vm.run();
15597 let cmd_status = vm.last_status;
15598
15599 CMDSUBST_OUTER_FDS.with(|s| {
15600 s.borrow_mut().pop();
15601 });
15602
15603 unsafe {
15604 libc::dup2(saved_stdout, libc::STDOUT_FILENO);
15605 libc::close(saved_stdout);
15606 if saved_stderr >= 0 {
15607 libc::close(saved_stderr);
15608 }
15609 }
15610
15611 // Inner cmd's status not propagated for the same reason as
15612 // run_command_substitution — see GAPS.md.
15613 let _ = cmd_status;
15614
15615 let mut buf = String::new();
15616 let mut reader = read_end;
15617 let _ = reader.read_to_string(&mut buf);
15618 // Strip trailing newlines (POSIX command substitution semantics)
15619 while buf.ends_with('\n') {
15620 buf.pop();
15621 }
15622 // Provenance: a command substitution is a lineage ORIGIN — the
15623 // bytes did not exist in the shell before this ran.
15624 if crate::provenance::active() {
15625 crate::provenance::on_cmd_subst(&prov_chunk_label(sub), &buf);
15626 }
15627 buf
15628 }
15629
15630 fn call_function(&mut self, name: &str, args: Vec<String>) -> Option<i32> {
15631 // c:Src/exec.c — when the command word is empty (e.g. `""`
15632 // or `"$nonexistent"`), zsh attempts the exec(2) which
15633 // returns EACCES ("permission denied") and exits 126. The
15634 // Rust port silently treated empty as a no-op (status 0).
15635 // Match zsh by emitting the diagnostic and returning 126.
15636 if name.is_empty() {
15637 let script_name =
15638 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
15639 let lineno: u64 = with_executor(|exec| {
15640 exec.scalar("LINENO")
15641 .and_then(|s| s.parse::<u64>().ok())
15642 .unwrap_or(1)
15643 });
15644 eprintln!("{}:{}: permission denied: ", script_name, lineno);
15645 with_executor(|exec| exec.set_last_status(126));
15646 return Some(126);
15647 }
15648 // c:Src/exec.c — redirect failure in this scope means the
15649 // command should NOT run. The Host::exec path already has
15650 // this gate (at fn exec above); call_function takes external
15651 // commands like `cat <&3` through a different code path, so
15652 // gate here too. Without this, bad-fd redirects produced
15653 // the diagnostic but the external command still ran, so $?
15654 // came out from the command's natural exit instead of the
15655 // forced 1.
15656 let redir_failed = with_executor(|exec| {
15657 let f = exec.redirect_failed;
15658 exec.redirect_failed = false;
15659 f
15660 });
15661 if redir_failed {
15662 with_executor(|exec| exec.set_last_status(1));
15663 return Some(1);
15664 }
15665 // Provenance: same argv record as `exec`, but ONLY when the name
15666 // really resolves to a shell function — an external command
15667 // reaches `exec` further down and would otherwise be recorded
15668 // twice for the same call site.
15669 if crate::provenance::active() && with_executor(|exec| exec.function_exists(name)) {
15670 let mut argv = Vec::with_capacity(args.len() + 1);
15671 argv.push(name.to_string());
15672 argv.extend(args.iter().cloned());
15673 crate::provenance::on_exec("call", &argv);
15674 }
15675 // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
15676 // functions, NOT builtins. zshrs ships fast native impls, but they
15677 // must behave like the zsh functions — command-not-found until
15678 // `autoload -Uz <name>` creates a function entry. When autoloaded we
15679 // run the native impl here (short-circuiting the fpath source, which
15680 // can hang zshrs's parser on zsh-specific syntax); when NOT autoloaded
15681 // we fall through (return None → resolution ends in command-not-found),
15682 // matching `zsh -f; zmv` → "command not found: zmv".
15683 if matches!(name, "zmv" | "zcp" | "zln" | "zcalc")
15684 && !with_executor(|exec| exec.function_exists(name))
15685 {
15686 return None;
15687 }
15688 match name {
15689 "zmv" => {
15690 return Some(crate::extensions::ext_builtins::zmv(&args, "mv"));
15691 }
15692 "zcp" => {
15693 return Some(crate::extensions::ext_builtins::zmv(&args, "cp"));
15694 }
15695 "zln" => {
15696 return Some(crate::extensions::ext_builtins::zmv(&args, "ln"));
15697 }
15698 "zcalc" => {
15699 return Some(crate::extensions::ext_builtins::zcalc(&args));
15700 }
15701 // znative — the plugin package manager (src/extensions/pkg/). Installs
15702 // + loads zsh script and native (Rust cdylib) plugins from a global
15703 // content-addressed store. `znative add owner/repo`, `znative load`, ...
15704 "znative" => {
15705 return Some(crate::extensions::pkg::builtin::znative(&args));
15706 }
15707 // ztest framework (src/extensions/ztest.rs — port of
15708 // ../strykelang's unit-test framework). All zassert_*/
15709 // ztest_* names route through the single try_dispatch
15710 // helper so adding/removing assertions only touches
15711 // ztest.rs.
15712 n if crate::extensions::ztest::try_dispatch_known(n) => {
15713 let status = with_executor(|exec| {
15714 crate::extensions::ztest::try_dispatch(exec, n, &args).unwrap_or(1)
15715 });
15716 return Some(status);
15717 }
15718 // Daemon-managed z* builtins — thin IPC wrappers. Short-circuit BEFORE
15719 // the function-lookup path so a missing daemon doesn't fall through to
15720 // "command not found". The name list is owned by the daemon crate
15721 // (zshrs_daemon::builtins::ZSHRS_BUILTIN_NAMES); routing through
15722 // try_dispatch keeps this site zero-touch as new z* builtins land.
15723 n if crate::daemon::builtins::is_zshrs_builtin(n) => {
15724 let argv: Vec<String> = std::iter::once(name.to_string()).chain(args).collect();
15725 return Some(crate::daemon::builtins::try_dispatch(n, &argv).unwrap_or(1));
15726 }
15727 _ => {}
15728 }
15729
15730 // c:Src/exec.c:3050-3068 — module-provided builtins (registered
15731 // via each module's `bintab` and folded into the canonical
15732 // `builtintab` by `createbuiltintable`) must dispatch BEFORE
15733 // PATH lookup. fusevm's `shell_builtins::builtin_id` doesn't
15734 // know about per-module entries like `log`
15735 // (Src/Modules/watch.c:693) — they reach call_function as
15736 // CallFunction ops. Consult the merged builtintab here so
15737 // `log` runs the canonical `bin_log` instead of falling
15738 // through to `/usr/bin/log` on macOS. Bug #72 in docs/BUGS.md.
15739 //
15740 // User-defined functions still take precedence over builtins
15741 // (zsh's `alias → function → builtin → external` resolution
15742 // order, c:Src/exec.c:3038-3068). Check `functions_compiled`
15743 // first so a user `log() { ... }` shadows the module bin_log.
15744 // c:Src/exec.c — shfunctab->getnode (the DISABLED-filtering
15745 // accessor) returns NULL for entries flipped to DISABLED via
15746 // `disable -f NAME`. functions_compiled holds the body
15747 // independently of the DISABLED flag, so check shfunctab first
15748 // and mask the lookup when the entry is disabled. Bug #221
15749 // in docs/BUGS.md.
15750 let user_fn_disabled = crate::ported::hashtable::shfunctab_lock()
15751 .read()
15752 .ok()
15753 .and_then(|t| {
15754 let entry = t.get_including_disabled(name)?;
15755 Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
15756 })
15757 .unwrap_or(false);
15758 let has_user_fn =
15759 !user_fn_disabled && with_executor(|exec| exec.functions_compiled.contains_key(name));
15760 if !has_user_fn {
15761 // c:Src/exec.c:3056 — `builtintab->getnode(builtintab,
15762 // cmdarg)` returns NULL for DISABLED entries, falling
15763 // execcmd through to PATH lookup. Mirror by gating the
15764 // bn_in_tab match on the BUILTINS_DISABLED set. Bug #106
15765 // in docs/BUGS.md.
15766 let disabled = crate::ported::builtin::BUILTINS_DISABLED
15767 .lock()
15768 .map(|s| s.contains(name))
15769 .unwrap_or(false);
15770 let bn_in_tab =
15771 !disabled && crate::ported::builtin::createbuiltintable().contains_key(name);
15772 if bn_in_tab {
15773 // c:Src/exec.c:4287 — `lastval = execbuiltin(args, assigns,
15774 // (Builtin) hn);`. The store happens BEFORE any errflag
15775 // handling, so a builtin that BOTH raises errflag (zerr) and
15776 // returns non-zero still publishes its status. zshrs relied on
15777 // the VM's trailing `SetStatus` op to publish it, and that op
15778 // is skipped once the builtin set ERRFLAG_ERROR — so
15779 // `() { private SECONDS }` (makeprivate's zerrnam + return 1)
15780 // reported 0 where zsh reports 1 (V10private.ztst:22).
15781 let __st = dispatch_builtin_raw(name, args);
15782 crate::ported::builtin::LASTVAL.store(__st, std::sync::atomic::Ordering::Relaxed); // c:4287
15783 with_executor(|exec| exec.set_last_status(__st)); // c:4287
15784 return Some(__st);
15785 }
15786 // zshrs-original opcode builtins (async, doctor, peach, …) are not
15787 // in builtintab, so a run-time-resolved name (`$var`) never reaches
15788 // them. Dispatch by name here — after ported builtins, before
15789 // external — matching the shell's function -> builtin -> external
15790 // order (`has_user_fn` was checked above, so functions still win).
15791 if let Some(status) = try_run_registered_builtin(name, &args) {
15792 return Some(status);
15793 }
15794 }
15795
15796 // c:Src/lex.c — alias expansion is a LEXER-TIME pass, not a
15797 // run-time lookup. zsh parses the whole `-c` argument (or
15798 // script) before executing, so aliases defined in the same
15799 // parse unit don't apply to commands parsed earlier. Only at
15800 // an INTERACTIVE prompt does each line parse separately with
15801 // the latest aliastab visible.
15802 //
15803 // Gate the run-time alias-rewrite path on `interactive` so
15804 // `alias hi='echo hello'; hi` in `-c` mode falls through to
15805 // "command not found" (matching zsh) while interactive REPL
15806 // input still re-parses with the live aliastab.
15807 let interactive = crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE);
15808 let already_expanding = if interactive {
15809 crate::ported::hashtable::aliastab_lock()
15810 .read()
15811 .ok()
15812 .and_then(|tab| tab.get(name).map(|a| a.inuse != 0))
15813 .unwrap_or(false)
15814 } else {
15815 true // suppress lookup entirely in non-interactive mode
15816 };
15817 let alias_body = if already_expanding {
15818 None
15819 } else {
15820 with_executor(|exec| exec.alias(name))
15821 };
15822 if let Some(body) = alias_body {
15823 let combined = if args.is_empty() {
15824 body
15825 } else {
15826 let quoted: Vec<String> = args
15827 .iter()
15828 .map(|a| {
15829 let escaped = a.replace('\'', "'\\''");
15830 format!("'{}'", escaped)
15831 })
15832 .collect();
15833 format!("{} {}", body, quoted.join(" "))
15834 };
15835 // Bump inuse → run → clear, matching C's lexer behavior.
15836 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
15837 if let Some(a) = tab.get_mut(name) {
15838 a.inuse += 1;
15839 }
15840 }
15841 let status = with_executor(|exec| exec.execute_script(&combined).unwrap_or(1));
15842 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
15843 if let Some(a) = tab.get_mut(name) {
15844 a.inuse = (a.inuse - 1).max(0);
15845 }
15846 }
15847 return Some(status);
15848 }
15849
15850 // $_ pre-body bump and pending-underscore tracking are
15851 // ZshrsHost-only concerns (prompt rendering). Apply BEFORE
15852 // delegating to dispatch_function_call so the body sees the
15853 // bumped value.
15854 //
15855 // c:Src/exec.c:3491 — `setunderscore((args && nonempty(args))
15856 // ? ((char *) getdata(lastnode(args))) : "")`. C sets $_ to
15857 // the LAST node of the WHOLE args list (which includes argv[0]
15858 // == the function name). So for a no-arg `f`, $_ becomes "f"
15859 // inside the function body. The Rust port at the CallFunction
15860 // op-handler receives `args` WITHOUT the command name
15861 // (compile_zsh.rs:1571 only pushes simple.words[1..]). The
15862 // last() fallback `|| fn_name.clone()` already covers the
15863 // no-arg case, but `exec.set_scalar("_", ...)` writes paramtab
15864 // — the canonical `$_` read goes through `underscoregetfn`
15865 // (params.rs:7836) which reads the `zunderscore` Mutex.
15866 // setsparam("_") doesn't update that mutex, so the body's
15867 // `${_}` returned empty. Bug #279 in docs/BUGS.md. Mirror the
15868 // C `setunderscore` by writing via `set_zunderscore` directly.
15869 let fn_name = name.to_string();
15870 {
15871 let dollar_underscore = args.last().cloned().unwrap_or_else(|| fn_name.clone());
15872 // c:3546 — zunderscore is the only store; the paramtab write
15873 // that used to accompany this cleared PM_UNSET (see pop_args).
15874 crate::ported::params::set_zunderscore(std::slice::from_ref(&dollar_underscore));
15875 }
15876
15877 // Delegate the actual function dispatch to the canonical
15878 // `dispatch_function_call` (which itself wraps the canonical
15879 // `doshfunc` port from `Src/exec.c:5823`). Single doshfunc
15880 // call-site keeps scope-mgmt invariants in one place.
15881 let status = with_executor(|exec| exec.dispatch_function_call(&fn_name, &args));
15882
15883 // Anonymous functions (`() { … } args`, compiled by
15884 // parse_anon_funcdef as `_zshrs_anon_N` / `_zshrs_anon_kw_N`)
15885 // execute exactly ONCE and must not persist. zsh runs the body and
15886 // frees the function, so `${functions}` / `typeset -f` never show
15887 // it. Remove every trace right after the single invocation —
15888 // AFTER `status` is captured, so the body's exit code is preserved
15889 // ($? — calling `unfunction` here would reset it to 0 instead).
15890 // Without this, real plugins that use `() { … }` (fzf-tab, zinit,
15891 // p10k, …) leaked dozens of `_zshrs_anon_N` into `$functions`,
15892 // diverging from zsh's function table on every such config.
15893 if fn_name.starts_with("_zshrs_anon_") {
15894 // `${functions}` / `typeset -f` enumerate the canonical
15895 // `shfunctab` (via scanpmfunctions); the bytecode call path
15896 // also keeps the body in the executor's compiled-fn maps. Clear
15897 // BOTH so no trace of the one-shot anon survives.
15898 crate::ported::hashtable::removeshfuncnode(&fn_name);
15899 with_executor(|exec| {
15900 exec.functions_compiled.remove(&fn_name);
15901 exec.function_source.remove(&fn_name);
15902 exec.function_line_base.remove(&fn_name);
15903 exec.function_def_file.remove(&fn_name);
15904 });
15905 }
15906
15907 // c:Src/exec.c:6207-6265 — doshfunc saves `ou = zunderscore`
15908 // around the body and runs `setunderscore(ou)` (c:6257) on the way
15909 // out, so a function call leaves `$_` at the CALL's last argument
15910 // rather than at whatever the body's last command set. The value
15911 // saved there is the one execcmd_exec installed just before the
15912 // call (c:3546), i.e. exactly `args.last()`.
15913 {
15914 let last_call_arg = args.last().cloned().unwrap_or_else(|| fn_name.clone());
15915 crate::ported::params::set_zunderscore(std::slice::from_ref(&last_call_arg));
15916 // c:6257
15917 }
15918
15919 status
15920 }
15921}
15922
15923// ───────────────────────────────────────────────────────────────────────────
15924/// Render a failed-redirect open error the way C's `zerrmsg` `%e` format
15925/// code does (Src/utils.c): `strerror(errno)` with the first character
15926/// lowercased, except `EIO` (kept capitalized) and `EINTR` (→ "interrupt").
15927/// C's redirect open failures call `zwarn("%e: %s", errno, fname)`
15928/// (Src/exec.c:3741); zshrs's `zwarning` takes a pre-built string, so the
15929/// `%e` part is built here. Replaces the prior hardcoded `ErrorKind` match
15930/// that fell back to a generic "redirect failed" for `EROFS`/`EACCES`/etc.
15931fn redir_errno_msg(err: &std::io::Error) -> String {
15932 let errno = match err.raw_os_error() {
15933 Some(n) if n != 0 => n,
15934 _ => return "redirect failed".to_string(),
15935 };
15936 if errno == libc::EINTR {
15937 return "interrupt".to_string(); // c:zerrmsg %e — EINTR special-case
15938 }
15939 let cptr = unsafe { libc::strerror(errno) };
15940 if cptr.is_null() {
15941 return "redirect failed".to_string();
15942 }
15943 let msg = unsafe { std::ffi::CStr::from_ptr(cptr) }.to_string_lossy();
15944 if errno == libc::EIO {
15945 return msg.into_owned(); // c:zerrmsg %e — EIO keeps capitalization
15946 }
15947 // c:zerrmsg %e — `fputc(tulower(errmsg[0])); fputs(errmsg + 1)`.
15948 let mut chars = msg.chars();
15949 match chars.next() {
15950 Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
15951 None => "redirect failed".to_string(),
15952 }
15953}
15954
15955// Host-routed shell ops: ShellExecutor methods invoked by ZshrsHost from the
15956// fusevm VM. Not a port of Src/exec.c (see file-level docs above) — they're
15957// the bridge between fusevm opcodes and ShellExecutor state.
15958// ───────────────────────────────────────────────────────────────────────────
15959impl ShellExecutor {
15960 // ─── Host-routed shell ops (called by ZshrsHost from fusevm) ────────────
15961
15962 /// Apply a single redirection. The current scope's saved-fd vec gets a
15963 /// dup of the original fd so it can be restored by `host_redirect_scope_end`.
15964 /// `op_byte` matches `fusevm::op::redirect_op::*`.
15965 /// Apply a file-open result to a redirect fd; on error, emit
15966 /// zsh-format diagnostic, set redirect_failed, sink fd to /dev/null.
15967 /// Shared between WRITE/APPEND/READ/CLOBBER arms in
15968 /// host_apply_redirect to keep the error-handling identical.
15969 fn redir_open_or_fail(
15970 fd: i32,
15971 result: std::io::Result<fs::File>,
15972 target: &str,
15973 redirect_failed: &mut bool,
15974 ) -> bool {
15975 match result {
15976 Ok(file) => {
15977 let new_fd = file.into_raw_fd();
15978 unsafe {
15979 // When the target fd was already closed (e.g. `exec 0<&-;
15980 // cmd < file`), open() returns the lowest free fd, which is
15981 // `fd` itself. Then `dup2(fd, fd)` is a no-op: closing new_fd
15982 // would CLOSE the fd we just opened, AND — since Rust's
15983 // File::open sets O_CLOEXEC and a no-op dup2 does NOT clear
15984 // it — an exec'd child would lose the descriptor. So in the
15985 // reuse case, keep the fd and clear its close-on-exec flag;
15986 // otherwise dup2 (which clears cloexec on the copy) + close.
15987 if new_fd != fd {
15988 libc::dup2(new_fd, fd);
15989 libc::close(new_fd);
15990 } else {
15991 libc::fcntl(fd, libc::F_SETFD, 0);
15992 }
15993 }
15994 true
15995 }
15996 Err(e) => {
15997 // c:Src/exec.c:3741 — zwarn("%e: %s", errno, fname) with the
15998 // real lineno prefix; redir_errno_msg builds the `%e` errno
15999 // message for all errnos (not just the few hardcoded before).
16000 let msg = redir_errno_msg(&e);
16001 crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
16002 *redirect_failed = true;
16003 // The /dev/null sink keeps a failed scoped redirect
16004 // from leaking the aborted command's output to the
16005 // wrong fd until scope-end restores it. For a bare
16006 // `exec` redirect (permanent, no scope restore) C
16007 // leaves the fd UNTOUCHED — execerr() aborts the
16008 // statement and the original fd 1 keeps flowing
16009 // (A04redirect: `exec >./nonexistent/x` then `echo
16010 // output` still prints). c:Src/exec.c:3735-3742.
16011 let permanent = with_executor(|exec| exec.exec_redirs_permanent);
16012 if !permanent {
16013 if let Ok(devnull) = fs::OpenOptions::new()
16014 .read(true)
16015 .write(true)
16016 .open("/dev/null")
16017 {
16018 let new_fd = devnull.into_raw_fd();
16019 unsafe {
16020 if new_fd != fd {
16021 libc::dup2(new_fd, fd);
16022 libc::close(new_fd);
16023 } else {
16024 libc::fcntl(fd, libc::F_SETFD, 0);
16025 }
16026 }
16027 }
16028 }
16029 false
16030 }
16031 }
16032 }
16033 /// `host_apply_redirect` — see implementation.
16034 pub fn host_apply_redirect(&mut self, fd: u8, op_byte: u8, target: &str) {
16035 // `&>` / `&>>` always target both fd 1 and fd 2 regardless of the
16036 // fd byte the parser supplied (the lexer's tokfd clamp makes the
16037 // raw value unreliable for these forms).
16038 let fd: i32 = if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
16039 1
16040 } else {
16041 fd as i32
16042 };
16043 // c:Src/exec.c — for DUP_READ / DUP_WRITE forms (<&N / >&N),
16044 // validate the source fd is open BEFORE the save-and-dup
16045 // dance below. The save's `dup(fd)` reclaims the lowest free
16046 // fd, which on closed-fd reuse would let dup2(src=N, …)
16047 // succeed against the freshly-claimed slot — masking the
16048 // user's "bad file descriptor" error. Check src_fd first.
16049 if matches!(op_byte, r::DUP_READ | r::DUP_WRITE) {
16050 let n_check = target.trim_start_matches('&');
16051 if n_check != "-" {
16052 if let Ok(src_fd) = n_check.parse::<i32>() {
16053 // c:Src/exec.c:3884-3897 — a descriptor above 9 that
16054 // the shell knows about is NOT the script's to
16055 // duplicate:
16056 //
16057 // else if (fn->fd2 > 9 &&
16058 // (fn->fd2 <= max_zsh_fd &&
16059 // ((fdtable[fn->fd2] != FDT_UNUSED &&
16060 // fdtable[fn->fd2] != FDT_EXTERNAL) ||
16061 // fn->fd2 == coprocin ||
16062 // fn->fd2 == coprocout))) {
16063 // fil = -1;
16064 // errno = EBADF;
16065 //
16066 // `FDT_EXTERNAL` is exempt because that is a
16067 // descriptor the script itself asked for (`{v}>file`,
16068 // c:2409) and 0/1/2 (c:Src/init.c:1900). Anything
16069 // past `max_zsh_fd` is left alone on purpose —
16070 // c:3886-3891: "the shell doesn't know about it. Just
16071 // assume the user knows what they're doing."
16072 //
16073 // Only the open-ness of the descriptor was checked
16074 // here, so `>&11` happily duplicated the shell's own
16075 // history database and `>&10` its log. The `exec
16076 // N>&-` half of this pair was already ported
16077 // (fusevm_bridge.rs:7047, c:3830-3835); this half was
16078 // not, and the ported copy in `ported/exec.rs:11466`
16079 // is not on the VM's redirection path.
16080 let shell_owned = src_fd > 9 && {
16081 let max_fd =
16082 crate::ported::utils::MAX_ZSH_FD.load(std::sync::atomic::Ordering::Relaxed);
16083 let cin = crate::ported::modules::clone::coprocin
16084 .load(std::sync::atomic::Ordering::Relaxed);
16085 let cout = crate::ported::modules::clone::coprocout
16086 .load(std::sync::atomic::Ordering::Relaxed);
16087 src_fd <= max_fd && {
16088 let kind = crate::ported::utils::fdtable_get(src_fd)
16089 & crate::ported::zsh_h::FDT_TYPE_MASK;
16090 (kind != crate::ported::zsh_h::FDT_UNUSED
16091 && kind != crate::ported::zsh_h::FDT_EXTERNAL)
16092 || src_fd == cin
16093 || src_fd == cout
16094 }
16095 };
16096 if unsafe { libc::fcntl(src_fd, libc::F_GETFD) } == -1 || shell_owned {
16097 // c:Src/exec.c — zwarn with real lineno prefix.
16098 crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src_fd));
16099 self.set_last_status(1);
16100 self.redirect_failed = true;
16101 return;
16102 }
16103 }
16104 }
16105 }
16106 // c:Src/exec.c:3978-3986 — bare `exec` redirects (nullexec==1)
16107 // skip the save entirely: "we specifically *don't* restore the
16108 // original fd's". C's save[] is per-execcmd, so exec's redirs
16109 // never enter an enclosing group's save list either; pushing
16110 // into `redirect_scope_stack.last_mut()` here (the enclosing
16111 // group's scope) made `{ exec 1>&-; … } 2>/dev/null` restore
16112 // stdout at group end — diverging from zsh, which keeps fd 1
16113 // closed for the rest of the script.
16114 if !self.exec_redirs_permanent {
16115 let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
16116 if saved >= 0 {
16117 if let Some(top) = self.redirect_scope_stack.last_mut() {
16118 top.push((fd, saved));
16119 } else {
16120 // No scope — leave saved fd open and let the next scope
16121 // reclaim it. (Caller without a scope leaks the dup; this
16122 // matches `WithRedirects` parser construction always wrapping.)
16123 unsafe { libc::close(saved) };
16124 }
16125 }
16126 // For `&>` / `&>>` also save fd 2 so the scope restores it after
16127 // the body. Otherwise stderr stays redirected past the command.
16128 if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
16129 let saved2 = unsafe { libc::fcntl(2, libc::F_DUPFD, 10) };
16130 if saved2 >= 0 {
16131 if let Some(top) = self.redirect_scope_stack.last_mut() {
16132 top.push((2, saved2));
16133 } else {
16134 unsafe { libc::close(saved2) };
16135 }
16136 }
16137 }
16138 }
16139 // c:Src/exec.c:3722-3724 + 2447-2480 — MULTIOS split when this
16140 // command's stdout IS the pipeline output. C registers the pipe
16141 // in mfds[1] (`addfd(forked, save, mfds, 1, output, 1, NULL)`)
16142 // BEFORE walking the explicit redirect list, so a write-side
16143 // redirect of fd 1 finds mfds[1] occupied and, with MULTIOS
16144 // set, "split[s] the stream": fd 1 becomes the write end of an
16145 // internal pipe whose reader tees every chunk to BOTH the
16146 // pipeline pipe and the new target. That is why
16147 // `{ echo a; echo b >&2; } 3>&1 1>&2 2>&3 3>&- | cat` sends
16148 // `a` to the pipe (via the tee) AND to stderr — plain dup2
16149 // replacement loses the pipe stream. The scope-depth gate
16150 // mirrors mfds being per-execcmd: only the redirect list
16151 // attached to the stage's own command joins the pipe; nested
16152 // commands inside the body (`{ echo a > f; } | cat`) get a
16153 // fresh "mfds" and replace as usual.
16154 if fd == 1
16155 && self
16156 .pipe_output_scope
16157 .is_some_and(|d| d + 1 == self.redirect_scope_stack.len())
16158 && crate::ported::options::opt_state_get("multios").unwrap_or(true)
16159 {
16160 // Resolve the new write target exactly as the plain arms
16161 // below would, but as a raw fd for the tee.
16162 let new_target_fd: i32 = match op_byte {
16163 r::DUP_WRITE => {
16164 // Numeric `>&N` only; `-` (close) and `p` (coproc)
16165 // fall through to the plain arms.
16166 target
16167 .trim_start_matches('&')
16168 .parse::<i32>()
16169 .map(|src| unsafe { libc::fcntl(src, libc::F_DUPFD, 10) })
16170 .unwrap_or(-1)
16171 }
16172 r::WRITE | r::CLOBBER => fs::File::create(target)
16173 .map(|f| f.into_raw_fd())
16174 .unwrap_or(-1),
16175 r::APPEND => fs::OpenOptions::new()
16176 .create(true)
16177 .append(true)
16178 .open(target)
16179 .map(|f| f.into_raw_fd())
16180 .unwrap_or(-1),
16181 _ => -1,
16182 };
16183 if new_target_fd >= 0 {
16184 let pipe_dup = unsafe { libc::fcntl(1, libc::F_DUPFD, 10) };
16185 match (pipe_dup >= 0).then(os_pipe::pipe) {
16186 Some(Ok((read_end, write_end))) => {
16187 // c:Src/exec.c:5222 / Src/utils.c:1990-2012 —
16188 // `mpipe()` runs both pipe ends through
16189 // `movefd()`, which lifts any fd below 10 out
16190 // of the user-visible `>&N` range. Same abort
16191 // hazard as the BUILTIN_MULTIOS_REDIRECT arm.
16192 let read_end = unsafe {
16193 <os_pipe::PipeReader as std::os::unix::io::FromRawFd>::from_raw_fd(
16194 crate::extensions::fds::movefd(read_end.into_raw_fd()),
16195 )
16196 };
16197 // Splitter: same read-loop shape as
16198 // BUILTIN_MULTIOS_REDIRECT, with one ordering
16199 // refinement. C's tee is a forked process
16200 // (closemn → teeproc) whose wakeup latency lets
16201 // the stage's DIRECT pipe writes land first —
16202 // observed zsh output for `{ echo a; echo b >&2; }
16203 // 3>&1 1>&2 2>&3 3>&- | cat` is `b` then `a`,
16204 // 15/15 runs. A Rust thread wakes faster than
16205 // the debug-build VM dispatches the next echo,
16206 // inverting the order. Emulate the C timing
16207 // observably: stream to the NEW target (file /
16208 // stderr dup) immediately, but defer the
16209 // pipe-bound copy until EOF (or a 64KB cap so a
16210 // long-running stream still flows instead of
16211 // growing memory unboundedly).
16212 let write_now = |tfd: i32, data: &[u8]| {
16213 let mut off = 0;
16214 while off < data.len() {
16215 let w = unsafe {
16216 libc::write(
16217 tfd,
16218 data[off..].as_ptr() as *const libc::c_void,
16219 data.len() - off,
16220 )
16221 };
16222 if w <= 0 {
16223 break;
16224 }
16225 off += w as usize;
16226 }
16227 };
16228 let handle = std::thread::spawn(move || {
16229 let mut rd = read_end;
16230 let mut buf = [0u8; 8192];
16231 let mut pipe_pending: Vec<u8> = Vec::new();
16232 loop {
16233 match std::io::Read::read(&mut rd, &mut buf) {
16234 Ok(0) | Err(_) => break,
16235 Ok(n) => {
16236 write_now(new_target_fd, &buf[..n]);
16237 pipe_pending.extend_from_slice(&buf[..n]);
16238 if pipe_pending.len() >= 65536 {
16239 write_now(pipe_dup, &pipe_pending);
16240 pipe_pending.clear();
16241 }
16242 }
16243 }
16244 }
16245 write_now(pipe_dup, &pipe_pending);
16246 unsafe {
16247 libc::close(pipe_dup);
16248 libc::close(new_target_fd);
16249 }
16250 });
16251 let write_raw = AsRawFd::as_raw_fd(&write_end);
16252 unsafe { libc::dup2(write_raw, 1) };
16253 drop(write_end);
16254 // Scope-end closes this dup (the last writer once
16255 // the saved fd 1 is restored) → EOF → join.
16256 let close_on_end = unsafe { libc::fcntl(1, libc::F_DUPFD, 10) };
16257 if let Some(top) = self.multios_scope_stack.last_mut() {
16258 top.push((close_on_end, handle));
16259 } else {
16260 unsafe { libc::close(close_on_end) };
16261 let _ = handle.join();
16262 }
16263 return;
16264 }
16265 _ => unsafe {
16266 // pipe()/dup failure — fall through to plain replace.
16267 if pipe_dup >= 0 {
16268 libc::close(pipe_dup);
16269 }
16270 libc::close(new_target_fd);
16271 },
16272 }
16273 }
16274 }
16275 match op_byte {
16276 r::WRITE => {
16277 // Honor `setopt noclobber`: refuse to overwrite an
16278 // existing regular file unless `>!` / `>|` (CLOBBER).
16279 // zsh internally stores the inverted-name `clobber`
16280 // (default ON); `setopt noclobber` writes
16281 // `clobber=false`. Honor both keys.
16282 //
16283 // c:Src/exec.c:2241-2245 clobber_open recover path:
16284 // after O_EXCL fails, reopen and `if (!S_ISREG(...))
16285 // return fd;` — non-regular targets (char/block-
16286 // special, FIFO, socket) bypass the noclobber check.
16287 // Bug #30 in docs/BUGS.md: this bridge-side check did
16288 // a bare `Path::exists()` and treated `/dev/null` as
16289 // a protected file, breaking `setopt no_clobber; echo
16290 // hi > /dev/null` and every `2> /dev/null` idiom.
16291 // Add a regular-file stat gate that matches the C
16292 // semantic. The canonical clobber_open at
16293 // src/ported/exec.rs:2123 already handles this; the
16294 // bridge duplicates a stripped-down version here and
16295 // must mirror the same check.
16296 let noclobber = opt_state_get("noclobber").unwrap_or(false)
16297 || !opt_state_get("clobber").unwrap_or(true);
16298 let target_meta = std::fs::metadata(target).ok();
16299 let target_is_regular_file = target_meta
16300 .as_ref()
16301 .map(|m| m.file_type().is_file())
16302 .unwrap_or(false);
16303 // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY permits
16304 // re-using an EMPTY regular file under noclobber: `setopt
16305 // noclobber clobberempty; : >f; echo hi >f` overwrites f.
16306 // The inline bridge check ignored this and errored.
16307 let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
16308 && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
16309 if noclobber && target_is_regular_file && !clobber_empty_ok {
16310 eprintln!(
16311 "{}:{}: file exists: {}",
16312 shname(),
16313 crate::ported::lex::lineno(),
16314 target
16315 );
16316 self.set_last_status(1);
16317 // c:Src/exec.c — set redirect_failed so the scope-end
16318 // hook (`with_redirects_end` in this file) forces
16319 // $? to 1 regardless of the still-running command's
16320 // own exit. Without this the next command (e.g.
16321 // `echo x` writing to /dev/null below) succeeds
16322 // and overwrites the redirect-failure status,
16323 // making noclobber unobservable from $?.
16324 self.redirect_failed = true;
16325 // Sink the upcoming command's stdout to /dev/null
16326 // so we don't leak its output to the terminal.
16327 // zsh skips the command entirely; we approximate by
16328 // discarding the output (the redirect target was
16329 // the user's chosen sink, but with noclobber the
16330 // file is protected — discarding matches the
16331 // user's intent better than printing to terminal).
16332 if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
16333 let new_fd = file.into_raw_fd();
16334 unsafe {
16335 libc::dup2(new_fd, fd);
16336 libc::close(new_fd);
16337 }
16338 }
16339 return;
16340 }
16341 if !Self::redir_open_or_fail(
16342 fd,
16343 fs::File::create(target),
16344 target,
16345 &mut self.redirect_failed,
16346 ) {
16347 self.set_last_status(1);
16348 }
16349 }
16350 r::CLOBBER => {
16351 if !Self::redir_open_or_fail(
16352 fd,
16353 fs::File::create(target),
16354 target,
16355 &mut self.redirect_failed,
16356 ) {
16357 self.set_last_status(1);
16358 }
16359 }
16360 r::APPEND => {
16361 // c:Src/exec.c:3924-3927 — `>>` honors NO_CLOBBER+!APPENDCREATE
16362 // by opening O_APPEND|O_WRONLY WITHOUT O_CREAT, so missing
16363 // files yield ENOENT. zsh source:
16364 // if (!isset(CLOBBER) && !isset(APPENDCREATE) &&
16365 // !IS_CLOBBER_REDIR(fn->type))
16366 // mode = O_WRONLY|O_APPEND|O_NOCTTY;
16367 // else mode = O_WRONLY|O_APPEND|O_CREAT|O_NOCTTY;
16368 // (IS_CLOBBER_REDIR — `>>!`/`>>|` — is currently flattened
16369 // to plain APPEND at compile time in
16370 // src/extensions/compile_zsh.rs:1654-1655, so the bang/pipe
16371 // forms can't be distinguished here yet.)
16372 let noclobber = opt_state_get("noclobber").unwrap_or(false)
16373 || !opt_state_get("clobber").unwrap_or(true);
16374 let append_create = opt_state_get("appendcreate").unwrap_or(false)
16375 || opt_state_get("append_create").unwrap_or(false);
16376 let open_result = if noclobber && !append_create {
16377 fs::OpenOptions::new().append(true).open(target) // no create
16378 } else {
16379 fs::OpenOptions::new()
16380 .create(true)
16381 .append(true)
16382 .open(target)
16383 };
16384 if !Self::redir_open_or_fail(fd, open_result, target, &mut self.redirect_failed) {
16385 self.set_last_status(1);
16386 }
16387 }
16388 r::READ => {
16389 if !Self::redir_open_or_fail(
16390 fd,
16391 fs::File::open(target),
16392 target,
16393 &mut self.redirect_failed,
16394 ) {
16395 self.set_last_status(1);
16396 }
16397 }
16398 r::READ_WRITE => {
16399 if let Ok(file) = fs::OpenOptions::new()
16400 .create(true)
16401 .truncate(false) // <> opens existing-or-new without truncating
16402 .read(true)
16403 .write(true)
16404 .open(target)
16405 {
16406 let new_fd = file.into_raw_fd();
16407 unsafe {
16408 // See redir_open_or_fail: when the opened fd IS the
16409 // destination (target fd was closed), keep it and clear
16410 // O_CLOEXEC; else dup2 + close.
16411 if new_fd != fd {
16412 libc::dup2(new_fd, fd);
16413 libc::close(new_fd);
16414 } else {
16415 libc::fcntl(fd, libc::F_SETFD, 0);
16416 }
16417 }
16418 }
16419 }
16420 r::DUP_READ | r::DUP_WRITE => {
16421 // Target is a numeric fd reference like `&3`. The parser
16422 // strips the `&` prefix before we get here in some paths,
16423 // others retain it — accept both. Also support `-` for
16424 // close-fd (`<&-` / `>&-`) per POSIX. The src_fd
16425 // validity check ran above before the save-and-dup.
16426 let n = target.trim_start_matches('&');
16427 if n == "-" {
16428 unsafe { libc::close(fd) };
16429 } else if n == "p" {
16430 // c:Src/exec.c — `<&p` / `>&p` route through the
16431 // coprocin / coprocout globals. zsh's `coproc CMD`
16432 // launch publishes those fds; the canonical
16433 // bin_print / bin_read `-p` arms already consume
16434 // them. The DUP redirect form is the third
16435 // consumer: it must dup the coproc fd onto the
16436 // target slot so the next command's stdin/stdout
16437 // is wired to the running coprocess. Bug #388.
16438 let coproc_fd = if op_byte == r::DUP_READ {
16439 crate::ported::modules::clone::coprocin
16440 .load(std::sync::atomic::Ordering::Relaxed)
16441 } else {
16442 crate::ported::modules::clone::coprocout
16443 .load(std::sync::atomic::Ordering::Relaxed)
16444 };
16445 if coproc_fd < 0 {
16446 eprintln!("{}:1: no coprocess", shname());
16447 self.set_last_status(1);
16448 self.redirect_failed = true;
16449 } else {
16450 unsafe {
16451 libc::dup2(coproc_fd, fd);
16452 }
16453 }
16454 } else if let Ok(src_fd) = n.parse::<i32>() {
16455 unsafe { libc::dup2(src_fd, fd) };
16456 } else if op_byte == r::DUP_WRITE {
16457 // c:Src/glob.c:2184-2187 xpandredir — a MERGEOUT
16458 // word that expands to a non-number becomes
16459 // REDIR_ERRWRITE: `cmd >& word` opens `word` and
16460 // routes BOTH fd 1 and fd 2 there. Reached only
16461 // for dynamic words (`>&$var`); static filenames
16462 // were converted at compile time.
16463 if let Ok(file) = fs::File::create(target) {
16464 let new_fd = file.into_raw_fd();
16465 unsafe {
16466 libc::dup2(new_fd, 1);
16467 libc::dup2(new_fd, 2);
16468 libc::close(new_fd);
16469 }
16470 }
16471 } else {
16472 // c:Src/glob.c:2185 — MERGEIN non-number:
16473 // `zerr("file number expected")`.
16474 crate::ported::utils::zerr("file number expected");
16475 self.set_last_status(1);
16476 self.redirect_failed = true;
16477 }
16478 }
16479 r::WRITE_BOTH => {
16480 if let Ok(file) = fs::File::create(target) {
16481 let new_fd = file.into_raw_fd();
16482 unsafe {
16483 libc::dup2(new_fd, 1);
16484 libc::dup2(new_fd, 2);
16485 libc::close(new_fd);
16486 }
16487 }
16488 }
16489 r::APPEND_BOTH => {
16490 if let Ok(file) = fs::OpenOptions::new()
16491 .create(true)
16492 .append(true)
16493 .open(target)
16494 {
16495 let new_fd = file.into_raw_fd();
16496 unsafe {
16497 libc::dup2(new_fd, 1);
16498 libc::dup2(new_fd, 2);
16499 libc::close(new_fd);
16500 }
16501 }
16502 }
16503 _ => {}
16504 }
16505 }
16506
16507 /// Push a fresh redirect scope. `_count` is informational — the actual
16508 /// saved fds are appended by host_apply_redirect into the top scope.
16509 pub fn host_redirect_scope_begin(&mut self, _count: u8) {
16510 // c:Src/exec.c:3722-3724 — the pipeline child set
16511 // `pipe_output_pending` right after dup2'ing its stdout onto
16512 // the pipe; the FIRST redirect scope opened in that child is
16513 // the stage command's own redirect list (same execcmd as the
16514 // pipe's addfd into mfds[1]). Capture the depth so only THAT
16515 // list's fd-1 write redirects MULTIOS-join the pipe.
16516 if self.pipe_output_pending {
16517 self.pipe_output_pending = false;
16518 self.pipe_output_scope = Some(self.redirect_scope_stack.len());
16519 }
16520 self.redirect_scope_stack.push(Vec::new());
16521 self.multios_scope_stack.push(Vec::new());
16522 }
16523
16524 /// Restore every redirect scope opened above `depth`.
16525 ///
16526 /// c:Src/exec.c:4364 — `fixfds(save)` runs on EVERY exit path out of
16527 /// `execcmd_exec`, the one an early `return` takes out of a compound
16528 /// command carrying redirections included (`while … done < f`,
16529 /// `{ …; return } < f`, `if …; then return; fi < f`). zshrs compiles
16530 /// `return` to a Jump past the body's `WithRedirectsEnd`, so that
16531 /// scope stayed on the stack and its saved fds never came back — the
16532 /// caller inherited the callee's redirected fd.
16533 ///
16534 /// gitstatus hit this: `gitstatus.plugin.zsh`'s daemon sources
16535 /// `gitstatus/install`, whose `_gitstatus_install_main` returns out of
16536 /// `while … done <"$gitstatus_dir"/install.info`. fd 0 stayed on
16537 /// install.info instead of reverting to the request FIFO, so
16538 /// `gitstatusd` read EOF on startup and exited ("EOF. Exiting."),
16539 /// `gitstatus_start` failed, `VCS_STATUS_REMOTE_URL` came back empty
16540 /// and powerlevel10k rendered the generic git icon in place of the
16541 /// per-forge one.
16542 pub fn unwind_redirect_scopes_to(&mut self, depth: usize) {
16543 while self.redirect_scope_stack.len() > depth {
16544 self.host_redirect_scope_end();
16545 }
16546 }
16547
16548 /// Pop the top redirect scope, restoring saved fds.
16549 pub fn host_redirect_scope_end(&mut self) {
16550 // c:Src/exec.c — restore saved fds FIRST so the multios
16551 // pipe-write end is released from `fd`, then close our
16552 // tracked close_on_end (the last surviving writer dup), then
16553 // join the splitter thread. If we closed close_on_end before
16554 // restoring saved, `fd` would still hold a pipe writer and
16555 // the thread would block forever waiting for EOF.
16556 if let Some(saved) = self.redirect_scope_stack.pop() {
16557 for (fd, saved_fd) in saved.into_iter().rev() {
16558 unsafe {
16559 libc::dup2(saved_fd, fd);
16560 libc::close(saved_fd);
16561 }
16562 }
16563 }
16564 if let Some(scope) = self.multios_scope_stack.pop() {
16565 // Close ALL tracked writer dups BEFORE joining any
16566 // thread. When one splitter holds a dup of another's
16567 // pipe write-end (two multios in one scope where a later
16568 // one duped fd 1 while an earlier splitter owned it),
16569 // joining in push order deadlocks: splitter A's EOF
16570 // waits on splitter B's writer dup, which only closes
16571 // after B's thread exits — blocked behind A's join.
16572 let mut handles = Vec::with_capacity(scope.len());
16573 for (write_fd, handle) in scope {
16574 if write_fd >= 0 {
16575 unsafe {
16576 libc::close(write_fd);
16577 }
16578 }
16579 handles.push(handle);
16580 }
16581 for handle in handles {
16582 let _ = handle.join();
16583 }
16584 }
16585 // The scope that captured the pipeline-output marker is gone;
16586 // deeper-nested future scopes must not re-match its depth.
16587 if self.pipe_output_scope == Some(self.redirect_scope_stack.len()) {
16588 self.pipe_output_scope = None;
16589 }
16590 }
16591
16592 /// Set up `content` as stdin (fd 0) for the next command.
16593 /// Used by `Op::HereDoc(idx)` and `Op::HereString`.
16594 ///
16595 /// c:Src/exec.c:4655 getherestr — C writes the body to a TEMP
16596 /// FILE (gettempfile → write_loop → close → reopen O_RDONLY →
16597 /// unlink), NOT a pipe. The previous pipe+writer-thread shape
16598 /// SIGPIPE'd the whole shell when the consumer never read the
16599 /// body (`: <<< ${(F)x/y}` — D04parameter chunk 211, flaky
16600 /// rc=141): the redirect-scope teardown closed the read end
16601 /// while the detached thread was still in write_all, and the
16602 /// shell's SIGPIPE disposition is SIG_DFL. A temp file has no
16603 /// reader/writer coupling — matching C exactly, including
16604 /// lseek-ability of fd 0, which pipes don't give.
16605 pub fn host_set_pending_stdin(&mut self, content: String) {
16606 // c:4673 — `gettempfile(NULL, 1, &s)`.
16607 let mut tmp = std::env::temp_dir();
16608 tmp.push(format!(
16609 "zshrs-herestr-{}-{:x}",
16610 std::process::id(),
16611 std::time::SystemTime::now()
16612 .duration_since(std::time::UNIX_EPOCH)
16613 .map(|d| d.as_nanos())
16614 .unwrap_or(0)
16615 ));
16616 // c:Src/exec.c:4719 — `unmetafy(t, &len);` runs BEFORE
16617 // `write_loop(fd, t, len)`: what lands in the temp file is the RAW
16618 // byte stream, never zshrs's metafied `Meta` + `byte ^ 32` pairs.
16619 // Writing `content.as_bytes()` leaked the metafication onto fd 0,
16620 // so `read -d $'\xa0' <<<$'first\xa0second'` saw `c2 83 c2 80`
16621 // where every other writer (`print`, a pipe) emits the single `a0`.
16622 let raw = crate::ported::utils::unmetafy_str(&content); // c:4719
16623 // c:4675 — `write_loop(fd, t, len); close(fd);`
16624 if std::fs::write(&tmp, &raw).is_err() {
16625 return; // c:4674 — tempfile failure → no redirect
16626 }
16627 // c:Src/utils.c gettempfile → mkstemp creates the temp file mode
16628 // 0600 IGNORING the umask, so the O_RDONLY reopen below always
16629 // succeeds. `std::fs::write` honors the umask, so under `umask
16630 // 0777` the file landed mode 0000 and the reopen failed with
16631 // EACCES — `cat <<<x` then read empty stdin. Force 0600 to match
16632 // mkstemp's umask-independent permissions.
16633 let _ = std::fs::set_permissions(
16634 &tmp,
16635 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
16636 );
16637 // c:4677 — `fd = open(s, O_RDONLY | O_NOCTTY);`
16638 let file = match std::fs::File::open(&tmp) {
16639 Ok(f) => f,
16640 Err(_) => {
16641 let _ = std::fs::remove_file(&tmp);
16642 return;
16643 }
16644 };
16645 // c:4678 — `unlink(s);` — fd stays valid, name disappears.
16646 let _ = std::fs::remove_file(&tmp);
16647 let saved = unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD, 10) };
16648 if saved >= 0 {
16649 if let Some(top) = self.redirect_scope_stack.last_mut() {
16650 top.push((libc::STDIN_FILENO, saved));
16651 } else {
16652 unsafe { libc::close(saved) };
16653 }
16654 }
16655 // c:Src/utils.c:redup — `if (x != y) { dup2(x, y); zclose(x); }`.
16656 // When fd 0 was already CLOSED before this heredoc runs,
16657 // `File::open` returns the lowest free descriptor, which is 0
16658 // itself — so `read_fd == STDIN_FILENO`. C's redup skips both the
16659 // dup2 (a no-op for equal fds) AND the close in that case, leaving
16660 // the just-opened temp file installed at fd 0. Unconditionally
16661 // dropping the File here closed that fd back to nothing, so an
16662 // external NULLCMD (`cat`) inherited a closed fd 0 and failed with
16663 // EBADF (`cat <<EOF` inside `$(...)` when exec 0<&- closed stdin).
16664 let read_fd = AsRawFd::as_raw_fd(&file);
16665 if read_fd != libc::STDIN_FILENO {
16666 // dup2 installs a fresh fd 0 with FD_CLOEXEC clear (dup2 never
16667 // copies the flag), then we close the CLOEXEC-tagged source.
16668 unsafe { libc::dup2(read_fd, libc::STDIN_FILENO) };
16669 drop(file); // c:redup zclose(x)
16670 } else {
16671 // File::open reused fd 0. Rust opens with O_CLOEXEC, so fd 0
16672 // now carries FD_CLOEXEC and would be auto-closed when an
16673 // external NULLCMD (`cat`) exec's — the child then reads a
16674 // closed fd 0 and fails with EBADF. zsh opens the heredoc temp
16675 // via `open(s, O_RDONLY|O_NOCTTY)` (no CLOEXEC), so its child
16676 // inherits the fd. Clear the flag to match, then keep fd 0 open
16677 // (redup's x==y arm: no dup2, no close).
16678 unsafe {
16679 let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFD);
16680 if flags >= 0 {
16681 libc::fcntl(libc::STDIN_FILENO, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
16682 }
16683 }
16684 std::mem::forget(file);
16685 }
16686 }
16687
16688 /// Spawn an external command using zshrs's full dispatch logic
16689 /// (intercepts, command_hash, redirect handling). Used by
16690 /// `ZshrsHost::exec` so the bytecode VM's `Op::Exec` and
16691 /// `Op::CallFunction` external fallback get the same semantics as
16692 /// the tree-walker's `execute_external` rather than a plain
16693 /// `Command::new` shortcut. Returns the exit status.
16694 pub fn host_exec_external(&mut self, args: &[String]) -> i32 {
16695 // Native p10k API: the `p10k(){ zshrs-p10k-api "$@" }` stub's
16696 // body lands here (the name is neither function nor builtin).
16697 // Route into the engine instead of a PATH miss.
16698 if let Some(name) = args.first() {
16699 if let Some(status) = crate::p10k::maybe_intercept_command(name, &args[1..]) {
16700 self.set_last_status(status);
16701 return status;
16702 }
16703 }
16704 // If a glob expansion in this command's argv triggered the
16705 // nomatch error path, suppress the actual exec and return
16706 // status 1 — mirrors zsh's command-aborted-on-glob-error
16707 // behaviour. The flag is reset BEFORE returning so the next
16708 // command starts clean.
16709 //
16710 // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH sets
16711 // ERRFLAG_ERROR but C's execlist clears the bit per-sublist
16712 // so subsequent commands run. Symmetric with the builtin
16713 // dispatcher's clear at fusevm_bridge.rs:299 — clear it here
16714 // too at the external-command post-command-boundary.
16715 consume_tilde_globsubst_carrier();
16716 if self.current_command_glob_failed.get() {
16717 self.current_command_glob_failed.set(false);
16718 crate::ported::utils::errflag.fetch_and(
16719 !crate::ported::zsh_h::ERRFLAG_ERROR,
16720 std::sync::atomic::Ordering::Relaxed,
16721 );
16722 self.set_last_status(1);
16723 return 1;
16724 }
16725 // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the
16726 // NOMATCH gate above, same external-path semantics (skip
16727 // command, `no match`, clear ERRFLAG so the next sublist
16728 // runs).
16729 if consume_badcshglob() {
16730 crate::ported::utils::errflag.fetch_and(
16731 !crate::ported::zsh_h::ERRFLAG_ERROR,
16732 std::sync::atomic::Ordering::Relaxed,
16733 );
16734 self.set_last_status(1);
16735 return 1;
16736 }
16737 let Some((cmd, rest)) = args.split_first() else {
16738 return 0;
16739 };
16740 // Empty command name (e.g. result of an empty `$(false)`
16741 // command-sub being the only word) — zsh: no command runs,
16742 // exit status preserved from prior step. Was hitting the
16743 // "command not found: " path with empty name.
16744 if cmd.is_empty() && rest.is_empty() {
16745 return self.last_status();
16746 }
16747 let rest_vec: Vec<String> = rest.to_vec();
16748 // Update `$_` with the just-arriving argv so the next command
16749 // reads `_=<last_arg>`. Mirrors C zsh's writeback in
16750 // `execcmd_exec` (Src/exec.c). Per `args.last()` semantics,
16751 // when invoked as `cmd a b c`, `$_` becomes "c" — for a bare
16752 // command with no args, `$_` becomes the command name itself.
16753 crate::ported::params::set_zunderscore(args);
16754
16755 // Builtins not in fusevm's name→id table fall through to
16756 // host.exec. Catch them here before the OS-level exec attempts
16757 // to spawn a non-existent binary.
16758 match cmd.as_str() {
16759 "sched" => return dispatch_builtin("sched", rest_vec.clone()),
16760 "echotc" => return dispatch_builtin("echotc", rest_vec.clone()),
16761 "echoti" => return dispatch_builtin("echoti", rest_vec.clone()),
16762 "zpty" => return dispatch_builtin("zpty", rest_vec.clone()),
16763 "ztcp" => return dispatch_builtin("ztcp", rest_vec.clone()),
16764 "zsocket" => {
16765 // c:Src/Modules/socket.c:276 BUILTIN spec — BUILTINS["zsocket"]
16766 // optstr "ad:ltv" parsed by execbuiltin.
16767 return dispatch_builtin("zsocket", rest_vec.clone());
16768 }
16769 "private" => {
16770 // c:Src/Modules/param_private.c:217 — bin_private via
16771 // BUILTINS["private"]. The autoload require_module
16772 // (exec.c:2700-2717) fires inside
16773 // dispatch_builtin_raw, the chokepoint for all routes.
16774 return dispatch_builtin("private", rest_vec.clone());
16775 }
16776 "zformat" => return dispatch_builtin("zformat", rest_vec.clone()),
16777 "zregexparse" => return dispatch_builtin("zregexparse", rest_vec.clone()),
16778 // `unalias`/`unhash`/`unfunction` share `bin_unhash` but
16779 // each carries its own funcid (BIN_UNALIAS / BIN_UNHASH /
16780 // BIN_UNFUNCTION) — dispatch_builtin handles the BUILTINS
16781 // lookup + funcid propagation via execbuiltin.
16782 "unalias" | "unhash" | "unfunction" => {
16783 return dispatch_builtin(cmd.as_str(), rest_vec.clone());
16784 }
16785 // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
16786 // functions — implemented natively in Rust so `autoload -Uz zmv`
16787 // works without shipping the function source (and without the
16788 // fpath source hanging the parser). The `function_exists` guard
16789 // keeps them command-not-found until autoloaded, exactly like zsh;
16790 // an un-guarded arm ran them for bare `zmv`, diverging from
16791 // `zsh -f; zmv` → "command not found: zmv".
16792 "zmv" if self.function_exists("zmv") => {
16793 return crate::extensions::ext_builtins::zmv(&rest_vec, "mv")
16794 }
16795 "zcp" if self.function_exists("zcp") => {
16796 return crate::extensions::ext_builtins::zmv(&rest_vec, "cp")
16797 }
16798 "zln" if self.function_exists("zln") => {
16799 return crate::extensions::ext_builtins::zmv(&rest_vec, "ln")
16800 }
16801 "zcalc" if self.function_exists("zcalc") => {
16802 return crate::extensions::ext_builtins::zcalc(&rest_vec)
16803 }
16804 "zselect" => {
16805 // Route through canonical dispatch_builtin which goes
16806 // via execbuiltin → BUILTINS["zselect"] (zselect.c:272).
16807 return dispatch_builtin("zselect", rest_vec.clone());
16808 }
16809 "cap" => return dispatch_builtin("cap", rest_vec.clone()),
16810 "getcap" => return dispatch_builtin("getcap", rest_vec.clone()),
16811 "setcap" => return dispatch_builtin("setcap", rest_vec.clone()),
16812 "yes" => return self.builtin_yes(&rest_vec),
16813 "nl" => return self.builtin_nl(&rest_vec),
16814 "env" => return self.builtin_env(&rest_vec),
16815 "printenv" => return self.builtin_printenv(&rest_vec),
16816 "tty" => return self.builtin_tty(&rest_vec),
16817 // c:Src/Modules/files.c:806 — BUILTINS["chgrp"] with
16818 // BIN_CHGRP funcid + "hRs" optstr.
16819 "chgrp" => return dispatch_builtin("chgrp", rest_vec.clone()),
16820 "nproc" => return self.builtin_nproc(&rest_vec),
16821 "expr" => return self.builtin_expr(&rest_vec),
16822 "sha256sum" => return self.builtin_sha256sum(&rest_vec),
16823 "base64" => return self.builtin_base64(&rest_vec),
16824 "tac" => return self.builtin_tac(&rest_vec),
16825 "expand" => return self.builtin_expand(&rest_vec),
16826 "unexpand" => return self.builtin_unexpand(&rest_vec),
16827 "paste" => return self.builtin_paste(&rest_vec),
16828 "fold" => return self.builtin_fold(&rest_vec),
16829 "shuf" => return self.builtin_shuf(&rest_vec),
16830 "comm" => return self.builtin_comm(&rest_vec),
16831 "cksum" => return self.builtin_cksum(&rest_vec),
16832 "factor" => return self.builtin_factor(&rest_vec),
16833 "tsort" => return self.builtin_tsort(&rest_vec),
16834 "sum" => return self.builtin_sum(&rest_vec),
16835 "mkfifo" => return self.builtin_mkfifo(&rest_vec),
16836 "link" => return self.builtin_link(&rest_vec),
16837 "unlink" => return self.builtin_unlink(&rest_vec),
16838 "dircolors" => return self.builtin_dircolors(&rest_vec),
16839 "groups" => return self.builtin_groups(&rest_vec),
16840 "arch" => return self.builtin_arch(&rest_vec),
16841 "nice" => return self.builtin_nice(&rest_vec),
16842 "logname" => return self.builtin_logname(&rest_vec),
16843 "tput" => return self.builtin_tput(&rest_vec),
16844 "users" => return self.builtin_users(&rest_vec),
16845 // "sync" => return self.bin_sync(&rest_vec),
16846 "zbuild" => return self.builtin_zbuild(&rest_vec),
16847 // `zf_*` aliases from `zsh/files` (Src/Modules/files.c
16848 // BUILTIN table at line 816-824). The C source binds
16849 // both unprefixed (`chmod`) and prefixed (`zf_chmod`)
16850 // names to the SAME `bin_chmod` etc. handlers — the
16851 // prefixed forms exist so a script can portably reach
16852 // the builtin even when a function or alias has shadowed
16853 // the bare name. Each arm routes through the canonical
16854 // zf_* aliases route through canonical BUILTINS entries
16855 // (files.c:816-824) — execbuiltin parses each fn's optstr
16856 // automatically.
16857 "mkdir" | "zf_mkdir" | "zf_rm" | "zf_rmdir" | "zf_chmod" | "zf_chown" | "zf_chgrp"
16858 | "zf_ln" | "zf_mv" | "zf_sync"
16859 // `--zsh` parity gate: zsh -fc has zsh/files UNLOADED
16860 // — bare `mkdir` is /bin/mkdir (so `command mkdir -p`
16861 // honors the system flag set; zconvey.plugin.zsh:44
16862 // got "File exists" from the in-process bin_mkdir
16863 // that this arm intercepted) and `zf_*` names are
16864 // command-not-found 127 until `zmodload zsh/files`.
16865 // Fall through to the external/exec path in --zsh
16866 // mode; default zshrs mode keeps the anti-fork
16867 // intercept.
16868 if !crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) =>
16869 {
16870 return dispatch_builtin(cmd.as_str(), rest_vec.clone());
16871 }
16872 // `zstat` — port of zsh/stat module (Src/Modules/stat.c
16873 // BUILTIN("zstat", …)). Returns file metadata as
16874 // `field value` pairs / an assoc / a plus-separated
16875 // list depending on flags. zsh ALSO registers `stat`
16876 // bound to the same handler, but that name conflicts
16877 // with the system `stat(1)` binary (every script that
16878 // calls `stat -f '%Lp' …` would break). zsh resolves
16879 // this through opt-in `zmodload`; zshrs's modules are
16880 // statically linked so we keep `stat` routing to the
16881 // external command and only intercept the unambiguous
16882 // `zstat` name.
16883 "zstat" => {
16884 // Canonical bin_stat per stat.c:638 via BUILTINS["zstat"].
16885 return dispatch_builtin("zstat", rest_vec.clone());
16886 }
16887 _ => {}
16888 }
16889
16890 // AOP intercepts: when an `intercept :before/:around/:after foo` block
16891 // is registered, dynamic-command-name dispatch must consult it before
16892 // spawning. Without this, `cmd=ls; $cmd` bypasses every intercept that
16893 // a literal `ls` would trigger. The full_cmd string mirrors what the
16894 // tree-walker era passed (cmd + args joined by space) so existing
16895 // pattern matchers continue to work.
16896 if !self.intercepts.is_empty() {
16897 let full_cmd = if rest_vec.is_empty() {
16898 cmd.clone()
16899 } else {
16900 format!("{} {}", cmd, rest_vec.join(" "))
16901 };
16902 if let Some(intercept_result) = self.run_intercepts(cmd, &full_cmd, &rest_vec) {
16903 return intercept_result.unwrap_or(127);
16904 }
16905 }
16906
16907 // User-defined function lookup before OS-level exec. zsh's
16908 // dynamic-command-name dispatch (`cmd=hook1; $cmd`) checks
16909 // the function table FIRST — without this, `$f` for a
16910 // function-name `f` was always falling through to
16911 // `execute_external` and erroring "command not found".
16912 // Plugin code uses this pattern constantly:
16913 // for f in "${precmd_functions[@]}"; do "$f"; done
16914 if self.function_exists(cmd) {
16915 if let Some(status) = self.dispatch_function_call(cmd, &rest_vec) {
16916 return status;
16917 }
16918 }
16919
16920 self.execute_external(cmd, &rest_vec, &[]).unwrap_or(127)
16921 }
16922}
16923
16924#[cfg(test)]
16925mod word_assemble_tests {
16926 use super::{word_assemble_plan9, Value};
16927
16928 fn arr(xs: &[&str]) -> Value {
16929 Value::array(xs.iter().map(|s| Value::str(*s)).collect())
16930 }
16931 fn out(v: Value) -> Vec<String> {
16932 match v {
16933 Value::Array(items) => items.iter().map(|i| i.to_str()).collect(),
16934 other => vec![other.to_str()],
16935 }
16936 }
16937
16938 // The edge-tracking fold (c:Src/subst.c:4316-4437). A naive per-segment
16939 // operator gets s,p,p and p,s,p wrong because it forgets which trailing
16940 // elements are still the "growing edge". These pin the exact zsh output
16941 // (verified against zsh 5.9) for every plan9/splice permutation.
16942 #[test]
16943 fn plan9_then_splice() {
16944 // "${(@)^a}${(@)b}" a=(1 2) b=(A B) -> 1A 2A B
16945 let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[true, false]);
16946 assert_eq!(out(r), vec!["1A", "2A", "B"]);
16947 }
16948 #[test]
16949 fn splice_then_plan9() {
16950 // "${(@)a}${(@)^b}" -> 1 2A 2B
16951 let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[false, true]);
16952 assert_eq!(out(r), vec!["1", "2A", "2B"]);
16953 }
16954 #[test]
16955 fn plan9_then_plan9_is_full_cross() {
16956 let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[true, true]);
16957 assert_eq!(out(r), vec!["1A", "1B", "2A", "2B"]);
16958 }
16959 #[test]
16960 fn splice_then_splice() {
16961 let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[false, false]);
16962 assert_eq!(out(r), vec!["1", "2A", "B"]);
16963 }
16964 #[test]
16965 fn plan9_splice_plan9_growing_edge() {
16966 // "${(@)^a}${(@)b}${(@)^c}" -> 1A 2A Bp Bq (only B, the edge, distributes)
16967 let r = word_assemble_plan9(
16968 &[arr(&["1", "2"]), arr(&["A", "B"]), arr(&["p", "q"])],
16969 &[true, false, true],
16970 );
16971 assert_eq!(out(r), vec!["1A", "2A", "Bp", "Bq"]);
16972 }
16973 #[test]
16974 fn splice_plan9_plan9_keeps_frozen_prefix() {
16975 // "${(@)a}${(@)^b}${(@)^c}" -> 1 2Ap 2Aq 2Bp 2Bq (1 stays frozen)
16976 let r = word_assemble_plan9(
16977 &[arr(&["1", "2"]), arr(&["A", "B"]), arr(&["p", "q"])],
16978 &[false, true, true],
16979 );
16980 assert_eq!(out(r), vec!["1", "2Ap", "2Aq", "2Bp", "2Bq"]);
16981 }
16982 #[test]
16983 fn empty_plan9_array_deletes_word() {
16984 // "${(@)^a}${(@)b}" with a=() -> word deleted
16985 let r = word_assemble_plan9(&[Value::array(vec![]), arr(&["A", "B"])], &[true, false]);
16986 assert!(out(r).is_empty(), "plan9 empty array deletes the word");
16987 }
16988 #[test]
16989 fn leading_literal_then_mixed() {
16990 // "X${(@)^a}${(@)b}" -> X1A X2A B
16991 let r = word_assemble_plan9(
16992 &[Value::str("X"), arr(&["1", "2"]), arr(&["A", "B"])],
16993 &[false, true, false],
16994 );
16995 assert_eq!(out(r), vec!["X1A", "X2A", "B"]);
16996 }
16997
16998 // c:Src/subst.c:4261 — a NON-plan9 empty expansion collapses to the empty
16999 // string and the word SURVIVES; only plan9 (c:4362 `uremnode`) deletes it.
17000 // A leading empty segment used to leave `words` empty, so every following
17001 // segment cross-multiplied against nothing and the word vanished.
17002 // Verified against zsh 5.9:
17003 // n=""; a=(x y z); print -rl -- $n${^a} -> x / y / z
17004 #[test]
17005 fn leading_empty_splice_keeps_word_and_crosses() {
17006 let r = word_assemble_plan9(
17007 &[Value::array(vec![]), arr(&["x", "y", "z"])],
17008 &[false, true],
17009 );
17010 assert_eq!(out(r), vec!["x", "y", "z"]);
17011 }
17012
17013 // n=""; a=(x y z); print -rl -- $n"pre"${^a} -> prex / prey / prez
17014 #[test]
17015 fn leading_empty_then_literal_then_plan9() {
17016 let r = word_assemble_plan9(
17017 &[
17018 Value::array(vec![]),
17019 Value::str("pre"),
17020 arr(&["x", "y", "z"]),
17021 ],
17022 &[false, false, true],
17023 );
17024 assert_eq!(out(r), vec!["prex", "prey", "prez"]);
17025 }
17026
17027 // n=""; a=(x y z); print -rl -- $n$a${^a} -> x / y / zx / zy / zz
17028 // The leading empty must not consume the splice's first element.
17029 #[test]
17030 fn leading_empty_does_not_eat_first_splice_element() {
17031 let r = word_assemble_plan9(
17032 &[
17033 Value::array(vec![]),
17034 arr(&["x", "y", "z"]),
17035 arr(&["x", "y", "z"]),
17036 ],
17037 &[false, false, true],
17038 );
17039 assert_eq!(out(r), vec!["x", "y", "zx", "zy", "zz"]);
17040 }
17041
17042 // n=""; e=(); print -rl -- $n${^e} -> nothing (plan9 empty still wins)
17043 #[test]
17044 fn leading_empty_then_empty_plan9_still_deletes_word() {
17045 let r = word_assemble_plan9(
17046 &[Value::array(vec![]), Value::array(vec![])],
17047 &[false, true],
17048 );
17049 assert!(
17050 out(r).is_empty(),
17051 "plan9 empty array still deletes the word"
17052 );
17053 }
17054}