zsh/fusevm_bridge.rs
1//! fusevm bytecode-VM bridge for ShellExecutor.
2//!
3//! ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4//! !!! LAST-RESORT FILE — NOT FOR NEW LOGIC !!!
5//!
6//! This file is a **bridge**, not a port. It exists ONLY because zshrs uses
7//! a fusevm bytecode VM where C zsh uses its own wordcode walker (Src/exec.c
8//! `execlist`). Every line here is plumbing that hooks fusevm opcodes onto
9//! the canonical ports in `src/ported/`.
10//!
11//! **Before adding code to this file, STOP and ask:**
12//!
13//! 1. Is this logic that already lives in `src/ported/`?
14//! → Call the canonical fn. Don't reinline.
15//!
16//! 2. Is this logic that SHOULD live in `src/ported/` but isn't ported yet?
17//! → Port it. Add it to `src/ported/<file>.rs` with a `c:` citation.
18//! Then call the canonical fn from here.
19//!
20//! 3. Is this purely fusevm/bytecode plumbing (Op decode, Value conversion,
21//! VM-stack manipulation, thread-local executor pointer, etc.)?
22//! → OK to put it here. Cite the closest C analog in the comment.
23//!
24//! **NEVER:** reinvent paramsubst/expansion/glob/typeset/redirect logic here.
25//! Those have canonical ports in `src/ported/subst.rs`, `src/ported/glob.rs`,
26//! `src/ported/builtin.rs`, etc. The bridge should be SHRINKING over time,
27//! not growing.
28//!
29//! See also: memory `feedback_no_shortcuts_in_porting` (port C bodies
30//! faithfully, no structural shells), `feedback_no_exec_script_from_ported`
31//! (the inverse direction — src/ported must not call back into the bridge).
32//! ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
33//!
34//! **Extension** — has no Src/exec.c counterpart. C zsh's `Src/exec.c::execlist`
35//! (and related routines) implement the native **wordcode VM** that executes
36//! compiler output from `parse.c`. zshrs compiles the parsed AST to fusevm
37//! bytecode and runs it on a stack VM; this
38//! file holds the bridge between fusevm's `ShellHost` trait and our
39//! `ShellExecutor` state, the thread-local executor pointer, all
40//! `BUILTIN_*` opcode constants, and the giant `register_builtins`
41//! handler table that wires zsh builtins onto fusevm CallBuiltin
42//! opcodes.
43
44#![allow(unused_imports)]
45
46use indexmap::IndexMap;
47use std::collections::{HashMap, HashSet};
48use std::env;
49use std::path::PathBuf;
50
51use crate::exec_jobs::JobState;
52use crate::intercepts::Intercept;
53use crate::ported::vm_helper::*;
54use std::io::Write;
55
56// ═══════════════════════════════════════════════════════════════════════════
57// Thread-local executor context for VM builtin dispatch
58// ═══════════════════════════════════════════════════════════════════════════
59
60use crate::ported::options::opt_state_get;
61use crate::ported::zsh_h::{isset, options, ERREXIT, MAX_OPS};
62use fusevm::op::redirect_op as r;
63use fusevm::shell_builtins::*;
64use fusevm::Value;
65use std::cell::{Cell, RefCell};
66use std::cmp::Ordering;
67use std::ffi::CString;
68use std::fs;
69use std::io::BufRead;
70use std::io::Read;
71use std::io::Write as _;
72use std::os::unix::fs::FileTypeExt;
73use std::os::unix::fs::MetadataExt;
74use std::os::unix::fs::PermissionsExt;
75use std::os::unix::io::AsRawFd;
76use std::os::unix::io::IntoRawFd;
77use std::time::Instant;
78use std::time::{SystemTime, UNIX_EPOCH};
79
80thread_local! {
81 /// Mirror of C zsh's `doneps4` local in execcmd_exec
82 /// (Src/exec.c:2517+). Tracks whether PS4 has been emitted
83 /// for the current xtrace line so a coalesced sequence of
84 /// XTRACE_ASSIGN + XTRACE_ARGS produces ONE line:
85 /// `<PS4>a=1 b=2 echo 1 2\n`
86 /// instead of three. Reset to false by XTRACE_ARGS /
87 /// XTRACE_NEWLINE after emitting the trailing `\n`.
88 static XTRACE_DONE_PS4: Cell<bool> = const { Cell::new(false) };
89
90 /// A NOMATCH raised by a typeset-family command's words BEFORE its first
91 /// postassign, set aside by BUILTIN_TYPESET_POSTASSIGNS_BEGIN and put
92 /// back by BUILTIN_TYPESET_POSTASSIGNS_END.
93 static TYPESET_WORD_GLOB_FAILED: Cell<bool> = const { Cell::new(false) };
94 /// Number of postassign words of the typeset-family command about to
95 /// dispatch, set by BUILTIN_TYPESET_POSTASSIGNS_BEGIN. C keeps those in
96 /// the command's `assigns` list, not `args` (c:Src/parse.c:2003-2008),
97 /// so they never become `$_` (c:Src/exec.c:3546).
98 static TYPESET_POSTASSIGN_COUNT: Cell<Option<usize>> = const { Cell::new(None) };
99
100 /// Set by BUILTIN_TYPESET_POSTASSIGNS_END when a NOMATCH fired in one of
101 /// the postassigns. Consumed by `dispatch_builtin`.
102 static TYPESET_POSTASSIGN_GLOB_FAILED: Cell<bool> = const { Cell::new(false) };
103
104 /// Port of C's `FILE *xtrerr` xtrace stream (Src/exec.c:81). C builds
105 /// each trace line in this stdio buffer — `printprompt4` does
106 /// `fprintf(xtrerr, …)`, then args via `fputs`/`fputc` — and
107 /// `fflush(xtrerr)` writes the WHOLE line to stderr in one syscall
108 /// (makecline c:2122-2123, addvars c:2588, condition c:1372). That
109 /// single flush is exactly why a forked pipeline stage's trace line
110 /// reaches the shared stderr fd atomically and never interleaves with
111 /// a concurrent stage. zshrs previously emitted PS4 and the command
112 /// text as separate `eprint!` writes, which raced under load. Model
113 /// the FILE buffer as this thread-local String (a forked child owns
114 /// its own copy); `xtrerr_fputs` appends, `xtrerr_flush` does the
115 /// single write.
116 static XTRERR: RefCell<String> = const { RefCell::new(String::new()) };
117
118 /// !!! WARNING: RUST-ONLY HELPER !!! `(scope depth, newxtrerr fd)` for
119 /// each simple command whose redirect scope took a copy of stderr for
120 /// xtrace (c:Src/exec.c:3766-3772). C holds newxtrerr in an
121 /// `execcmd_exec` local and closes it on the way out (c:4445-4452);
122 /// zshrs closes it when that redirect scope ends.
123 static XTRERR_COPIES: RefCell<Vec<(usize, i32)>> = const { RefCell::new(Vec::new()) };
124
125 /// Stack of (RETFLAG, BREAKS, CONTFLAG, EXIT_PENDING) tuples saved
126 /// at try-block exit so the always-arm body can run cleanly even
127 /// when the try-block fired `return` / `break` / `continue` /
128 /// `exit`. Restored right before the post-always re-jump so the
129 /// escape resumes propagation past the construct.
130 /// c:Src/exec.c WC_TRYBLOCK — zsh's wordcode walker handles this
131 /// inline; the zshrs port lifts it into a paired SET / RESTORE
132 /// pair around the always-arm.
133 /// Tuple: `(retflag, breaks, contflag, exit_pending, try_errflag,
134 /// try_interrupt)`. The last two are c:Src/loop.c:762-763's
135 /// `save_try_errflag` / `save_try_interrupt` — the ENCLOSING try
136 /// block's values, restored at c:778-779 so nested
137 /// `{…} always {…}` constructs don't leak `$TRY_BLOCK_ERROR`
138 /// outward.
139 static TRY_ESCAPE_SAVE: RefCell<Vec<(i32, i32, i32, i32, i64, i64)>> =
140 const { RefCell::new(Vec::new()) };
141 /// Re-entry guard for BUILTIN_DEBUG_TRAP. While the DEBUG trap
142 /// body is running, the per-statement DEBUG_TRAP dispatch in the
143 /// trap body must NOT re-fire (otherwise infinite recursion +
144 /// stack overflow). zsh's in_trap counter at Src/signals.c
145 /// serves the same purpose.
146 static DEBUG_TRAP_REENTRY: Cell<bool> = const { Cell::new(false) };
147 /// Stack of (saved_stdout, saved_stderr) tuples pushed by
148 /// `cmd_subst` around its nested-VM run. RUST-ONLY: zsh forks
149 /// each cmdsub so trap output during the cmdsub naturally
150 /// lands on the PARENT's stdout. zshrs's in-process cmdsub
151 /// dups fd 1 → pipe, so a trap firing during cmdsub would
152 /// emit into the captured value. Traps consult this stack
153 /// to route their body output to the topmost saved_stdout
154 /// instead of the cmdsub's fd 1. Bug #56 in docs/BUGS.md.
155 pub static CMDSUBST_OUTER_FDS: RefCell<Vec<(i32, i32)>> =
156 const { RefCell::new(Vec::new()) };
157 /// c:Src/exec.c:5025 getproc (PATH_DEV_FD branch) — the parent
158 /// keeps the `>(cmd)` pipe WRITE end open under `/dev/fd/N`,
159 /// parks it in the job's filelist (`fdtable[fd] =
160 /// FDT_PROC_SUBST; addfilelist(NULL, fd)`), and deletefilelist
161 /// closes it when the consuming job finishes — that close is
162 /// what lets the `>(cmd)` child's reader see EOF. zshrs runs
163 /// commands in-process, so the equivalent is: record
164 /// `(scope_depth, fd)` here and drain after the consuming
165 /// command (external exec or builtin dispatch) completes.
166 static PSUB_PENDING_FDS: RefCell<Vec<(usize, i32)>> = const { RefCell::new(Vec::new()) };
167 /// `(scope_depth, path)` for `=(cmd)` temp files, unlinked at the same
168 /// job-end boundary as the pending fds (c:Src/jobs.c deletefilelist).
169 static PSUB_PENDING_FILES: RefCell<Vec<(usize, String)>> = const { RefCell::new(Vec::new()) };
170 /// Scope depth for PSUB_PENDING_FDS tagging. Incremented around
171 /// nested execution contexts (cmd-subst bodies, shell-function
172 /// bodies) so a command running INSIDE the nested context only
173 /// drains its own `>(cmd)` fds, never the enclosing command's
174 /// (e.g. `tee >(wc) $(print x)` — print must not close tee's
175 /// fd). Mirrors C's per-job filelist ownership.
176 static PSUB_SCOPE_DEPTH: Cell<usize> = const { Cell::new(0) };
177 /// Forked `<(cmd)`/`>(cmd)` child pids awaiting reap. Drained
178 /// non-blockingly (WNOHANG) by note_psub_child so proc-sub children
179 /// don't accumulate as zombies across a shell session.
180 static PSUB_CHILDREN: RefCell<Vec<i32>> = const { RefCell::new(Vec::new()) };
181}
182
183/// Record a proc-sub child pid and best-effort reap any already-exited
184/// proc-sub children (WNOHANG). Non-blocking: still-running children
185/// stay parked and get reaped on a later call.
186pub(crate) fn note_psub_child(pid: i32) {
187 if pid <= 0 {
188 return;
189 }
190 PSUB_CHILDREN.with(|v| {
191 let mut v = v.borrow_mut();
192 v.push(pid);
193 v.retain(|&p| {
194 let mut status = 0;
195 // WNOHANG: reap if exited, else keep parked.
196 let r = unsafe { libc::waitpid(p, &mut status, libc::WNOHANG) };
197 // r == p → reaped; r == 0 → still running (keep); r < 0 →
198 // already gone/not ours (drop).
199 r == 0
200 });
201 });
202}
203
204/// Port of `fputs(s, xtrerr)` / `fprintf(xtrerr, "%s", s)` (Src/exec.c):
205/// append `s` to the buffered xtrace line. Nothing reaches stderr until
206/// [`xtrerr_flush`] (the port of `fflush(xtrerr)`) writes the line whole.
207pub(crate) fn xtrerr_fputs(s: &str) {
208 XTRERR.with(|b| b.borrow_mut().push_str(s));
209}
210
211/// Port of `fflush(xtrerr)` (Src/exec.c:1373/2123/2596): write the
212/// buffered xtrace line to stderr in ONE `write` and clear the buffer, so
213/// the line lands on the shared fd atomically (no interleaving across
214/// concurrent pipeline stages).
215pub(crate) fn xtrerr_flush() {
216 XTRERR.with(|b| {
217 let mut buf = b.borrow_mut();
218 if !buf.is_empty() {
219 // c:Src/utils.c:1714 — the line goes to `xtrerr`, which is
220 // stderr unless a simple command's redirect scope holds a copy
221 // of the pre-redirection stderr (c:Src/exec.c:3766-3772).
222 let fd = crate::ported::utils::xtrerr.load(std::sync::atomic::Ordering::Relaxed);
223 // c:Src/exec.c:2061 — the pieces were written with `quotedzputs(s, xtrerr)`,
224 // which emits METAFIED text un-metafied (a lone high byte from
225 // `mb_niceformat` under a single-byte locale is one byte on the wire).
226 let bytes = crate::ported::utils::unmetafy_str(&buf);
227 if fd == libc::STDERR_FILENO {
228 use std::io::Write;
229 let _ = std::io::stderr().write_all(&bytes);
230 } else {
231 let _ = crate::ported::utils::write_loop(fd, &bytes);
232 }
233 buf.clear();
234 }
235 });
236}
237
238/// RAII guard bumping the psub scope depth — see PSUB_SCOPE_DEPTH.
239pub(crate) struct PsubScope;
240
241impl PsubScope {
242 pub(crate) fn enter() -> Self {
243 PSUB_SCOPE_DEPTH.with(|d| d.set(d.get() + 1));
244 PsubScope
245 }
246}
247
248impl Drop for PsubScope {
249 fn drop(&mut self) {
250 PSUB_SCOPE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
251 }
252}
253
254/// RAII guard bumping `$ZSH_SUBSHELL` for the duration of an
255/// in-process command substitution.
256///
257/// c:Src/exec.c:1161 — entersubsh() does `zsh_subshell++;` and zsh's
258/// cmdsub FORKS, so the increment dies with the child and the parent's
259/// value is untouched. zshrs runs cmdsubs in-process on a nested VM,
260/// so the visible param must be bumped on entry and restored on exit.
261/// Writes paramtab u_val directly because ZSH_SUBSHELL is PM_READONLY
262/// (same bypass pattern as the subshell-builtin bump below); also
263/// mirrors into ported::exec::zsh_subshell so exec.c:4376-style
264/// `forked | zsh_subshell` reads agree.
265pub(crate) struct CmdSubstSubshellBump {
266 saved_val: i64,
267 saved_str: Option<String>,
268}
269
270impl CmdSubstSubshellBump {
271 pub(crate) fn enter() -> Self {
272 let mut saved_val = 0i64;
273 let mut saved_str = None;
274 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
275 if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
276 saved_val = pm.u_val;
277 saved_str = pm.u_str.clone();
278 pm.u_val = saved_val + 1;
279 pm.u_str = Some((saved_val + 1).to_string());
280 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
281 }
282 }
283 crate::ported::exec::zsh_subshell.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
284 CmdSubstSubshellBump {
285 saved_val,
286 saved_str,
287 }
288 }
289}
290
291impl Drop for CmdSubstSubshellBump {
292 fn drop(&mut self) {
293 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
294 if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
295 pm.u_val = self.saved_val;
296 pm.u_str = self.saved_str.take();
297 }
298 }
299 crate::ported::exec::zsh_subshell.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
300 }
301}
302
303/// `addfilelist(nam, 0)` (c:Src/exec.c:4960) for an `=(cmd)` temp file:
304/// park it for the job-end unlink in `close_pending_psub_fds`, tagged with
305/// the current scope depth.
306pub(crate) fn psub_pending_file_add(nam: &str) {
307 let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
308 PSUB_PENDING_FILES.with(|v| v.borrow_mut().push((depth, nam.to_string())));
309}
310
311/// Port of deletefilelist() from Src/jobs.c (the `>(cmd)` fd arm):
312/// closes every pending proc-subst write end created at or inside
313/// the current scope depth, exactly when C deletes the consuming
314/// job's filelist (getproc parks the fd there via
315/// `addfilelist(NULL, fd)`, Src/exec.c:5025+).
316fn close_pending_psub_fds() {
317 close_pending_psub_from(PSUB_SCOPE_DEPTH.with(|d| d.get()));
318}
319
320/// c:Src/jobs.c:1452-1460 cleanfilelists — at shell exit EVERY job's
321/// filelist is deleted, whichever scope parked it, so the pending `>(cmd)`
322/// fds and `=(cmd)` temp files of all depths go. Called from
323/// `jobs::cleanfilelists`: these lists stand in for the jobtab filelists
324/// (see PSUB_PENDING_FDS).
325pub(crate) fn cleanfilelists_pending() {
326 close_pending_psub_from(0);
327}
328
329/// The drain behind both: everything parked at scope `depth` or deeper.
330fn close_pending_psub_from(depth: usize) {
331 PSUB_PENDING_FDS.with(|v| {
332 v.borrow_mut().retain(|&(d, fd)| {
333 if d >= depth {
334 unsafe { libc::close(fd) };
335 false
336 } else {
337 true
338 }
339 });
340 });
341 // c:Src/jobs.c deletefilelist — `=(cmd)` temp files are unlinked at the
342 // same job-end boundary. `f==(print x); [[ -f $f ]]` is false after the
343 // command line ends.
344 PSUB_PENDING_FILES.with(|v| {
345 v.borrow_mut().retain(|(d, path)| {
346 if *d >= depth {
347 let _ = fs::remove_file(path);
348 false
349 } else {
350 true
351 }
352 });
353 });
354}
355
356/// RAII drain guard — instantiated at the top of the consuming-
357/// command paths (dispatch_builtin, ZshrsHost::exec) so the pending
358/// `>(cmd)` write ends close on every exit path once the command
359/// finished, exactly when C's job filelist would be deleted.
360struct PsubFdGuard;
361
362impl Drop for PsubFdGuard {
363 fn drop(&mut self) {
364 close_pending_psub_fds();
365 }
366}
367
368/// Peek the outermost cmdsub-saved (stdout, stderr) fds, if any.
369/// Returns None when no cmdsub is currently capturing. Used by the
370/// trap dispatcher in `src/ported/signals.rs::dotrap` to route trap
371/// body output to the parent's real stdout (matching zsh's forked
372/// cmdsub behaviour) instead of the cmdsub's pipe-bound fd 1.
373/// Bug #56 in docs/BUGS.md.
374/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
375///
376/// C keeps a shell function in exactly ONE place — the `Shfunc` node in
377/// `shfunctab` — so `dosavetrap` copies a `TRAP<SIG>` function by
378/// duplicating that node (`Src/signals.c:638-656`) and `endtrapscope`
379/// puts it back with a single `shfunctab->addnode` (c:929-931).
380///
381/// zshrs splits one function across FIVE stores: the `shfunc` node in
382/// `shfunctab` (metadata + body text) plus the executor's
383/// `functions_compiled` (the fusevm `Chunk` that actually runs),
384/// `function_source`, `function_line_base` and `function_def_file`.
385/// Snapshotting only the hashtable node therefore restores the outer
386/// function's *metadata* while the inner function's *bytecode* keeps
387/// running. `FuncSnapshot` captures all five so the C-level "copy the
388/// node" / "add the node back" steps are faithful in behaviour.
389#[derive(Default)]
390pub struct FuncSnapshot {
391 pub shf: Option<crate::ported::zsh_h::shfunc>,
392 pub chunk: Option<fusevm::Chunk>,
393 pub source: Option<String>,
394 pub line_base: Option<i64>,
395 pub def_file: Option<Option<String>>,
396}
397
398impl FuncSnapshot {
399 /// True when `name` had no definition at snapshot time — restoring
400 /// such a snapshot deletes the function everywhere.
401 pub fn is_empty(&self) -> bool {
402 self.shf.is_none() && self.chunk.is_none() && self.source.is_none()
403 }
404}
405
406/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
407/// Capture every store that holds `name`'s definition. See
408/// [`FuncSnapshot`].
409pub fn snapshot_function(name: &str) -> FuncSnapshot {
410 let shf = crate::ported::hashtable::shfunctab_lock()
411 .read()
412 .ok()
413 .and_then(|t| t.get(name).cloned());
414 let (chunk, source, line_base, def_file) = try_with_executor(|exec| {
415 (
416 exec.functions_compiled.get(name).cloned(),
417 exec.function_source.get(name).cloned(),
418 exec.function_line_base.get(name).copied(),
419 exec.function_def_file.get(name).cloned(),
420 )
421 })
422 .unwrap_or((None, None, None, None));
423 FuncSnapshot {
424 shf,
425 chunk,
426 source,
427 line_base,
428 def_file,
429 }
430}
431
432/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
433/// Put a [`FuncSnapshot`] back under `name`. An empty snapshot (the
434/// function did not exist when it was taken) removes it from every
435/// store, mirroring C's `removehashnode(shfunctab, …)`.
436pub fn restore_function(name: &str, snap: FuncSnapshot) {
437 if let Ok(mut t) = crate::ported::hashtable::shfunctab_lock().write() {
438 match snap.shf {
439 Some(shf) => {
440 t.add(shf);
441 }
442 None => {
443 t.remove(name);
444 }
445 }
446 }
447 let _ = try_with_executor(|exec| {
448 match snap.chunk {
449 Some(c) => {
450 exec.functions_compiled.insert(name.to_string(), c);
451 }
452 None => {
453 exec.functions_compiled.remove(name);
454 }
455 }
456 match snap.source {
457 Some(s) => {
458 exec.function_source.insert(name.to_string(), s);
459 }
460 None => {
461 exec.function_source.remove(name);
462 }
463 }
464 match snap.line_base {
465 Some(n) => {
466 exec.function_line_base.insert(name.to_string(), n);
467 }
468 None => {
469 exec.function_line_base.remove(name);
470 }
471 }
472 match snap.def_file {
473 Some(f) => {
474 exec.function_def_file.insert(name.to_string(), f);
475 }
476 None => {
477 exec.function_def_file.remove(name);
478 }
479 }
480 });
481}
482
483pub fn cmdsubst_outer_stdout() -> Option<i32> {
484 CMDSUBST_OUTER_FDS.with(|s| s.borrow().last().map(|(o, _)| *o))
485}
486
487thread_local! {
488 /// The pipeline fds a stage still has to install onto 0/1, as
489 /// `(input, output)` with -1 meaning "leave this fd alone".
490 ///
491 /// c:Src/exec.c:3720-3724 — the pipe's read/write ends are the
492 /// FIRST entries of the command's multio table:
493 /// /* Add pipeline input/output to mnodes */
494 /// if (input) addfd(forked, save, mfds, 0, input, 0, NULL);
495 /// if (output) addfd(forked, save, mfds, 1, output, 1, NULL);
496 /// and that runs AFTER prefork (c:3304) + globlist (c:3702) have
497 /// expanded the command's argument words. So a `$(...)` inside a
498 /// stage's ARGS reads the shell's original fd 0, not the pipe:
499 /// `print -rl -- c a b | print -r -- "[$(cat)]"` prints `[]`.
500 /// (The stage's own fork at c:3000 happens before the expansion,
501 /// which is why `${x::=v}` in a non-last stage doesn't survive —
502 /// but the fds are still installed after it.)
503 ///
504 /// zshrs's [`BUILTIN_RUN_PIPELINE`] forks per stage, so it parks
505 /// the stage's fds here instead of dup2'ing them itself, and the
506 /// compiled stage chunk installs them via
507 /// [`BUILTIN_PIPE_FDS_INSTALL`] at the C-faithful point: after the
508 /// arg-word ops, before the redirect scope
509 /// (compile_zsh.rs::emit_stage_fds_install). A compound stage
510 /// (`{ … }`, `( … )`, a function) installs at chunk entry — its
511 /// body legitimately reads the pipe.
512 static PENDING_STAGE_FDS: std::cell::Cell<(i32, i32)> = const { std::cell::Cell::new((-1, -1)) };
513}
514
515/// Park the current stage's `(input, output)` pipe fds for the stage
516/// chunk's `BUILTIN_PIPE_FDS_INSTALL` to pick up. Returns the previous
517/// value so a nested pipeline (`print -- "$(a | b)" | c`) can restore
518/// the outer stage's still-uninstalled fds when it finishes.
519fn stage_fds_park(input: i32, output: i32) -> (i32, i32) {
520 PENDING_STAGE_FDS.with(|c| c.replace((input, output)))
521}
522
523/// Take (and clear) the parked stage fds.
524fn stage_fds_take() -> (i32, i32) {
525 PENDING_STAGE_FDS.with(|c| c.replace((-1, -1)))
526}
527
528// Thread-local pointer to the current ShellExecutor.
529// Set before VM execution, cleared after. Used by builtin handlers.
530thread_local! {
531 static CURRENT_EXECUTOR: RefCell<Option<*mut ShellExecutor>> = const { RefCell::new(None) };
532 /// The installed session executor, registered by
533 /// `exec::install_session_executor`. Lets [`with_session_context`]
534 /// establish a VM execution context for STARTUP work that runs
535 /// before the loop's first `execode` enters one (rc-file sourcing in
536 /// `run_init_scripts`, c:1914). Mirrors exec.rs's own
537 /// `SESSION_EXECUTOR`; kept here so the context helper lives next to
538 /// `ExecutorContext`/`CURRENT_EXECUTOR`.
539 static SESSION_EXECUTOR_PTR: std::cell::Cell<Option<*mut ShellExecutor>> = const { std::cell::Cell::new(None) };
540 /// GLOB_ASSIGN eligibility carrier. Set true by BUILTIN_MARK_GLOB_ELIGIBLE
541 /// (emitted by the compiler ONLY when a scalar-assignment RHS carries an
542 /// UNQUOTED glob token — Star/Quest/Inbrack), read+cleared by the next
543 /// BUILTIN_SET_VAR. Matches C zsh's `GLOB_ASSIGN` (Src/exec.c:2554): only
544 /// a literal unquoted glob pattern in the wordcode is globbed; values from
545 /// `$param` / `$(cmd)` / quoted strings are NOT (verified against zsh).
546 /// The runtime SET_VAR value arrives untokenized (the compiler DQ-wraps to
547 /// suppress compile-time globbing), so quoting can no longer be recovered
548 /// from the value bytes — this flag carries the compile-time decision.
549 static SET_VAR_GLOB_ELIGIBLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
550 /// c:Src/exec.c:3088-3096 — the next builtin dispatch comes from a
551 /// WC_TYPESET command, which looks the builtin up with `getnode2`
552 /// (DISABLED ignored). Set by BUILTIN_TYPESET_RESWD, consumed by
553 /// dispatch_builtin.
554 ///
555 /// !!! WARNING: RUST-ONLY CARRIER !!! C knows `type == WC_TYPESET` in the
556 /// same function that looks the builtin up; here the compile-time fact has
557 /// to cross from the marker op to the dispatch op.
558 static TYPESET_RESWD_DISPATCH: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
559 /// c:Src/exec.c:4147-4154 — `addvars(state, varspc, flags); if (errflag)
560 /// { …; lastval = 1; fixfds(save); goto done; }`: an expansion error in
561 /// the prefix assignments of `X=… cmd` skips `cmd`. Set by
562 /// BUILTIN_SEAL_INLINE_ENV (the point right after those assignments),
563 /// consumed by the dispatch that would run the command, and cleared by
564 /// BUILTIN_END_INLINE_ENV so it never outlives the command.
565 ///
566 /// !!! WARNING: RUST-ONLY CARRIER !!! C tests errflag inline between
567 /// addvars and the dispatch in one function; here those are separate
568 /// VM ops, and errflag alone cannot tell this skip from an error raised
569 /// by the command words, which C handles at c:3760 instead.
570 static PREFIX_ASSIGN_FAILED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
571 /// The command word of the `X=… cmd` whose prefix assignments are
572 /// expanding right now: Some between BUILTIN_PREFIX_ASSIGNS_START and
573 /// BUILTIN_SEAL_INLINE_ENV, "" when the word is an expansion that only
574 /// BUILTIN_EXEC_DYNAMIC resolves.
575 ///
576 /// !!! WARNING: RUST-ONLY CARRIER !!! C decides whether to fork
577 /// (c:Src/exec.c:3719-3730) before addvars runs, so a `${name?msg}` in
578 /// the assignments already knows which process it is in
579 /// (c:Src/subst.c:3371 `mypid == getpid()`). zshrs expands them in the
580 /// shell before the dispatch op resolves the command.
581 static PREFIX_ASSIGN_CMD: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
582 /// A `${name?msg}` in those assignments owes c:Src/subst.c:3375's
583 /// `zexit(1)` unless the command turns out to be forked; see
584 /// `prefix_assign_defer_exit`.
585 static PREFIX_ASSIGN_EXIT_PENDING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
586 /// Set by BUILTIN_EXEC_DYNAMIC_REDIRS, taken by the next
587 /// BUILTIN_EXEC_DYNAMIC: that command had redirections.
588 static EXEC_DYNAMIC_REDIRS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
589 /// Facts about the next command that only its EXTERNAL spawn acts on; an
590 /// external spawn consumes them, any other command clears them.
591 /// EXEC_CARRIER_DASH — c:Src/exec.c:772-776, the `-` precommand
592 /// (BINF_DASH), set by BUILTIN_EXEC_DASH: argv[0] is prefixed with `-`.
593 /// EXEC_CARRIER_FORKED — c:Src/exec.c:3063/4369, set by
594 /// BUILTIN_EXEC_FORKED_SIMPLE: the child forked for this command execs it.
595 ///
596 /// !!! WARNING: RUST-ONLY CARRIER !!! C accumulates BINF_DASH into
597 /// `cflags` in the same function that calls `execute()`; here the
598 /// compile-time fact crosses from the marker op to the spawn.
599 static EXEC_DASH: std::cell::Cell<u8> = const { std::cell::Cell::new(0) };
600 /// c:Src/exec.c:3536-3538 — the job text of the simple command about to
601 /// dispatch (BUILTIN_JOB_TEXT). The foreground external wait takes it;
602 /// a builtin or function dispatch drops it.
603 ///
604 /// !!! WARNING: RUST-ONLY CARRIER !!! C computes `text` in execcmd_exec,
605 /// the function that also forks and waits.
606 static JOB_TEXT: std::cell::RefCell<Option<fusevm::Value>> = const { std::cell::RefCell::new(None) };
607 /// Counts sublists (bumped by BUILTIN_STMT_PROLOGUE_FAST). Together with
608 /// REDIR_SCOPE_OPENED it tells a builtin whether the redirect scope on
609 /// top of the stack belongs to its own command.
610 ///
611 /// !!! WARNING: RUST-ONLY CARRIER !!! C's `execcmd_exec` holds the
612 /// command's `save[]` array locally and tests `save[1] == -2`
613 /// (c:Src/exec.c:4300); zshrs keeps one shared scope stack for simple
614 /// and compound commands alike.
615 static SUBLIST_SERIAL: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
616 /// `(SUBLIST_SERIAL, stack depth)` recorded when the newest redirect
617 /// scope opened. A simple command opens its scope right before its own
618 /// dispatch, inside its sublist; a compound command's body starts a new
619 /// sublist first.
620 static REDIR_SCOPE_OPENED: std::cell::Cell<(u64, usize)> = const { std::cell::Cell::new((0, 0)) };
621 /// Per open redirect scope (index = depth - 1): whether ERRFLAG_ERROR
622 /// was clear when the scope opened. c:Src/exec.c:3637-3643 aborts on an
623 /// argument-expansion errflag BEFORE the redirect loop, so an errflag
624 /// first seen while a redirection is applied came from that
625 /// redirection's own target (xpandredir). See redir_target_expansion_failed.
626 static REDIR_SCOPE_ERRFLAG_CLEAN: std::cell::RefCell<Vec<bool>> =
627 const { std::cell::RefCell::new(Vec::new()) };
628 /// c:Src/exec.c:3775-3777 — `if (input) addfd(forked, save, mfds, 0,
629 /// input, 0, NULL);` seeds `mfds[0]` with the pipeline input BEFORE the
630 /// stage command's redirect list is walked, so an input redirection
631 /// joins the pipe as a second multio member (`cat o1 | cat <o2` reads
632 /// both). BUILTIN_PIPE_FDS_INSTALL records the sublist serial it
633 /// installed the pipe in; the redirect scope that opens in the same
634 /// sublist (the stage command's own) turns it into PIPE_INPUT_SCOPE.
635 ///
636 /// !!! WARNING: RUST-ONLY CARRIER !!! C does both steps inside one
637 /// execcmd_exec call; zshrs splits them across two VM ops.
638 static PIPE_INPUT_INSTALLED: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
639 /// Redirect-stack depth of the scope whose fd-0 redirection must keep
640 /// the pipeline input as its first multio member; consumed by the first
641 /// such redirection.
642 static PIPE_INPUT_SCOPE: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
643}
644
645/// Register the session executor pointer (called from
646/// `install_session_executor`). See [`with_session_context`].
647pub fn register_session_executor(exec: &mut ShellExecutor) {
648 SESSION_EXECUTOR_PTR.with(|c| c.set(Some(exec as *mut ShellExecutor)));
649}
650
651/// Run `f` with the registered session executor established as
652/// `CURRENT_EXECUTOR`, so code reaching the live executor via
653/// `try_with_executor` works even when no per-command `execode` context
654/// is active yet.
655///
656/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
657/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
658/// first `execode`. Without an active context those sourced bodies
659/// `try_with_executor` → `None` → no-op, so the shell silently ignored
660/// the user's dotfiles. The scope is entered once around the startup
661/// sourcing window and dropped before the loop begins — deliberately NOT
662/// Run `f` with the registered session executor established as
663/// `CURRENT_EXECUTOR`, so code reaching the live executor via
664/// `try_with_executor` works even when no per-command `execode` context
665/// is active yet.
666///
667/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
668/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
669/// first `execode`. Without an active context those sourced bodies
670/// `try_with_executor` → `None` → no-op, so the shell silently ignored
671/// the user's dotfiles. The scope is entered once around the startup
672/// sourcing window and dropped before the loop begins.
673///
674/// `execute_script_zsh_pipeline` now carries the same session-executor
675/// rung of its own (exec.rs), since `execstring` callers that fire
676/// BETWEEN commands — `checksched`, `dotrap` — have no active context
677/// either and were silently dropping their command. That rung is reached
678/// only when `CURRENT_EXECUTOR` is unset, so a nested command
679/// substitution (which always runs inside a chunk, and therefore inside
680/// an `ExecutorContext`) takes the active-executor path as before and
681/// never re-enters the session executor.
682pub fn with_session_context<R>(f: impl FnOnce() -> R) -> R {
683 let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
684 match ptr {
685 // SAFETY: set by install_session_executor to an executor that
686 // outlives the single-threaded interactive session.
687 Some(ptr) => {
688 let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
689 f()
690 }
691 None => f(),
692 }
693}
694
695/// Merge finished background-compinit results into shell state, callable
696/// from any site (active VM context first, session executor otherwise —
697/// the ZLE completion path runs OUTSIDE a VM frame, where
698/// `try_with_executor` alone is None and $_comps stayed empty forever).
699pub fn drain_compinit_bg_hook() {
700 if try_with_executor(|exec| exec.drain_compinit_bg()).is_some() {
701 return;
702 }
703 let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
704 if let Some(ptr) = ptr {
705 // SAFETY: per with_session_context.
706 let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
707 unsafe { (*ptr).drain_compinit_bg() }
708 }
709}
710
711/// Run the body of a PLAIN sourced file through the per-command loop —
712/// `ShellExecutor::execute_script_per_command`, the port of the
713/// `switch (loop(0, 0))` arm of C's `source()` (c:Src/init.c:1625-1641).
714///
715/// `bin_dot` (`src/ported/builtin.rs`, C's `Src/builtin.c:6118`
716/// `ret = source(enam = buf);`) is the only caller. It lives HERE rather
717/// than beside the other executor accessors in `src/ported/exec.rs`
718/// because everything under `src/ported/` is a line-by-line port and
719/// `build.rs` rejects a `fn` there with no C counterpart — this bridge
720/// shim has none, since C's `source()` reaches its interpreter through
721/// plain globals where Rust needs an explicit executor handle.
722///
723/// Executor ladder, in the order the sibling wrappers in `exec.rs` use:
724/// the innermost ACTIVE executor when one is in scope — inside
725/// `$(source f)` that is the sub-VM that owns the capture, which is why
726/// the substitution still collects the file's output — and the installed
727/// session executor otherwise, for a `source` that runs before the main
728/// loop's first `execode` has entered a context. `Ok(0)` with neither,
729/// matching `exec::execute_script`'s no-executor return.
730pub fn source_file_per_command(src: &str) -> Result<i32, String> {
731 if let Some(r) = try_with_executor(|exec| exec.execute_script_per_command(src)) {
732 return r;
733 }
734 let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
735 match ptr {
736 // SAFETY: per with_session_context.
737 Some(ptr) => {
738 let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
739 unsafe { (*ptr).execute_script_per_command(src) }
740 }
741 None => Ok(0),
742 }
743}
744
745/// Run the deparsed text of an ALREADY-COMPILED `.zwc` program on the live
746/// executor — the compiled arm of `source()`, `execode(prog, 1, 0,
747/// "filecode")` (c:Src/init.c:1621).
748///
749/// Same executor resolution as [`source_file_per_command`]: the live
750/// executor when one is in scope, the installed session executor otherwise,
751/// `Ok(0)` with neither. See
752/// [`crate::vm_helper::ShellExecutor::execute_zwc_program`] for why the plain
753/// script entry point is wrong here.
754pub fn execute_zwc_program(src: &str) -> Result<i32, String> {
755 if let Some(r) = try_with_executor(|exec| exec.execute_zwc_program(src)) {
756 return r;
757 }
758 let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
759 match ptr {
760 // SAFETY: per with_session_context.
761 Some(ptr) => {
762 let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
763 unsafe { (*ptr).execute_zwc_program(src) }
764 }
765 None => Ok(0),
766 }
767}
768
769/// RAII guard that sets/clears the thread-local executor pointer.
770///
771/// Idempotent: calling `enter` when a context is already active is a no-op
772/// for the entry side, and the guard's drop only clears the thread-local if
773/// *this* call was the one that set it. Nested `execute_command` invocations
774/// (e.g. from inside a builtin handler) reuse the outer pointer instead of
775/// stomping it.
776pub(crate) struct ExecutorContext {
777 we_set_it: bool,
778}
779
780impl ExecutorContext {
781 pub(crate) fn enter(executor: &mut ShellExecutor) -> Self {
782 let we_set_it = CURRENT_EXECUTOR.with(|cell| {
783 let mut slot = cell.borrow_mut();
784 if slot.is_some() {
785 false
786 } else {
787 *slot = Some(executor as *mut ShellExecutor);
788 true
789 }
790 });
791 ExecutorContext { we_set_it }
792 }
793}
794
795impl Drop for ExecutorContext {
796 fn drop(&mut self) {
797 if self.we_set_it {
798 CURRENT_EXECUTOR.with(|cell| {
799 *cell.borrow_mut() = None;
800 });
801 }
802 }
803}
804
805/// Access the current executor from a builtin handler.
806/// # Safety
807/// Only call this from within a VM execution context (after ExecutorContext::enter).
808#[inline]
809pub(crate) fn with_executor<F, R>(f: F) -> R
810where
811 F: FnOnce(&mut ShellExecutor) -> R,
812{
813 CURRENT_EXECUTOR.with(|cell| {
814 let ptr = cell
815 .borrow()
816 .expect("with_executor called outside VM context");
817 // SAFETY: The pointer is valid for the duration of VM execution,
818 // and we're single-threaded within the executor.
819 let executor = unsafe { &mut *ptr };
820 f(executor)
821 })
822}
823
824/// Non-panicking variant of [`with_executor`]: runs `f` against the
825/// current executor and returns `Some(result)`, or `None` when no
826/// executor is in scope (`CURRENT_EXECUTOR` unset — e.g. unit tests /
827/// compsys contexts with no fusevm bridge running).
828///
829/// This is the primitive the `crate::ported::exec` accessor wrappers
830/// (array/assoc/dispatch_function_call/execute_script/...) use to
831/// reach the live executor while preserving the exact "no executor →
832/// fall back to the direct param table / default value" semantics that
833/// the deleted `exec_hooks` OnceLock layer encoded via its
834/// "is-the-hook-installed?" check. `CURRENT_EXECUTOR` being set is the
835/// faithful equivalent of "the bridge installed the hooks".
836#[inline]
837pub(crate) fn try_with_executor<F, R>(f: F) -> Option<R>
838where
839 F: FnOnce(&mut ShellExecutor) -> R,
840{
841 CURRENT_EXECUTOR.with(|cell| {
842 let ptr = (*cell.borrow())?;
843 // SAFETY: same contract as with_executor — the pointer is valid
844 // for the duration of VM execution and access is single-threaded.
845 let executor = unsafe { &mut *ptr };
846 Some(f(executor))
847 })
848}
849
850/// Look up a canonical builtin by name in `BUILTINS` and dispatch
851/// via `execbuiltin` (Src/builtin.c:250). NO shadow check — calls the
852/// builtin even if a user function with the same name exists. Used by
853/// the `builtin foo` prefix opcode (which explicitly bypasses function
854/// lookup per zsh semantics) and by internal call sites where shadowing
855/// is unwanted. For zsh's normal name-resolution order (function shadows
856/// builtin), use `dispatch_builtin` instead.
857/// Shell-identifier prefix for diagnostic lines. Reads the canonical
858/// scriptname (`zsh` in `--zsh` parity mode, `zshrs` otherwise) so a
859/// single helper replaces hardcoded `"zshrs:"` literals across the
860/// file's eprintln paths.
861fn shname() -> String {
862 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string())
863}
864
865/// c:Src/subst.c:505-507 + Src/exec.c:3378-3380 — per-command
866/// CSH_NULL_GLOB outcome check. During this command's word expansion
867/// `expand_glob` accumulated `badcshglob |= 1` per failed glob and
868/// `|= 2` per successful one (Src/glob.c:1871-1875). Exactly 1 —
869/// failures and no successes — is the csh-style error: `no match`,
870/// command skipped, status 1. Any other value (0 = no globs, 2/3 =
871/// at least one matched) is silent. Always resets the counter for
872/// the next command (C resets at prefork entry, subst.rs:1307).
873/// Returns true when the error fired; callers mirror their
874/// glob_failed handling (builtins leave ERRFLAG_ERROR set so the
875/// script aborts, externals clear it so the next sublist runs —
876/// verified against zsh 5.9.1).
877/// Restore the user's GLOB_SUBST after a `${~spec}` carrier flip
878/// (see subst::TILDE_GLOBSUBST_CARRIER). Runs at the same
879/// command-dispatch boundaries that consume glob_failed /
880/// badcshglob — by then every glob op of the current word pipeline
881/// has read the carrier.
882pub(crate) fn consume_tilde_globsubst_carrier() {
883 // The default-word report belongs to the word pipeline just finished too.
884 crate::ported::subst::DEFAULT_WORD_GLOBSUBST_OFF.with(|c| c.set(false));
885 crate::ported::subst::TILDE_GLOBSUBST_CARRIER.with(|c| {
886 if let Some(saved) = c.take() {
887 crate::ported::options::opt_state_set("globsubst", saved);
888 }
889 });
890}
891
892/// Pop `argc` stack slots for a whole-array assignment: the LAST popped
893/// (deepest pushed) is the param name, the rest are the values in stack
894/// order, with any `Value::Array` flattened to its elements. Mirrors the
895/// Flatten an array-assignment RHS value into scalar strings, descending
896/// through nested `Value::Array`s. zsh arrays are always flat, so recursion
897/// only collapses the wrapper layers the compiler introduces — in particular
898/// the single `Value::Array` built by `Op::MakeArray` for `arr=(...)` literals
899/// (used to dodge `CallBuiltin`'s u8 argc cap), whose own elements may
900/// themselves be arrays from an unquoted `$other_array` expansion. A
901/// one-level flatten would stringify those inner arrays into a single element.
902fn flatten_array_value(v: Value, out: &mut Vec<String>) {
903 match v {
904 Value::Array(items) => {
905 for it in items.iter() {
906 flatten_array_value(it.clone(), out);
907 }
908 }
909 other => out.push(other.to_str()),
910 }
911}
912
913/// c:Src/exec.c:2595-2598, 2605-2608, 2620-2623 (addvars) — `if (errflag)
914/// { state->pc = opc; return; }` after ecgetlist, prefork and globlist. An
915/// array assignment whose element expansion failed (`x=( *(zz) )`, a NOMATCH)
916/// never reaches assignaparam, so the parameter keeps its old type and value.
917/// Shared gate of BUILTIN_SET_ARRAY / BUILTIN_APPEND_ARRAY.
918fn array_assignment_expansion_failed() -> bool {
919 (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
920 & crate::ported::zsh_h::ERRFLAG_ERROR)
921 != 0
922 || with_executor(|exec| exec.current_command_glob_failed.get())
923}
924
925/// pop/flatten prologue of BUILTIN_SET_ARRAY / BUILTIN_APPEND_ARRAY.
926fn pop_array_args_with_name(vm: &mut fusevm::VM, argc: u8) -> (String, Vec<String>) {
927 let n = argc as usize;
928 let mut popped: Vec<Value> = Vec::with_capacity(n);
929 for _ in 0..n {
930 popped.push(vm.pop());
931 }
932 popped.reverse();
933 let name = popped.pop().map(|v| v.to_str()).unwrap_or_default();
934 let mut values: Vec<String> = Vec::new();
935 for v in popped {
936 flatten_array_value(v, &mut values);
937 }
938 (name, values)
939}
940
941/// c:Src/exec.c:3785-3796 — the redirect loop runs `xpandredir(fn, redir)`
942/// on each target and then `if (errflag) { closemnodes(mfds); fixfds(save);
943/// execerr(); }`: an expansion error in a target (`> $((1/0))`,
944/// `> ${u?msg}`, a NULL_GLOB `> nx(N)`) is a failed redirection. zshrs
945/// expands the target before the redirect op runs, so the op sees the
946/// errflag: when it was clear as this command's redirect scope opened, mark
947/// the redirection failed and skip it. The redirect_failed gates then treat
948/// it like any other failure — for an external command, which C forks before
949/// this loop (c:3719), only the child dies (call_function).
950/// !!! RUST-ONLY HELPER: C tests errflag inline in the redirect loop.
951fn redir_target_expansion_failed(exec: &mut ShellExecutor) -> bool {
952 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
953 & crate::ported::zsh_h::ERRFLAG_ERROR)
954 == 0
955 {
956 return false;
957 }
958 let depth = exec.redirect_scope_stack.len();
959 let clean_at_open = depth > 0
960 && REDIR_SCOPE_ERRFLAG_CLEAN.with(|v| v.borrow().get(depth - 1).copied().unwrap_or(false));
961 if clean_at_open {
962 exec.redirect_failed = true;
963 }
964 clean_at_open
965}
966
967/// Asked by paramsubst's `${name?msg}` arms before they take
968/// c:Src/subst.c:3375's `zexit(1)`: true when the expansion belongs to the
969/// prefix assignments of `X=… cmd`, whose process is not known yet. The exit
970/// is then owed (PREFIX_ASSIGN_EXIT_PENDING) and `settle_prefix_assign_exit`
971/// pays it once the command word resolves.
972pub(crate) fn prefix_assign_defer_exit() -> bool {
973 let assigning = PREFIX_ASSIGN_CMD.with(|c| c.borrow().is_some());
974 if assigning {
975 PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.set(true));
976 }
977 assigning
978}
979
980/// Pays a deferred `${name?msg}` exit once `name` (the expanded command word,
981/// "" for none) is known. c:Src/exec.c:3719-3730 forks an external command
982/// before c:4358 runs its prefix assignments, so the error ends only that
983/// child (c:3377 `_exit(1)`, c:4359 `if (errflag) _exit(1)`): the shell sees
984/// status 1, which the dispatch's PREFIX_ASSIGN_FAILED arm reports. A shell
985/// function, a builtin or no command at all runs the assignments in the
986/// shell (c:4157), which exits there (c:3375).
987fn settle_prefix_assign_exit(name: &str) {
988 if !PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.replace(false)) {
989 return;
990 }
991 let forked = !name.is_empty()
992 && !with_executor(|exec| exec.function_exists(name))
993 && !crate::ported::builtin::createbuiltintable().contains_key(name);
994 if !forked {
995 crate::ported::builtin::STOPMSG.store(1, std::sync::atomic::Ordering::Relaxed); // c:3374
996 // !!! DASH-FAMILY GATE (dash_mode::fatal_error_status): same as the
997 // paramsubst arms. !!!
998 crate::ported::builtin::zexit(
999 crate::extensions::dash_mode::fatal_error_status().unwrap_or(1),
1000 crate::ported::zsh_h::ZEXIT_NORMAL,
1001 ); // c:3375
1002 }
1003}
1004
1005/// c:Src/exec.c:3719 + 3755-3763 — an external command is forked
1006/// (execcmd_fork) BEFORE `globlist(args, 0)`, so a NOMATCH (or the
1007/// CSH_NULL_GLOB `no match`, c:Src/subst.c:505-507) raised while globbing
1008/// its arguments ends the CHILD with status 1; the shell's errflag stays
1009/// clean and the next command runs (`rm *nomatch; print after` prints the
1010/// error, then `after`). zshrs globs in the shell, so every path that runs
1011/// a name zsh would fork for calls this first: `Some(1)` means skip the
1012/// command, with this command's ERRFLAG_ERROR taken back out.
1013fn external_glob_failure_status() -> Option<i32> {
1014 consume_tilde_globsubst_carrier();
1015 let failed = with_executor(|exec| exec.current_command_glob_failed.replace(false))
1016 || consume_badcshglob();
1017 if !failed {
1018 return None;
1019 }
1020 crate::ported::utils::errflag.fetch_and(
1021 !crate::ported::zsh_h::ERRFLAG_ERROR,
1022 std::sync::atomic::Ordering::Relaxed,
1023 );
1024 with_executor(|exec| exec.set_last_status(1));
1025 Some(1)
1026}
1027
1028fn consume_badcshglob() -> bool {
1029 let v = crate::ported::glob::BADCSHGLOB.swap(0, std::sync::atomic::Ordering::Relaxed);
1030 if v == 1 {
1031 crate::ported::utils::zerr("no match"); // c:Src/subst.c:507
1032 true
1033 } else {
1034 false
1035 }
1036}
1037
1038/// Map a builtin name to the zsh module that owns it, IFF zsh does
1039/// not auto-load that builtin on first use. Used by
1040/// `dispatch_builtin_raw` to gate `--zsh` mode dispatch behind
1041/// `zmodload`, mirroring `zsh -fc <name>` returning 127 for these
1042/// names without an explicit module load.
1043///
1044/// Returns `Some(module_name)` if `name` belongs to a non-auto-load
1045/// module per the per-module `Src/Modules/<x>.c` `bintab[]` plus
1046/// the auto-load flag set at module-build time. `None` for core
1047/// builtins and for auto-loaded module builtins (sched, log, echotc,
1048/// echoti, zformat, zparseopts, zregexparse, zstyle, strftime,
1049/// private, vared, zle, bindkey, comp*) which work without zmodload.
1050fn module_bound_builtin_module(name: &str) -> Option<&'static str> {
1051 match name {
1052 "zftp" => Some("zsh/zftp"),
1053 "zsocket" => Some("zsh/net/socket"),
1054 "ztcp" => Some("zsh/net/tcp"),
1055 "zstat" => Some("zsh/stat"),
1056 "zselect" => Some("zsh/zselect"),
1057 "zpty" => Some("zsh/zpty"),
1058 "zprof" => Some("zsh/zprof"),
1059 "zsystem" | "syserror" => Some("zsh/system"),
1060 "clone" => Some("zsh/clone"),
1061 "zcurses" => Some("zsh/curses"),
1062 "ztie" | "zuntie" | "zgdbmpath" => Some("zsh/db/gdbm"),
1063 "pcre_compile" | "pcre_match" | "pcre_study" => Some("zsh/pcre"),
1064 "example" => Some("zsh/example"),
1065 "cap" | "getcap" | "setcap" => Some("zsh/cap"),
1066 "zgetattr" | "zsetattr" | "zdelattr" | "zlistattr" => Some("zsh/attr"),
1067 // c:Src/Modules/datetime.c — `strftime` is registered via
1068 // partab[] when zsh/datetime loads. Verified by
1069 // `zsh -fc 'strftime -s s %Y 0'` → 127 "command not found".
1070 "strftime" => Some("zsh/datetime"),
1071 _ => None,
1072 }
1073}
1074
1075/// Lookup half of [`try_run_registered_builtin`]: the handler for a
1076/// zshrs-original opcode builtin NAME, or `None`. Split from the run half so
1077/// the `builtin` precommand walk can ask whether the name exists before the
1078/// redirections open (c:Src/exec.c:3489 `builtintab->getnode`) without
1079/// running the command.
1080///
1081/// !!! Keep in sync with the matching `vm.register_builtin(...)` closures in
1082/// `register_builtins`: both must route a name to the same executor method.
1083fn registered_builtin(name: &str) -> Option<fn(&[String]) -> i32> {
1084 let f: fn(&[String]) -> i32 = match name {
1085 "ai" => |argv| with_executor(|e| e.builtin_ai(argv)),
1086 "async" => |argv| with_executor(|e| e.builtin_async(argv)),
1087 "await" => |argv| with_executor(|e| e.builtin_await(argv)),
1088 "barrier" => |argv| with_executor(|e| e.builtin_barrier(argv)),
1089 "peach" => |argv| with_executor(|e| e.builtin_peach(argv)),
1090 "pmap" => |argv| with_executor(|e| e.builtin_pmap(argv)),
1091 "pgrep" => |argv| with_executor(|e| e.builtin_pgrep(argv)),
1092 "intercept" => |argv| with_executor(|e| e.builtin_intercept(argv)),
1093 "intercept_proceed" => |argv| with_executor(|e| e.builtin_intercept_proceed(argv)),
1094 "doctor" => |argv| with_executor(|e| e.builtin_doctor(argv)),
1095 "dbview" => |argv| with_executor(|e| e.builtin_dbview(argv)),
1096 "profile" => |argv| with_executor(|e| e.builtin_profile(argv)),
1097 "provenance" => |argv| with_executor(|e| e.builtin_provenance(argv)),
1098 "zbanner" => |argv| with_executor(|e| e.builtin_zbanner(argv)),
1099 "caller" => |argv| with_executor(|e| e.builtin_caller(argv)),
1100 "help" => |argv| with_executor(|e| e.builtin_help(argv)),
1101 "cdreplay" => |argv| with_executor(|e| e.builtin_cdreplay(argv)),
1102 "zsleep" => |argv| crate::extensions::ext_builtins::zsleep(argv),
1103 _ => return None,
1104 };
1105 Some(f)
1106}
1107
1108/// Whether NAME is a zshrs-original opcode builtin or an enabled
1109/// host-registered native command: the names [`try_run_registered_builtin`]
1110/// would run.
1111pub(crate) fn is_registered_builtin(name: &str) -> bool {
1112 registered_builtin(name).is_some() || crate::native_cmds::is_enabled(name)
1113}
1114
1115/// Whether NAME is an autoload stub that has not been loaded yet: an enabled
1116/// shfunctab entry with PM_UNDEFINED set (c:Src/zsh.h:1895). Loading clears
1117/// the flag (c:Src/exec.c:5790, c:5815); a function defined in the shell
1118/// never carries it. Gates the native zmv/zcp/zln/zcalc ports, which stand
1119/// in for the fpath load of the stock autoload functions only.
1120fn autoload_stub_pending(name: &str) -> bool {
1121 crate::ported::hashtable::shfunctab_lock()
1122 .read()
1123 .ok()
1124 .and_then(|t| t.get(name).map(|f| (f.node.flags as u32 & crate::ported::zsh_h::PM_UNDEFINED as u32) != 0))
1125 .unwrap_or(false)
1126}
1127
1128/// Whether a shell function named NAME shadows a builtin of that name at
1129/// command dispatch (c:Src/exec.c:3485-3489: `shfunctab->getnode` before
1130/// `builtintab->getnode`). `getnode` skips DISABLED nodes (c:Src/hashtable.c:239), so a function
1131/// switched off with `disable -f NAME` does not shadow (Bug #221 in
1132/// docs/BUGS.md). `functions_compiled` keeps the body regardless of the
1133/// DISABLED flag, hence the shfunctab probe first.
1134fn user_fn_shadows_builtin(name: &str) -> bool {
1135 let disabled = crate::ported::hashtable::shfunctab_lock()
1136 .read()
1137 .ok()
1138 .and_then(|t| {
1139 let entry = t.get_including_disabled(name)?;
1140 Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
1141 })
1142 .unwrap_or(false);
1143 !disabled && with_executor(|exec| exec.functions_compiled.contains_key(name))
1144}
1145
1146/// The builtin table as the `builtin` precommand modifier consults it
1147/// (c:Src/exec.c:3489 `builtintab->getnode(builtintab, cmdarg)`): the ported
1148/// table minus DISABLED nodes (`getnode` skips them, so `disable typeset;
1149/// builtin typeset` is "no such builtin"), plus the zshrs builtins that live
1150/// outside it: the daemon `z*` family and the opcode builtins above.
1151pub(crate) fn builtin_prefix_finds(name: &str) -> bool {
1152 if crate::daemon::builtins::is_zshrs_builtin(name) {
1153 return true;
1154 }
1155 let disabled = crate::ported::builtin::BUILTINS_DISABLED
1156 .lock()
1157 .map(|s| s.contains(name))
1158 .unwrap_or(false);
1159 (crate::ported::builtin::createbuiltintable().contains_key(name) && !disabled)
1160 || is_registered_builtin(name)
1161}
1162
1163/// Run a daemon `z*` builtin (zcache, zd, zjob, …) by name. They are
1164/// dispatched by name instead of living in builtintab, so each resolver that
1165/// can land on a builtin name — literal head, `$var` head, `builtin NAME` —
1166/// routes them through here. `None` when `name` is not one of them.
1167fn run_daemon_builtin(name: &str, rest: &[String]) -> Option<i32> {
1168 if !crate::daemon::builtins::is_zshrs_builtin(name) {
1169 return None;
1170 }
1171 let argv: Vec<String> = std::iter::once(name.to_string())
1172 .chain(rest.iter().cloned())
1173 .collect();
1174 Some(crate::daemon::builtins::try_dispatch(name, &argv).unwrap_or(1))
1175}
1176
1177/// Dispatch a zshrs-ORIGINAL builtin by NAME, argv-style. These are
1178/// registered as fusevm opcodes in [`register_builtins`] (async, doctor,
1179/// peach, …), so a *literal* name compiles to `CallBuiltin` and runs. But
1180/// they are absent from the static `BUILTINS` port table and the merged
1181/// `builtintab`, so when the command name is resolved only at run time —
1182/// `$var` indirection, `builtin NAME` — the ported command-resolution path
1183/// never finds them and reports "command not found" / "no such builtin",
1184/// even though `whence` (correctly) calls them builtins. The
1185/// `register_builtins` closures use the VM only to pop args and then call an
1186/// executor method, so the identical dispatch works here from any parent-side
1187/// resolver that has an executor — no VM re-entry (which would alias the
1188/// running `&mut VM`). Returns `None` for a name that is not one of them, so
1189/// the caller falls through to external lookup.
1190///
1191/// !!! Keep in sync with the matching `vm.register_builtin(...)` closures in
1192/// `register_builtins`: both must route a name to the same executor method.
1193pub(crate) fn try_run_registered_builtin(name: &str, argv: &[String]) -> Option<i32> {
1194 if let Some(run) = registered_builtin(name) {
1195 return Some(run(argv));
1196 }
1197 // Host-registered native commands (`extensions/native_cmds.rs`): the
1198 // fat binary's sibling runtimes — `git` (zvcs), `arb` (arblang),
1199 // `stryke` (strykelang) in the zshrs-native build. Unknown here in the
1200 // thin shell, where the table is empty and this falls through to
1201 // `None` exactly as before.
1202 //
1203 // Reached from the two places that ask "is this a builtin?": the
1204 // pre-PATH arm of the ZshrsHost dispatch (after functions and after
1205 // builtintab, so a user `git()` still shadows it) and the forced
1206 // `builtin NAME` precommand. `command git` consults neither, so the
1207 // escape hatch to the `git` on PATH is untouched.
1208 //
1209 // The registry's contract is full argv — argv[0] is the command name
1210 // as invoked, which zvcs needs for its `git-<verb>` dashed form and
1211 // for its `zvcs: <command>: <reason>` diagnostics — while every arm
1212 // above takes the operands alone, so the name is spliced back on here.
1213 if !crate::native_cmds::is_enabled(name) {
1214 return None;
1215 }
1216 let full: Vec<String> = std::iter::once(name.to_string())
1217 .chain(argv.iter().cloned())
1218 .collect();
1219 crate::native_cmds::dispatch(name, &full)
1220}
1221
1222/// `command -v` / `command -V`: c:Src/exec.c:3149-3157 — `pushnode(preargs,
1223/// "command"); hn = &commandbn.node; is_builtin = 1;`, i.e. bin_whence with
1224/// the BIN_COMMAND funcid. `post` is the flag words then the names; `dash_p`
1225/// says a `-p` was among the flags.
1226fn command_whence(post: &[String], dash_p: bool) -> i32 {
1227 let mut ops = options {
1228 ind: [0u8; MAX_OPS],
1229 args: Vec::new(),
1230 argscount: 0,
1231 argsalloc: 0,
1232 };
1233 let mut name_pos = 0usize;
1234 let mut flag_byte = b'v';
1235 for (i, a) in post.iter().enumerate() {
1236 if a.starts_with('-') && a.len() >= 2 {
1237 let body = &a.as_bytes()[1..];
1238 if body.contains(&b'V') {
1239 flag_byte = b'V';
1240 }
1241 name_pos = i + 1;
1242 } else {
1243 name_pos = i;
1244 break;
1245 }
1246 }
1247 ops.ind[flag_byte as usize] = 1;
1248 // c:Src/builtin.c:4157 + c:4165-4167 — bin_whence reads `-p` off
1249 // `ops` twice: to prefer a builtin over an external for
1250 // `command -p[vV]`, and as `findcmd`'s `default_path` argument.
1251 if dash_p {
1252 ops.ind[b'p' as usize] = 1;
1253 }
1254 let whence_args: Vec<String> = post[name_pos..].to_vec();
1255 crate::ported::builtin::bin_whence(
1256 "command",
1257 &whence_args,
1258 &ops,
1259 crate::ported::hashtable_h::BIN_COMMAND,
1260 )
1261}
1262
1263/// Whether the redirect scope on top of the stack was opened by the
1264/// command now dispatching — its OWN redirections — rather than by an
1265/// enclosing compound command. C asks the same of the command's local
1266/// `redir` list / `save[]` array (c:Src/exec.c:3651, c:4300).
1267/// !!! RUST-ONLY — no C counterpart.
1268pub(crate) fn own_redirect_scope(exec: &ShellExecutor) -> bool {
1269 let depth = exec.redirect_scope_stack.len();
1270 depth > 0 && REDIR_SCOPE_OPENED.with(|c| c.get()) == (SUBLIST_SERIAL.with(|c| c.get()), depth)
1271}
1272
1273pub(crate) fn dispatch_builtin_raw(name: &str, args: Vec<String>) -> i32 {
1274 // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
1275 // Native p10k engine intercept (src/extensions/p10k): sourcing
1276 // powerlevel10k.zsh-theme activates the Rust segment engine
1277 // instead of executing the ~13k-line zsh theme. The user's
1278 // `.p10k.zsh` CONFIG is NOT intercepted — it sources normally so
1279 // its POWERLEVEL9K_* typesets land in the paramtab, which the
1280 // engine reads live at every render. Placed here (the chokepoint
1281 // every builtin route funnels through) so `source`, `.`, and
1282 // `builtin source` all hit it.
1283 if matches!(name, "source" | ".") {
1284 if let Some(status) = crate::p10k::maybe_intercept_theme_source(&args) {
1285 // Register a `p10k` stub function so `${+functions[p10k]}`
1286 // guards in .zshrc templates stay truthy. The body forwards
1287 // to the bridge-intercepted `zshrs-p10k-api` name so
1288 // `p10k segment` (custom-segment protocol) and the other
1289 // API subcommands reach the native engine (p10k_api).
1290 try_with_executor(|exec| {
1291 let _ = exec.execute_script("function p10k() { zshrs-p10k-api \"$@\" }");
1292 });
1293 return status;
1294 }
1295 }
1296 // Native p10k API dispatch — the `p10k` stub function forwards
1297 // here (see the theme intercept above). Must run before the
1298 // generic builtintab lookup: the name is not a real builtin.
1299 if let Some(status) = crate::p10k::maybe_intercept_command(name, &args) {
1300 return status;
1301 }
1302 // c:Src/exec.c:3674-3682 — a builtin runs in the current shell.
1303 crate::exec_jobs::mark_thisjob_cursh(true);
1304 // c:Src/exec.c:2700-2717 — `private` is an autoloaded builtin in
1305 // zsh (autofeature b:private of zsh/param/private): first use
1306 // runs ensurefeature → require_module → load_module → boot_,
1307 // marking the module MOD_INIT_B (what `zmodload -e` reads) and
1308 // installing the wrap_private FuncWrap (param_private.c:712).
1309 // doshfunc gates the wrapper dispatch on that load state, so
1310 // this require_module is what activates private scoping. The
1311 // raw dispatcher is the chokepoint every builtin route funnels
1312 // through; require_module is idempotent after the first call
1313 // (needs_load checks MOD_INIT_B).
1314 if name == "private" {
1315 if let Ok(mut tab) = crate::ported::module::MODULESTAB.lock() {
1316 let _ = crate::ported::module::require_module(
1317 &mut tab,
1318 "zsh/param/private",
1319 None,
1320 0,
1321 false,
1322 );
1323 // c:2710 ensurefeature
1324 }
1325 }
1326 // c:Src/Modules/param_private.c:682-685 setup_ — loading
1327 // zsh/param/private REPLACES the `local` builtintab node's
1328 // handlerfunc + optstr with bin_private's ("Even more horrible
1329 // hack"), so once the module is loaded `local` IS bin_private: it
1330 // accepts the -P/-Pa private-scope flags, and without -P delegates
1331 // to bin_typeset, which already treats `local` and `private`
1332 // identically (is_locallike, builtin.rs:3666). Replicate the swap by
1333 // routing `local` through the `private` node only after the module
1334 // is loaded — before then, `local -P` still errors "bad option: -P"
1335 // exactly like stock zsh. The `private` node carries the augmented
1336 // optstr (with P) that the `local` node lacks.
1337 if name == "local"
1338 && crate::ported::module::MODULESTAB
1339 .lock()
1340 .map(|t| t.is_bound("zsh/param/private"))
1341 .unwrap_or(false)
1342 {
1343 // c:Src/Modules/param_private.c:683-685 — the swap copies EXACTLY
1344 // two fields:
1345 // ((Builtin)hn)->handlerfunc = bintab[0].handlerfunc;
1346 // ((Builtin)hn)->optstr = bintab[0].optstr;
1347 // `defopts` is NOT copied, so the `local` node keeps its own
1348 // (NULL) defaults while `private`'s node keeps `"P"`. That is
1349 // what makes `local x` delegate straight to bin_typeset
1350 // (c:225-229 `if (!OPT_ISSET(ops, 'P'))`) while `private x`
1351 // opens a private scope. Dispatching `local` through the
1352 // `private` NODE inherited defopts="P", so every `local NAME`
1353 // ran the private-promotion path: `() { local h=scalar;
1354 // private -A h }` reported "can't change type of private param"
1355 // where zsh reports "can't change scope of existing param"
1356 // (V10private.ztst:13), and `local` silently made privates.
1357 // zshrs's builtintab maps to `&'static builtin` rows in an
1358 // immutable static, so mirror C's field swap on a private
1359 // static copy of the `local` node instead of mutating the table.
1360 static LOCAL_AS_PRIVATE: std::sync::LazyLock<crate::ported::zsh_h::builtin> =
1361 std::sync::LazyLock::new(|| crate::ported::zsh_h::builtin {
1362 // c:683 `save_local = *(Builtin)hn;` — start from the
1363 // real `local` row so name/flags/minargs/maxargs/funcid/
1364 // defopts all stay `local`'s.
1365 node: crate::ported::zsh_h::hashnode {
1366 next: None,
1367 nam: "local".to_string(),
1368 flags: (crate::ported::zsh_h::BINF_PLUSOPTS
1369 | crate::ported::zsh_h::BINF_MAGICEQUALS
1370 | crate::ported::zsh_h::BINF_PSPECIAL
1371 | crate::ported::zsh_h::BINF_ASSIGN) as i32,
1372 },
1373 // c:684 — handlerfunc from bintab[0] (bin_private).
1374 handlerfunc: Some(
1375 crate::ported::modules::param_private::bin_private
1376 as crate::ported::zsh_h::HandlerFunc,
1377 ),
1378 minargs: 0,
1379 maxargs: -1,
1380 funcid: 0,
1381 // c:685 — optstr from bintab[0] (private's, which adds `P`).
1382 // Same string as the `private` row in builtin.rs (5.9.2's `p`).
1383 optstr: Some("AE:%F:%HL:%PR:%TUZ:%ahi:%lmnprtux".to_string()),
1384 // c:683-685 — NOT copied: `local` keeps its own defaults.
1385 defopts: None,
1386 });
1387 let bn_ptr = &*LOCAL_AS_PRIVATE as *const _ as *mut _;
1388 return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
1389 }
1390 // c:Bugs #475/#504/#555 — bash-only builtins (`mapfile`,
1391 // `readarray`, `compopt`) should emit "command not found" in
1392 // `--zsh` mode matching zsh's external-command-lookup miss.
1393 // The per-opcode closures for caller/help/complete/compgen
1394 // already gate via IS_ZSH_MODE at their registration sites;
1395 // names without dedicated opcodes (compopt/mapfile/readarray)
1396 // route through this generic builtintab lookup and need the
1397 // gate here.
1398 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
1399 && matches!(name, "compopt" | "mapfile" | "readarray")
1400 {
1401 eprintln!("zsh:1: command not found: {}", name);
1402 let _ = args;
1403 return 127;
1404 }
1405 // c:Src/exec.c:2700-2724 resolvebuiltin — autoloaded-builtin stub
1406 // (registered by `zmodload -ab MOD NAME`, Src/module.c:426
1407 // add_autobin) fires on first use: ensurefeature loads the owning
1408 // module, then dispatch proceeds against the real builtin. Must
1409 // run BEFORE the module-bound 127 gate below — `zmodload -ab
1410 // zsh/zselect zselect; zselect` previously died there with
1411 // `command not found` because the gate only checked is_loaded,
1412 // never the autoload ledger.
1413 if let Some(rc) = crate::ported::exec::resolvebuiltin(name) {
1414 if rc != 0 {
1415 // Load failed or feature undefined — diagnostics already
1416 // printed (load_module zwarn / resolvebuiltin zerr).
1417 // C's execbuiltin head returns 1 (Src/builtin.c:264-267).
1418 return 1;
1419 }
1420 // Module loaded — fall through; the is_loaded gates below now
1421 // pass and the normal dispatch chain runs the real builtin.
1422 }
1423 // c:Src/Modules/<mod>.c boot_/setup_ chain — module-bound builtins
1424 // (zftp, zsocket, ztcp, zstat, etc.) are only registered into
1425 // `builtintab` when their module is loaded via `zmodload`. In
1426 // zsh `-fc` (the parity test harness's invocation), the modules
1427 // are NOT pre-loaded, so each name reports "command not found"
1428 // with exit 127. zshrs intentionally pre-loads all module bintabs
1429 // in `createbuiltintable` (builtin.rs:131-152) for the default
1430 // mode so users can call these without `zmodload`; that auto-load
1431 // diverges from zsh's gate behavior. Match zsh's stance only when
1432 // the user explicitly asked for parity via `--zsh`.
1433 //
1434 // The list is the union of builtins from modules that zsh does
1435 // NOT auto-load (verified via `zsh -fc <name>` returning 127):
1436 // zsh/zftp → zftp
1437 // zsh/net/socket → zsocket
1438 // zsh/net/tcp → ztcp
1439 // zsh/stat → zstat (NOT `stat`; that name resolves to
1440 // /bin/stat on PATH per zsh's setup)
1441 // zsh/zselect → zselect
1442 // zsh/zpty → zpty
1443 // zsh/zprof → zprof
1444 // zsh/system → zsystem, syserror
1445 // zsh/clone → clone
1446 // zsh/curses → zcurses
1447 // zsh/db/gdbm → ztie, zuntie, zgdbmpath
1448 // zsh/pcre → pcre_compile, pcre_match, pcre_study
1449 // zsh/example → example
1450 // zsh/cap → cap, getcap, setcap
1451 // zsh/attr → zgetattr, zsetattr, zdelattr, zlistattr
1452 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
1453 && module_bound_builtin_module(name)
1454 .map(|m| {
1455 !crate::ported::module::MODULESTAB
1456 .lock()
1457 .map(|t| t.is_loaded(m))
1458 .unwrap_or(false)
1459 })
1460 .unwrap_or(false)
1461 {
1462 eprintln!("zsh:1: command not found: {}", name);
1463 let _ = args;
1464 return 127;
1465 }
1466 // c:Src/Modules/files.c:806-824 — zsh/files registers `chmod`,
1467 // `chown`, `chgrp`, `ln`, `mkdir`, `mv`, `rm`, `rmdir`, `sync`
1468 // (plus their `zf_*` aliases) into builtintab on module load.
1469 // Without an explicit `zmodload zsh/files`, zsh resolves the
1470 // names through PATH lookup — `zsh -fc 'chmod +x f'` runs
1471 // `/bin/chmod`, whose argv-parser accepts symbolic modes like
1472 // `+x` that bin_chmod's octal-only parser rejects with
1473 // "invalid mode `+x'". The shadow-aware wrapper at
1474 // `dispatch_builtin` (line 438) already has this gate, but the
1475 // direct `dispatch_builtin_raw` path used by fusevm's
1476 // CallBuiltin opcode bypasses it. Mirror the gate here so the
1477 // low-level dispatch matches C's PATH-fall-through behavior.
1478 // The gate is NOT emulation-mode dependent. C has no `zsh/files`
1479 // builtins in `builtintab` until the module is loaded, in ANY mode, so a
1480 // bare `rm`/`mv`/`chmod` falls through to PATH — `chmod +x FILE` runs
1481 // /bin/chmod and succeeds. Conditioning this on `IS_ZSH_MODE` meant the
1482 // native binary answered `chmod +x` from `bin_chmod`'s octal-only parser
1483 // ("invalid mode `+x'"), and `rm -s` / `mv -s` from the module's argument
1484 // parser instead of the system tool's. `dispatch_builtin` at :1087 already
1485 // gates unconditionally; this low-level `CallBuiltin` path did not.
1486 if files_builtin_runs_from_path(name) {
1487 // PATH lookup uses the LITERAL name: bare `mkdir` finds
1488 // /bin/mkdir; a `zf_*` alias finds nothing and exits 127 —
1489 // matching zsh -fc `zf_mkdir d` → "command not found:
1490 // zf_mkdir" (the aliases exist ONLY in the loaded module's
1491 // builtintab, Src/Modules/files.c:816-824; PATH has no
1492 // /bin/zf_rm). The previous zf_-strip silently ran the
1493 // system binary instead.
1494 if let Some(status) = external_glob_failure_status() {
1495 return status;
1496 }
1497 let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1498 return status;
1499 }
1500 // c:Src/Modules/stat.c:637-638 — zsh/stat registers BOTH `stat`
1501 // and `zstat`. `zstat` is in the module_bound 127-gate above (no
1502 // /usr/bin/zstat exists), but the bare `stat` name must FALL
1503 // THROUGH to PATH when zsh/stat isn't loaded — zsh -fc
1504 // 'stat -f %Lp f' runs /usr/bin/stat, while bin_stat's parser
1505 // rejects stat(1) flags ("bad option: -c"). Same fall-through
1506 // shape as the zsh/files gate above.
1507 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
1508 && name == "stat"
1509 && !crate::ported::module::MODULESTAB
1510 .lock()
1511 .map(|t| t.is_loaded("zsh/stat"))
1512 .unwrap_or(false)
1513 {
1514 let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1515 return status;
1516 }
1517 // c:Src/exec.c:3050-3068 — builtin lookup hits `builtintab` (the
1518 // merged table containing module-provided builtins). The previous
1519 // port walked only the core `BUILTINS` slice, so per-module
1520 // entries like `log` (Src/Modules/watch.c:693 `BUILTIN("log", …,
1521 // bin_log, …)`) were registered into builtintab via
1522 // createbuiltintable but never reached at dispatch — `log` fell
1523 // through to PATH and ran `/usr/bin/log`. Bug #72 in docs/BUGS.md.
1524 let tab = crate::ported::builtin::createbuiltintable();
1525 if let Some(bn_static) = tab.get(name) {
1526 let bn_ptr = *bn_static as *const _ as *mut _;
1527 return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
1528 }
1529 1
1530}
1531
1532/// Shadow-aware dispatch matching zsh's name-resolution order:
1533/// alias → reserved word → **function (shadows builtin)** → builtin →
1534/// external. All `BUILTIN_X` opcode handlers route through here so a
1535/// user-defined `cd () { … }` (or `r`, `fc`, `which`, … anything in
1536/// fusevm's name→opcode map) takes precedence over the C builtin —
1537/// matching `Src/exec.c:execcmd_exec`'s dispatch at c:3050-3068.
1538/// Without this, compile-time builtin resolution silently ignored
1539/// user wrappers (e.g. ZPWR's `cd () { builtin cd "$@"; … }`).
1540/// True for builtins that are bound by zsh/files's boot_/setup_
1541/// chain (Src/Modules/files.c:806-824). These are the bare-name
1542/// `mkdir`/`rm`/`mv`/`ln`/`chmod`/`chown`/`chgrp`/`sync`/`rmdir`
1543/// AND their `zf_*` aliases at c:816-824. Without explicit
1544/// `zmodload zsh/files`, the names fall through to PATH lookup
1545/// (zsh's `type rm` reports `/bin/rm`). Bug #28.
1546fn module_gated_files_builtin(name: &str) -> bool {
1547 matches!(
1548 name,
1549 "mkdir"
1550 | "rmdir"
1551 | "rm"
1552 | "mv"
1553 | "ln"
1554 | "chmod"
1555 | "chown"
1556 | "chgrp"
1557 | "sync"
1558 | "zf_mkdir"
1559 | "zf_rmdir"
1560 | "zf_rm"
1561 | "zf_mv"
1562 | "zf_ln"
1563 | "zf_chmod"
1564 | "zf_chown"
1565 | "zf_chgrp"
1566 | "zf_sync"
1567 )
1568}
1569
1570/// A zsh/files name while the module is not loaded: C has no such entry in
1571/// `builtintab` then, so the name runs from PATH like any external.
1572fn files_builtin_runs_from_path(name: &str) -> bool {
1573 module_gated_files_builtin(name)
1574 && !crate::ported::module::MODULESTAB
1575 .lock()
1576 .map(|t| t.is_loaded("zsh/files"))
1577 .unwrap_or(false)
1578}
1579
1580thread_local! {
1581 /// Set when a command-word glob (BUILTIN_GLOB_EXPAND / BUILTIN_GLOBLIST)
1582 /// is what raised errflag, so the abort takes C's globlist status
1583 /// (c:Src/exec.c:3760 `lastval = 1`) rather than the prefork one
1584 /// (c:3523 `if (!lastval) lastval = 1`).
1585 ///
1586 /// !!! WARNING: RUST-ONLY CARRIER !!! C knows which of the two checks
1587 /// it is at; zshrs expands and globs each word in one pass and reaches
1588 /// a single gate at dispatch.
1589 static GLOB_WORD_ERRFLAG: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1590}
1591
1592/// Run a command-word glob and note whether it raised errflag.
1593fn glob_word_noting_errflag(raw: Value, skip_glob: bool) -> Value {
1594 use std::sync::atomic::Ordering;
1595 let before = crate::ported::utils::errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR;
1596 let out = glob_expand_word_value(raw, skip_glob);
1597 if before == 0
1598 && crate::ported::utils::errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR != 0
1599 {
1600 GLOB_WORD_ERRFLAG.with(|c| c.set(true));
1601 }
1602 out
1603}
1604
1605/// Glob one BUILTIN_EXPAND_TEXT mode-10 result inside BUILTIN_GLOBLIST.
1606/// EXPAND_TEXT left the glob-eligible words tokenized (their `haswilds`
1607/// test, c:Src/glob.c:1230, ran on the tokenized word) and every other word
1608/// untokenized, so the token test picks the same words out again; noglob was
1609/// already applied there. The glob call and the empty-result shapes are the
1610/// ones mode 0 uses.
1611fn glob_tokenized_word(raw: Value, noglob: bool) -> Value {
1612 use std::sync::atomic::Ordering;
1613 let words: Vec<String> = match &raw {
1614 Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
1615 other => vec![other.to_str()],
1616 };
1617 let mut parts: Vec<String> = Vec::with_capacity(words.len());
1618 for w in words {
1619 // c:Src/glob.c:1872 — NO_GLOB leaves the word literal; a word that came
1620 // back tokenized only for its deferred filesub reaches here too.
1621 // c:Src/glob.c:1230 — `unset(EXECOPT)` is part of the same early return.
1622 if noglob
1623 || !crate::ported::zsh_h::isset(crate::ported::zsh_h::EXECOPT)
1624 || !crate::ported::pattern::haswilds(&w)
1625 {
1626 // c:Src/glob.c:1232 — zglob untokenizes a word it does not glob; a
1627 // deferred `=cmd` / `~user` that filesub left alone (NOMATCH off)
1628 // still carries its token.
1629 parts.push(crate::ported::lex::untokenize(&w).to_string());
1630 continue;
1631 }
1632 let ef = || crate::ported::utils::errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR;
1633 let before = ef();
1634 let globbed = with_executor(|exec| exec.expand_glob(&w));
1635 if before == 0 && ef() != 0 {
1636 GLOB_WORD_ERRFLAG.with(|c| c.set(true)); // c:Src/exec.c:3760-3761
1637 }
1638 parts.extend(globbed);
1639 }
1640 if parts.len() == 1 {
1641 Value::str(parts.into_iter().next().unwrap_or_default())
1642 } else {
1643 Value::array(parts.into_iter().map(Value::str).collect())
1644 }
1645}
1646
1647/// The value of a word whose prefork an `(e)` NULL stopped
1648/// (c:Src/subst.c:142-147): each node left in the list, rendered with its quote
1649/// tokens (c:Src/exec.c:2134 untokenize), one word per node.
1650fn stopped_nodes_value(nodes: Vec<String>) -> Value {
1651 let mut words: Vec<Value> = nodes
1652 .iter()
1653 .map(|n| Value::str(crate::ported::lex::untokenize_ztokens(n)))
1654 .collect();
1655 match words.len() {
1656 0 => Value::str(String::new()),
1657 1 => words.remove(0),
1658 _ => Value::array(words),
1659 }
1660}
1661
1662/// Run the filesub BUILTIN_EXPAND_TEXT mode 10 deferred on one word's
1663/// elements (c:Src/subst.c:178-182, `flags` 0 for a command argument).
1664fn filesub_deferred_word(raw: Value) -> Value {
1665 let expand = |s: String| -> String {
1666 if s.starts_with(crate::ported::zsh_h::Tilde) || s.starts_with(crate::ported::zsh_h::Equals) {
1667 crate::ported::subst::filesub(&s, 0)
1668 } else {
1669 s
1670 }
1671 };
1672 match raw {
1673 Value::Array(items) => {
1674 Value::array(items.iter().map(|v| Value::str(expand(v.to_str()))).collect())
1675 }
1676 other => Value::str(expand(other.to_str())),
1677 }
1678}
1679
1680/// Status for a command whose words raised errflag, stored into LASTVAL:
1681/// c:Src/exec.c:3760-3761 `lastval = 1` after a globlist error, else
1682/// c:3523-3524 `if (!lastval) lastval = 1` after a prefork error.
1683fn words_errflag_status() -> i32 {
1684 use std::sync::atomic::Ordering;
1685 let status = if GLOB_WORD_ERRFLAG.with(|c| c.replace(false)) {
1686 1 // c:3761
1687 } else {
1688 match crate::ported::builtin::LASTVAL.load(Ordering::Relaxed) {
1689 0 => 1, // c:3524
1690 lastval => lastval,
1691 }
1692 };
1693 crate::ported::builtin::LASTVAL.store(status, Ordering::Relaxed);
1694 status
1695}
1696
1697thread_local! {
1698 /// c:Src/subst.c:142-147 / c:326-327 — paramsubst returned NULL (a failed
1699 /// `(e)` re-lex), so prefork stops: the current word ends at the `$`
1700 /// (c:1878) and the later words of the same list stay unexpanded. Read,
1701 /// never cleared, by BUILTIN_PREFORK_CUT_CHECK; cleared at the sublist
1702 /// prologue and when the command dispatches.
1703 ///
1704 /// !!! WARNING: RUST-ONLY CARRIER !!! C's NULL return unwinds prefork's
1705 /// loop in one step; zshrs expands a command's words across many VM ops.
1706 static PREFORK_CUT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1707}
1708
1709/// Take `subst::PARAMSUBST_NULL` right after a direct paramsubst call and
1710/// turn it into the PREFORK_CUT carrier.
1711fn take_paramsubst_null() -> bool {
1712 let null_return = crate::ported::subst::PARAMSUBST_NULL.with(|c| c.replace(false));
1713 if null_return {
1714 PREFORK_CUT.with(|c| c.set(true));
1715 }
1716 null_return
1717}
1718
1719/// Consume the `-` precommand carrier (see BUILTIN_EXEC_DASH).
1720pub(crate) fn take_exec_dash() -> bool {
1721 take_exec_carrier() & EXEC_CARRIER_DASH != 0
1722}
1723
1724/// `EXEC_DASH` bit: the `-` precommand (c:Src/exec.c:772-776).
1725pub(crate) const EXEC_CARRIER_DASH: u8 = 1;
1726/// `EXEC_DASH` bit: exec in the already-forked child (c:Src/exec.c:4369).
1727pub(crate) const EXEC_CARRIER_FORKED: u8 = 2;
1728/// `EXEC_DASH` bit: the exec replaces the shell itself, so SHLVL drops first
1729/// (exec_shlvl_decrement).
1730pub(crate) const EXEC_CARRIER_SHLVL: u8 = 4;
1731
1732/// c:Src/exec.c:3717 — `nsigtrapped || havefiles() || fdtable_flocks`: what
1733/// stops an exiting list's last command from exec'ing in place. nsigtrapped
1734/// counts real traps only (c:Src/signals.c:725 — an ignored signal is not
1735/// counted); zshrs keeps string traps in traps_table as well as sigtrapped.
1736fn fake_exec_blocked() -> bool {
1737 use crate::ported::zsh_h::{ZSIG_FUNC, ZSIG_TRAPPED};
1738 let trapped = crate::ported::signals::sigtrapped
1739 .lock()
1740 .map(|t| t.iter().any(|&st| st & (ZSIG_TRAPPED | ZSIG_FUNC) != 0))
1741 .unwrap_or(true)
1742 || crate::ported::builtin::traps_table()
1743 .lock()
1744 .map(|t| t.values().any(|body| !body.is_empty()))
1745 .unwrap_or(true);
1746 let files = crate::ported::jobs::JOBTAB
1747 .get()
1748 .and_then(|tab| tab.lock().ok().map(|jt| crate::ported::jobs::havefiles(&jt)))
1749 .unwrap_or(false);
1750 trapped
1751 || files
1752 || crate::ported::utils::FDTABLE_FLOCKS.load(std::sync::atomic::Ordering::Relaxed) != 0
1753}
1754
1755/// c:Src/exec.c:4332-4336 —
1756/// ```c
1757/// if (!subsh) {
1758/// /* for either implicit or explicit "exec", decrease $SHLVL
1759/// * as we're now done as a shell */
1760/// if (!forked)
1761/// setiparam("SHLVL", --shlvl);
1762/// ```
1763/// For an exec that replaces the shell itself (explicit `exec`, or the fake
1764/// exec of the `-c` string's last command): the command inherits one level
1765/// LESS than the shell counted for itself — it is taking the shell's place,
1766/// not nesting inside it.
1767///
1768/// C gets the environment update for free: SHLVL is PM_EXPORTED (from
1769/// `addenv` in createparamtable, c:Src/params.c:951), and `setiparam` on an
1770/// exported parameter runs setnumvalue → setstrvalue(NULL) → export_param →
1771/// addenv (c:2872, c:2841, c:2672). zshrs's `setnumvalue`
1772/// (src/ported/params.rs:5588-5595) has no `setstrvalue(v, NULL)` tail, so
1773/// the paramtab write alone would leave the environment holding the
1774/// pre-decrement number and the exec'd command would read one too high.
1775/// Publish it explicitly.
1776pub(crate) fn exec_shlvl_decrement() {
1777 let cur = crate::ported::params::getsparam("SHLVL")
1778 .and_then(|s| s.parse::<i64>().ok())
1779 .unwrap_or(1);
1780 crate::ported::params::setiparam("SHLVL", cur - 1); // c:4336
1781 crate::ported::params::addenv("SHLVL", &(cur - 1).to_string()); // c:2672
1782}
1783
1784/// Take the job text of the command being dispatched (see JOB_TEXT).
1785pub(crate) fn take_job_text() -> Option<String> {
1786 JOB_TEXT.with(|t| t.borrow_mut().take()).map(|v| v.to_str())
1787}
1788
1789/// Consume every external-spawn carrier bit (see EXEC_DASH).
1790pub(crate) fn take_exec_carrier() -> u8 {
1791 EXEC_DASH.with(|c| c.replace(0))
1792}
1793
1794pub(crate) fn dispatch_builtin(name: &str, args: Vec<String>) -> i32 {
1795 // c:Src/exec.c:772-776 — BINF_DASH only changes an external's argv[0].
1796 take_exec_dash();
1797 take_job_text();
1798 PREFORK_CUT.with(|c| c.set(false)); // the words are complete
1799
1800 // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close any
1801 // `>(cmd)` write ends owned by this command once it finishes
1802 // (drops on every return path below).
1803 let _psub_fds = PsubFdGuard;
1804 // c:Src/exec.c — when any redirect in the current scope failed
1805 // (e.g. noclobber blocked a `>` overwrite), zsh refuses to
1806 // execute the command and exits with status 1. The Rust port
1807 // still applied the command (writing to the /dev/null sink
1808 // installed by host_apply_redirect's noclobber arm) so the
1809 // success status overwrote the intended 1. Short-circuit here
1810 // for builtins (the external-exec equivalent lives in
1811 // ZshrsHost::exec).
1812 let redir_failed = with_executor(|exec| {
1813 let f = exec.redirect_failed;
1814 exec.redirect_failed = false;
1815 f
1816 });
1817 if redir_failed {
1818 // c:Src/exec.c:4367-4386 — POSIX special-builtin escalation:
1819 // a failed redirect on a PSPECIAL builtin (set, readonly,
1820 // typeset, ...) under POSIX_BUILTINS is FATAL in a
1821 // non-interactive shell (`exit(1)` at c:4383). The `command`
1822 // prefix resets this (BINF_COMMAND, c:4369) — that path
1823 // dispatches through bin_command, not here.
1824 if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
1825 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
1826 && builtin_is_pspecial(name)
1827 {
1828 use std::sync::atomic::Ordering;
1829 crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
1830 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
1831 }
1832 // c:Src/exec.c:252-256 execerr — `redir_err = lastval = 1;`. The
1833 // redirect arm already stored it, but the statement prologue between
1834 // the Redirect op and this call (BUILTIN_XTRACE_ARGS) copies the VM's
1835 // stale status back over LASTVAL, and the errflag abort that follows
1836 // exits with LASTVAL: `print hi <&""` exited 0 where zsh exits 1.
1837 crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
1838 return 1;
1839 }
1840 // c:Src/glob.c:1876-1880 NOMATCH path — when expand_glob() failed
1841 // on a no-match glob, zsh aborts the simple command after zerr()
1842 // printed "no matches found". In C, this works because zerr()
1843 // sets ERRFLAG_ERROR (Src/utils.c) and execcmd_exec()
1844 // (Src/exec.c:3050+) checks errflag before invoking the builtin
1845 // table. Rust's builtin dispatch doesn't sit on the same errflag
1846 // gate, so we explicitly consume the per-command glob-fail cell
1847 // and short-circuit with status 1. Mirrors the external-path
1848 // guard at host_exec_external (line 5167). Without this:
1849 // `echo /never/*` would print empty (silently rolled back to ""
1850 // by the empty glob expansion). Parity bug #13.
1851 //
1852 // A zsh/files name that runs from PATH (the arm below) is an external:
1853 // its glob failure ends the forked child, not the shell.
1854 if files_builtin_runs_from_path(name) {
1855 if let Some(status) = external_glob_failure_status() {
1856 return status;
1857 }
1858 }
1859 consume_tilde_globsubst_carrier();
1860 let glob_failed = with_executor(|exec| {
1861 let f = exec.current_command_glob_failed.get();
1862 exec.current_command_glob_failed.set(false); // c:1879 cleanup
1863 f
1864 });
1865 let postassign_glob_failed = TYPESET_POSTASSIGN_GLOB_FAILED.with(|c| c.replace(false));
1866 if postassign_glob_failed && !glob_failed {
1867 // c:Src/exec.c:4167-4285 — a typeset-family command's postassigns
1868 // (every word from its first `NAME=…` on, c:Src/parse.c:1986-1989 and
1869 // c:2008-2050) are globbed inside the builtin branch, after the
1870 // command words: `globlist(…); if (errflag) { …; break; }`. Then
1871 // c:4287 `if (!errflag) { … lastval = ret; }` skips the builtin
1872 // WITHOUT storing a status, so `lastval` keeps the previous command's
1873 // value (`print hi; local -a a=(zzq*)` exits 0, after `false` 1)
1874 // while ERRFLAG_ERROR still ends the script. A command word globbed
1875 // before them fails at c:3755-3763 instead, with `lastval = 1`.
1876 return crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
1877 }
1878 if glob_failed {
1879 // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH zerr sets
1880 // ERRFLAG_ERROR (via utils.c:184). For a BUILTIN command the
1881 // expansion runs IN the shell process, so errflag stays set
1882 // and the rest of the input aborts (zsh -fc 'echo /nope_*;
1883 // echo after' prints nothing after the error — verified
1884 // against zsh 5.9). The continue-after-nomatch behaviour
1885 // belongs ONLY to externals: C forks BEFORE expansion there,
1886 // so the child's zerr can't touch the parent's errflag (zsh
1887 // -fc 'ls /nope_*; echo after' prints `after`) — that path's
1888 // clear lives in fn exec / execute_external. Leave
1889 // ERRFLAG_ERROR set here; BUILTIN_ERREXIT_CHECK trigger 4
1890 // aborts the remaining script at the next command boundary.
1891 return 1; // c:1880 — command aborted, status 1
1892 }
1893 // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the NOMATCH
1894 // gate above: all of this command's globs failed silently (words
1895 // dropped, badcshglob accumulated 1s and no 2s) → `no match`,
1896 // skip the builtin, status 1. Like the NOMATCH path, ERRFLAG
1897 // from zerr stays set for builtins so the rest of the script
1898 // aborts (zsh -fc 'setopt cshnullglob; print *nope* x; print
1899 // after' prints only the error — verified zsh 5.9.1).
1900 if consume_badcshglob() {
1901 // c:Src/exec.c:3380 — `lastval = 1;` so the shell's final
1902 // exit status reflects the aborted command.
1903 with_executor(|exec| exec.set_last_status(1));
1904 return 1;
1905 }
1906 // c:Src/exec.c:4162-4295 — assignment-builtin (BINF_ASSIGN family:
1907 // typeset / declare / local / export / readonly / integer / float /
1908 // private) whose `name=value` postassign arg raised errflag while
1909 // its RHS was preforked (PREFORK_ASSIGN, c:4239-4245) — the classic
1910 // case is a math error in `typeset -F fv=$((1/0))`. The postassign
1911 // loop `break`s on errflag (c:4243) and then `if (!errflag)
1912 // execbuiltin(...)` (c:4287) SKIPS the builtin entirely, so `lastval`
1913 // is left UNCHANGED from before the command (0 fresh, 1 after
1914 // `false`). This differs from a PLAIN assignment `x=$((1/0))`, which
1915 // goes through execsimple c:1375 `lv = errflag ? errflag : cmdoutval`
1916 // → 1, and from a NON-assign builtin `print $((1/0))`, whose main
1917 // args-prefork errflag lands on c:3760 `lastval = 1`. Only the
1918 // assignment-BUILTIN postassign path preserves the prior status.
1919 // Mirror it here: the fusevm reg_passthru dispatch still calls us
1920 // with errflag set (unlike C's pre-invoke gate), so consume that
1921 // state and return the prior LASTVAL instead of running the builtin.
1922 {
1923 use std::sync::atomic::Ordering;
1924 let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
1925 let ef = live & crate::ported::zsh_h::ERRFLAG_ERROR;
1926 let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
1927 // Only the SOFT recoverable error (math failure like `$((1/0))`,
1928 // ERRFLAG_ERROR without ERRFLAG_HARD) preserves the prior status
1929 // per c:4287. A HARD error (`${var?msg}`, which c:Src/subst.c
1930 // OR's ERRFLAG_HARD onto errflag) is a script-abort that yields
1931 // status 1 regardless of the prior status — leave that to the
1932 // normal dispatch/abort path below (which returns 1 and keeps
1933 // ERRFLAG_HARD set for the downstream errexit gate).
1934 if ef != 0 && hard == 0 && builtin_is_assign_family(name) {
1935 // c:4287 — execbuiltin skipped; lastval unchanged.
1936 return crate::ported::builtin::LASTVAL.load(Ordering::Relaxed);
1937 }
1938 // c:Src/exec.c:3523-3525 — an expansion error in the command words
1939 // (prefork, c:3357-3359) ends the command before anything runs:
1940 // if (errflag) { if (!lastval) lastval = 1; … return; }
1941 // so the previous command's non-zero status survives
1942 // (`nosuchcmd; print ${.bad}` exits 127). A glob failure took the
1943 // c:3760 `lastval = 1` return above; a HARD error keeps status 1.
1944 if ef != 0 && hard == 0 {
1945 return words_errflag_status();
1946 }
1947 }
1948 if let Some(status) = try_user_fn_override(name, &args) {
1949 // c:Src/jobs.c:1748 waitonejob — canonical single-command
1950 // pipestats update via the no-procs else-branch.
1951 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1952 let mut synth = crate::ported::zsh_h::job::default();
1953 crate::ported::jobs::waitonejob(&mut synth);
1954 return status;
1955 }
1956 // c:Src/builtin.c:587 + Src/exec.c:3056 — a builtin disabled via
1957 // `disable <name>` has its `DISABLED` flag set in `builtintab`;
1958 // `builtintab->getnode` (the DISABLED-filtering accessor) returns
1959 // NULL for it at lookup time, so execcmd_exec falls through to
1960 // PATH lookup and runs the external. The Rust port stores the
1961 // disabled set in `BUILTINS_DISABLED`; the previous dispatcher
1962 // only checked the immutable `createbuiltintable` HashMap which
1963 // never reflects disablement — so `disable echo; echo hi` kept
1964 // running the bin_echo builtin. Bug #106 in docs/BUGS.md.
1965 //
1966 // dispatch_builtin (the high-level wrapper used by the BUILTIN_*
1967 // opcode handlers and reg_passthru! callsites) is the correct
1968 // gate: `dispatch_builtin_raw` is the low-level entry point
1969 // used by `bin_builtin` itself which MUST bypass the disabled
1970 // set (man zshbuiltins: `builtin name` runs the builtin
1971 // regardless of disable state). Place the check here so the
1972 // bypass path stays clean.
1973 // c:Src/exec.c:3088-3096 — a WC_TYPESET command finds the builtin with
1974 // `getnode2`, which ignores DISABLED: `disable typeset` leaves the
1975 // reserved-word form running the builtin.
1976 let typeset_reswd = TYPESET_RESWD_DISPATCH.with(|c| c.replace(false));
1977 let disabled = !typeset_reswd
1978 && crate::ported::builtin::BUILTINS_DISABLED
1979 .lock()
1980 .map(|s| s.contains(name))
1981 .unwrap_or(false);
1982 if disabled {
1983 let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1984 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1985 let mut synth = crate::ported::zsh_h::job::default();
1986 crate::ported::jobs::waitonejob(&mut synth);
1987 return status;
1988 }
1989 // c:Src/Modules/files.c:806-814 — `mkdir`, `rm`, `mv`, `ln`, `chmod`,
1990 // `chown`, `chgrp`, `sync`, `rmdir` are bound by the `zsh/files`
1991 // module's boot_/setup_ chain. Without explicit `zmodload zsh/files`,
1992 // these bare names fall through to PATH (`/bin/rm`, `/usr/bin/chmod`,
1993 // etc.) in zsh; `type rm` reports `rm is /bin/rm`. The `zf_*`
1994 // aliases (`zf_rm`, `zf_chmod`, …) are bound by the same module
1995 // and gated the same way. Bug #28 in docs/BUGS.md.
1996 if files_builtin_runs_from_path(name) {
1997 // PATH lookup uses the literal name. In --zsh parity mode
1998 // `zf_rm` must 127 like zsh -fc (no /bin/zf_rm); default
1999 // zshrs mode keeps the convenience zf_-strip so the alias
2000 // still reaches the system binary.
2001 let path_name = if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2002 name
2003 } else {
2004 name.strip_prefix("zf_").unwrap_or(name)
2005 };
2006 let status =
2007 with_executor(|exec| exec.execute_external(path_name, &args, &[])).unwrap_or(127);
2008 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
2009 let mut synth = crate::ported::zsh_h::job::default();
2010 crate::ported::jobs::waitonejob(&mut synth);
2011 return status;
2012 }
2013 // c:Src/exec.c:3997 `int q = queue_signal_level();`
2014 // c:Src/exec.c:4231 `dont_queue_signals();`
2015 // c:Src/exec.c:4243 `restore_queue_signals(q);`
2016 //
2017 // C runs EVERY builtin with signal queueing switched OFF. Two
2018 // consequences the zshrs port was missing:
2019 //
2020 // 1. `dont_queue_signals()` DRAINS the pending queue (it calls
2021 // run_queued_signals()), so a signal that arrived while an
2022 // enclosing scope held queue_signals() — doshfunc holds one
2023 // for the whole call, c:Src/exec.c:5835 — fires its trap at
2024 // the NEXT command boundary rather than at function exit.
2025 // 2. While the builtin runs, queueing stays off, so a signal the
2026 // builtin sends to itself (`kill -USR1 $$`) dispatches the
2027 // trap synchronously inside the builtin — which is why zsh
2028 // prints pre/trap/post for
2029 // `f() { print pre; kill -USR1 $$; print post }`.
2030 //
2031 // Without this bracket every trap raised inside a function was
2032 // deferred to the enclosing unqueue_signals() (i.e. script end).
2033 // c:Src/exec.c:3546 — `setunderscore((args && nonempty(args)) ?
2034 // ((char *) getdata(lastnode(args))) : "");`. execcmd_exec sets `$_`
2035 // to the last word of the command it is ABOUT to run — after the
2036 // words were expanded, before the builtin/external executes — so a
2037 // builtin that READS `_` at run time (`typeset -p _`, `${(P)…}`,
2038 // `$parameters[_]`) sees its own last argument, not the previous
2039 // command's. zshrs only did this for a handful of builtins (echo,
2040 // print, true, false, `:`) and for the external/function paths;
2041 // every reg_passthru! builtin was left reading the stale value.
2042 // C's `args` list carries argv[0], so a bare `cat` sets `_=cat` —
2043 // hence the fallback to `name` when there are no arguments.
2044 // c:Src/parse.c:2003-2008 — under the TYPESET reserved word the words
2045 // from the first `NAME=…` on are postassigns, not args: `local b c=2`
2046 // sets `_=b`, `typeset -g a=1` sets `_=-g`.
2047 let n_post = TYPESET_POSTASSIGN_COUNT.with(|c| c.take()).filter(|_| typeset_reswd).unwrap_or(0);
2048 let n_args = args.len().saturating_sub(n_post);
2049 let underscore = args[..n_args].last().cloned().unwrap_or_else(|| name.to_string()); // c:3546
2050 crate::ported::params::set_zunderscore(std::slice::from_ref(&underscore)); // c:3546
2051 let q = crate::ported::signals_h::queue_signal_level(); // c:3997
2052 crate::ported::signals_h::dont_queue_signals(); // c:4231
2053 // c:Src/exec.c:4300 `save[1] == -2` — this command's own redirections
2054 // did not touch fd 1.
2055 let stdout_unredirected = with_executor(|exec| {
2056 !(own_redirect_scope(exec)
2057 && exec
2058 .redirect_scope_stack
2059 .last()
2060 .is_some_and(|top| top.iter().any(|&(fd, _)| fd == 1)))
2061 });
2062 let ret = dispatch_builtin_raw(name, args);
2063 // c:Src/exec.c:4298-4305
2064 // fflush(stdout);
2065 // if (save[1] == -2) {
2066 // if (ferror(stdout)) {
2067 // zwarn("write error: %e", errno);
2068 // clearerr(stdout);
2069 // }
2070 // } else
2071 // clearerr(stdout);
2072 let ferror = crate::stdout_ferror::take_stdout_ferror();
2073 if ferror != 0 && stdout_unredirected {
2074 crate::ported::utils::zwarn(&format!(
2075 "write error: {}",
2076 crate::ported::utils::zsh_errno_msg(ferror)
2077 ));
2078 }
2079 // c:Src/exec.c:4289-4293 — "In case of interruption assume builtin
2080 // status is less useful than what interrupt set":
2081 // if (!(errflag & ERRFLAG_INT)) lastval = ret;
2082 // The SIGINT handler wrote `lastval = 128 + SIGINT` (c:Src/signals.c:463)
2083 // and a forced INT-trap return wrote its own value (c:Src/signals.c:1202),
2084 // both into LASTVAL. Hand that back, so the VM's trailing SetStatus
2085 // publishes it instead of the interrupted builtin's 0:
2086 // zsh -f -i -c 'kill -INT $$' → exit 130 (zshrs exited 0)
2087 let status = if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2088 & crate::ported::zsh_h::ERRFLAG_INT)
2089 != 0
2090 {
2091 crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed)
2092 } else {
2093 ret
2094 };
2095 crate::ported::signals_h::restore_queue_signals(q); // c:4243
2096 // c:Src/jobs.c:1748 waitonejob — canonical single-command pipestats update.
2097 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
2098 let mut synth = crate::ported::zsh_h::job::default();
2099 crate::ported::jobs::waitonejob(&mut synth);
2100 // c:Src/exec.c:4367-4386 — done: tail. A PSPECIAL builtin that
2101 // raised errflag under POSIX_BUILTINS exits the non-interactive
2102 // shell with status 1 ("hard error in POSIX" — e.g. bin_dot's
2103 // zerrnam at Src/builtin.c:6133). Arm the deferred-exit pair so
2104 // the next ERREXIT_CHECK unwinds; EXIT_VAL=1 matches C's
2105 // hardcoded exit(1), NOT the builtin's own status (dot returns
2106 // 127 but POSIX exits 1).
2107 if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
2108 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
2109 && builtin_is_pspecial(name)
2110 && (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2111 & crate::ported::zsh_h::ERRFLAG_ERROR)
2112 != 0
2113 {
2114 use std::sync::atomic::Ordering;
2115 crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
2116 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
2117 }
2118 status
2119}
2120
2121/// c:Src/zsh.h:1467 BINF_PSPECIAL — true when `name` is a POSIX
2122/// special builtin per the canonical builtin table flags
2123/// (Src/builtin.c:48-129: `.`, `:`, break, continue, declare, eval,
2124/// exit, export, float, integer, local, readonly, return, set,
2125/// shift, source, times, trap, typeset, unset).
2126fn builtin_is_pspecial(name: &str) -> bool {
2127 crate::ported::builtin::createbuiltintable()
2128 .get(name)
2129 .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_PSPECIAL) != 0)
2130 .unwrap_or(false)
2131}
2132
2133/// c:Src/zsh.h:1486 BINF_ASSIGN — the assignment-builtin family
2134/// (typeset / declare / local / export / readonly / integer / float /
2135/// private). Their `name=value` args are handled as postassigns
2136/// (c:Src/exec.c:4162-4295), whose errflag-abort skips execbuiltin and
2137/// preserves the prior `lastval`. Read the flag straight from the
2138/// builtin table (same pattern as `builtin_is_pspecial`).
2139fn builtin_is_assign_family(name: &str) -> bool {
2140 crate::ported::builtin::createbuiltintable()
2141 .get(name)
2142 .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_ASSIGN) != 0)
2143 .unwrap_or(false)
2144}
2145
2146// The former `install_exec_hooks()` fn-pointer registry is gone. Code
2147// under `src/ported/` now reaches `ShellExecutor` operations
2148// (array/assoc storage, script eval, function dispatch, command
2149// substitution) through the `crate::ported::exec::*` accessor wrappers,
2150// which resolve the live executor via `try_with_executor`
2151// (`CURRENT_EXECUTOR`). The bridge lives in exec.rs — the sanctioned
2152// fusevm-access exception — per `feedback_no_exec_script_from_ported` /
2153// `feedback_no_shellexecutor_in_ported`.
2154
2155/// The outcome of c:`Src/subst.c:3914-3927`, the JOIN half of the
2156/// join-then-split block: either the vector was collapsed onto a scalar
2157/// (`isarr = 0`) or it survived as an array (`isarr` still non-zero).
2158enum JoinC3914 {
2159 /// c:3917 / c:3925 — `val = sepjoin(aval, …, 1); isarr = 0;`
2160 Joined(String),
2161 /// Neither arm fired; `aval` reaches c:3931 with `isarr` non-zero, and
2162 /// that gate (`force_split && !isarr`) then keeps the split off too.
2163 Elements(Vec<String>),
2164}
2165
2166/// Port of the JOIN half of c:`Src/subst.c:3914-3927`, for an ARRAY-shaped
2167/// UNQUOTED expansion whose `spbreak` is non-zero — i.e. c:3912's gate is open
2168/// and c:3913's `force_split` is 1 (the `ssub` callers join at c:4226 instead
2169/// and never reach here).
2170///
2171/// ```c
2172/// if (isarr || quoted_array_with_offset) {
2173/// if (nojoin == 0 || sep) {
2174/// val = sepjoin(aval, sep, 1);
2175/// isarr = 0;
2176/// } else if (force_split &&
2177/// (spsep || nojoin == 2 || (!ifs && isarr < 0))) {
2178/// val = sepjoin(aval, (nojoin == 1 ? NULL : spsep), 1);
2179/// isarr = 0;
2180/// }
2181/// }
2182/// ```
2183///
2184/// `sep`/`spsep` are the `(j:…:)` / `(s:…:)` separators, which never reach the
2185/// compiler fast paths (a flagged expansion goes to `paramsubst`), so the two
2186/// live discriminators are `nojoin` and whether IFS is UNSET or merely EMPTY:
2187///
2188/// * c:1819 / c:2569 — `nojoin = !(ifs && *ifs)`, i.e. 1 when IFS is unset OR
2189/// set to the empty string, 0 for any non-empty IFS.
2190/// * c:3030-3032 — `if (isarr) { if (nojoin) isarr = -1; }`. This is why a
2191/// `[*]` splice and a `[@]` splice behave IDENTICALLY here: whenever `nojoin`
2192/// is set they are BOTH `isarr < 0`, and when it is clear c:3916 joins them
2193/// both. The `[@]`-vs-`[*]` distinction only survives into the QUOTED arm at
2194/// c:3033, which this block is never reached with.
2195///
2196/// So: a non-empty IFS joins on `IFS[0]`; an UNSET IFS joins on `sepjoin`'s
2197/// default `" "` (c:`Src/utils.c:3936-3945`); an EMPTY IFS joins on nothing at
2198/// all and the element vector survives.
2199fn join_c3914(elems: Vec<String>, ifs: Option<&str>) -> JoinC3914 {
2200 // c:1819 — `!(ifs && *ifs)`.
2201 let nojoin = !matches!(ifs, Some(s) if !s.is_empty());
2202 // c:Src/utils.c:3936-3945 `sepjoin(s, NULL, heap)` — the separator is the
2203 // first metachar of `ifs`, except that a NULL `ifs` (unset) or one whose
2204 // first byte is already a space takes the literal `" "` fast path.
2205 let sep = match ifs {
2206 Some(s) if !s.starts_with(' ') => s.chars().next().map(String::from).unwrap_or_default(),
2207 _ => " ".to_string(),
2208 };
2209 // c:3916 `nojoin == 0`, then c:3919-3925's `(!ifs && isarr < 0)`.
2210 if !nojoin || ifs.is_none() {
2211 JoinC3914::Joined(elems.join(&sep))
2212 } else {
2213 JoinC3914::Elements(elems)
2214 }
2215}
2216
2217/// Port of c:`Src/subst.c:3932` `aval = sepsplit(val, spsep, 0, 1)` with
2218/// `spsep == NULL` (so c:`Src/utils.c:3971-3972` falls through to
2219/// `spacesplit(s, allownull=0, …)`), followed by c:`Src/subst.c:184-187`'s
2220/// empty-word removal.
2221///
2222/// `spacesplit` distinguishes the two kinds of empty field, and that is the
2223/// whole reason this cannot be a plain `split().filter(non-empty)`:
2224///
2225/// * An empty field delimited by IFS-NON-whitespace comes back as `nulstring`
2226/// (c:`Src/subst.c:36` `char nulstring[] = {Nularg, '\0'};`, emitted at
2227/// c:`Src/utils.c:3732` and c:3752). That word is one byte long, so prefork's
2228/// `uremnode` at c:186 keeps it and `remnulargs` at c:169 turns it into `""`.
2229/// `IFS=:; a=(x '' y)` therefore splits `x::y` back to THREE words.
2230/// * A field left by a skipped run of IFS-WHITESPACE is a real `""`
2231/// (c:`Src/utils.c:3734` / c:3757) and c:186 DOES delete it — unless the
2232/// caller is attaching it to neighbouring text, which is what `keep_empties`
2233/// (the `BUILTIN_FORCE_SPLIT` argc contract) says.
2234/// c:Src/glob.c:3663 `remnulargs` for a finished word: remove the c:36
2235/// `nulstring` markers a deferring word kept through its segments (see
2236/// BUILTIN_FORCE_SPLIT and `paramsubst_to_value_pf`), once the end-of-word drop
2237/// has decided which empty nodes survive. A marker-free value is returned as is.
2238/// argc bits the compiler adds to the segment path's end-of-word drop
2239/// (BUILTIN_ARRAY_DROP_EMPTY / BUILTIN_WORD_ELIDE_EMPTY) when the word's first
2240/// or last segment is a quoted empty literal (`""`, `''`). c:Src/subst.c:36 —
2241/// the lexer leaves its Dnull/Snull in the word, so the node that literal is
2242/// glued to is non-empty at c:183 and survives: the first node for a leading
2243/// literal, the last for a trailing one (c:4366-4437).
2244pub const WORD_DROP_KEEPS_FIRST: u8 = 4;
2245/// See [`WORD_DROP_KEEPS_FIRST`].
2246pub const WORD_DROP_KEEPS_LAST: u8 = 8;
2247
2248/// Whether node `i` (of `0..=last`) is anchored by a quoted empty literal.
2249fn word_drop_keeps(argc: u8, i: usize, last: usize) -> bool {
2250 (i == 0 && argc & WORD_DROP_KEEPS_FIRST != 0) || (i == last && argc & WORD_DROP_KEEPS_LAST != 0)
2251}
2252
2253fn strip_nulstring_markers(v: Value) -> Value {
2254 let nul = crate::ported::zsh_h::Nularg;
2255 match v {
2256 Value::Array(items) if items.iter().any(|x| x.as_str_cow().contains(nul)) => Value::array(
2257 items
2258 .iter()
2259 .map(|x| Value::str(x.as_str_cow().replace(nul, "")))
2260 .collect(),
2261 ),
2262 Value::Str(s) if s.contains(nul) => Value::str(s.replace(nul, "")),
2263 other => other,
2264 }
2265}
2266
2267fn sepsplit_c3932(s: &str, keep_empties: bool) -> Vec<String> {
2268 let raw = crate::ported::utils::sepsplit(s, None, false);
2269 let nulstring = crate::ported::zsh_h::Nularg.to_string();
2270 let mut out: Vec<String> = Vec::with_capacity(raw.len());
2271 for w in raw {
2272 if w == nulstring {
2273 out.push(String::new());
2274 } else if w.is_empty() {
2275 if keep_empties {
2276 out.push(String::new());
2277 }
2278 } else {
2279 out.push(w);
2280 }
2281 }
2282 out
2283}
2284
2285/// c:`Src/subst.c:3932-3938` for a bare read: `sepsplit` plus the 0/1/N shape.
2286/// Inside a word that opened with `BUILTIN_WORD_DEFER_EMPTIES`, c:184-187 runs on
2287/// the FINISHED node (after c:4366-4437 glue the affixes on), so the truly empty
2288/// edge fields go out raw for the end-of-word drop to judge:
2289/// `setopt shwordsplit; a=(a b ''); print -l x${a}y` is `xa` `b` `y`.
2290fn split_words_c3932(val: &str) -> Value {
2291 if crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get()) > 0 {
2292 return splice_words_value(crate::ported::utils::sepsplit(val, None, false));
2293 }
2294 splice_words_value(sepsplit_c3932(val, false))
2295}
2296
2297/// c:`Src/subst.c:3933-3938` — how a split (or untouched) word vector reaches
2298/// the caller: nothing at all, a SCALAR for a single field, an array otherwise.
2299///
2300/// ```c
2301/// if (!aval || !aval[0]) val = dupstring("");
2302/// else if (!aval[1]) val = aval[0];
2303/// else isarr = nojoin ? 1 : 2;
2304/// ```
2305fn splice_words_value(words: Vec<String>) -> Value {
2306 match words.len() {
2307 0 => Value::array(Vec::new()),
2308 1 => Value::str(words.into_iter().next().unwrap()),
2309 _ => Value::array(words.into_iter().map(Value::str).collect()),
2310 }
2311}
2312
2313/// c:`Src/subst.c:184-187` — prefork's `uremnode` deletes an UNQUOTED word that
2314/// came out empty. Applied to an element vector that c:3912's gate left alone.
2315fn prefork_c184_drop_empty(elems: Vec<String>) -> Vec<String> {
2316 elems.into_iter().filter(|e| !e.is_empty()).collect()
2317}
2318
2319/// Read a parameter as `paramsubst` does at c:`Src/subst.c:2916-2917`, which is
2320/// where both halves of the value come from:
2321///
2322/// ```c
2323/// if ((isarr = (v->scanflags & SCANPM_ISVAR_AT) ? -1
2324/// : v->scanflags ? 1 : 0)) {
2325/// … aval = getarrvalue(v);
2326/// } else {
2327/// … val = getstrvalue(v);
2328/// ```
2329///
2330/// The `bool` is that `isarr != 0` test — whether `fetchvalue` handed back an
2331/// ARRAY (or a hash, whose values `getarrvalue` enumerates) rather than a
2332/// scalar. Every caller needs it, because c:3914's join arm and c:3931's split
2333/// arm are mutually exclusive on exactly that bit.
2334///
2335/// A scalar comes back as a one-element vector so the two shapes share a type;
2336/// the `bool` is what tells them apart (a genuine one-element ARRAY is not the
2337/// same thing, though c:3899-3906 goes on to treat it as a scalar for the
2338/// join/split block — that rule belongs to the caller, not here).
2339fn param_value_elems_c2916(exec: &mut ShellExecutor, name: &str) -> (Vec<String>, bool) {
2340 if let Some(v) = crate::dash_mode::bash_special_array(name) {
2341 // bash `"${PIPESTATUS[*]}"` / `"${FUNCNAME[*]}"` join.
2342 return (v, true);
2343 }
2344 if name == "@" || name == "*" || name == "argv" {
2345 // c:Src/params.c:392,393,430 — the three IPDEF9 rows over `pparams`.
2346 return (exec.pparams(), true);
2347 }
2348 if let Some(assoc_map) = exec.assoc(name) {
2349 // c:Src/params.c — assoc-splat values for `"${h[@]}"` / `"${h[*]}"`.
2350 // Bug #109 in docs/BUGS.md.
2351 return (assoc_map.values().cloned().collect::<Vec<_>>(), true);
2352 }
2353 if let Some(arr) = exec.array(name) {
2354 // bash sparse arrays: `"${a[*]}"` joins only LIVE elements, dropping
2355 // hole slots. No-op in --zsh (no holes tracked).
2356 return (crate::bash_arrays::compact(name, arr), true);
2357 }
2358 if let Some(arr) = crate::ported::subst::arrays_get(name) {
2359 // c:Src/Modules/parameter.c:2239-2291 partab[] — the PM_ARRAY magic
2360 // specials (reswords/patchars/dis_*/…) are getfn-backed, so
2361 // `getaparam`'s `pm->u.arr` read (behind `exec.array`) comes back NULL
2362 // and the join fell through to the scalar fallback, which is empty.
2363 // Same omission the `[@]` splat had.
2364 return (arr, true);
2365 }
2366 if let Some(map) = crate::ported::subst::assoc_get(name) {
2367 // c:Src/Modules/parameter.c:2235-2298 partab[] — the PM_HASHED magic
2368 // assocs (aliases/functions/options/…) live behind a scanfn+getfn pair,
2369 // not in the executor's assoc storage, so `exec.assoc` above misses
2370 // them and `${aliases[*]}` joined an empty scalar where zsh joins the
2371 // alias VALUES.
2372 return (map.values().cloned().collect::<Vec<_>>(), true);
2373 }
2374 (vec![exec.get_variable(name)], false) // c:2927 getstrvalue
2375}
2376
2377/// Register all zsh builtins with the VM.
2378/// c:Src/exec.c:4473-4509 (`save_params`) — the per-name body of the loop,
2379/// run for a prefix assignment while its inline-env frame is still recording.
2380/// Shared by the scalar (BUILTIN_SET_VAR) and array (BUILTIN_SET_ARRAY)
2381/// assignment builtins: C walks every WC_ASSIGN in the chain regardless of
2382/// its type (c:4510-4511 advances over WC_ASSIGN_SCALAR and array words alike).
2383fn save_inline_prefix_param(exec: &mut ShellExecutor, name: &str) {
2384 // c:Src/exec.c:4475-4478 (54941) — `ss = itype_end(s, INAMESPC, 0);
2385 // slen = *ss == '[' || *ss == Inbrack ? ss - s : strlen(s);`: a
2386 // subscripted assignment (`H[2,4]=oo f`) saves its base parameter.
2387 let name = match name.find('[') {
2388 Some(i) => name[..i].to_string(),
2389 None => name.to_string(),
2390 };
2391 let prev_env = env::var(&name).ok();
2392 // c:Src/exec.c:4476 — `pm = paramtab->getnode(paramtab, s)`.
2393 // c:4491-4493 — an existing name is SNAPSHOTTED, not removed:
2394 // `tpm = zshcalloc(...); tpm->node.nam = ztrdup(pm->node.nam);
2395 // copyparam(tpm, pm, 0);` — the WHOLE Param, type flags and
2396 // value union together. Saving only `getsparam(name)` (a
2397 // scalar string) is what destroyed every non-scalar: an array
2398 // came back as the IFS-joined string retyped to PM_SCALAR, and
2399 // an association came back as None — the scalar getter has
2400 // nothing to read for a PM_HASHED param — so the restore took
2401 // the "did not exist" arm and unset it outright.
2402 let pm_snapshot: Option<crate::ported::zsh_h::param> = {
2403 let live = crate::ported::params::paramtab()
2404 .read()
2405 .ok()
2406 .and_then(|t| t.get(&name).map(|p| (**p).clone()));
2407 // c:4480 / c:4494 — three arms, and the third saves
2408 // nothing: a PM_SPECIAL that is also PM_READONLY leaves
2409 // `tpm` NULL and only joins the remove list (c:4504).
2410 let fakecopy = live.as_ref().map(|p| {
2411 (p.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) != 0
2412 });
2413 let live = match (live, fakecopy) {
2414 (Some(p), Some(true))
2415 if (p.node.flags & crate::ported::zsh_h::PM_READONLY as i32) != 0 =>
2416 {
2417 None
2418 }
2419 (other, _) => other,
2420 };
2421 live.map(|mut tpm| {
2422 // c:Src/params.c:1273-1291 — copyparam reads the value
2423 // back through the type's GSU getfn rather than trusting
2424 // the union slot it already holds, because a special's or
2425 // a tied scalar's value does not live there.
2426 match crate::ported::zsh_h::PM_TYPE(tpm.node.flags as u32) {
2427 crate::ported::zsh_h::PM_INTEGER => {
2428 // c:1279 — `tpm->u.val = pm->gsu.i->getfn(pm);`
2429 // A special's getfn (`uidgetfn`, `gidgetfn`, …) does not
2430 // read `u.val`, which `getiparam` returns for any
2431 // PM_INTEGER node: `UID=$UID cmd` saved 0 and the restore
2432 // ran `setuid(0)`. zshrs reaches those getfns by name.
2433 tpm.u_val = crate::ported::params::lookup_special_var(&name)
2434 .and_then(|v| v.parse::<i64>().ok())
2435 .unwrap_or_else(|| crate::ported::params::getiparam(&name));
2436 }
2437 crate::ported::zsh_h::PM_EFLOAT
2438 | crate::ported::zsh_h::PM_FFLOAT => {
2439 // c:1283 — `tpm->u.dval = pm->gsu.f->getfn(pm);`
2440 tpm.u_dval = crate::ported::params::getnparam(&name).1;
2441 }
2442 crate::ported::zsh_h::PM_ARRAY => {
2443 // c:1286 — `tpm->u.arr = zarrdup(pm->gsu.a->getfn(pm));`
2444 tpm.u_arr = crate::ported::params::getaparam(&name);
2445 }
2446 crate::ported::zsh_h::PM_HASHED => {
2447 // c:1289 — `tpm->u.hash = copyparamtable(...)`.
2448 // See `hashed_snapshot` below for where the pairs go.
2449 }
2450 _ => {
2451 // c:1276 — `tpm->u.str = ztrdup(pm->gsu.s->getfn(pm));`
2452 tpm.u_str = crate::ported::params::getsparam(&name);
2453 }
2454 }
2455 // c:1269-1272 / c:1301-1302 — the `!fakecopy` postlude:
2456 // the copy is going back into the parameter table as a
2457 // REAL parameter, so it stops being special and gets the
2458 // standard get/set vtable for its type. That is what
2459 // makes a user's `typeset -T PAIR pair` scalar come back
2460 // as a plain scalar holding the joined string instead of
2461 // re-reading the partner array the prefix assignment
2462 // just overwrote.
2463 if fakecopy == Some(false) {
2464 tpm.node.flags &= !(crate::ported::zsh_h::PM_SPECIAL as i32); // c:1271
2465 crate::ported::params::assigngetset(&mut tpm); // c:1302
2466 }
2467 tpm
2468 })
2469 };
2470 // !!! RUST-ONLY FIELD — THE SECOND HALF OF c:1289 !!!
2471 // C keeps an association's pairs in `pm->u.hash`, so
2472 // `copyparam`'s `copyparamtable` copy travels inside the Param
2473 // above and `restore_params`' `tpm->gsu.h->setfn(tpm, pm->u.hash)`
2474 // (c:4561) puts them back. zshrs keeps them in the name-keyed
2475 // `paramtab_hashed_storage` instead, OUTSIDE the Param —
2476 // `arrhashsetfn`, the only writer of whole associations, never
2477 // populates `u_hash` at all (the same structural mismatch
2478 // `stdunsetfn` documents at params.rs:11044). So the pairs have
2479 // to be carried alongside the node and put back alongside it.
2480 let hashed_snapshot = if pm_snapshot.as_ref().is_some_and(|p| {
2481 crate::ported::zsh_h::PM_TYPE(p.node.flags as u32)
2482 == crate::ported::zsh_h::PM_HASHED
2483 }) {
2484 crate::ported::params::paramtab_hashed_storage()
2485 .lock()
2486 .ok()
2487 .and_then(|m| m.get(&name).cloned())
2488 } else {
2489 None
2490 };
2491 exec.inline_env_stack
2492 .last_mut()
2493 .unwrap()
2494 .saved
2495 .push(crate::vm_helper::SavedInlineParam {
2496 name: name.clone(),
2497 pm: pm_snapshot,
2498 hashed: hashed_snapshot,
2499 prev_env,
2500 });
2501}
2502
2503pub(crate) fn register_builtins(vm: &mut fusevm::VM) {
2504 // src/ported/ reaches the live executor (param store, function
2505 // dispatch, nested script/cmdsubst exec) through the
2506 // `crate::ported::exec::*` accessor wrappers, which read
2507 // `CURRENT_EXECUTOR` via `try_with_executor`. No install step is
2508 // needed: the executor is in scope for the duration of any VM run
2509 // (set by `ExecutorContext::enter`), so the wrappers resolve it
2510 // directly. (Replaces the former `exec_hooks` OnceLock fn-ptr
2511 // registry, now deleted.)
2512 // Engage fusevm's tiered JIT (block + tracing) so hot, fully-eligible
2513 // numeric chunks run in native code and — with the `jit-disk-cache`
2514 // feature (on by default) — persist that native code to
2515 // `~/.cache/fusevm-jit`, letting repeated zsh invocations skip Cranelift
2516 // codegen. fusevm gates the JIT on per-chunk eligibility and warms up by
2517 // an invocation threshold, falling back to the interpreter for any chunk
2518 // it cannot compile (e.g. host-builtin/`Extended` command dispatch), so
2519 // enabling it here never changes observable behaviour — it only caches
2520 // the numeric hot path. Idempotent: re-enabling on each VM is a no-op.
2521 vm.enable_tracing_jit();
2522 // Macro for builtins that user functions are allowed to shadow.
2523 // zsh dispatch order is alias → function → builtin; without the
2524 // try_user_fn_override probe a `cat() { ... }; cat` would silently
2525 // run the C builtin and ignore the user function.
2526 macro_rules! reg_overridable {
2527 ($vm:expr, $id:expr, $name:literal, $method:ident) => {
2528 $vm.register_builtin($id, |vm, argc| {
2529 let args = pop_args(vm, argc);
2530 // c:Src/exec.c getproc + Src/jobs.c deletefilelist —
2531 // close `>(cmd)` write ends owned by this command
2532 // once it finishes (shadows bypass dispatch_builtin
2533 // and ZshrsHost::exec, so they need their own guard:
2534 // `tee >(wc -c) </dev/null` left wc blocked).
2535 let _psub_fds = PsubFdGuard;
2536 if let Some(s) = try_user_fn_override($name, &args) {
2537 return Value::Status(s);
2538 }
2539 // c:Src/exec.c — redirect failure in the current
2540 // scope means the command must NOT run. coreutils
2541 // shadows (cat / head / tail / etc.) take a separate
2542 // dispatch path from dispatch_builtin, so they need
2543 // their own gate. Without this `cat <&3` after a
2544 // closed-fd diagnostic still ran the shadow and
2545 // overwrote $? from the forced 1.
2546 let redir_failed = with_executor(|exec| {
2547 let f = exec.redirect_failed;
2548 exec.redirect_failed = false;
2549 f
2550 });
2551 // c:Src/exec.c:4343-4350 — the prefix assignments of an
2552 // external run in the forked child (`addvars(…); if
2553 // (errflag) _exit(1);`): `UID=0 date` is skipped with
2554 // status 1 and the shell's errflag stays clean.
2555 let prefix_failed = PREFIX_ASSIGN_FAILED.with(|c| c.replace(false));
2556 if redir_failed || prefix_failed {
2557 // c:Src/exec.c:3719 forks the external command these
2558 // shadows stand in for BEFORE the redirection loop at
2559 // c:3785, so a redirection `zerr` (`cat <&""` → "file
2560 // number expected") lands in the child: the shell only
2561 // sees status 1 (c:252-256 execerr in the child) and runs
2562 // the next command. Same rule as call_function's arm.
2563 let bits = if prefix_failed {
2564 crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD
2565 } else {
2566 crate::ported::zsh_h::ERRFLAG_ERROR
2567 };
2568 crate::ported::utils::errflag.fetch_and(
2569 !bits,
2570 std::sync::atomic::Ordering::Relaxed,
2571 );
2572 // A NOMATCH / NULL_GLOB failure in the redirect target's
2573 // filename generation belongs to the same child.
2574 let _ = external_glob_failure_status();
2575 crate::ported::builtin::LASTVAL
2576 .store(1, std::sync::atomic::Ordering::Relaxed);
2577 return Value::Status(1);
2578 }
2579 // A failed argument glob fails in that forked child too.
2580 if let Some(status) = external_glob_failure_status() {
2581 return Value::Status(status);
2582 }
2583 // `[builtins].coreutils_shadows = off` in
2584 // ~/.zshrs/zshrs.toml (or `ZSHRS_NO_COREUTILS_SHADOWS=1`
2585 // env override) bypasses the in-process shadow and
2586 // fork-execs the real /bin/X. Safety valve for any
2587 // script that hits an edge-case divergence between
2588 // the zshrs shadow and system coreutils. Cached
2589 // after first call, so the hot path is one atomic
2590 // load per shadowed-builtin invocation.
2591 // c:Src/exec.c:3545-3547 — these shadows stand in for
2592 // EXTERNAL commands (`cat`, `head`, …), which in zsh reach
2593 // execcmd_exec and set `$_` to the command's last word
2594 // before running. The in-process shadow below bypasses
2595 // dispatch_builtin AND execute_external_bg, so without this
2596 // `cat f; print $_` reported the PREVIOUS command's last
2597 // argument.
2598 {
2599 let last = args.last().cloned().unwrap_or_else(|| $name.to_string());
2600 crate::ported::params::set_zunderscore(std::slice::from_ref(&last));
2601 // c:3546
2602 }
2603 // With the shadow off (always under `--zsh`) the name is the
2604 // external command it stands in for, so it takes the external
2605 // path: c:Src/exec.c:4369 `execute()` replaces a child already
2606 // forked for it (`sleep 5 &`, a pipeline stage) instead of
2607 // forking again, and a signal sent to the job reaches the
2608 // command itself (`kill -HUP %1` is `hangup`, not `exit 1`).
2609 if !crate::daemon_presence::coreutils_shadows_enabled() {
2610 return Value::Status(with_executor(|exec| {
2611 exec.execute_external($name, &args, &[]).unwrap_or(127)
2612 }));
2613 }
2614 let status = with_executor(|exec| exec.$method(&args));
2615 Value::Status(status)
2616 });
2617 };
2618 }
2619
2620 // Pure-passthru builtin: pops args, routes to canonical
2621 // `dispatch_builtin(name, args)` (which goes via execbuiltin →
2622 // BUILTINS[name] → bin_X). No pre/post bridge work. Used by
2623 // ~25 handlers that were 4-line copy-paste boilerplate.
2624 macro_rules! reg_passthru {
2625 ($vm:expr, $id:expr, $name:literal) => {
2626 $vm.register_builtin($id, |vm, argc| {
2627 let args = pop_args(vm, argc);
2628 // function > builtin: a same-named user function wins over
2629 // the builtin on the normal (CallBuiltin) invocation path.
2630 // The compiler's `user_function_shadow` already routes the
2631 // same-compile-unit case through CallFunction; this probe
2632 // extends that to the cross-unit / interactive case (define
2633 // `zstyle() { … }` on one line, call it on the next). The
2634 // forced `builtin NAME` / `command NAME` paths dispatch
2635 // through their own handlers, not this one, so they still
2636 // reach the builtin as required.
2637 if let Some(s) = try_user_fn_override($name, &args) {
2638 return Value::Status(s);
2639 }
2640 Value::Status(dispatch_builtin($name, args))
2641 });
2642 };
2643 }
2644
2645 // zshrs-original extension builtins (async / peach / doctor / …) that
2646 // route to an ExecutorContext method. Like `reg_overridable!`, they
2647 // probe `try_user_fn_override` FIRST so a user function of the same
2648 // name wins — zsh's alias → function → builtin dispatch order. Without
2649 // the probe, `doctor() { … }; doctor` silently ran the builtin and
2650 // ignored the function (function > builtin violated for these).
2651 macro_rules! reg_ext_overridable {
2652 ($vm:expr, $id:expr, $name:literal, $method:ident) => {
2653 $vm.register_builtin($id, |vm, argc| {
2654 let args = pop_args(vm, argc);
2655 if let Some(s) = try_user_fn_override($name, &args) {
2656 return Value::Status(s);
2657 }
2658 Value::Status(with_executor(|exec| exec.$method(&args)))
2659 });
2660 };
2661 }
2662
2663 // Core builtins
2664 vm.register_builtin(BUILTIN_CD, |vm, argc| {
2665 let args = pop_args(vm, argc);
2666 if let Some(s) = try_user_fn_override("cd", &args) {
2667 return Value::Status(s);
2668 }
2669 // c:Src/builtin.c:1258 — the chpwd hook runs inside cd_new_pwd.
2670 Value::Status(dispatch_builtin("cd", args))
2671 });
2672
2673 vm.register_builtin(BUILTIN_PWD, |vm, argc| {
2674 let args = pop_args(vm, argc);
2675 if let Some(s) = try_user_fn_override("pwd", &args) {
2676 return Value::Status(s);
2677 }
2678 // Route through the canonical execbuiltin path so the `rLP`
2679 // optstr at BUILTINS["pwd"] is parsed into `ops`.
2680 let status = dispatch_builtin("pwd", args);
2681 Value::Status(status)
2682 });
2683
2684 vm.register_builtin(BUILTIN_ECHO, |vm, argc| {
2685 let args = pop_args(vm, argc);
2686 if let Some(s) = try_user_fn_override("echo", &args) {
2687 return Value::Status(s);
2688 }
2689 // Update `$_` to the last arg before running. C zsh sets
2690 // zunderscore in execcmd_exec for every simple command,
2691 // including builtins.
2692 crate::ported::params::set_zunderscore(&args);
2693 let status = dispatch_builtin("echo", args);
2694 Value::Status(status)
2695 });
2696
2697 vm.register_builtin(BUILTIN_PRINT, |vm, argc| {
2698 let args = pop_args(vm, argc);
2699 if let Some(s) = try_user_fn_override("print", &args) {
2700 return Value::Status(s);
2701 }
2702 crate::ported::params::set_zunderscore(&args);
2703 let status = dispatch_builtin("print", args);
2704 Value::Status(status)
2705 });
2706
2707 reg_passthru!(vm, BUILTIN_PRINTF, "printf");
2708 reg_passthru!(vm, BUILTIN_EXPORT, "export");
2709 reg_passthru!(vm, BUILTIN_UNSET, "unset");
2710 // `source` (Src/builtin.c c:116) wired to bin_dot via BUILTINS.
2711 reg_passthru!(vm, BUILTIN_SOURCE, "source");
2712 reg_passthru!(vm, BUILTIN_DOT, ".");
2713 reg_passthru!(vm, BUILTIN_LOGOUT, "logout");
2714
2715 vm.register_builtin(BUILTIN_EXIT, |vm, argc| {
2716 let args = pop_args(vm, argc);
2717 let status = dispatch_builtin("exit", args);
2718 Value::Status(status)
2719 });
2720
2721 vm.register_builtin(BUILTIN_RETURN, |vm, argc| {
2722 let args = pop_args(vm, argc);
2723 // zsh: bare `return` (no arg) returns with the status of
2724 // the most recently executed command — `false; return`
2725 // returns 1, not 0. Direct port of zsh's bin_break/RETURN.
2726 // The executor's `last_status` is stale here (synced at
2727 // statement boundaries, not after each VM op), so read
2728 // the live `vm.last_status` instead.
2729 let live_status = vm.last_status;
2730 let status = {
2731 // Sync canonical LASTVAL to the VM's view BEFORE
2732 // bin_break("return") reads it for the no-arg fallback.
2733 with_executor(|exec| exec.set_last_status(live_status));
2734 dispatch_builtin("return", args)
2735 };
2736 Value::Status(status)
2737 });
2738
2739 vm.register_builtin(BUILTIN_TRUE, |vm, argc| {
2740 let args = pop_args(vm, argc);
2741 if let Some(s) = try_user_fn_override("true", &args) {
2742 return Value::Status(s);
2743 }
2744 // c:Src/exec.c:1257 — zsh sets `zunderscore` AT THE END of
2745 // each command (the `if (!noerrs)` block runs `zsfree(prev_argv0); …;
2746 // zunderscore = …`). For no-arg `true`, $_ becomes the
2747 // command name itself. Set DIRECTLY (not via pending_underscore)
2748 // so the NEXT command's argv-expansion of `$_` reads "true",
2749 // not the stale prior value — pending_underscore is consumed
2750 // by pop_args which runs AFTER argv expansion, too late.
2751 // c:Src/exec.c:1257 — `zunderscore = …` at end-of-command.
2752 // With args, $_ = args.last(). Without args, $_ = command name.
2753 // Write DIRECTLY to the canonical zunderscore static (the
2754 // underscoregetfn at params.rs:7003 reads from there); the
2755 // paramtab "_" slot is shadowed by lookup_special_var so
2756 // set_scalar on it has no effect on `$_` reads.
2757 if args.is_empty() {
2758 crate::ported::params::set_zunderscore(&["true".to_string()]);
2759 } else {
2760 crate::ported::params::set_zunderscore(&args);
2761 }
2762 // Route through canonical execbuiltin so PS4 xtrace fires
2763 // via the c:442 printprompt4 path.
2764 Value::Status(dispatch_builtin("true", args))
2765 });
2766 vm.register_builtin(BUILTIN_FALSE, |vm, argc| {
2767 let args = pop_args(vm, argc);
2768 if let Some(s) = try_user_fn_override("false", &args) {
2769 return Value::Status(s);
2770 }
2771 // Direct set; see BUILTIN_TRUE above for rationale.
2772 if args.is_empty() {
2773 crate::ported::params::set_zunderscore(&["false".to_string()]);
2774 } else {
2775 crate::ported::params::set_zunderscore(&args);
2776 }
2777 // Route through canonical execbuiltin — see BUILTIN_TRUE
2778 // above for the same rationale (xtrace + fast-path removal).
2779 let status = dispatch_builtin("false", args);
2780 Value::Status(status)
2781 });
2782 vm.register_builtin(BUILTIN_COLON, |vm, argc| {
2783 let args = pop_args(vm, argc);
2784 // Direct set; see BUILTIN_TRUE above for rationale.
2785 if args.is_empty() {
2786 crate::ported::params::set_zunderscore(&[":".to_string()]);
2787 } else {
2788 crate::ported::params::set_zunderscore(&args);
2789 }
2790 let status = dispatch_builtin(":", args);
2791 Value::Status(status)
2792 });
2793
2794 // `[ … ]` — c:Src/builtin.c:7231 `bin_test` with BIN_BRACKET. See
2795 // BUILTIN_TEST_BRACKET.
2796 vm.register_builtin(BUILTIN_TEST_BRACKET, |vm, argc| {
2797 let args = pop_args(vm, argc);
2798 Value::Status(dispatch_builtin("[", args))
2799 });
2800 vm.register_builtin(BUILTIN_TEST, |vm, argc| {
2801 let args = pop_args(vm, argc);
2802 // c:Src/builtin.c:7231 `bin_test` with funcid BIN_TEST. The `[`
2803 // spelling compiles to BUILTIN_TEST_BRACKET, so this slot is only ever
2804 // `test`. It used to guess the spelling from a trailing `]`, which ran
2805 // `test a ]` as `[ a ]` (status 0) where zsh reports "condition
2806 // expected: a" (status 2), and ran `[ a` as `test a`.
2807 Value::Status(dispatch_builtin("test", args))
2808 });
2809
2810 // Variable declaration. `local` (Src/builtin.c bin_local) handles
2811 // the scope chain (`pm->old = oldpm` at Src/params.c:1137 inside
2812 // createparam, `pm->level = locallevel` at Src/builtin.c:2576).
2813 // `typeset` / `declare` are aliases — fusevm maps both to
2814 // BUILTIN_TYPESET; compile_zsh special-cases `declare` to keep
2815 // the `declare:` error prefix.
2816 reg_passthru!(vm, BUILTIN_LOCAL, "local");
2817 reg_passthru!(vm, BUILTIN_TYPESET, "typeset");
2818
2819 reg_passthru!(vm, BUILTIN_DECLARE, "declare");
2820 reg_passthru!(vm, BUILTIN_READONLY, "readonly");
2821 reg_passthru!(vm, BUILTIN_INTEGER, "integer");
2822 reg_passthru!(vm, BUILTIN_FLOAT, "float");
2823 reg_passthru!(vm, BUILTIN_READ, "read");
2824 // c:Bug #504 — fusevm reserves BUILTIN_MAPFILE for the bash
2825 // mapfile/readarray builtins. Neither exists in zsh; in --zsh
2826 // parity mode the dispatch must emit "command not found" + rc=127
2827 // matching zsh's external-command-lookup miss. The previous wiring
2828 // left BUILTIN_MAPFILE unregistered, so fusevm's VM treated the op
2829 // as a no-op rc=0 — `mapfile` (and `readarray`) silently succeeded
2830 // in --zsh mode. The host gate in `dispatch_builtin_raw` never
2831 // fired because the compile path emitted `Op::CallBuiltin(31, ..)`
2832 // directly. Register the slot so the gate runs (or a future
2833 // non-zsh mode can wire in a real impl).
2834 vm.register_builtin(fusevm::shell_builtins::BUILTIN_MAPFILE, |vm, argc| {
2835 let args = pop_args(vm, argc);
2836 // The fusevm name→id map collapses both `mapfile` and
2837 // `readarray` to the same opcode; pick the right diagnostic
2838 // by sniffing the user's actual invocation. The xtrace ARGS
2839 // push earlier records the cmd-prefix as the bottom of the
2840 // popped argv, but `args` here excludes the prefix — so we
2841 // can't recover the user-typed name from the stack. Default
2842 // to `mapfile` (the more-common spelling); both produce
2843 // identical diagnostics in any case.
2844 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2845 eprintln!("zsh:1: command not found: mapfile");
2846 let _ = args;
2847 return Value::Status(127);
2848 }
2849 // Non-zsh modes (bash drop-in): mapfile / readarray reads lines
2850 // from stdin (or `-u fd`) into an array. Handled by the ported
2851 // bash builtin in ext_builtins.
2852 Value::Status(crate::extensions::ext_builtins::readarray(&args))
2853 });
2854 reg_passthru!(vm, BUILTIN_BREAK, "break");
2855 reg_passthru!(vm, BUILTIN_CONTINUE, "continue");
2856 reg_passthru!(vm, BUILTIN_SHIFT, "shift");
2857
2858 vm.register_builtin(BUILTIN_EVAL, |vm, argc| {
2859 // Direct port of `bin_eval(UNUSED(char *nam), char **argv, UNUSED(Options ops), UNUSED(int func))` body from Src/builtin.c:6151:
2860 // `if (!*argv) return 0;`
2861 // `prog = parse_string(zjoin(argv, ' ', 1), 1);`
2862 // `execode(prog, 1, 0, "eval");`
2863 // The execode invocation lives here (not in the canonical
2864 // free-fn) because it must run through the bytecode VM's
2865 // current executor — the same VM that's mid-dispatch.
2866 let mut args = pop_args(vm, argc);
2867 // c:Src/builtin.c:407-411 — generic `--` end-of-options
2868 // strip applied by `execbuiltin` for builtins that have
2869 // NULL optstr AND no BINF_HANDLES_OPTS. `eval` qualifies
2870 // (Src/builtin.c:65 `BUILTIN("eval", BINF_PSPECIAL, ...,
2871 // NULL, NULL)`). The BUILTIN_EVAL fast-path bypasses
2872 // execbuiltin, so we mirror the strip inline. Bug #319.
2873 if args.first().is_some_and(|s| s == "--") {
2874 args.remove(0);
2875 }
2876 if args.is_empty() {
2877 return Value::Status(0); // c:6160
2878 }
2879 let src = args.join(" "); // c:6166
2880 // c:Src/builtin.c:6164-6165 — `if (!ineval) scriptname =
2881 // "(eval)";`. Diagnostics emitted while the eval body runs
2882 // (command-not-found, parse errors, etc.) use scriptname as
2883 // the source-context prefix. Without setting it here the
2884 // BUILTIN_EVAL fast-path leaked the outer "zsh" prefix
2885 // through, breaking the `(eval):N:` convention zsh uses
2886 // for in-eval errors. Bug #420.
2887 // c:Src/builtin.c:6209 — `execode(prog, 1, 0, "eval");`. execode
2888 // (c:Src/exec.c:1245-1266) APPENDS its context argument to
2889 // `zsh_eval_context` for the duration of the body, so code inside
2890 // `eval` sees `cmdarg:eval` (and `cmdarg:shfunc:eval` when the eval is
2891 // in a function). zshrs pushed "shfunc" and, since #1065, "cmdsubst",
2892 // but never "eval". Popped on every return path by the guard, matching
2893 // execode's stack discipline. Bug #1065 (eval leg).
2894 let _eval_ctx_guard = crate::ported::exec::EvalContextFrame::push("eval");
2895 // c:Src/builtin.c:6163-6178 — `eval` pushes a funcstack frame named
2896 // "(eval)" (tp = FS_EVAL), gated on `ineval = !isset(EVALLINENO)` /
2897 // `if (!ineval)` — i.e. pushed when EVAL_LINENO is SET, which is the
2898 // zsh default. zshrs already set `scriptname = "(eval)"` (below) but
2899 // never pushed the frame, so `eval '…${#funcstack}'` reported 0 where
2900 // zsh reports 1, and inside a function `${(j:,:)funcstack}` was `f`
2901 // rather than `(eval),f`. Both shells already agreed under
2902 // `unsetopt evallineno` (no frame), so the option gate is load-bearing
2903 // and is mirrored here. Popped on every return path by the guard.
2904 // Bug #1066.
2905 // The push itself is the canonical port (`EvalFuncstackFrame::push`,
2906 // exec.rs, c:6155-6193) — shared with `eval_string`, which the
2907 // compsys `_dispatch` port uses for its `eval "$comp"` sites so both
2908 // eval entry points produce a byte-identical `(eval)` frame. It was
2909 // inline here and set `lineno`/`flineno` to 0 with no `filename`,
2910 // which C computes at c:6161 / c:6174-6188 — so `$functrace` read
2911 // `<caller>:0` and `$funcfiletrace` lost the defining file.
2912 let _eval_fs_guard = crate::ported::exec::EvalFuncstackFrame::push();
2913 let oscriptname = crate::ported::utils::scriptname_get();
2914 crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
2915 // Recursion backstop — c:Src/jobs.c:1878-1884. zsh caps eval recursion
2916 // via its job table (every eval'd pipeline grabs a job slot; the table
2917 // caps at MAX_MAXJOBS → "job table full or recursion limit exceeded").
2918 // The fusevm runtime allocates no job per pipeline, and nested evals
2919 // push no funcstack frame (INEVAL suppression, c:6164), so eval nesting
2920 // is invisible to both the job table AND FUNCNEST/FUNCSTACK — runaway
2921 // `eval`-string recursion overflowed the 256 MB main-thread stack →
2922 // uncatchable SIGBUS. Track eval re-entry depth (the Rust proxy for
2923 // held job slots) and refuse at the same MAX_MAXJOBS ceiling.
2924 let eval_depth = crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| {
2925 let v = d.get() + 1;
2926 d.set(v);
2927 v
2928 });
2929 let mut status = if eval_depth >= crate::ported::jobs::MAX_MAXJOBS {
2930 crate::ported::utils::zerr("job table full or recursion limit exceeded");
2931 1
2932 } else {
2933 with_executor(|exec| {
2934 // c:6175 execode — no end-of-script hooks (c:Src/builtin.c:6213)
2935 exec.execute_string_without_exit_hooks(&src).unwrap_or(1)
2936 })
2937 };
2938 crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
2939 // c:Src/builtin.c:6211-6212 — `if (errflag && !lastval)
2940 // lastval = errflag;`
2941 // c:Src/builtin.c:6221 — `errflag &= ~ERRFLAG_ERROR;`
2942 // eval is a CONTAINMENT boundary: an error inside the eval
2943 // body (readonly reassign, bad assoc set, ${unset?msg}, …)
2944 // breaks the eval body's lists via errflag, then eval clears
2945 // the flag and returns lastval, and the CALLER's next list
2946 // runs. zsh 5.9: `eval 'assoc=(odd)'; echo "after $?"`
2947 // prints `after 1` in -c, script, and stdin contexts.
2948 {
2949 use std::sync::atomic::Ordering;
2950 let ef = crate::ported::utils::errflag.load(Ordering::Relaxed)
2951 & crate::ported::zsh_h::ERRFLAG_ERROR;
2952 if ef != 0 && status == 0 {
2953 status = ef; // c:6212 lastval = errflag
2954 }
2955 crate::ported::utils::errflag
2956 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
2957 // The NOMATCH cell is the Rust half of that error state (set
2958 // beside `zerr` for c:Src/glob.c:1877). The builtin dispatcher
2959 // reads it BEFORE the next command runs, so one left over from
2960 // `eval 'x=( *nomatch )'` skipped the caller's next command.
2961 with_executor(|exec| exec.current_command_glob_failed.set(false));
2962 }
2963 crate::ported::utils::set_scriptname(oscriptname);
2964 Value::Status(status)
2965 });
2966
2967 // `builtin foo args…`: precmd-modifier that forces builtin dispatch,
2968 // bypassing alias AND function lookup. Without this, `builtin cd /`
2969 // inside a user `cd () { … }` wrapper recurses (real-world ZPWR pattern).
2970 // Handler pops argc args from the stack, treats args[0] as the builtin
2971 // name, and dispatches the rest via `dispatch_builtin` → `execbuiltin`
2972 // → `bin_*` directly. No function/alias lookup happens.
2973 vm.register_builtin(BUILTIN_BUILTIN, |vm, argc| {
2974 let args = pop_args(vm, argc);
2975 // c:Src/exec.c:3483-3487 — the precommand-modifier walk checks
2976 // `shfunctab` for the command word BEFORE `builtintab`, and only
2977 // skips that check once a prefix has already been consumed
2978 // (`cflags & (BINF_BUILTIN|BINF_COMMAND)`). So on the FIRST word a
2979 // shell function literally named `builtin` shadows the builtin —
2980 // `builtin() { ... }; builtin whence -va x` runs the function.
2981 // zshrs resolves `builtin` at bytecode-compile time (no function
2982 // table yet), so the shadow test has to happen here, at dispatch.
2983 if let Some(status) = try_user_fn_override("builtin", &args) {
2984 return Value::Status(status);
2985 }
2986 // c:Src/exec.c:3760-3763 — `if (errflag) { lastval = 1; goto err; }`
2987 // after argument expansion: a bad glob qualifier or pattern in the
2988 // words (`builtin print x(a)` "number expected") skips the builtin
2989 // with status 1 and leaves errflag set, so the script ends. This
2990 // handler is reached without dispatch_builtin's gates, and ran the
2991 // builtin, which reported 0.
2992 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2993 & crate::ported::zsh_h::ERRFLAG_ERROR)
2994 != 0
2995 {
2996 // c:3523-3525 / c:3760-3762 — see words_errflag_status.
2997 return Value::Status(words_errflag_status());
2998 }
2999 // c:Src/exec.c:3104-3146 — the precommand walk strips a `-` word after
3000 // `builtin` too (it is itself a BINF_PREFIX node, c:Src/builtin.c:42);
3001 // when nothing is left, c:3380-3406 is the empty-command return and
3002 // `builtin -` succeeds without running anything.
3003 let mut full = Vec::with_capacity(args.len() + 1);
3004 full.push("builtin".to_string());
3005 full.extend(args.iter().cloned());
3006 if crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE)
3007 .is_empty_command
3008 {
3009 return Value::Status(0);
3010 }
3011 let Some((name, rest)) = args.split_first() else {
3012 // `builtin` with no args → list builtins (zsh emits nothing,
3013 // exit 0). Match that behavior; the BIN_BUILTIN bin_* in C
3014 // does the same default-list-nothing.
3015 return Value::Status(0);
3016 };
3017 // zshrs extension builtins (daemon z* family: zd, zcache, zjob,
3018 // …) are dispatched by name via try_dispatch instead of living
3019 // in builtintab — but they ARE builtins, so the `builtin`
3020 // precommand must reach them (`builtin zd ping` errored
3021 // "no such builtin: zd" while bare `zd ping` worked).
3022 if let Some(status) = run_daemon_builtin(name, rest) {
3023 return Value::Status(status);
3024 }
3025 // c:Src/exec.c:3435-3436 — `builtin NAME` with NAME not in
3026 // builtintab emits `zwarn("no such builtin: %s", cmdarg)`
3027 // and returns 1. zshrs's dispatch_builtin_raw bare-returned 1
3028 // silently. Probe the table here so the diagnostic fires
3029 // before dispatch.
3030 let tab = crate::ported::builtin::createbuiltintable();
3031 // c:Src/exec.c:3489-3499 — `builtintab->getnode` skips DISABLED
3032 // entries, so `disable typeset; builtin typeset x=1` is "no such
3033 // builtin: typeset" with status 1, like a name that was never a builtin.
3034 let disabled = crate::ported::builtin::BUILTINS_DISABLED
3035 .lock()
3036 .map(|s| s.contains(name.as_str()))
3037 .unwrap_or(false);
3038 if !tab.contains_key(name.as_str()) || disabled {
3039 // zshrs-original opcode builtins (async, doctor, peach, …) aren't
3040 // in builtintab; `builtin NAME` must still reach them.
3041 if let Some(status) = try_run_registered_builtin(name, rest) {
3042 return Value::Status(status);
3043 }
3044 // c:Src/exec.c:3436 — `zwarn("no such builtin: %s", cmdarg);`.
3045 // Route through the ported `zwarn` rather than formatting the
3046 // prefix by hand: zwarn emits zsh's `zsh:LINE:` prefix, and the
3047 // hand-rolled `eprintln!` here printed `zshrs:1:` instead. Of the
3048 // twelve error shapes probed this was the ONLY one carrying the
3049 // wrong prefix — the ported twin at exec.rs:9878 already used
3050 // zwarn correctly, so this was a reimplementation shadowing a
3051 // faithful port (same shape as #1027 / #1031 / #1044 / #1050).
3052 // Bug #1063.
3053 crate::ported::utils::zwarn(&format!("no such builtin: {}", name));
3054 return Value::Status(1);
3055 }
3056 // `builtin foo` MUST bypass function shadow — that's the whole
3057 // point of the prefix. Use the _raw helper, not the shadow-aware
3058 // one. Without this, `cd () { builtin cd "$@"; }` recurses.
3059 // c:Src/exec.c:3484 — BINF_BUILTIN suppresses the shfunctab probe.
3060 // `bin_compadd` runs that probe itself, so publish the modifier across
3061 // the call (see `ForcedBuiltinGuard`).
3062 let _forced = crate::ported::zle::complete::ForcedBuiltinGuard::enter();
3063 Value::Status(dispatch_builtin_raw(name, rest.to_vec()))
3064 });
3065
3066 // `command foo args…` — BINF_COMMAND prefix (Src/builtin.c:44). Zsh
3067 // semantic: bypass alias+function lookup, search builtin then $PATH.
3068 // Without this, `cd () { command cd "$@" }` would re-invoke the user
3069 // wrapper (same root cause as the `builtin` bug). Flags `-p`/`-v`/`-V`
3070 // route to bin_whence with BIN_COMMAND funcid; bare `command foo`
3071 // dispatches builtin if present, else external (no fork — direct
3072 // spawn via execute_external since zshrs is non-forking).
3073 // BUILTIN_COMMAND — `command [-p] [-v|-V] cmd args…` BIN_PREFIX
3074 // (Src/builtin.c:45). PURE PASSTHRU: prepend "command" and hand
3075 // to `exec::execcmd_compile_head` (the fusevm-bytecode-time head
3076 // resolver mirroring `Src/exec.c::execcmd_exec` precommand-modifier
3077 // walk at c:3104-3187). That helper already does the -p / -v / -V
3078 // option parsing, surfaces `has_command_vv` for the whence
3079 // redirect, and reports the dispatch shape (is_builtin vs external).
3080 vm.register_builtin(BUILTIN_COMMAND, |vm, argc| {
3081 let args = pop_args(vm, argc);
3082 // c:Src/exec.c:3483-3487 — same shfunctab-before-builtintab rule
3083 // as BUILTIN_BUILTIN above: a shell function named `command`
3084 // shadows the `command` precommand modifier on the first word.
3085 if let Some(status) = try_user_fn_override("command", &args) {
3086 return Value::Status(status);
3087 }
3088 let mut full = Vec::with_capacity(args.len() + 1);
3089 full.push("command".to_string());
3090 full.extend(args.clone());
3091 let dispatch =
3092 crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
3093 let post = &full[dispatch.precmd_skip..];
3094 // c:Src/exec.c:3212-3214 — `} else if (has_p) { /* Use default path */
3095 // use_defpath = 1; … }`. `-p` is a flag carried down to the exec
3096 // (c:4369 `execute(args, cflags, use_defpath)`) and to whence
3097 // (c:Src/builtin.c:4165-4167 `findcmd(*argv, 1, func == BIN_COMMAND &&
3098 // OPT_ISSET(ops,'p'))`), where the compiled-in DEFAULT_PATH
3099 // (configure.ac:1954, `getconf _CS_PATH` at build time) is searched
3100 // directly. C never assigns to `$path` for this.
3101 //
3102 // command's OWN options end at the first non-flag arg —
3103 // everything after the command name belongs to IT. The
3104 // previous `.any()` scan over ALL args stole `-p` from
3105 // `command mkdir -p DIR` (zconvey.plugin.zsh:44), stripping
3106 // the flag before /bin/mkdir ran → "File exists" errors on
3107 // every re-source.
3108 let mut lead = 0usize;
3109 let mut dash_p = false;
3110 let mut kept_flags: Vec<String> = Vec::new();
3111 for a in post.iter() {
3112 let s = a.as_str();
3113 if s == "--" {
3114 lead += 1;
3115 break;
3116 }
3117 if s.starts_with('-')
3118 && s.len() >= 2
3119 && s[1..].chars().all(|c| c == 'p' || c == 'v' || c == 'V')
3120 {
3121 if s.contains('p') {
3122 dash_p = true;
3123 }
3124 // -v / -V drive the whence-style lookup downstream —
3125 // keep them in post (only the PATH-reset `p` is
3126 // consumed here).
3127 let rest: String = s[1..].chars().filter(|c| *c != 'p').collect();
3128 if !rest.is_empty() {
3129 kept_flags.push(format!("-{}", rest));
3130 }
3131 lead += 1;
3132 continue;
3133 }
3134 break;
3135 }
3136 let mut post: Vec<String> = {
3137 let mut v = kept_flags;
3138 v.extend(post[lead..].iter().cloned());
3139 v
3140 };
3141 // c:Src/exec.c:3176-3177 — `BINF_COMMAND` arm strips a single
3142 // leading `--` end-of-options marker.
3143 // `execcmd_compile_head` (src/ported/exec.rs:1042) performs
3144 // this removal on its LOCAL `preargs` Vec but doesn't surface
3145 // the modified args; the caller still sees `--` in `full` and
3146 // tried to dispatch it as the command name. Bug #251. Mirror
3147 // the C strip here so `command -- echo hi` and
3148 // `command -p -- echo hi` route correctly.
3149 if let Some(first) = post.first() {
3150 if first == "--" {
3151 post.remove(0);
3152 }
3153 }
3154 let post = post.as_slice();
3155 // The previous implementation spelled `-p` as a temporary `$PATH`
3156 // reassignment around the call, seeded by forking `getconf PATH`.
3157 // Three things were wrong with that and all three are gone now:
3158 // every `$PATH` write empties `cmdnamtab` (c:Src/params.c pathsetfn),
3159 // so the entry `execcmd_exec` had just made for the command was wiped
3160 // on restore and `hash` came back empty where the oracle lists the
3161 // command; the child process inherited the DEFAULT_PATH in its
3162 // environment instead of the caller's `$PATH`; and a shell whose point
3163 // is not forking paid a fork per `command -p` for a value the C build
3164 // bakes in at configure time.
3165 // c:Src/exec.c:3760-3763 — `if (errflag) { lastval = 1; goto err; }`
3166 // after argument expansion. The in-shell forms (`command -v`, and a
3167 // builtin under POSIX_BUILTINS) keep errflag, so the script ends; the
3168 // external form clears it below, as its forked child would.
3169 let expansion_error = (crate::ported::utils::errflag
3170 .load(std::sync::atomic::Ordering::Relaxed)
3171 & crate::ported::zsh_h::ERRFLAG_ERROR)
3172 != 0;
3173 if expansion_error && (dispatch.has_command_vv || dispatch.is_builtin) {
3174 // c:3523-3525 / c:3760-3762 — see words_errflag_status.
3175 return Value::Status(words_errflag_status());
3176 }
3177 if dispatch.has_command_vv {
3178 return Value::Status(command_whence(post, dash_p));
3179 }
3180 if dispatch.is_empty_command {
3181 return Value::Status(0);
3182 }
3183 let Some((name, rest)) = post.split_first() else {
3184 return Value::Status(0);
3185 };
3186 // c:Src/exec.c:3275-3278 — `execcmd_compile_head` cleared
3187 // hn for the BINF_COMMAND + !POSIXBUILTINS case, surfacing
3188 // is_builtin=false. Run as external. Under POSIXBUILTINS
3189 // dispatch.is_builtin would be true; honour it.
3190 let n = name.clone();
3191 let r = rest.to_vec();
3192 if dispatch.is_builtin
3193 && crate::ported::builtin::BUILTINS
3194 .iter()
3195 .any(|b| b.node.nam == n.as_str())
3196 {
3197 // c:Src/exec.c:3484 — BINF_COMMAND suppresses it too.
3198 let _forced = crate::ported::zle::complete::ForcedBuiltinGuard::enter();
3199 return Value::Status(dispatch_builtin_raw(&n, r));
3200 }
3201 // c:Src/exec.c:3755-3763 — `globlist(args, 0); if (errflag) { lastval
3202 // = 1; goto err; }`. Without POSIX_BUILTINS `command NAME` is always
3203 // an external, and C forks before that globlist, so a NOMATCH error
3204 // skips the command in the child while the parent's errflag stays
3205 // clean: `command ls zzq*; print $?` prints the error, then 1. Here
3206 // the words were expanded in the shell, so drop the failed command
3207 // and its ERRFLAG_ERROR the way the external dispatcher does. Any
3208 // other expansion error (`command print x(a)` "number expected")
3209 // takes the same path.
3210 let glob_failed = with_executor(|exec| {
3211 let f = exec.current_command_glob_failed.get();
3212 exec.current_command_glob_failed.set(false);
3213 f
3214 });
3215 // A prefork error (c:3357-3359, a bad `${…}` or math) happens BEFORE
3216 // that fork, in the shell: c:3523-3525 keeps a non-zero lastval and
3217 // errflag ends the list (`command print ${.bad}` after `(exit 5)`
3218 // exits 5). Only a globlist error belongs to the forked child.
3219 if expansion_error && !glob_failed && !GLOB_WORD_ERRFLAG.with(|c| c.get()) {
3220 return Value::Status(words_errflag_status());
3221 }
3222 GLOB_WORD_ERRFLAG.with(|c| c.set(false));
3223 if glob_failed || expansion_error {
3224 crate::ported::utils::errflag.fetch_and(
3225 !crate::ported::zsh_h::ERRFLAG_ERROR,
3226 std::sync::atomic::Ordering::Relaxed,
3227 );
3228 with_executor(|exec| exec.set_last_status(1));
3229 return Value::Status(1);
3230 }
3231 // c:Src/exec.c:3719 forks the external command before the redirection
3232 // loop at c:3785, so a redirection that failed (`command cat <&""` →
3233 // "file number expected") fails in the child: the command never runs
3234 // and the shell continues with status 1. The redirect arm only
3235 // records the failure; without consuming it here `command cat` ran
3236 // the real cat on the untouched stdin and blocked.
3237 let redir_failed = with_executor(|exec| {
3238 let f = exec.redirect_failed;
3239 exec.redirect_failed = false;
3240 f
3241 });
3242 if redir_failed {
3243 crate::ported::utils::errflag.fetch_and(
3244 !crate::ported::zsh_h::ERRFLAG_ERROR,
3245 std::sync::atomic::Ordering::Relaxed,
3246 );
3247 crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
3248 return Value::Status(1);
3249 }
3250 // c:Src/exec.c:3275-3278 — `command NAME` asks for the thing on
3251 // `PATH`, not the in-process one. The host-registered native commands
3252 // (`extensions/native_cmds.rs`) are caught inside `execute_external`,
3253 // which is this very call, so they are marked as explicitly forced
3254 // past for its duration — matching what `command cat` already does to
3255 // the coreutils shadow.
3256 let _forced = crate::native_cmds::force_external();
3257 // c:Src/exec.c:4369 — `execute(args, cflags, use_defpath)`. The `-p`
3258 // flag is the third argument, not a mutation of the environment.
3259 Value::Status(
3260 with_executor(|exec| {
3261 if dash_p {
3262 exec.execute_external_defpath(&n, &r, &[])
3263 } else {
3264 exec.execute_external(&n, &r, &[])
3265 }
3266 })
3267 .unwrap_or(127),
3268 )
3269 });
3270
3271 // `exec cmd args…` — BINF_EXEC prefix (Src/builtin.c:45). Zsh
3272 // semantic: replace the current shell process with `cmd`. On Unix
3273 // this is `execvp(2)`; the call only returns on error. zshrs is
3274 // non-forking, so the shell process IS the calling process —
3275 // execvp here directly replaces it. Options `-a name` (override
3276 // argv[0]), `-c` (clean env), `-l` (login shell — prepend `-`)
3277 // ported minimally; advanced redirect-only `exec >file` is handled
3278 // upstream by compile_zsh and never reaches this handler.
3279 vm.register_builtin(BUILTIN_EXEC, |vm, argc| {
3280 let args = pop_args(vm, argc);
3281 // c:Src/exec.c:3029-3278 — the precommand walk, `exec` included:
3282 // its options (c:3178-3274, `-a`/`-c`/`-l` and the "exec requires
3283 // a command to execute" / "unknown exec flag" errors), then any
3284 // further BINF_PREFIX word (`command`, `builtin`, `exec`, `-`) whose
3285 // flags join cflags with BINF_EXEC still set (c:3116-3118). So
3286 // `exec command X` runs the external X in the shell's place and
3287 // `exec builtin X` the builtin. Under POSIX_BUILTINS the walk stops
3288 // after `exec`'s options (c:3098-3103): the name is a command.
3289 let mut full = Vec::with_capacity(args.len() + 1);
3290 full.push("exec".to_string());
3291 full.extend(args);
3292 let walk = crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
3293 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3294 & crate::ported::zsh_h::ERRFLAG_ERROR)
3295 != 0
3296 {
3297 vm.last_status = 1; // c:3205 `lastval = 1;`
3298 crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
3299 return Value::Status(1); // c:3203-3207 / c:3230-3234 / c:3249-3256 `goto done`
3300 }
3301 if walk.has_command_vv {
3302 // c:3149-3157 — whence, then the exec'd shell is gone.
3303 let post = &walk.preargs[walk.precmd_skip..];
3304 let dash_p = post.iter().take_while(|a| a.starts_with('-')).any(|a| a.contains('p'));
3305 let status = command_whence(post, dash_p);
3306 let _ = std::io::stdout().flush();
3307 std::process::exit(status);
3308 }
3309 if walk.is_empty_command {
3310 // `exec` with no command + no redirects = no-op success
3311 // (c:3365-3406; the redirect-only form never reaches here).
3312 return Value::Status(0);
3313 }
3314 let args: Vec<String> = walk.preargs[walk.precmd_skip..].to_vec();
3315 let cmd = args[0].clone();
3316 if (walk.cflags & crate::ported::zsh_h::BINF_BUILTIN) != 0 && !walk.is_builtin {
3317 // c:3490-3492 — `zwarn("no such builtin: %s", cmdarg); lastval = 1;`
3318 crate::ported::utils::zwarn(&format!("no such builtin: {}", cmd));
3319 return Value::Status(1);
3320 }
3321 let login = (walk.cflags & crate::ported::zsh_h::BINF_DASH) != 0; // c:3246
3322 let clean_env = (walk.cflags & crate::ported::zsh_h::BINF_CLEARENV) != 0; // c:3243
3323 let argv0_override = walk.exec_argv0.clone(); // c:3263-3274
3324 // c:4369 — `execute(args, cflags, use_defpath)`: `exec command -p X`.
3325 let use_defpath = walk.use_defpath;
3326 let prog = if use_defpath && !cmd.contains('/') {
3327 crate::ported::exec::search_defpath(&cmd, libc::PATH_MAX as usize).unwrap_or_else(|| cmd.clone())
3328 } else {
3329 cmd.clone()
3330 };
3331 let rest: Vec<String> = args[1..].to_vec();
3332 let display_argv0 = match argv0_override {
3333 Some(a) => a,
3334 None => {
3335 if login {
3336 format!("-{}", cmd)
3337 } else {
3338 cmd.clone()
3339 }
3340 }
3341 };
3342 // c:Src/exec.c:3468/3582 — execcmd bails out before running anything
3343 // once a redirection has failed: the failure calls zerr, which sets
3344 // errflag, and both bail-outs test it. `exec` is not exempt, so
3345 // exec ls 3>&98; print after
3346 // in zsh reports the bad fd, does NOT run ls, and the SHELL SURVIVES
3347 // to run `print after`. zshrs consumed the flag in
3348 // BUILTIN_EXEC_PERM_REDIRS (returning status 1) but then dispatched
3349 // the command regardless — replacing the shell with it, so anything
3350 // after the exec never ran, and `exec 99>&98` reported a spurious
3351 // `command not found: 99` for the leftover fd word.
3352 if with_executor(|exec| {
3353 let f = exec.redirect_failed;
3354 exec.redirect_failed = false;
3355 f
3356 }) {
3357 vm.last_status = 1;
3358 return Value::Status(1);
3359 }
3360
3361 // c:Src/exec.c::execcmd — `exec funcname` runs the function
3362 // in-process as the shell's last act, then exits with the
3363 // function's status. zsh's dispatcher falls through from the
3364 // BINF_EXEC prefix into the normal Builtin/External/Function
3365 // resolution and only execvp's if the target ISN'T a
3366 // function. Bug #101 in docs/BUGS.md: zshrs's exec went
3367 // straight to execvp and errored `not found` for shell
3368 // functions.
3369 //
3370 // For both subshell and top-level contexts: dispatch through
3371 // the function/builtin lookup first; only fall through to
3372 // execvp/spawn if the name isn't shell-resolvable.
3373 // c:Src/exec.c:3098-3103 — `else if (isset(POSIXBUILTINS) &&
3374 // (cflags & BINF_EXEC)) break;` "POSIX doesn't allow "exec" to
3375 // operate on builtins or shell functions": the walk stops before
3376 // the shfunctab and builtintab lookups, so the name is searched
3377 // as an external command. A walked chain has already resolved the
3378 // head (c:3104-3108 skips functions after `builtin` / `command`).
3379 let has_user_fn = walk.is_shfunc; // c:3104-3108
3380 if has_user_fn {
3381 let status =
3382 with_executor(|exec| exec.dispatch_function_call(&cmd, &rest).unwrap_or(127));
3383 // Top-level `exec funcname` — exit the shell with the
3384 // function's status (mirrors C's "exec replaces shell as
3385 // last act"). Subshell `(exec funcname)` — return through
3386 // the EXIT_PENDING path so the subshell body aborts and
3387 // the parent resumes via subshell_end.
3388 let in_subshell_now = exec_in_process_subshell();
3389 if in_subshell_now {
3390 crate::ported::builtin::EXIT_VAL
3391 .store(status, std::sync::atomic::Ordering::Relaxed);
3392 crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
3393 return Value::Status(status);
3394 }
3395 std::process::exit(status);
3396 }
3397 // c:Src/exec.c — builtin path: `exec builtin` runs the
3398 // builtin in-process and exits.
3399 let bn_in_tab = walk.is_builtin; // c:3120-3129
3400 if bn_in_tab {
3401 let status = dispatch_builtin_raw(&cmd, rest.clone());
3402 let in_subshell_now = exec_in_process_subshell();
3403 if in_subshell_now {
3404 crate::ported::builtin::EXIT_VAL
3405 .store(status, std::sync::atomic::Ordering::Relaxed);
3406 crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
3407 return Value::Status(status);
3408 }
3409 std::process::exit(status);
3410 }
3411 // c:Src/exec.c — `exec` inside a subshell (`(exec cmd)`)
3412 // replaces ONLY the subshell child process; the parent shell
3413 // continues. C zsh always forks for `(...)`, so the actual
3414 // execvp lands in the forked child. zshrs runs subshells via
3415 // a snapshot/restore pattern in the SAME process — calling
3416 // execvp here would replace the parent too. Bug #94 in
3417 // docs/BUGS.md.
3418 //
3419 // Detect subshell context via the non-empty
3420 // `subshell_snapshots` stack. When in a subshell: spawn the
3421 // command as a child, wait for it, then signal the subshell
3422 // body to abort (return Status(N) and the caller's
3423 // subshell_end will pop the snapshot and resume the parent).
3424 let in_subshell = exec_in_process_subshell();
3425 if in_subshell && !clean_env {
3426 let status = exec_execute(&args, walk.exec_argv0.clone(), login, use_defpath, false);
3427 crate::ported::builtin::EXIT_VAL.store(status, std::sync::atomic::Ordering::Relaxed);
3428 crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
3429 return Value::Status(status);
3430 }
3431 if in_subshell {
3432 // c:Src/exec.c:4352 `closem(FDT_INTERNAL, 0)` before execve.
3433 crate::lowfd::cloexec_internal_fds();
3434 let mut command = std::process::Command::new(&prog);
3435 command.arg0(&display_argv0);
3436 command.args(&rest);
3437 if clean_env {
3438 command.env_clear();
3439 }
3440 // Queue signals across spawn+wait so the SIGCHLD reaper
3441 // can't reap this child before child.wait() does — see
3442 // ForegroundWaitGuard.
3443 let _wait_guard = ForegroundWaitGuard::enter();
3444 let status = match command.spawn() {
3445 Ok(mut child) => match child.wait() {
3446 Ok(s) => crate::exec_jobs::wait_status_val(s),
3447 Err(_) => 127,
3448 },
3449 Err(e) => {
3450 // c:Src/exec.c:797 — `zerr("%e: %s", lerrno, arg0)`
3451 // when arg0 contains `/`.
3452 // c:872-876 — when arg0 has no `/` (PATH search
3453 // path), C tracks the "good" errno
3454 // via `isgooderr`; if all PATH entries
3455 // were ENOENT-not-good, eno stays 0
3456 // and C emits `command not found: %s`
3457 // instead of strerror.
3458 // %e expands to strerror(errno) with the first
3459 // letter lowercased (unless errno == EIO; see
3460 // Src/utils.c:362-368). `zerr` prepends the
3461 // scriptname:lineno: prefix — matching zsh's
3462 // canonical `zsh:N: <errmsg>: <cmd>` pattern.
3463 // Previously emitted `zshrs: exec: {}: not found`
3464 // (wrong prefix, hardcoded message, missing
3465 // lineno). Bug #140 in docs/BUGS.md.
3466 let errno = e.raw_os_error().unwrap_or(libc::ENOENT);
3467 let has_slash = cmd.contains('/');
3468 if !has_slash && errno == libc::ENOENT {
3469 // c:876 — PATH search exhausted with no good
3470 // errno → `command not found: arg0`.
3471 crate::ported::utils::zerr(&format!("command not found: {}", crate::ported::utils::nicedupstring(&cmd)));
3472 } else {
3473 let mut errmsg = crate::ported::compat::strerror(errno);
3474 if errno != libc::EIO {
3475 if let Some(c) = errmsg.chars().next() {
3476 errmsg = format!(
3477 "{}{}",
3478 c.to_ascii_lowercase(),
3479 &errmsg[c.len_utf8()..]
3480 );
3481 }
3482 }
3483 crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
3484 }
3485 // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
3486 if errno == libc::EACCES || errno == libc::ENOEXEC {
3487 126
3488 } else {
3489 127
3490 }
3491 }
3492 };
3493 // Mark the subshell as exec-replaced so subsequent body
3494 // commands skip — mirrors the post-execvp "child process
3495 // is gone" reality in C. EXIT_PENDING + EXIT_VAL drive
3496 // the next ERREXIT_CHECK to unwind to the subshell-end
3497 // patch.
3498 crate::ported::builtin::EXIT_VAL.store(status, std::sync::atomic::Ordering::Relaxed);
3499 crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
3500 return Value::Status(status);
3501 }
3502 // Both guards of exec_shlvl_decrement hold here: the in-subshell
3503 // form returned at the branch above (so `subsh` is false), and this
3504 // call replaces THIS process rather than a child (so `forked` is
3505 // false).
3506 exec_shlvl_decrement();
3507 // c:Src/exec.c:4369 — `execute(args, cflags, use_defpath)` in this
3508 // very process, exec'ing in place (see exec_execute). `exec -c`
3509 // keeps the Command route below: C hands it `blank_env` (c:778-779),
3510 // which carries no `_` either.
3511 if !clean_env {
3512 std::process::exit(exec_execute(&args, walk.exec_argv0.clone(), login, use_defpath, true));
3513 }
3514 let mut command = std::process::Command::new(&prog);
3515 command.arg0(&display_argv0);
3516 command.args(&rest);
3517 if clean_env {
3518 command.env_clear();
3519 }
3520 use std::os::unix::process::CommandExt;
3521 // `exec` returns the OS error iff exec(2) failed; on success
3522 // it never returns. Match zsh: print the error to stderr with
3523 // the `exec` prefix and exit 127 (cmd not found) or 126 (not
3524 // executable).
3525 // c:Src/exec.c:4352 `closem(FDT_INTERNAL, 0)` before execve.
3526 crate::lowfd::cloexec_internal_fds();
3527 let err = command.exec();
3528 // c:Src/exec.c:797 / c:872-876 — same format as in-subshell
3529 // branch. arg0-has-/ → `<strerror>: <cmd>`; arg0-no-/ +
3530 // ENOENT → `command not found: <cmd>`. Lowercase strerror
3531 // first letter unless EIO. Bug #140 in docs/BUGS.md.
3532 let errno = err.raw_os_error().unwrap_or(libc::ENOENT);
3533 let has_slash = cmd.contains('/');
3534 if !has_slash && errno == libc::ENOENT {
3535 crate::ported::utils::zerr(&format!("command not found: {}", crate::ported::utils::nicedupstring(&cmd)));
3536 } else {
3537 let mut errmsg = crate::ported::compat::strerror(errno);
3538 if errno != libc::EIO {
3539 if let Some(c) = errmsg.chars().next() {
3540 errmsg = format!("{}{}", c.to_ascii_lowercase(), &errmsg[c.len_utf8()..]);
3541 }
3542 }
3543 crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
3544 }
3545 // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
3546 let code = if errno == libc::EACCES || errno == libc::ENOEXEC {
3547 126
3548 } else {
3549 127
3550 };
3551 std::process::exit(code);
3552 });
3553
3554 reg_passthru!(vm, BUILTIN_LET, "let");
3555
3556 // Job control
3557 reg_passthru!(vm, BUILTIN_JOBS, "jobs");
3558 reg_passthru!(vm, BUILTIN_FG, "fg");
3559 reg_passthru!(vm, BUILTIN_BG, "bg");
3560 reg_passthru!(vm, BUILTIN_KILL, "kill");
3561 reg_passthru!(vm, BUILTIN_DISOWN, "disown");
3562 reg_passthru!(vm, BUILTIN_WAIT, "wait");
3563 reg_passthru!(vm, BUILTIN_SUSPEND, "suspend");
3564
3565 // History — `fc` / `history` / `r` all route to `bin_fc` (zsh
3566 // registers them as aliases of the same builtin per Src/builtin.c).
3567 reg_passthru!(vm, BUILTIN_FC, "fc");
3568 reg_passthru!(vm, BUILTIN_HISTORY, "history");
3569 reg_passthru!(vm, BUILTIN_R, "r");
3570
3571 // Aliases — alias is `BINF_MAGICEQUALS` per Src/builtin.c:50.
3572 // c:Src/exec.c:3298-3304 — when a builtin has BINF_MAGICEQUALS,
3573 // execcmd_exec sets esprefork = PREFORK_TYPESET and calls
3574 // `prefork(args, esprefork, NULL)` on the argv. prefork (subst.c:
3575 // 100) drives `filesub` on each word (c:133), which (c:677-686)
3576 // looks for the assignment Equals and runs `filesubstr` on the
3577 // VALUE side. That's how `alias bad===` triggers equalsubstr's
3578 // "= not found" via the inner Equals after the first `=`.
3579 //
3580 // The fusevm dispatch path doesn't go through execcmd_exec, so
3581 // BUILTIN_ALIAS previously passed args straight to bin_alias with
3582 // no expansion — `alias x=~/foo` stored literal `~/foo` (no tilde
3583 // expand), `alias bad===` stored a broken entry without firing
3584 // the "= not found" diagnostic. The prefork(PREFORK_TYPESET) runs
3585 // per arg word via BUILTIN_MAGIC_EQUALS_PREFORK ops that
3586 // compile_simple emits BEFORE the redirect scope opens (matching
3587 // c:3304 prefork-before-addfd order), so the dispatch here is a
3588 // plain passthrough — re-running prefork would double-fire the
3589 // "= not found" diagnostic.
3590 reg_passthru!(vm, BUILTIN_ALIAS, "alias");
3591 // c:Src/exec.c:3298-3304 — per-word magic-equals prefork; see the
3592 // const doc at BUILTIN_MAGIC_EQUALS_PREFORK. prefork's filesub
3593 // trigger (subst.c:678 `strchr(*namptr+1, Equals)`) looks for the
3594 // EQUALS TOKEN, not literal `=`. The fusevm path delivers args
3595 // already-untokenized, so re-tokenize each element via
3596 // `shtokenize` (the same call C's lexer makes implicitly when
3597 // assembling the word) so prefork sees Equals tokens at `=`
3598 // boundaries and Tilde tokens at `~` starts. After prefork
3599 // expands, untokenize for storage.
3600 vm.register_builtin(BUILTIN_MAGIC_EQUALS_PREFORK, |vm, _argc| {
3601 let raw = vm.pop();
3602 let inputs: Vec<String> = match raw {
3603 Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
3604 other => vec![other.to_str()],
3605 };
3606 let mut as_linklist: crate::ported::linklist::LinkList<String> = Default::default();
3607 for s in &inputs {
3608 let mut tokd = s.clone();
3609 crate::ported::glob::shtokenize(&mut tokd);
3610 as_linklist.push_back(tokd);
3611 }
3612 let mut rf = 0i32;
3613 crate::ported::subst::prefork(
3614 &mut as_linklist,
3615 crate::ported::zsh_h::PREFORK_TYPESET,
3616 &mut rf,
3617 );
3618 let mut expanded: Vec<String> = Vec::with_capacity(inputs.len());
3619 while let Some(s) = as_linklist.pop_front() {
3620 expanded.push(crate::ported::lex::untokenize(&s).to_string());
3621 }
3622 if expanded.len() == 1 {
3623 Value::str(expanded.into_iter().next().unwrap())
3624 } else {
3625 Value::array(expanded.into_iter().map(Value::str).collect())
3626 }
3627 });
3628
3629 // Options. `setopt` (BIN_SETOPT=0) / `unsetopt` (BIN_UNSETOPT=1)
3630 // share bin_setopt (options.c:580) — funcid bit discriminates
3631 // the polarity via BUILTINS table entries.
3632 reg_passthru!(vm, BUILTIN_SET, "set");
3633 reg_passthru!(vm, BUILTIN_SETOPT, "setopt");
3634 reg_passthru!(vm, BUILTIN_UNSETOPT, "unsetopt");
3635
3636 vm.register_builtin(BUILTIN_SHOPT, |vm, argc| {
3637 let args = pop_args(vm, argc);
3638 // `shopt` is a BASH builtin; no shell in the zsh family has it
3639 // (c:Src/builtin.c:40-137 `builtins[]` has no `shopt` row), so
3640 // outside bash drop-in mode the name must resolve through PATH
3641 // like any other unknown command — `zsh -fc shopt` →
3642 // `zsh:1: command not found: shopt`, rc 127.
3643 //
3644 // compile_zsh:3109 already forces `builtin_id = None` for the
3645 // literal name so the compile-time `CallBuiltin` fast path is
3646 // skipped, but that guard alone stopped working: the emitted
3647 // `Op::CallFunction` reaches fusevm's run-time
3648 // `VM::run_builtin_by_name` (fusevm-0.23.0/src/vm.rs:3348 →
3649 // :979) once the host's `call_function` answers None, and THAT
3650 // resolves the name straight back to this registered handler.
3651 // mapfile / readarray / compopt survive only because their
3652 // opcodes gate on the mode inside the handler; do the same
3653 // here. Mirrors the BUILTIN_COMPGEN / BUILTIN_COMPLETE gates
3654 // below, including their user-function precedence: a shell
3655 // function named `shopt` (bashcompinit-style shims define one)
3656 // still wins over the fall-through.
3657 if !crate::extensions::dash_mode::bash_mode() {
3658 if crate::ported::utils::getshfunc("shopt").is_some() {
3659 let status = with_executor(|exec| exec.dispatch_function_call("shopt", &args))
3660 .unwrap_or(127);
3661 return Value::Status(status);
3662 }
3663 // PATH lookup with the literal name, so the diagnostic and
3664 // the status are the shell's own external-miss ones
3665 // (`zsh:1:` under --zsh, `zshrs:1:` natively) rather than a
3666 // second hand-rolled spelling of them.
3667 let status =
3668 with_executor(|exec| exec.execute_external("shopt", &args, &[])).unwrap_or(127);
3669 return Value::Status(status);
3670 }
3671 let status = crate::extensions::ext_builtins::shopt(&args);
3672 Value::Status(status)
3673 });
3674
3675 reg_passthru!(vm, BUILTIN_EMULATE, "emulate");
3676 reg_passthru!(vm, BUILTIN_GETOPTS, "getopts");
3677 reg_passthru!(vm, BUILTIN_AUTOLOAD, "autoload");
3678 reg_passthru!(vm, BUILTIN_FUNCTIONS, "functions");
3679 reg_passthru!(vm, BUILTIN_TRAP, "trap");
3680 reg_passthru!(vm, BUILTIN_DIRS, "dirs");
3681 // pushd / popd dispatch through canonical bin_cd via execbuiltin
3682 // — the BUILTINS table at src/ported/builtin.rs:9298 wires
3683 // `pushd` to bin_cd with funcid=BIN_PUSHD, and `popd` similarly
3684 // with BIN_POPD. Without these reg_passthru lines the fusevm
3685 // BUILTIN_PUSHD/POPD opcodes had no handler installed, so the
3686 // emitted CallBuiltin(110, …) silently returned a no-op and the
3687 // dirstack/$dirstack/pwd all stayed unchanged.
3688 reg_passthru!(vm, BUILTIN_PUSHD, "pushd");
3689 reg_passthru!(vm, BUILTIN_POPD, "popd");
3690 // type / whence / where / which all route through `bin_whence`
3691 // (canonical port at `src/ported/builtin.rs:3734` of
3692 // `Src/builtin.c:3975`). Each gets its own opcode so funcid +
3693 // defopts come from the BUILTINS table entry — execbuiltin
3694 // applies them correctly via the module-level dispatch_builtin.
3695 reg_passthru!(vm, BUILTIN_WHENCE, "whence");
3696 reg_passthru!(vm, BUILTIN_TYPE, "type");
3697 reg_passthru!(vm, BUILTIN_WHICH, "which");
3698 reg_passthru!(vm, BUILTIN_WHERE, "where");
3699 reg_passthru!(vm, BUILTIN_HASH, "hash");
3700 reg_passthru!(vm, BUILTIN_REHASH, "rehash");
3701
3702 // `unhash`/`unalias`/`unfunction` share `bin_unhash` (Src/builtin.c
3703 // c:4350) but each carries its own funcid (BIN_UNHASH /
3704 // BIN_UNALIAS / BIN_UNFUNCTION) in the BUILTINS table.
3705 reg_passthru!(vm, BUILTIN_UNHASH, "unhash");
3706 vm.register_builtin(BUILTIN_UNALIAS, |vm, argc| {
3707 let args = pop_args(vm, argc);
3708 Value::Status(dispatch_builtin("unalias", args))
3709 });
3710 vm.register_builtin(BUILTIN_UNFUNCTION, |vm, argc| {
3711 let args = pop_args(vm, argc);
3712 Value::Status(dispatch_builtin("unfunction", args))
3713 });
3714
3715 // Completion
3716 vm.register_builtin(BUILTIN_COMPGEN, |vm, argc| {
3717 let args = pop_args(vm, argc);
3718 // c:Bug #475/#555 — `compgen` is a bash-only builtin. In
3719 // `--zsh` mode emit "command not found" matching zsh's
3720 // external-command lookup miss — UNLESS a user FUNCTION of
3721 // that name exists: zsh has no such builtin, so bashcompinit's
3722 // `compgen() {...}` definition wins the dispatch there. The
3723 // unconditional 127 shadowed it and broke every
3724 // bashcompinit-style completion file (zsh-more-completions
3725 // _msync/_gocomplete/_qshell/_cw), spraying "command not
3726 // found: complete" at every deferred compinit load.
3727 // c:Src/exec.c:3105-3110 — a shell FUNCTION is consulted before the
3728 // builtin table, UNCONDITIONALLY. This check used to sit INSIDE the
3729 // `IS_ZSH_MODE` arm below, so in NATIVE mode zshrs's own extension
3730 // builtin ran and a user function of the same name was ignored.
3731 //
3732 // Live cost: `bashcompinit` defines shell functions named `complete`
3733 // and `compgen`, so in native mode `complete -F _fn cmd` silently
3734 // registered NOTHING and returned 0:
3735 // zsh -f _comps[zzbashc]=[_bash_complete -F _zzfn]
3736 // zshrs -f _comps[zzbashc]=[] <- BUG
3737 // zshrs --zsh -f _comps[zzbashc]=[_bash_complete -F _zzfn]
3738 // That breaks every bashcompinit-bridged completer on the host.
3739 //
3740 // `try_user_fn_override` is the shape the correct registrations
3741 // already use — `zsleep` (below) and `compdef` — which is why
3742 // `compdef` alone always worked.
3743 if let Some(status) = try_user_fn_override("compgen", &args) {
3744 return Value::Status(status);
3745 }
3746 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
3747 // No user function, and `compgen` is a bash-only builtin, so in a
3748 // zsh-emulation shell it genuinely does not exist.
3749 eprintln!("zsh:1: command not found: compgen");
3750 let _ = args;
3751 return Value::Status(127);
3752 }
3753 let status = with_executor(|exec| exec.builtin_compgen(&args));
3754 Value::Status(status)
3755 });
3756
3757 vm.register_builtin(BUILTIN_COMPLETE, |vm, argc| {
3758 let args = pop_args(vm, argc);
3759 // c:Bug #475 — `complete` is a bash-only builtin. Same gate +
3760 // user-function precedence as BUILTIN_COMPGEN above.
3761 // Same unconditional function-before-builtin rule as BUILTIN_COMPGEN
3762 // above (c:Src/exec.c:3105-3110); this check was inside the mode gate
3763 // too, so `bashcompinit`'s `complete` function lost to the extension
3764 // builtin in native mode.
3765 if let Some(status) = try_user_fn_override("complete", &args) {
3766 return Value::Status(status);
3767 }
3768 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
3769 eprintln!("zsh:1: command not found: complete");
3770 let _ = args;
3771 return Value::Status(127);
3772 }
3773 let status = with_executor(|exec| exec.builtin_complete(&args));
3774 Value::Status(status)
3775 });
3776
3777 reg_passthru!(vm, BUILTIN_COMPADD, "compadd");
3778 reg_passthru!(vm, BUILTIN_COMPSET, "compset");
3779
3780 // See the const's doc comment for the contract. Stack (bottom→top):
3781 // base, e1, …, eN — argc = N + 1.
3782 // See the consts' doc comments.
3783 vm.register_builtin(BUILTIN_TYPESET_POSTASSIGNS_BEGIN, |vm, argc| {
3784 let n_post = (argc == 1).then(|| vm.pop().to_int().max(0) as usize);
3785 TYPESET_POSTASSIGN_COUNT.with(|c| c.set(n_post));
3786 let word_failed = with_executor(|exec| exec.current_command_glob_failed.replace(false));
3787 TYPESET_WORD_GLOB_FAILED.with(|c| c.set(word_failed));
3788 Value::Int(0)
3789 });
3790 // See BUILTIN_TYPESET_RESWD.
3791 vm.register_builtin(BUILTIN_TYPESET_RESWD, |_vm, _argc| {
3792 TYPESET_RESWD_DISPATCH.with(|c| c.set(true));
3793 Value::Int(0)
3794 });
3795 // See BUILTIN_EXEC_DASH.
3796 vm.register_builtin(BUILTIN_EXEC_DASH, |_vm, _argc| {
3797 EXEC_DASH.with(|c| c.set(c.get() | EXEC_CARRIER_DASH));
3798 Value::Int(0)
3799 });
3800 // See BUILTIN_EXECPLINE_CHILD_BLOCK / _UNBLOCK.
3801 // The depth argument is absent from chunks compiled before the job frame
3802 // was added (the autoload cache); those keep the signal half only.
3803 vm.register_builtin(BUILTIN_EXECPLINE_CHILD_BLOCK, |vm, argc| {
3804 crate::ported::signals_h::child_block(); // c:Src/exec.c:1748
3805 // argc 2: the pipeline is a lone current-shell compound command.
3806 let cursh = argc == 2 && vm.pop().to_int() != 0;
3807 if argc >= 1 {
3808 let depth = vm.pop().to_int() as u32;
3809 let frame = vm as *const fusevm::VM as usize;
3810 crate::exec_jobs::execpline_slot_open(frame, depth); // c:Src/exec.c:1656-1666
3811 if cursh {
3812 crate::exec_jobs::mark_thisjob_cursh(false); // c:Src/exec.c:3674-3680
3813 }
3814 }
3815 Value::Int(0)
3816 });
3817 vm.register_builtin(BUILTIN_EXECPLINE_CHILD_UNBLOCK, |vm, argc| {
3818 if argc == 1 {
3819 let depth = vm.pop().to_int() as u32;
3820 let frame = vm as *const fusevm::VM as usize;
3821 crate::exec_jobs::execpline_slot_close(frame, depth); // c:Src/jobs.c:1754, exec.c:1981
3822 }
3823 crate::ported::signals_h::child_unblock(); // c:Src/exec.c:2017
3824 Value::Int(0)
3825 });
3826 // See BUILTIN_JOB_TEXT.
3827 vm.register_builtin(BUILTIN_JOB_TEXT, |vm, _argc| {
3828 let text = vm.pop();
3829 JOB_TEXT.with(|t| *t.borrow_mut() = Some(text));
3830 Value::Int(0)
3831 });
3832 // See BUILTIN_EXEC_FORKED_SIMPLE.
3833 vm.register_builtin(BUILTIN_EXEC_FORKED_SIMPLE, |vm, argc| {
3834 let mode = if argc >= 1 { vm.pop().to_int() } else { 0 };
3835 let exiting_tail = mode != 0;
3836 let this_vm = vm as *const fusevm::VM as usize;
3837 if mode == 2 {
3838 // The `-c` string's last command (compile_zsh exiting_tail_mode
3839 // 2): the shell exits after it, so it is exec'd in place under
3840 // the same conditions, and not being forked it takes SHLVL down
3841 // (c:Src/exec.c:4334-4336).
3842 if !fake_exec_blocked() {
3843 EXEC_DASH.with(|c| c.set(c.get() | EXEC_CARRIER_FORKED | EXEC_CARRIER_SHLVL));
3844 }
3845 return Value::Int(0);
3846 }
3847 if exiting_tail {
3848 // c:Src/exec.c:3715-3718 — the fake exec of an exiting list's
3849 // last command needs `last1 == 1` (the compiler's mark, in a
3850 // child that was forked) and no traps, no files for the job to
3851 // clean up and no flock()s to keep: `nsigtrapped || havefiles()
3852 // || fdtable_flocks` otherwise forks as usual.
3853 if FORKED_SIMPLE_VM.with(|f| f.get()) != this_vm || fake_exec_blocked() {
3854 return Value::Int(0);
3855 }
3856 }
3857 if FORKED_SIMPLE_VM.with(|f| f.replace(0)) == this_vm {
3858 EXEC_DASH.with(|c| c.set(c.get() | EXEC_CARRIER_FORKED));
3859 }
3860 Value::Int(0)
3861 });
3862 // See BUILTIN_PREFORK_CUT_CHECK.
3863 vm.register_builtin(BUILTIN_PREFORK_CUT_CHECK, |_vm, _argc| {
3864 // c:Src/subst.c:118-121 — prefork's word loop also returns once
3865 // errflag is set, before expanding the next word.
3866 let errflag_set = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3867 & crate::ported::zsh_h::ERRFLAG_ERROR
3868 != 0;
3869 Value::Bool(PREFORK_CUT.with(|c| c.get()) || errflag_set)
3870 });
3871 // See BUILTIN_GLOBLIST. c:Src/subst.c:488-498 globlist:
3872 // badcshglob = 0;
3873 // for (node = firstnode(list); !errflag && node; node = next)
3874 // { next = nextnode(node); zglob(list, node, nocheck); }
3875 // `badcshglob` stays with the per-word glob path (glob_expand_word_value
3876 // feeds consume_badcshglob), so only the loop shape changes here.
3877 vm.register_builtin(BUILTIN_GLOBLIST, |vm, argc| {
3878 let text_mask = vm.pop().to_int() as u64;
3879 let mask = vm.pop().to_int() as u64;
3880 let n = (argc as usize).saturating_sub(2);
3881 let mut words: Vec<Value> = (0..n).map(|_| vm.pop()).collect();
3882 words.reverse();
3883 let noglob =
3884 opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
3885 let errflag_set = || {
3886 (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3887 & crate::ported::zsh_h::ERRFLAG_ERROR)
3888 != 0
3889 };
3890 // c:Src/subst.c:165-191 — prefork's second pass: filesub on each word
3891 // in order, returning at the first error, before globlist ever runs.
3892 // Only EXPAND_TEXT mode-10 words deferred theirs (see that arm).
3893 for (i, word) in words.iter_mut().enumerate() {
3894 if errflag_set() {
3895 break; // c:187-190
3896 }
3897 // c:178 — `if (unset(SHFILEEXPANSION))`: under SH_FILE_EXPANSION
3898 // the file expansion already ran in the first pass (c:122-138),
3899 // BEFORE substitution, so a `~` that arrived through a GLOB_SUBST
3900 // expansion stays literal (`emulate sh; v='~/x'; print $v`).
3901 if i < 64
3902 && text_mask & (1u64 << i) != 0
3903 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHFILEEXPANSION)
3904 {
3905 *word = filesub_deferred_word(std::mem::replace(word, Value::Int(0)));
3906 }
3907 }
3908 let mut out: Vec<Value> = Vec::with_capacity(n);
3909 for (i, word) in words.into_iter().enumerate() {
3910 let stopped = errflag_set(); // c:494 `!errflag`
3911 let bit = if i < 64 { 1u64 << i } else { 0 };
3912 if stopped || (mask | text_mask) & bit == 0 {
3913 out.push(word);
3914 } else if text_mask & bit != 0 {
3915 out.push(glob_tokenized_word(word, noglob)); // c:495 zglob
3916 } else {
3917 out.push(glob_word_noting_errflag(word, noglob)); // c:495 zglob
3918 }
3919 }
3920 for v in out {
3921 vm.push(v);
3922 }
3923 Value::Int(0)
3924 });
3925 vm.register_builtin(BUILTIN_TYPESET_POSTASSIGNS_END, |_vm, _argc| {
3926 let word_failed = TYPESET_WORD_GLOB_FAILED.with(|c| c.replace(false));
3927 let postassign_failed =
3928 with_executor(|exec| exec.current_command_glob_failed.replace(word_failed));
3929 TYPESET_POSTASSIGN_GLOB_FAILED.with(|c| c.set(postassign_failed));
3930 Value::Int(0)
3931 });
3932
3933 vm.register_builtin(BUILTIN_TYPESET_PAREN_PACK, |vm, argc| {
3934 let mut vals: Vec<Value> = Vec::with_capacity(argc as usize);
3935 for _ in 0..argc {
3936 vals.push(vm.pop());
3937 }
3938 vals.reverse();
3939 let mut it = vals.into_iter();
3940 let mut out = it.next().map(|v| v.to_str()).unwrap_or_default();
3941 for v in it {
3942 match v {
3943 // Array → splice items as separate elements (splat);
3944 // empty array contributes nothing (empty elision).
3945 Value::Array(items) => {
3946 for item in items.iter() {
3947 out.push('\u{1f}');
3948 out.push_str(&item.to_str());
3949 }
3950 }
3951 other => {
3952 out.push('\u{1f}');
3953 out.push_str(&other.to_str());
3954 }
3955 }
3956 }
3957 Value::str(out)
3958 });
3959
3960 vm.register_builtin(BUILTIN_TYPESET_PAREN_CLOSE, |vm, _argc| {
3961 let base = vm.pop().to_str();
3962 Value::str(format!("{}\u{1f})", base))
3963 });
3964
3965 vm.register_builtin(BUILTIN_COMPDEF, |vm, argc| {
3966 let args = pop_args(vm, argc);
3967 // ACTUALLY A ZSH FUNCTION: compdef is defined by `compinit`, it is
3968 // never a builtin. Without the completion system set up it is
3969 // command-not-found (127) in every mode — `zsh -f; compdef` prints
3970 // "command not found: compdef". A user/compsys `compdef` FUNCTION
3971 // (autoload compinit → compinit defines compdef) wins and runs the
3972 // fast native impl; otherwise it's command-not-found. Previously the
3973 // extension builtin ran in native mode (bare `compdef` → "I need
3974 // arguments"), diverging from zsh.
3975 // compinit installs a `compdef` function stub (see
3976 // NATIVE_COMPDEF_MARKER) purely so `${+functions[compdef]}` is
3977 // true; route that exact body to the fast native impl instead of
3978 // dispatching the stub. A genuine user/compsys compdef function
3979 // (any other body) still wins via try_user_fn_override below.
3980 let is_native_stub = crate::ported::hashtable::shfunctab_lock()
3981 .read()
3982 .ok()
3983 .and_then(|t| t.get("compdef").and_then(|shf| shf.body.clone()))
3984 .map(|b| b.trim() == crate::extensions::ext_builtins::NATIVE_COMPDEF_MARKER)
3985 .unwrap_or(false);
3986 if is_native_stub {
3987 return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
3988 }
3989 if let Some(s) = try_user_fn_override("compdef", &args) {
3990 return Value::Status(s);
3991 }
3992 if with_executor(|exec| exec.function_exists("compdef")) {
3993 return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
3994 }
3995 eprintln!("zsh:1: command not found: compdef");
3996 Value::Status(127)
3997 });
3998
3999 vm.register_builtin(BUILTIN_COMPINIT, |vm, argc| {
4000 let args = pop_args(vm, argc);
4001 // ACTUALLY A ZSH FUNCTION: compinit is a contrib FUNCTION (autoloaded
4002 // from $fpath), never a builtin. Without `autoload -Uz compinit` it is
4003 // command-not-found
4004 // (127) in every mode — `zsh -f; compinit` prints
4005 // "command not found: compinit". zshrs previously ran its builtin
4006 // unconditionally, so bare `compinit` succeeded. Gate on a compinit
4007 // function entry existing (which `autoload -Uz compinit` creates);
4008 // once the user has autoloaded/defined it, run zshrs's implementation.
4009 if !with_executor(|exec| exec.function_exists("compinit")) {
4010 eprintln!("zsh:1: command not found: compinit");
4011 let _ = args;
4012 return Value::Status(127);
4013 }
4014 Value::Status(with_executor(|exec| exec.builtin_compinit(&args)))
4015 });
4016
4017 reg_ext_overridable!(vm, BUILTIN_CDREPLAY, "cdreplay", builtin_cdreplay);
4018
4019 // Zsh-specific
4020 reg_passthru!(vm, BUILTIN_ZSTYLE, "zstyle");
4021 reg_passthru!(vm, BUILTIN_ZMODLOAD, "zmodload");
4022 reg_passthru!(vm, BUILTIN_BINDKEY, "bindkey");
4023 reg_passthru!(vm, BUILTIN_ZLE, "zle");
4024 reg_passthru!(vm, BUILTIN_VARED, "vared");
4025 reg_passthru!(vm, BUILTIN_ZCOMPILE, "zcompile");
4026 reg_passthru!(vm, BUILTIN_ZFORMAT, "zformat");
4027 reg_passthru!(vm, BUILTIN_ZPARSEOPTS, "zparseopts");
4028 reg_passthru!(vm, BUILTIN_ZREGEXPARSE, "zregexparse");
4029
4030 // Resource limits
4031 reg_passthru!(vm, BUILTIN_ULIMIT, "ulimit");
4032 reg_passthru!(vm, BUILTIN_LIMIT, "limit");
4033 reg_passthru!(vm, BUILTIN_UNLIMIT, "unlimit");
4034 reg_passthru!(vm, BUILTIN_UMASK, "umask");
4035
4036 // Misc
4037 reg_passthru!(vm, BUILTIN_TIMES, "times");
4038
4039 vm.register_builtin(BUILTIN_CALLER, |vm, argc| {
4040 let args = pop_args(vm, argc);
4041 // c:Bug #475 — `caller` is a bash-only builtin. In `--zsh`
4042 // mode emit the canonical "command not found" diagnostic
4043 // and rc=127 matching zsh's external-command-lookup miss.
4044 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
4045 eprintln!("zsh:1: command not found: caller");
4046 let _ = args;
4047 return Value::Status(127);
4048 }
4049 Value::Status(with_executor(|exec| exec.builtin_caller(&args)))
4050 });
4051
4052 vm.register_builtin(BUILTIN_HELP, |vm, argc| {
4053 let args = pop_args(vm, argc);
4054 // c:Bug #475 — `help` is a bash-only builtin. Same gate as
4055 // BUILTIN_CALLER above.
4056 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
4057 eprintln!("zsh:1: command not found: help");
4058 let _ = args;
4059 return Value::Status(127);
4060 }
4061 Value::Status(with_executor(|exec| exec.builtin_help(&args)))
4062 });
4063
4064 reg_passthru!(vm, BUILTIN_ENABLE, "enable");
4065 reg_passthru!(vm, BUILTIN_DISABLE, "disable");
4066 reg_passthru!(vm, BUILTIN_TTYCTL, "ttyctl");
4067 reg_passthru!(vm, BUILTIN_SYNC, "sync");
4068 reg_passthru!(vm, BUILTIN_MKDIR, "mkdir");
4069 reg_passthru!(vm, BUILTIN_STRFTIME, "strftime");
4070
4071 vm.register_builtin(BUILTIN_ZSLEEP, |vm, argc| {
4072 let args = pop_args(vm, argc);
4073 // function > builtin: a user `zsleep() { … }` wins.
4074 if let Some(s) = try_user_fn_override("zsleep", &args) {
4075 return Value::Status(s);
4076 }
4077 Value::Status(crate::extensions::ext_builtins::zsleep(&args))
4078 });
4079
4080 reg_passthru!(vm, BUILTIN_ZSYSTEM, "zsystem");
4081
4082 // PCRE
4083 reg_passthru!(vm, BUILTIN_PCRE_COMPILE, "pcre_compile");
4084 reg_passthru!(vm, BUILTIN_PCRE_MATCH, "pcre_match");
4085 reg_passthru!(vm, BUILTIN_PCRE_STUDY, "pcre_study");
4086
4087 // Database (GDBM)
4088 reg_passthru!(vm, BUILTIN_ZTIE, "ztie");
4089 reg_passthru!(vm, BUILTIN_ZUNTIE, "zuntie");
4090 reg_passthru!(vm, BUILTIN_ZGDBMPATH, "zgdbmpath");
4091
4092 // Prompt
4093 //
4094 // ACTUALLY ZSH FUNCTIONS: `promptinit` is Functions/Prompts/promptinit,
4095 // and `prompt` is a function that file defines — neither is a builtin,
4096 // but fusevm's name table hands both an opcode. Resolve them the way
4097 // execcmd does a name that is not in builtintab (c:Src/exec.c:3484-3488
4098 // shfunctab probe, else the command search): run the shell function,
4099 // or fall to the external lookup and its "command not found".
4100 //
4101 // A native stand-in used to answer both names: a hard-coded theme list
4102 // (one of them, `minimal`, not shipped by 5.9) in place of promptinit's
4103 // `$^fpath/prompt_*_setup(N)` scan, so a user's own themes were never
4104 // listed, and a `prompt` that shadowed the real function even after
4105 // promptinit had defined it — `prompt -l` printed one theme per line,
4106 // `prompt -h THEME` never showed the theme's help, and `prompt adam1`
4107 // assigned a canned PS1 without running prompt_adam1_setup.
4108 vm.register_builtin(BUILTIN_PROMPTINIT, |vm, argc| {
4109 let args = pop_args(vm, argc);
4110 if let Some(s) = try_user_fn_override("promptinit", &args) {
4111 return Value::Status(s);
4112 }
4113 let argv: Vec<String> = std::iter::once("promptinit".to_string()).chain(args).collect();
4114 Value::Status(with_executor(|exec| exec.host_exec_external(&argv)))
4115 });
4116
4117 vm.register_builtin(BUILTIN_PROMPT, |vm, argc| {
4118 let args = pop_args(vm, argc);
4119 if let Some(s) = try_user_fn_override("prompt", &args) {
4120 return Value::Status(s);
4121 }
4122 let argv: Vec<String> = std::iter::once("prompt".to_string()).chain(args).collect();
4123 Value::Status(with_executor(|exec| exec.host_exec_external(&argv)))
4124 });
4125
4126 // Async / Parallel (zshrs extensions) — all overridable by a
4127 // same-named user function (function > builtin).
4128 reg_ext_overridable!(vm, BUILTIN_ASYNC, "async", builtin_async);
4129 reg_ext_overridable!(vm, BUILTIN_AWAIT, "await", builtin_await);
4130 reg_ext_overridable!(vm, BUILTIN_PMAP, "pmap", builtin_pmap);
4131 reg_ext_overridable!(vm, BUILTIN_PGREP, "pgrep", builtin_pgrep);
4132 reg_ext_overridable!(vm, BUILTIN_PEACH, "peach", builtin_peach);
4133 reg_ext_overridable!(vm, BUILTIN_BARRIER, "barrier", builtin_barrier);
4134
4135 // `ai` — zshrs-original LLM builtin (`src/extensions/ai.rs`).
4136 // fusevm's name registry maps `"ai"` to `BUILTIN_AI` (264), so the
4137 // compiler emits `CallBuiltin(BUILTIN_AI)` for a literal `ai`. The VM
4138 // silently skips an unregistered id: without this line `ai` is a no-op.
4139 reg_ext_overridable!(vm, BUILTIN_AI, "ai", builtin_ai);
4140
4141 // Intercept (AOP)
4142 reg_ext_overridable!(vm, BUILTIN_INTERCEPT, "intercept", builtin_intercept);
4143 reg_ext_overridable!(
4144 vm,
4145 BUILTIN_INTERCEPT_PROCEED,
4146 "intercept_proceed",
4147 builtin_intercept_proceed
4148 );
4149
4150 // Debug / Profile
4151 reg_ext_overridable!(vm, BUILTIN_DOCTOR, "doctor", builtin_doctor);
4152 reg_ext_overridable!(vm, BUILTIN_DBVIEW, "dbview", builtin_dbview);
4153 reg_ext_overridable!(vm, BUILTIN_PROFILE, "profile", builtin_profile);
4154 reg_ext_overridable!(vm, BUILTIN_PROVENANCE, "provenance", builtin_provenance);
4155
4156 reg_passthru!(vm, BUILTIN_ZPROF, "zprof");
4157
4158 // ═══════════════════════════════════════════════════════════════════════
4159 // Coreutils builtins (anti-fork, gated by !posix_mode)
4160 //
4161 // All of these are routinely wrapped by user functions in real
4162 // dotfiles (zpwr, oh-my-zsh, etc.) — `cat() { ... }`, `ls() { ... }`,
4163 // `find() { ... }`. Each handler MUST consult try_user_fn_override
4164 // first (via reg_overridable!) so the user definition wins, matching
4165 // zsh's alias → function → builtin dispatch order.
4166 // ═══════════════════════════════════════════════════════════════════════
4167
4168 reg_overridable!(vm, BUILTIN_CAT, "cat", builtin_cat);
4169 reg_overridable!(vm, BUILTIN_HEAD, "head", builtin_head);
4170 reg_overridable!(vm, BUILTIN_TAIL, "tail", builtin_tail);
4171 reg_overridable!(vm, BUILTIN_WC, "wc", builtin_wc);
4172 reg_overridable!(vm, BUILTIN_BASENAME, "basename", builtin_basename);
4173 reg_overridable!(vm, BUILTIN_DIRNAME, "dirname", builtin_dirname);
4174 reg_overridable!(vm, BUILTIN_TOUCH, "touch", builtin_touch);
4175 reg_overridable!(vm, BUILTIN_REALPATH, "realpath", builtin_realpath);
4176 reg_overridable!(vm, BUILTIN_SORT, "sort", builtin_sort);
4177 reg_overridable!(vm, BUILTIN_FIND, "find", builtin_find);
4178 reg_overridable!(vm, BUILTIN_UNIQ, "uniq", builtin_uniq);
4179 reg_overridable!(vm, BUILTIN_CUT, "cut", builtin_cut);
4180 reg_overridable!(vm, BUILTIN_TR, "tr", builtin_tr);
4181 reg_overridable!(vm, BUILTIN_SEQ, "seq", builtin_seq);
4182 reg_overridable!(vm, BUILTIN_REV, "rev", builtin_rev);
4183 reg_overridable!(vm, BUILTIN_TEE, "tee", builtin_tee);
4184 reg_overridable!(vm, BUILTIN_SLEEP, "sleep", builtin_sleep);
4185 reg_overridable!(vm, BUILTIN_WHOAMI, "whoami", builtin_whoami);
4186 reg_overridable!(vm, BUILTIN_ID, "id", builtin_id);
4187
4188 reg_overridable!(vm, BUILTIN_HOSTNAME, "hostname", builtin_hostname);
4189 reg_overridable!(vm, BUILTIN_UNAME, "uname", builtin_uname);
4190 reg_overridable!(vm, BUILTIN_DATE, "date", builtin_date);
4191 reg_overridable!(vm, BUILTIN_MKTEMP, "mktemp", builtin_mktemp);
4192 // `cp` — zshrs extension (NOT in upstream zsh; upstream's
4193 // zsh/files module ships `ln`/`mv`/`rm`/`chmod`/`chown` but no
4194 // `cp`). In-process implementation in
4195 // `ext_builtins::cp_impl` — recursive copy with -r/-R, -f, -i,
4196 // -n, -p (chown + utimensat), -v. ID 263 is the first slot
4197 // past fusevm's built-in range (260-262) and before BUILTIN_MAX
4198 // (280).
4199 /// `BUILTIN_CP` constant.
4200 pub const BUILTIN_CP: u16 = 263;
4201 reg_overridable!(vm, BUILTIN_CP, "cp", builtin_cp);
4202
4203 // Pipeline execution — bytecode-native fork-per-stage. Pops N sub-chunk
4204 // indices, forks N children with stdin/stdout wired through N-1 pipes,
4205 // each child runs its stage's compiled bytecode and exits. Parent waits
4206 // and returns the last stage's status.
4207 //
4208 // Caveats: post-fork in a multi-threaded program, only async-signal-safe
4209 // ops are POSIX-safe. We violate this (running the bytecode VM after fork
4210 // touches mutexes like REGEX_CACHE). In practice, most pipeline stages
4211 // don't touch shared mutex state — externals fork/exec away, builtins do
4212 // pure I/O. Risks are bounded; if a stage does touch a held mutex, the
4213 // child deadlocks.
4214 vm.register_builtin(BUILTIN_RUN_PIPELINE, |vm, argc| {
4215 let n = argc as usize;
4216 if n == 0 {
4217 return Value::Status(0);
4218 }
4219
4220 // c:Src/exec.c — every pipeline stage forks from the current
4221 // shell state, so each stage observes the pre-pipeline $? until
4222 // it runs its own command. Stage sub-VMs start fresh with
4223 // last_status=0, so seed them with the parent's lastval; without
4224 // this `false; echo $? | cat` prints 0 instead of zsh's 1.
4225 let parent_status = vm.last_status;
4226
4227 // Pop N sub-chunk indices (LIFO → reverse to stage order)
4228 let mut indices: Vec<u16> = Vec::with_capacity(n);
4229 for _ in 0..n {
4230 indices.push(vm.pop().to_int() as u16);
4231 }
4232 indices.reverse();
4233
4234 // Clone each stage's sub-chunk
4235 let stages: Vec<fusevm::Chunk> = indices
4236 .iter()
4237 .filter_map(|&i| vm.chunk.sub_chunks.get(i as usize).cloned())
4238 .collect();
4239 if stages.len() != n {
4240 return Value::Status(1);
4241 }
4242
4243 // Single stage — no pipe, just run inline
4244 if n == 1 {
4245 let stage = stages.into_iter().next().unwrap();
4246 crate::fusevm_disasm::maybe_print_stdout("pipeline:single", &stage);
4247 let mut stage_vm = fusevm::VM::new(stage);
4248 stage_vm.last_status = parent_status;
4249 register_builtins(&mut stage_vm);
4250 let _ = stage_vm.run();
4251 return Value::Status(stage_vm.last_status);
4252 }
4253
4254 // A `time`d pipeline: record each forked stage for dumptime (see
4255 // TimedPipeline). Taken, so a pipeline nested in a stage runs untimed.
4256 let mut timed = TIMED_PIPELINE
4257 .with(|t| t.borrow_mut().take())
4258 .filter(|t| t.texts.len() == n);
4259 let mut child_started: Vec<std::time::Instant> = Vec::with_capacity(n - 1);
4260
4261 // c:Src/exec.c:1748 — `child_block();` before any stage is forked,
4262 // held through the in-shell last stage and the stage waits below,
4263 // released at c:2017. See ChildBlockSpan.
4264 #[cfg(unix)]
4265 let _child_block = ChildBlockSpan::enter();
4266
4267 // Build N-1 pipes
4268 let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(n - 1);
4269 for _ in 0..n - 1 {
4270 let mut fds = [0i32; 2];
4271 if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
4272 // Cleanup any pipes we already created
4273 for (r, w) in &pipes {
4274 unsafe {
4275 libc::close(*r);
4276 libc::close(*w);
4277 }
4278 }
4279 return Value::Status(1);
4280 }
4281 pipes.push((fds[0], fds[1]));
4282 }
4283
4284 // zsh runs the LAST stage of a pipeline in the CURRENT shell
4285 // (not a forked child) so a trailing `read x` keeps its
4286 // assignment in the parent. Other shells (bash) fork every
4287 // stage. Honor zsh by leaving stage N-1 inline. Forks the
4288 // first N-1 stages with fork(); runs the last in this process
4289 // with stdin dup2'd to the last pipe's read end and stdout
4290 // restored after.
4291 let last_idx = n - 1;
4292 let stages_vec: Vec<fusevm::Chunk> = stages.into_iter().collect();
4293
4294 let mut child_pids: Vec<libc::pid_t> = Vec::with_capacity(n - 1);
4295 for (i, chunk) in stages_vec.iter().take(last_idx).enumerate() {
4296 match unsafe { libc::fork() } {
4297 -1 => {
4298 // fork failed — kill any children we already started
4299 for pid in &child_pids {
4300 unsafe { libc::kill(*pid, libc::SIGTERM) };
4301 }
4302 for (r, w) in &pipes {
4303 unsafe {
4304 libc::close(*r);
4305 libc::close(*w);
4306 }
4307 }
4308 return Value::Status(1);
4309 }
4310 0 => {
4311 forked_child_subsh_levels(); // c:Src/exec.c:1221
4312 EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
4313 // Reset SIGPIPE to default so a broken-pipe write
4314 // kills the child cleanly instead of triggering a
4315 // Rust println! panic. The parent shell ignores
4316 // SIGPIPE so it can handle EPIPE itself, but child
4317 // pipeline stages should die quietly when their
4318 // downstream stage closes early (e.g. `seq | head -3`).
4319 unsafe {
4320 libc::signal(libc::SIGPIPE, libc::SIG_DFL);
4321 }
4322 // c:Src/exec.c — pipeline children are forked
4323 // subshells; their EXIT trap context is reset so
4324 // the parent's `trap '...' EXIT` doesn't fire when
4325 // the child exits. Mirror by dropping EXIT from
4326 // the inherited traps_table inside the child.
4327 // c:Src/exec.c:2917-2918 — a forked PIPELINE stage enters
4328 // the subshell with ESUB_KEEPTRAP:
4329 // if ((type != WC_SUBSH) && !(how & Z_ASYNC))
4330 // flags |= ESUB_KEEPTRAP;
4331 // so entersubsh's c:1127 reset loop is SKIPPED and the
4332 // stage keeps the parent's traps. Only the EXIT trap goes,
4333 // so the parent's `trap '…' EXIT` does not fire when the
4334 // stage exits. Applying the full reset here instead was
4335 // wrong: it wiped the inherited-SIGQUIT record and the
4336 // parent's other trap flags inside every pipeline stage.
4337 //
4338 // Drop it from BOTH stores, since a body-less entry lives
4339 // only in sigtrapped and the `trap` listing now reads it
4340 // (c:Src/builtin.c:7358-7361); clearing just the body left
4341 // `trap | grep -c EXIT` reporting the stale flag.
4342 if let Ok(mut tt) = crate::ported::builtin::traps_table().lock() {
4343 tt.remove("EXIT");
4344 }
4345 if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
4346 if let Some(slot) = st.get_mut(crate::ported::signals_h::SIGEXIT as usize) {
4347 *slot = 0;
4348 }
4349 }
4350 // c:Src/exec.c:2862 → 1219 — pipeline children run
4351 // entersubsh with ESUB_PGRP, which clears the job
4352 // table (clearjobtab, Src/jobs.c:1780). Without
4353 // this, `sleep 5 & jobs -p | wc -l` reports 1 in
4354 // the forked stage where zsh reports 0. The fork
4355 // already copy-isolates the statics, so mutating
4356 // them here can't leak to the parent.
4357 with_executor(|exec| {
4358 let monitor =
4359 crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
4360 crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
4361 });
4362 // c:Src/exec.c:1153-1154 — the same entersubsh call sets
4363 // `subsh = 1` in the forked stage. PRINT_EXIT_VALUE reads
4364 // it (c:4309 `&& !subsh`), which is why zsh prints nothing
4365 // for the failing stage of `false | true`.
4366 crate::ported::exec::subsh.store(1, std::sync::atomic::Ordering::Relaxed);
4367 *crate::ported::jobs::THISJOB
4368 .get_or_init(|| std::sync::Mutex::new(-1))
4369 .lock()
4370 .unwrap() = -1;
4371 // c:Src/exec.c:3720-3724 — the stage's own fds go
4372 // onto 0/1 only AFTER its argument words have been
4373 // expanded (prefork c:3304 / globlist c:3702), so
4374 // park them and let the stage chunk's
4375 // BUILTIN_PIPE_FDS_INSTALL do the dup2 at the
4376 // C-faithful point. `print -rl -- c a b |
4377 // print -r -- "[$(cat)]" | cat` therefore prints
4378 // `[]` — the middle stage's `$(cat)` reads the
4379 // shell's stdin, not the pipe.
4380 let in_fd = if i > 0 { pipes[i - 1].0 } else { -1 };
4381 let out_fd = pipes[i].1;
4382 // (Pipe-output MULTIOS marking — c:Src/exec.c:3724 —
4383 // is emitted INTO the stage chunk by compile_pipe
4384 // via BUILTIN_PIPE_OUTPUT_MARK, gated on the stage's
4385 // top-level command actually carrying redirects, so
4386 // a nested `{ echo a > f; } | cat` body redirect
4387 // does not wrongly join the pipe.)
4388 // Close every pipe fd this stage doesn't need. The
4389 // two it does keep are closed by the install op
4390 // right after their dup2.
4391 for (r, w) in &pipes {
4392 unsafe {
4393 if *r != in_fd && *r != out_fd {
4394 libc::close(*r);
4395 }
4396 if *w != in_fd && *w != out_fd {
4397 libc::close(*w);
4398 }
4399 }
4400 }
4401 stage_fds_park(in_fd, out_fd);
4402
4403 // Run this stage's bytecode on a fresh VM
4404 crate::fusevm_disasm::maybe_print_stdout(
4405 &format!("pipeline:child:stage:{i}"),
4406 chunk,
4407 );
4408 let mut stage_vm = fusevm::VM::new(chunk.clone());
4409 stage_vm.last_status = parent_status;
4410 register_builtins(&mut stage_vm);
4411 let _ = stage_vm.run();
4412 // Flush any buffered output before exiting
4413 let _ = std::io::stdout().flush();
4414 let _ = std::io::stderr().flush();
4415 std::process::exit(stage_vm.last_status);
4416 }
4417 pid => {
4418 child_pids.push(pid);
4419 child_started.push(std::time::Instant::now()); // c:Src/jobs.c:1480 bgtime
4420 }
4421 }
4422 }
4423
4424 // Parent runs the LAST stage inline. Save stdin, park the last
4425 // pipe's read end for the chunk's BUILTIN_PIPE_FDS_INSTALL
4426 // (c:Src/exec.c:3722 `addfd(..., 0, input, 0, NULL)` — after
4427 // the stage's args are expanded, so `… | print -r -- "[$(cat)]"`
4428 // has its `$(cat)` read the shell's stdin, not the pipe), run
4429 // the chunk, restore stdin. Close every other pipe fd so the
4430 // producer side gets EOF when the last upstream stage exits.
4431 // Shell-internal save — keep it out of the script's fd range (movefd,
4432 // c:Src/exec.c:2425).
4433 let saved_stdin = unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD_CLOEXEC, 10) };
4434 let last_in_fd = if last_idx > 0 {
4435 pipes[last_idx - 1].0
4436 } else {
4437 -1
4438 };
4439 // Close all pipe fds in the parent except the one the last
4440 // stage still has to install. (Children already have their own
4441 // copies; the install op closes the read end after its dup2.)
4442 for (r, w) in &pipes {
4443 unsafe {
4444 if *r != last_in_fd {
4445 libc::close(*r);
4446 }
4447 libc::close(*w);
4448 }
4449 }
4450 let outer_stage_fds = stage_fds_park(last_in_fd, -1);
4451
4452 // Run the last stage's bytecode on a sub-VM with the host wired up.
4453 // By default (zsh semantics) the sub-VM runs IN THIS PROCESS so the
4454 // last stage's reads/assignments update the parent's state directly
4455 // (`echo x | read v` sets $v; `cmd | mapfile arr` sets arr).
4456 //
4457 // !!! BASH-MODE GATE !!! bash forks EVERY pipeline stage (unless
4458 // `shopt -s lastpipe`), so the last stage runs in a SUBSHELL and its
4459 // variable/array assignments do NOT persist — `echo x | read v; echo
4460 // $v` prints an empty line, `cmd | mapfile arr` leaves arr unset.
4461 // Fork the last stage under `--bash` to match. The parent's existing
4462 // `stage_fds_take()` below closes its `last_in_fd` copy; the forked
4463 // child inherits the parked pipe fd and installs it onto stdin, and
4464 // the writer stages (already forked) supply its input.
4465 //
4466 // c:Src/exec.c:3042-3043 — zsh itself forks the last stage of a
4467 // multi-stage pipeline under sh emulation:
4468 // if ((how & Z_ASYNC) || output ||
4469 // (last1 == 2 && input && EMULATION(EMULATE_SH))) {
4470 // ... execcmd_fork(...)
4471 // so `emulate sh -c 'echo x | read v; echo $v'` prints an empty
4472 // line. ksh emulation keeps the in-shell last stage.
4473 let mut last_proc: Option<crate::ported::zsh_h::process> = None;
4474 let last_stage_status = if crate::dash_mode::bash_mode()
4475 || crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_SH)
4476 {
4477 let last_chunk = stages_vec.into_iter().last().unwrap();
4478 crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
4479 match unsafe { libc::fork() } {
4480 -1 => 1,
4481 0 => {
4482 forked_child_subsh_levels(); // c:Src/exec.c:1221
4483 EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
4484 // Subshell child: run the last stage, then _exit with its
4485 // status. Reset SIGPIPE + drop the EXIT trap like the
4486 // other pipeline children above.
4487 unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL) };
4488 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
4489 t.remove("EXIT");
4490 }
4491 let mut stage_vm = fusevm::VM::new(last_chunk);
4492 stage_vm.last_status = parent_status;
4493 register_builtins(&mut stage_vm);
4494 stage_vm.set_shell_host(Box::new(ZshrsHost));
4495 let _ = stage_vm.run();
4496 let st = stage_vm.last_status;
4497 let _ = std::io::stdout().flush();
4498 let _ = std::io::stderr().flush();
4499 unsafe { libc::_exit(st) };
4500 }
4501 pid => {
4502 let started = std::time::Instant::now();
4503 // Same EINTR retry as the stage reap loop below.
4504 let waited = wait4_eintr(pid);
4505 // c:Src/exec.c:3042-3043 — this last stage forked, so it
4506 // is a process of a timed job whatever its command is.
4507 if let (Some(t), Some((status, ru))) = (timed.as_ref(), waited.as_ref()) {
4508 last_proc = Some(timed_proc(pid, &t.texts[n - 1], *status, ru, started));
4509 }
4510 match waited.map(|(status, _)| status) {
4511 Some(status) if libc::WIFEXITED(status) => libc::WEXITSTATUS(status),
4512 Some(status) if libc::WIFSIGNALED(status) => 128 + libc::WTERMSIG(status),
4513 Some(_) => 1,
4514 None => 0,
4515 }
4516 }
4517 }
4518 } else {
4519 let last_chunk = stages_vec.into_iter().last().unwrap();
4520 crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
4521 // A timed pipeline's in-shell last stage is a process of the job
4522 // only when it forks (TimedPipeline::last_forks); its times are
4523 // those of the externals it waits for (FG_CHILD_TIMES). Not a
4524 // RUSAGE_CHILDREN delta: the reaper on another thread can collect
4525 // a forked stage inside this window and its usage would land here.
4526 let last_timed = timed.as_ref().filter(|t| t.last_forks).map(|_| {
4527 FG_CHILD_TIMES.with(|c| c.set(Some((0, 0))));
4528 std::time::Instant::now()
4529 });
4530 let mut stage_vm = fusevm::VM::new(last_chunk);
4531 stage_vm.last_status = parent_status;
4532 register_builtins(&mut stage_vm);
4533 stage_vm.set_shell_host(Box::new(ZshrsHost));
4534 // The externals this stage waits for are not the job's process
4535 // group leader (the first stage is), so they skip update_job's
4536 // foreground tail; see exec_jobs::foreground_job_signalled.
4537 crate::exec_jobs::NOT_JOB_LEADER.with(|d| d.set(d.get() + 1));
4538 let _ = stage_vm.run();
4539 crate::exec_jobs::NOT_JOB_LEADER.with(|d| d.set(d.get() - 1));
4540 let _ = std::io::stdout().flush();
4541 let _ = std::io::stderr().flush();
4542 if let (Some(t), Some(started)) = (timed.as_ref(), last_timed) {
4543 let (utime, stime) = FG_CHILD_TIMES.with(|c| c.take()).unwrap_or((0, 0));
4544 let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
4545 ru.ru_utime = usec_timeval(utime);
4546 ru.ru_stime = usec_timeval(stime);
4547 let status = (stage_vm.last_status & 0xff) << 8; // W_EXITCODE
4548 last_proc = Some(timed_proc(0, &t.texts[n - 1], status, &ru, started));
4549 }
4550 stage_vm.last_status
4551 };
4552
4553 // Reclaim the read end if the stage chunk never reached its
4554 // install op (an expansion error aborted it, or the stage was
4555 // a shape that dispatches without one), then restore the outer
4556 // stage's still-pending fds for a nested pipeline.
4557 let (leftover_in, _) = stage_fds_take();
4558 if leftover_in >= 0 {
4559 unsafe { libc::close(leftover_in) };
4560 }
4561 stage_fds_park(outer_stage_fds.0, outer_stage_fds.1);
4562
4563 // Restore stdin
4564 if saved_stdin >= 0 {
4565 unsafe {
4566 libc::dup2(saved_stdin, libc::STDIN_FILENO);
4567 libc::close(saved_stdin);
4568 }
4569 }
4570
4571 // Wait for all forked stages, capture per-stage statuses for PIPESTATUS.
4572 let mut pipestatus: Vec<i32> = Vec::with_capacity(n);
4573 for (i, pid) in child_pids.into_iter().enumerate() {
4574 // EINTR retry: the SIGCHLD handler interrupts this wait and
4575 // leaves `status` untouched, which used to read back as a
4576 // clean exit 0 for every forked stage. See waitpid_eintr.
4577 let waited = match timed.as_mut() {
4578 Some(t) => wait4_eintr(pid).map(|(status, ru)| {
4579 let started = child_started.get(i).copied().unwrap_or_else(std::time::Instant::now);
4580 t.procs.push(timed_proc(pid, &t.texts[i], status, &ru, started));
4581 status
4582 }),
4583 None => waitpid_eintr(pid),
4584 };
4585 if i == 0 {
4586 // c:Src/jobs.c:497-498 — `if (pn->pid == jn->gleader) status =
4587 // pn->status;`: the first stage leads the job's process group,
4588 // and its status is the one update_job's foreground tail reads.
4589 if let Some(status) = waited {
4590 crate::exec_jobs::foreground_job_signalled(status);
4591 }
4592 }
4593 let s = match waited {
4594 Some(status) if libc::WIFEXITED(status) => libc::WEXITSTATUS(status),
4595 Some(status) if libc::WIFSIGNALED(status) => 128 + libc::WTERMSIG(status),
4596 Some(_) => 1,
4597 // Unreapable (ECHILD — the handler got there first).
4598 // Nothing better to report than success; the stage's
4599 // real status is gone.
4600 None => 0,
4601 };
4602 pipestatus.push(s);
4603 }
4604 // Append the in-parent last-stage status so `pipestatus` ends
4605 // with N entries (one per stage).
4606 pipestatus.push(last_stage_status);
4607 if let Some(mut t) = timed {
4608 t.procs.extend(last_proc);
4609 TIMED_PIPELINE.with(|slot| *slot.borrow_mut() = Some(t));
4610 }
4611 // Pipeline exit status: by default, the LAST stage's status.
4612 // With `setopt pipefail` (or `set -o pipefail`), use the
4613 // first non-zero stage status (so failures earlier in the
4614 // pipeline propagate even if the last stage succeeded).
4615 let pipefail_on = with_executor(|exec| opt_state_get("pipefail").unwrap_or(false));
4616 let last_status = if pipefail_on {
4617 pipestatus
4618 .iter()
4619 .copied()
4620 .rfind(|&s| s != 0)
4621 .or_else(|| pipestatus.last().copied())
4622 .unwrap_or(0)
4623 } else {
4624 *pipestatus.last().unwrap_or(&0)
4625 };
4626
4627 // c:Src/params.c:265,438 — only `pipestatus` (lowercase) is the
4628 // zsh special parameter; bash's `PIPESTATUS` doesn't exist in
4629 // zsh's special-params table. Prior port also populated
4630 // `PIPESTATUS` "for portability" — but that's a real divergence
4631 // from zsh: a script doing `[[ -z $PIPESTATUS ]]` to detect
4632 // zsh-vs-bash would mis-classify. Bug #64 in docs/BUGS.md.
4633 // c:Src/jobs.c:438-441 (storepipestats) — the per-stage statuses go
4634 // into the C globals `pipestats[]` / `numpipestats` whatever the
4635 // emulation. Under sh/ksh `pipestatus` is not a special parameter
4636 // (no PM_SPECIAL, so the paramtab mirror below is skipped), but the
4637 // globals still carry the pipeline — `--mksh`'s `$PIPESTATUS` and
4638 // doshfunc's funcsave snapshot (c:Src/exec.c:5864) read them.
4639 {
4640 use crate::ported::jobs::{NUMPIPESTATS, PIPESTATS};
4641 use std::sync::Mutex;
4642 let count = pipestatus.len().min(crate::ported::zsh_h::MAX_PIPESTATS);
4643 let p = PIPESTATS.get_or_init(|| Mutex::new([0; crate::ported::zsh_h::MAX_PIPESTATS]));
4644 if let Ok(mut pguard) = p.lock() {
4645 pguard[..count].copy_from_slice(&pipestatus[..count]); // c:438 memcpy
4646 }
4647 let n = NUMPIPESTATS.get_or_init(|| Mutex::new(0));
4648 if let Ok(mut nguard) = n.lock() {
4649 *nguard = count; // c:441
4650 }
4651 }
4652 with_executor(|exec| {
4653 // c:Src/jobs.c:83 `int pipestats[MAX_PIPESTATS]` — the values
4654 // live in that C GLOBAL, reached through `pipestatus`'s GSU
4655 // (c:Src/params.c pipestatgetfn). A `typeset -h +g pipestatus`
4656 // local shadow carries no PM_SPECIAL and no GSU, so the C
4657 // writer cannot reach it; skip the paramtab mirror likewise or
4658 // the shadow loses its PM_UNSET (B02typeset.ztst:37,38).
4659 let shadowed = crate::ported::params::paramtab()
4660 .read()
4661 .ok()
4662 .and_then(|t| {
4663 t.get("pipestatus")
4664 .map(|pm| (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) == 0)
4665 })
4666 .unwrap_or(false);
4667 if !shadowed {
4668 let strs: Vec<String> = pipestatus.iter().map(|s| s.to_string()).collect();
4669 exec.set_array("pipestatus".to_string(), strs);
4670 }
4671 });
4672
4673 Value::Status(last_status)
4674 });
4675
4676 // Array→String join. Pops one value; if it's an Array (e.g. from Op::Glob),
4677 // joins string-coerced elements with a single space. Pass-through for
4678 // non-arrays so the op is safe to chain after any String-or-Array producer.
4679 // Scalar coercion of an assembled word: pop a Value; if it's an
4680 // Array (produced by a splice segment like `"$@"` / `"${arr[@]}"`),
4681 // IFS[0]-join it to a single scalar; a scalar passes through. This
4682 // is the assignment-context coercion C zsh applies in multsub when
4683 // the expansion is the RHS of a SCALAR assignment (Src/subst.c
4684 // c:3032 sepjoin under ssub) — `v="$@"` joins the positionals with
4685 // ${IFS[1]} rather than leaving an array whose splat would lose all
4686 // but the first element. Joins via sepjoin so a custom / empty IFS
4687 // is honored (not a hardcoded space).
4688 vm.register_builtin(BUILTIN_ARRAY_JOIN, |vm, _argc| {
4689 let val = vm.pop();
4690 match val {
4691 Value::Array(items) => {
4692 let strs: Vec<String> = items.iter().map(|v| v.to_str()).collect();
4693 Value::str(crate::ported::utils::sepjoin(&strs, None))
4694 }
4695 other => other,
4696 }
4697 });
4698
4699 // `cmd &` background execution. Compile_list emits this for any item
4700 // followed by ListOp::Amp: the job text + the cmd's sub-chunk index are
4701 // pushed, then this builtin pops both, looks up the chunk, forks. The
4702 // child detaches via setsid (so SIGINT to the foreground job doesn't kill
4703 // it), runs the bytecode on a fresh VM with builtins re-registered, exits
4704 // with the last status. The parent registers the job in the canonical
4705 // JOBTAB (c:Src/exec.c::execpline Z_ASYNC arm) and returns Status(0).
4706 vm.register_builtin(BUILTIN_RUN_BG, |vm, _argc| {
4707 // `&|` / `&!` set disown → the job is dropped from the table (no
4708 // `[N] pid` announcement, no `[N] done`), matching C exec.c:1752-1758.
4709 let disown = vm.pop().to_int() != 0;
4710 // Stage count. 1 for anything but a multi-stage pipeline; the compiler
4711 // pushes one (text, sub_chunk_index) pair per stage ahead of it.
4712 let nstages = vm.pop().to_int().max(1) as usize;
4713 let mut stages: Vec<(usize, String)> = Vec::with_capacity(nstages);
4714 for _ in 0..nstages {
4715 let idx = vm.pop().to_int() as usize;
4716 let text = vm.pop().to_str();
4717 stages.push((idx, text));
4718 }
4719 stages.reverse(); // popped last-stage-first
4720 let chunks: Vec<fusevm::Chunk> = match stages
4721 .iter()
4722 .map(|(i, _)| vm.chunk.sub_chunks.get(*i).cloned())
4723 .collect::<Option<Vec<_>>>()
4724 {
4725 Some(c) => c,
4726 None => return Value::Status(1),
4727 };
4728
4729 // c:Src/exec.c:1748 — `child_block();`, released by the async arm's
4730 // `child_unblock();` (c:1815) once the job is registered. A child
4731 // that exits at once must not be reaped before `addproc` knows its
4732 // pid. See ChildBlockSpan.
4733 #[cfg(unix)]
4734 let _child_block = ChildBlockSpan::enter();
4735
4736 if nstages > 1 {
4737 // c:Src/exec.c:1795 — the Z_ASYNC arm runs execpline2 in THIS
4738 // shell, so every stage is forked here and becomes its own proc
4739 // entry on the one job. Build the N-1 pipes, fork N children, and
4740 // addproc each; nothing in the parent waits.
4741 let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(nstages - 1);
4742 for _ in 0..nstages - 1 {
4743 let mut fds = [0i32; 2];
4744 if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
4745 for (r, w) in &pipes {
4746 unsafe {
4747 libc::close(*r);
4748 libc::close(*w);
4749 }
4750 }
4751 return Value::Status(1);
4752 }
4753 pipes.push((fds[0], fds[1]));
4754 }
4755 // c:Src/exec.c:2916 — every async stage enters the subshell with
4756 // ESUB_PGRP, so they all land in ONE new process group led by the
4757 // first stage (c:Src/jobs.c:1582-1583 sets jn->gleader to the first
4758 // proc's pid). Keeping them together is what makes `kill %1` reach
4759 // the whole pipeline. Both parent and child set the pgid so neither
4760 // side can lose the race.
4761 let mut gleader: libc::pid_t = 0;
4762 let mut procs: Vec<(libc::pid_t, String)> = Vec::with_capacity(nstages);
4763 for (i, chunk) in chunks.iter().enumerate() {
4764 match unsafe { libc::fork() } {
4765 -1 => {
4766 for (pid, _) in &procs {
4767 unsafe { libc::kill(*pid, libc::SIGTERM) };
4768 }
4769 for (r, w) in &pipes {
4770 unsafe {
4771 libc::close(*r);
4772 libc::close(*w);
4773 }
4774 }
4775 return Value::Status(1);
4776 }
4777 0 => {
4778 forked_child_subsh_levels(); // c:Src/exec.c:1221
4779 EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
4780 // c:Src/exec.c:2916 — every async stage runs
4781 // `entersubsh(ESUB_ASYNC|ESUB_PGRP)`: traps reset, MONITOR
4782 // off, SIGINT/SIGQUIT ignored without job control, the
4783 // interactive SIGTERM ignore dropped, job table cleared,
4784 // subsh set. With job control it joins the job's group,
4785 // led by the first stage (c:1149-1190); the port reads
4786 // that leader from jobtab[thisjob], which zshrs fills in
4787 // only after every fork, so join it here instead.
4788 let monitor = crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR);
4789 crate::ported::exec::entersubsh(
4790 crate::ported::exec::esub::ASYNC | crate::ported::exec::esub::PGRP,
4791 None,
4792 );
4793 if monitor {
4794 unsafe { libc::setpgid(0, gleader) };
4795 }
4796 // Mirrors the RUN_PIPELINE stage child: default SIGPIPE
4797 // so a broken pipe kills the stage quietly, and drop the
4798 // parent's EXIT trap (c:Src/exec.c:2917-2918).
4799 unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL) };
4800 if let Ok(mut tt) = crate::ported::builtin::traps_table().lock() {
4801 tt.remove("EXIT");
4802 }
4803 if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
4804 if let Some(slot) =
4805 st.get_mut(crate::ported::signals_h::SIGEXIT as usize)
4806 {
4807 *slot = 0;
4808 }
4809 }
4810 *crate::ported::jobs::THISJOB
4811 .get_or_init(|| std::sync::Mutex::new(-1))
4812 .lock()
4813 .unwrap() = -1;
4814 let in_fd = if i > 0 { pipes[i - 1].0 } else { -1 };
4815 // Unlike a foreground pipeline (whose last stage runs
4816 // inline in the shell), an async pipeline forks its last
4817 // stage too, so that stage simply has no pipe on stdout.
4818 let out_fd = if i + 1 < nstages { pipes[i].1 } else { -1 };
4819 for (r, w) in &pipes {
4820 unsafe {
4821 if *r != in_fd && *r != out_fd {
4822 libc::close(*r);
4823 }
4824 if *w != in_fd && *w != out_fd {
4825 libc::close(*w);
4826 }
4827 }
4828 }
4829 stage_fds_park(in_fd, out_fd);
4830 crate::fusevm_disasm::maybe_print_stdout(
4831 &format!("background_job:stage:{i}"),
4832 chunk,
4833 );
4834 let mut stage_vm = fusevm::VM::new(chunk.clone());
4835 register_builtins(&mut stage_vm);
4836 let _ = stage_vm.run();
4837 let _ = std::io::stdout().flush();
4838 let _ = std::io::stderr().flush();
4839 std::process::exit(stage_vm.last_status);
4840 }
4841 pid => {
4842 if gleader == 0 {
4843 gleader = pid;
4844 }
4845 if crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) {
4846 unsafe { libc::setpgid(pid, gleader) };
4847 }
4848 procs.push((pid, stages[i].1.clone()));
4849 }
4850 }
4851 }
4852 for (r, w) in &pipes {
4853 unsafe {
4854 libc::close(*r);
4855 libc::close(*w);
4856 }
4857 }
4858 // c:Src/exec.c:2891-2892 — `if (how & Z_ASYNC) lastpid = pid;` runs
4859 // for EVERY stage as it is forked, so `$!` ends up holding the LAST
4860 // stage's pid even though the job's group leader is the first.
4861 if let Some((last, _)) = procs.last() {
4862 crate::ported::modules::clone::lastpid
4863 .store(*last, std::sync::atomic::Ordering::Relaxed);
4864 }
4865 {
4866 use crate::ported::jobs;
4867 use std::sync::Mutex;
4868 let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
4869 let idx = {
4870 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
4871 let idx = jobs::initjob(&mut tab); // c:exec.c:1756
4872 for (pid, text) in &procs {
4873 // c:exec.c:2907 — one addproc per forked stage.
4874 jobs::addproc(
4875 &mut tab[idx],
4876 *pid,
4877 text,
4878 false,
4879 Some(std::time::Instant::now()),
4880 -1,
4881 -1,
4882 );
4883 }
4884 tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1803
4885 idx
4886 };
4887 jobs::clearoldjobtab(); // c:exec.c:1798
4888 if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
4889 *tj = idx as i32;
4890 }
4891 if disown {
4892 // c:exec.c:1808-1811
4893 {
4894 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
4895 jobs::pipecleanfilelist(&mut tab[idx], false);
4896 jobs::deletejob(&mut tab[idx], true);
4897 }
4898 if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
4899 *tj = -1;
4900 }
4901 } else {
4902 jobs::spawnjob(); // c:exec.c:1814
4903 }
4904 }
4905 with_executor(|exec| {
4906 for (pid, text) in &procs {
4907 exec.jobs.add_pid_job(*pid, text.clone(), JobState::Running);
4908 }
4909 });
4910 return Value::Status(0);
4911 }
4912
4913 let chunk = chunks.into_iter().next().unwrap();
4914 let job_text = stages.into_iter().next().unwrap().1;
4915 match unsafe { libc::fork() } {
4916 -1 => Value::Status(1),
4917 0 => {
4918 forked_child_subsh_levels(); // c:Src/exec.c:1221
4919 EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
4920 // c:Src/exec.c:2916 — an async command's child runs
4921 // `entersubsh(ESUB_ASYNC|ESUB_PGRP)`: string traps reset,
4922 // MONITOR off (no ESUB_JOB_CONTROL, c:1245-1246), SIGINT/
4923 // SIGQUIT ignored without job control (c:1137-1140), its own
4924 // process group with it (c:1149-1190), SIGTERM/SIGINT back to
4925 // default in an interactive shell (c:1224-1230), subsh set. The old `setsid()` did none of
4926 // that: the child kept MONITOR and the interactive SIGTERM
4927 // ignore, so it put its own commands in fresh groups and
4928 // `kill %N` reached nothing.
4929 crate::ported::exec::entersubsh(
4930 crate::ported::exec::esub::ASYNC | crate::ported::exec::esub::PGRP,
4931 None,
4932 );
4933 // `$ZSH_SUBSHELL` itself is bumped by the chunk's leading
4934 // BUILTIN_EXECCMD_FORKED_LEVEL, which knows whether the job
4935 // is a `( … )` whose own subshell_begin is the one bump.
4936 crate::fusevm_disasm::maybe_print_stdout("background_job", &chunk);
4937 let mut bg_vm = fusevm::VM::new(chunk);
4938 register_builtins(&mut bg_vm);
4939 let _ = bg_vm.run();
4940 let _ = std::io::stdout().flush();
4941 let _ = std::io::stderr().flush();
4942 std::process::exit(bg_vm.last_status);
4943 }
4944 pid => {
4945 // c:Src/exec.c:2887-2890 — C's parent blocks on the synch
4946 // pipe until the child has run entersubsh, so the child's
4947 // own group exists before `kill %N` can target it. Set it
4948 // from this side too (the async-pipeline arm above does the
4949 // same) so neither side can lose the race.
4950 if crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) {
4951 unsafe { libc::setpgid(pid, pid) };
4952 }
4953 // Parent: record the PID into `$!` (most recent
4954 // backgrounded job's pid). zsh exposes this for any
4955 // script that needs `wait $!`. Also register the
4956 // bare-pid job so a no-args `wait` can synchronize.
4957 // c:Src/jobs.c:73 — `lastpid = pid;` after a
4958 // background fork. zshrs's `$!` getter
4959 // (params.rs::lookup_special_var "!") reads from
4960 // the same atomic, so a single store here is the
4961 // canonical writer.
4962 crate::ported::modules::clone::lastpid
4963 .store(pid, std::sync::atomic::Ordering::Relaxed);
4964 // c:Src/exec.c:1700 — `thisjob = newjob = initjob()`:
4965 // allocate the canonical jobtab slot. c:Src/exec.c:2950
4966 // zfork path → addproc(pid, text, 0, &bgtime, ...) hangs
4967 // the proc entry (with its display text) off the job.
4968 // c:Src/exec.c:1744-1746 — `clearoldjobtab();
4969 // jobtab[thisjob].stat |= STAT_NOSTTY;` then c:1758
4970 // `spawnjob()` promotes it to curjob (top-level shell
4971 // only), marks STAT_LOCKED and resets thisjob.
4972 {
4973 use crate::ported::jobs;
4974 use std::sync::Mutex;
4975 let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
4976 let idx = {
4977 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
4978 let idx = jobs::initjob(&mut tab); // c:exec.c:1700
4979 jobs::addproc(
4980 &mut tab[idx],
4981 pid,
4982 &job_text,
4983 false,
4984 Some(std::time::Instant::now()),
4985 -1,
4986 -1,
4987 ); // c:exec.c:2950 addproc
4988 tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
4989 idx
4990 };
4991 jobs::clearoldjobtab(); // c:exec.c:1744
4992 if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
4993 *tj = idx as i32;
4994 }
4995 if disown {
4996 // c:exec.c:1752-1755 — `pipecleanfilelist(...);
4997 // deletejob(jobtab + thisjob, 1); thisjob = -1;` — a
4998 // disowned job leaves the table entirely, so neither
4999 // spawnjob's `[N] pid` nor the later `[N] done` prints.
5000 // This is what keeps zinit-turbo's `… &|` completion
5001 // jobs silent (they load inside a `zle -F` handler).
5002 {
5003 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
5004 jobs::pipecleanfilelist(&mut tab[idx], false); // c:1753
5005 jobs::deletejob(&mut tab[idx], true); // c:1754
5006 }
5007 if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
5008 *tj = -1; // c:1755
5009 }
5010 } else {
5011 jobs::spawnjob(); // c:exec.c:1758
5012 }
5013 }
5014 with_executor(|exec| {
5015 exec.jobs
5016 .add_pid_job(pid, job_text.clone(), JobState::Running);
5017 });
5018 Value::Status(0)
5019 }
5020 }
5021 });
5022
5023 // ── Indexed-array storage ─────────────────────────────────────────────
5024 //
5025 // Stack: pushed values then name (LAST). `arr=(a b c)` → 4 args
5026 // (a, b, c, arr). `arr=($(cmd))` → 2 args (FlatArray, arr).
5027 //
5028 // PURE PASSTHRU: pop name + values, dispatch to canonical
5029 // `setaparam` / `sethparam` (C port of `Src/params.c:3595/3602`).
5030 // assignaparam already handles PM_UNIQUE dedupe, type-flag flip,
5031 // PM_NAMEREF rejection, ASSPM_AUGMENT prepend, and createparam
5032 // for fresh names.
5033 vm.register_builtin(BUILTIN_SET_ARRAY, |vm, argc| {
5034 // `${~spec}` carrier: an assignment statement is a word-
5035 // pipeline boundary too — restore the user's GLOB_SUBST
5036 // before the NEXT word expands (`Z[d]=${~Z[d]}; print
5037 // ${options[globsubst]}` must read the user value).
5038 consume_tilde_globsubst_carrier();
5039 let n = argc as usize;
5040 let mut popped: Vec<Value> = Vec::with_capacity(n);
5041 for _ in 0..n {
5042 popped.push(vm.pop());
5043 }
5044 popped.reverse();
5045 if popped.is_empty() {
5046 return Value::Status(1);
5047 }
5048 let name = popped.pop().unwrap().to_str();
5049 let mut values: Vec<String> = Vec::new();
5050 for v in popped {
5051 flatten_array_value(v, &mut values);
5052 }
5053 if array_assignment_expansion_failed() {
5054 crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
5055 return Value::Status(1);
5056 }
5057 // Bash sparse: a full `a=(...)` reassign resets the array to dense
5058 // (drops any prior holes from subscript-assign / unset).
5059 if crate::dash_mode::sparse_arrays() {
5060 crate::bash_arrays::clear(&name);
5061 }
5062 let blocked = with_executor(|exec| {
5063 // c:Src/exec.c:4473-4509 save_params — an ARRAY prefix
5064 // assignment (`z=(9 8 7) f`) is a WC_ASSIGN in the same chain
5065 // as a scalar one, so it is snapshotted the same way and put
5066 // back by restore_params when the command returns.
5067 let prefix_restored = exec
5068 .inline_env_stack
5069 .last()
5070 .is_some_and(|frame| frame.recording);
5071 if prefix_restored {
5072 save_inline_prefix_param(exec, &name);
5073 }
5074 // Assoc init `typeset -A m; m=(k v k v ...)` — route to
5075 // canonical sethparam (Src/params.c:3602) which parses the
5076 // flat (k,v) pair list internally.
5077 if exec.assoc(&name).is_some() {
5078 // `[k]=v` / `[k]+=v` elements arrive from the compiler
5079 // as Marker / key / value triples (compile_zsh's port
5080 // of keyvalpairelement, c:Src/subst.c:49-79).
5081 let marker = crate::ported::zsh_h::Marker;
5082 let values = if values.iter().any(|e| e.starts_with(marker)) {
5083 // c:Src/params.c:3544-3560 — under ASSPM_KEY_VALUE
5084 // assocs strictly enforce `[key]=value`: every
5085 // stride-of-3 element must be a Marker. Mixing
5086 // plain pairs with kv triads is an error.
5087 let mut i = 0usize;
5088 while i < values.len() {
5089 if !values[i].starts_with(marker) {
5090 crate::ported::utils::zerr(
5091 "bad [key]=value syntax for associative array",
5092 );
5093 crate::ported::utils::errflag.fetch_or(
5094 crate::ported::zsh_h::ERRFLAG_ERROR,
5095 std::sync::atomic::Ordering::Relaxed,
5096 );
5097 exec.set_last_status(1);
5098 return true;
5099 }
5100 i += 3;
5101 }
5102 if values.len() % 3 != 0 {
5103 // c:Src/params.c:4124-4131 arrhashsetfn — a
5104 // truncated triad leaves an odd non-Marker
5105 // count → "bad set of key/value pairs".
5106 crate::ported::utils::zerr(
5107 "bad set of key/value pairs for associative array",
5108 );
5109 crate::ported::utils::errflag.fetch_or(
5110 crate::ported::zsh_h::ERRFLAG_ERROR,
5111 std::sync::atomic::Ordering::Relaxed,
5112 );
5113 exec.set_last_status(1);
5114 return true;
5115 }
5116 // c:Src/params.c:4136-4168 arrhashsetfn — whole
5117 // assignment builds a FRESH table; a `Marker +`
5118 // triad (`[k]+=v`) appends to the value inserted
5119 // EARLIER IN THIS SAME LITERAL (assignstrvalue
5120 // with eltflags=ASSPM_AUGMENT against the new ht),
5121 // so `h=([k]=a [k]+=b)` yields "ab". Resolve the
5122 // appends here, then hand flat pairs to sethparam.
5123 let mut order: Vec<String> = Vec::new();
5124 let mut map: std::collections::HashMap<String, String> =
5125 std::collections::HashMap::new();
5126 for ch in values.chunks(3) {
5127 let elt_append = ch[0].chars().nth(1) == Some('+');
5128 let k = ch[1].clone();
5129 let v = ch[2].clone();
5130 let nv = if elt_append {
5131 format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
5132 } else {
5133 v
5134 };
5135 if !map.contains_key(&k) {
5136 order.push(k.clone());
5137 }
5138 map.insert(k, nv);
5139 }
5140 order
5141 .into_iter()
5142 .flat_map(|k| {
5143 let v = map.get(&k).cloned().unwrap_or_default();
5144 [k, v]
5145 })
5146 .collect()
5147 } else {
5148 values
5149 };
5150 // Odd-count rejection lives in the canonical chain:
5151 // sethparam → setarrvalue (c:3651/c:2920) →
5152 // arrhashsetfn's zerr "bad set of key/value pairs"
5153 // (Src/params.c:4128-4131). zerr sets ERRFLAG_ERROR,
5154 // which aborts the remaining list at the next command
5155 // boundary (BUILTIN_ERREXIT_CHECK trigger 4) —
5156 // matching `zsh -fc 'typeset -A m; m=(odd); print x'`
5157 // printing nothing after the error. Like C's sethparam
5158 // (c:3652-3653 returns v->pm regardless), the Rust
5159 // port returns Some on the odd-count path — the
5160 // failure travels via errflag, so check BOTH.
5161 let pre_err = crate::ported::utils::errflag
5162 .load(std::sync::atomic::Ordering::Relaxed)
5163 & crate::ported::zsh_h::ERRFLAG_ERROR;
5164 let res = crate::ported::params::sethparam(&name, values.clone());
5165 let now_err = crate::ported::utils::errflag
5166 .load(std::sync::atomic::Ordering::Relaxed)
5167 & crate::ported::zsh_h::ERRFLAG_ERROR;
5168 if res.is_none() || (pre_err == 0 && now_err != 0) {
5169 // c:Src/exec.c:2632-2633 addvars — `if
5170 // (!assignaparam(name, arr, myflags)) lastval = 1;`
5171 // — failed assignment sets lastval so the errflag
5172 // abort exits 1 (init.c loop() breaks, zsh_main
5173 // returns lastval).
5174 exec.set_last_status(1);
5175 return true;
5176 }
5177 #[cfg(feature = "recorder")]
5178 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5179 let ctx = exec.recorder_ctx();
5180 let attrs = exec.recorder_attrs_for(&name);
5181 let mut pairs: Vec<(String, String)> = Vec::with_capacity(values.len() / 2);
5182 let mut iter = values.iter().cloned();
5183 while let Some(k) = iter.next() {
5184 if let Some(v) = iter.next() {
5185 pairs.push((k, v));
5186 }
5187 }
5188 crate::recorder::emit_assoc_assign(&name, pairs, attrs, false, ctx);
5189 }
5190 return false;
5191 }
5192 // Indexed-array: setaparam (Src/params.c:3766) wraps
5193 // assignaparam with ASSPM_WARN — handles PM_UNIQUE dedupe,
5194 // type-flag flip, PM_READONLY rejection.
5195 //
5196 // `[k]=v` elements arrive as Marker / key / value triples
5197 // (compile_zsh's keyvalpairelement port). Mirror
5198 // c:Src/exec.c:2552-2553 — `if (prefork_ret &
5199 // PREFORK_KEY_VALUE) myflags |= ASSPM_KEY_VALUE;` — so
5200 // assignaparam runs its kv-resolution block (sparse fill
5201 // for PM_ARRAY, c:3447-3541; strict-triad enforcement for
5202 // special PM_HASHED targets like `options`, c:3544-3560).
5203 let values = values;
5204 let has_kv = values
5205 .iter()
5206 .any(|e| e.starts_with(crate::ported::zsh_h::Marker));
5207 // The tied-array mirror to a PM_TIED scalar
5208 // (`typeset -T PATH path`) lives canonically in
5209 // setarrvalue's dispatch in C zsh; until that wires
5210 // through assignaparam, mirror here so PATH stays in sync
5211 // after `path=(/x)`.
5212 //
5213 // The mirrored value must be the array AS STORED, which for a
5214 // PM_UNIQUE tie means deduped. c:4066-4076 arrsetfn fixes the
5215 // order:
5216 // if (pm->node.flags & PM_UNIQUE) uniqarray(x);
5217 // pm->u.arr = x;
5218 // if (pm->ename && x) arrfixenv(pm->ename, x);
5219 // — the dedupe happens FIRST, so the scalar publishes the same
5220 // list the array holds and the two halves of a tie always agree.
5221 // Mirroring the raw `values` broke exactly that:
5222 // typeset -U path; path=(/a /b /a)
5223 // $path → /a /b (right)
5224 // $PATH → /a:/b:/a (wrong; zsh gives /a:/b)
5225 // i.e. `typeset -U path`, the standard PATH-dedup idiom in
5226 // essentially every .zshrc. assignaparam's own arrfixenv does not
5227 // rescue it: that call is gated on the param having a gsu_a wired,
5228 // and `path` has none.
5229 //
5230 // The dedupe is applied here rather than by reading the array back
5231 // after assignaparam, because this mirror must stay BEFORE it.
5232 // `exec.set_scalar` is heavier than C's arrfixenv — arrfixenv only
5233 // rewrites the environment string, while set_scalar re-derives the
5234 // ARRAY from the scalar. Running it afterwards makes `path=()`
5235 // publish PATH="" and then re-split that back into a one-element
5236 // `path=("")`, where zsh leaves 0 elements.
5237 if let Some((scalar_name, sep)) = exec
5238 .tied_array_to_scalar
5239 .get(&name)
5240 .cloned()
5241 {
5242 let uniq = crate::ported::params::paramtab()
5243 .read()
5244 .ok()
5245 .and_then(|t| t.get(&name).map(|p| p.node.flags))
5246 .map(|f| (f as u32 & crate::ported::zsh_h::PM_UNIQUE) != 0)
5247 .unwrap_or(false);
5248 let mirror = if uniq {
5249 crate::ported::params::simple_arrayuniq(values.clone()) // c:4068
5250 } else {
5251 values.clone()
5252 };
5253 exec.set_scalar(scalar_name, mirror.join(&sep)); // c:4074-4075
5254 }
5255 // c:Src/exec.c:2632-2633 addvars — `if (!assignaparam(...))
5256 // lastval = 1;` — a failed assignment (bad subscript, bad
5257 // [key]=value syntax, readonly) exits 1 and the errflag
5258 // abort stops the remaining list. Track errflag pre/post
5259 // like the assoc branch above.
5260 let kv_flag = if has_kv {
5261 crate::ported::zsh_h::ASSPM_KEY_VALUE
5262 } else {
5263 0
5264 };
5265 let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
5266 & crate::ported::zsh_h::ERRFLAG_ERROR;
5267 // c:Src/exec.c:2573 — `flags = !(addflags & ADDVAR_RESTORE) ?
5268 // ASSPM_WARN : 0;`: a prefix assignment that is put back after
5269 // the command is implicitly scoped and never warns, the same rule
5270 // the scalar arm applies.
5271 let warn = if prefix_restored { 0 } else { crate::ported::zsh_h::ASSPM_WARN };
5272 let res = crate::ported::params::assignaparam(&name, values.clone(), warn | kv_flag);
5273 let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
5274 & crate::ported::zsh_h::ERRFLAG_ERROR;
5275 if res.is_none() && pre_err == 0 && now_err != 0 {
5276 exec.set_last_status(1);
5277 return true;
5278 }
5279 // Bash sparse: an explicit-index array literal `a=([2]=x [5]=y)`
5280 // leaves the un-indexed slots as HOLES (bash: count 2, indices
5281 // {2,5}), not dense empties. assignaparam has already placed each
5282 // value at its 0-based index; mark every OTHER slot a hole. Gated
5283 // to a PURE indexed literal (every element a `[idx]=val` triple) —
5284 // a mixed positional/indexed literal (`a=(x [3]=y z)`) needs the
5285 // positional-counter replay we don't model, so it stays dense.
5286 if crate::dash_mode::sparse_arrays() && has_kv {
5287 let marker = crate::ported::zsh_h::Marker;
5288 let pure_indexed = !values.is_empty()
5289 && values.len() % 3 == 0
5290 && values.chunks(3).all(|ch| ch[0].starts_with(marker));
5291 if pure_indexed {
5292 let mut explicit: std::collections::BTreeSet<usize> =
5293 std::collections::BTreeSet::new();
5294 for ch in values.chunks(3) {
5295 if let Ok(i) = ch[1].trim().parse::<usize>() {
5296 explicit.insert(i);
5297 }
5298 }
5299 let len = exec.array(&name).map(|a| a.len()).unwrap_or(0);
5300 crate::bash_arrays::note_holes_outside(&name, &explicit, len);
5301 }
5302 }
5303 #[cfg(feature = "recorder")]
5304 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5305 let ctx = exec.recorder_ctx();
5306 let attrs = exec.recorder_attrs_for(&name);
5307 emit_path_or_assign(&name, &values, attrs, false, &ctx);
5308 }
5309 false
5310 });
5311 let status = if blocked { 1 } else { 0 };
5312 // c:Src/jobs.c:1748-1757 waitonejob — in C an array-assignment
5313 // simple command goes through execpline → waitjobs; with no
5314 // procs the else-branch stores `pipestats[0] = lastval;
5315 // numpipestats = 1`. Bare SCALAR assignments never create a
5316 // job (no waitjobs), so this clobber is array/assoc-assignment
5317 // specific: `false|true; x=(1 2); echo $pipestatus` → `0` in
5318 // zsh while `x=1` preserves `1 0`. Bug #373.
5319 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
5320 let mut synth = crate::ported::zsh_h::job::default();
5321 crate::ported::jobs::waitonejob(&mut synth);
5322 Value::Status(status)
5323 });
5324 // `arr+=(d e f)` — array append. Same calling conventions as SET_ARRAY.
5325 //
5326 // PURE PASSTHRU shape: pop name + values, dispatch through the
5327 // canonical assoc / array setter. assignaparam's ASSPM_AUGMENT
5328 // flag handles the C-source-equivalent "preserve prior value"
5329 // semantics; for now we read the current array, extend with
5330 // new values, write through set_array (which routes to
5331 // setaparam → assignaparam where PM_UNIQUE dedupe lands).
5332 vm.register_builtin(BUILTIN_APPEND_ARRAY, |vm, argc| {
5333 let n = argc as usize;
5334 let mut popped: Vec<Value> = Vec::with_capacity(n);
5335 for _ in 0..n {
5336 popped.push(vm.pop());
5337 }
5338 popped.reverse();
5339 if popped.is_empty() {
5340 return Value::Status(1);
5341 }
5342 let name = popped.pop().unwrap().to_str();
5343 let mut values: Vec<String> = Vec::new();
5344 for v in popped {
5345 flatten_array_value(v, &mut values);
5346 }
5347 if array_assignment_expansion_failed() {
5348 crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
5349 return Value::Status(1);
5350 }
5351 let blocked = with_executor(|exec| -> bool {
5352 // Assoc append `m+=(k1 v1 ...)`: merge the (k,v) pairs into
5353 // the existing map and write back via canonical sethparam
5354 // (Src/params.c:3602). The canonical C path would go
5355 // assignaparam(ASSPM_AUGMENT) → arrhashsetfn(ASSPM_AUGMENT)
5356 // at Src/params.c:3850, but the zshrs port of
5357 // arrhashsetfn doesn't yet implement value-storage
5358 // (pending Param.u_hash backend wireup) — until that
5359 // lands, do the augment + write here so the storage
5360 // actually mutates.
5361 if exec.assoc(&name).is_some() {
5362 // `[k]=v` / `[k]+=v` elements arrive as Marker / key /
5363 // value triples (compile_zsh's port of
5364 // keyvalpairelement, c:Src/subst.c:49-79).
5365 let marker = crate::ported::zsh_h::Marker;
5366 let mut map = exec.assoc(&name).unwrap_or_default();
5367 if values.iter().any(|e| e.starts_with(marker)) {
5368 // c:Src/params.c:3544-3560 — strict triad rule.
5369 let mut i = 0usize;
5370 while i < values.len() {
5371 if !values[i].starts_with(marker) {
5372 crate::ported::utils::zerr(
5373 "bad [key]=value syntax for associative array",
5374 );
5375 crate::ported::utils::errflag.fetch_or(
5376 crate::ported::zsh_h::ERRFLAG_ERROR,
5377 std::sync::atomic::Ordering::Relaxed,
5378 );
5379 exec.set_last_status(1);
5380 return true;
5381 }
5382 i += 3;
5383 }
5384 if values.len() % 3 != 0 {
5385 // c:Src/params.c:4124-4131 — odd pair count.
5386 crate::ported::utils::zerr(
5387 "bad set of key/value pairs for associative array",
5388 );
5389 crate::ported::utils::errflag.fetch_or(
5390 crate::ported::zsh_h::ERRFLAG_ERROR,
5391 std::sync::atomic::Ordering::Relaxed,
5392 );
5393 exec.set_last_status(1);
5394 return true;
5395 }
5396 // c:Src/params.c:4133-4168 arrhashsetfn with
5397 // ASSPM_AUGMENT — ht = the EXISTING table, so
5398 // `[k]+=v` appends to the current value
5399 // (assignstrvalue eltflags=ASSPM_AUGMENT,
5400 // c:4144-4150) and `[k]=v` overwrites.
5401 for ch in values.chunks(3) {
5402 let elt_append = ch[0].chars().nth(1) == Some('+');
5403 let k = ch[1].clone();
5404 let v = ch[2].clone();
5405 let nv = if elt_append {
5406 format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
5407 } else {
5408 v
5409 };
5410 map.insert(k, nv);
5411 }
5412 } else {
5413 // c:Src/params.c:4076-4085 arrhashsetfn — the SAME odd-count
5414 // gate the non-augment form uses runs before ASSPM_AUGMENT
5415 // merges anything:
5416 // for (aptr = val; *aptr; ++aptr)
5417 // if (**aptr != Marker) ++alen;
5418 // if (alen % 2) { freearray(val);
5419 // zerr("bad set of key/value pairs for associative
5420 // array"); return; }
5421 // c:4086 `if (flags & ASSPM_AUGMENT)` is reached only AFTER
5422 // it, so `h+=(k2)` with a lone key is refused and the hash is
5423 // left alone. The walk below just dropped the unpaired key
5424 // (`if let Some(v) = it.next()`), so the append silently
5425 // no-opped at status 0 where zsh errors and exits 1.
5426 if values.len() % 2 != 0 {
5427 crate::ported::utils::zerr(
5428 "bad set of key/value pairs for associative array",
5429 ); // c:4083
5430 crate::ported::utils::errflag.fetch_or(
5431 crate::ported::zsh_h::ERRFLAG_ERROR,
5432 std::sync::atomic::Ordering::Relaxed,
5433 );
5434 // c:Src/exec.c:2632-2633 — a failed assignment sets
5435 // lastval 1, the same tail the triad form above uses.
5436 exec.set_last_status(1);
5437 return true;
5438 }
5439 let mut it = values.iter().cloned();
5440 while let Some(k) = it.next() {
5441 if let Some(v) = it.next() {
5442 map.insert(k, v);
5443 }
5444 }
5445 }
5446 exec.set_assoc(name, map);
5447 return false;
5448 }
5449 // Indexed-array append `arr+=(d e f)` — route directly
5450 // through canonical assignaparam with ASSPM_AUGMENT
5451 // (`Src/params.c:3570-3585` append-on-array branch).
5452 // assignaparam reads the prior array internally and
5453 // appends the new values, so the bridge no longer needs
5454 // to pre-concat manually. Marker triples from `[k]=v`
5455 // elements add ASSPM_KEY_VALUE (c:Src/exec.c:2552-2553)
5456 // so the kv sparse-fill block (c:Src/params.c:3447-3541)
5457 // resolves them against the existing elements.
5458 let kv_flag = if values
5459 .iter()
5460 .any(|e| e.starts_with(crate::ported::zsh_h::Marker))
5461 {
5462 crate::ported::zsh_h::ASSPM_KEY_VALUE
5463 } else {
5464 0
5465 };
5466 let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
5467 & crate::ported::zsh_h::ERRFLAG_ERROR;
5468 let res = crate::ported::params::assignaparam(
5469 &name,
5470 values.clone(),
5471 crate::ported::zsh_h::ASSPM_AUGMENT | kv_flag,
5472 );
5473 let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
5474 & crate::ported::zsh_h::ERRFLAG_ERROR;
5475 if res.is_none() && pre_err == 0 && now_err != 0 {
5476 // c:Src/exec.c:2632-2633 — failed assignment → lastval 1.
5477 exec.set_last_status(1);
5478 return true;
5479 }
5480 #[cfg(feature = "recorder")]
5481 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5482 let ctx = exec.recorder_ctx();
5483 let attrs = exec.recorder_attrs_for(&name);
5484 emit_path_or_assign(&name, &values, attrs, true, &ctx);
5485 }
5486 // Tied-scalar mirror — TODO faithful: should live in
5487 // setarrvalue's gsu dispatch once boot_ paramtab wiring
5488 // lands (Task #16). Re-read the canonical post-augment
5489 // array so the joined scalar matches.
5490 let tied_scalar = exec.tied_array_to_scalar.get(&name).cloned();
5491 if let Some((scalar_name, sep)) = tied_scalar {
5492 let merged = exec.array(&name).unwrap_or_default();
5493 let joined = merged.join(&sep);
5494 exec.set_scalar(scalar_name.clone(), joined.clone());
5495 let _ = crate::ported::params::zputenv(&format!("{}={}", &scalar_name, &joined));
5496 // c:Src/params.c:5354
5497 }
5498 false
5499 });
5500 // c:Src/jobs.c:1748-1757 waitonejob — `arr+=(...)` is an
5501 // array-assignment simple command and clobbers pipestats to
5502 // `[lastval]` exactly like `arr=(...)` above. Bug #373.
5503 let status = if blocked { 1 } else { 0 };
5504 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
5505 let mut synth = crate::ported::zsh_h::job::default();
5506 crate::ported::jobs::waitonejob(&mut synth);
5507 Value::Status(status)
5508 });
5509 // `name[@]=(...)` / `name[*]=(...)` — whole-array SET with the assoc
5510 // guard (c:Src/params.c:3324-3327). Stack: [v0..vn, name].
5511 vm.register_builtin(BUILTIN_SET_ARRAY_AT, |vm, argc| {
5512 let (name, values) = pop_array_args_with_name(vm, argc);
5513 let status = with_executor(|exec| {
5514 // c:Src/exec.c:4475-4478 (54941) — `a[@]=(…) f` saves `a`.
5515 if exec.inline_env_stack.last().is_some_and(|frame| frame.recording) {
5516 save_inline_prefix_param(exec, &name);
5517 }
5518 if exec.assoc(&name).is_some() {
5519 // c:Src/params.c:3324-3327 — `[@]` (any slice) on a
5520 // PM_HASHED target is an error.
5521 crate::ported::utils::zerr(&format!(
5522 "{}: attempt to set slice of associative array",
5523 name
5524 ));
5525 crate::ported::utils::errflag.fetch_or(
5526 crate::ported::zsh_h::ERRFLAG_ERROR,
5527 std::sync::atomic::Ordering::Relaxed,
5528 );
5529 exec.set_last_status(1);
5530 return 1;
5531 }
5532 exec.set_array(name, values); // whole replace (c:3528 setarrvalue)
5533 0
5534 });
5535 Value::Status(status)
5536 });
5537 // `name[@]+=(...)` / `name[*]+=(...)` — whole-array APPEND (push) with
5538 // the same assoc guard.
5539 vm.register_builtin(BUILTIN_APPEND_ARRAY_AT, |vm, argc| {
5540 let (name, values) = pop_array_args_with_name(vm, argc);
5541 let status = with_executor(|exec| {
5542 // c:Src/exec.c:4475-4478 (54941) — `a[@]+=(…) f` saves `a`.
5543 if exec.inline_env_stack.last().is_some_and(|frame| frame.recording) {
5544 save_inline_prefix_param(exec, &name);
5545 }
5546 if exec.assoc(&name).is_some() {
5547 crate::ported::utils::zerr(&format!(
5548 "{}: attempt to set slice of associative array",
5549 name
5550 ));
5551 crate::ported::utils::errflag.fetch_or(
5552 crate::ported::zsh_h::ERRFLAG_ERROR,
5553 std::sync::atomic::Ordering::Relaxed,
5554 );
5555 exec.set_last_status(1);
5556 return 1;
5557 }
5558 let mut cur = exec.array(&name).unwrap_or_default();
5559 cur.extend(values);
5560 exec.set_array(name, cur); // c:3511-3528 AUGMENT on array → push
5561 0
5562 });
5563 Value::Status(status)
5564 });
5565 vm.register_builtin(BUILTIN_RUN_SELECT, |vm, argc| {
5566 if argc < 2 {
5567 return Value::Status(1);
5568 }
5569 let n = argc as usize;
5570 let mut popped: Vec<Value> = Vec::with_capacity(n);
5571 for _ in 0..n {
5572 popped.push(vm.pop());
5573 }
5574 // popped: [sub_idx, name, word_N, ..., word_1] (popping from top)
5575 let sub_idx_val = popped.remove(0);
5576 let name_val = popped.remove(0);
5577 // c:Src/loop.c — `select` flattens Array values (from `$@`,
5578 // `${arr[@]}`, etc.) into the menu. Without per-element
5579 // splice, `select x do ... done` (bare, iterating $@)
5580 // collapsed all positionals into one joined entry.
5581 let mut words: Vec<String> = Vec::new();
5582 for v in popped.into_iter().rev() {
5583 match v {
5584 Value::Array(items) => {
5585 for item in items.iter() {
5586 words.push(item.to_str());
5587 }
5588 }
5589 other => words.push(other.to_str()),
5590 }
5591 }
5592
5593 let sub_idx = sub_idx_val.to_int() as usize;
5594 let name = name_val.to_str();
5595
5596 // c:Src/loop.c:250-255 — `execsubst(args); if (errflag) { state->pc =
5597 // end; …; return 1; }`: a word list that failed to expand (a NOMATCH
5598 // glob) ends the `select` with status 1, ahead of the empty-list 0.
5599 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
5600 return Value::Status(1);
5601 }
5602
5603 // c:Src/loop.c:248-252 — `if (!args || empty(args)) {
5604 // state->pc = end; ... return 0; }`. An empty option list
5605 // skips the body entirely; without this gate the prompt loop
5606 // runs indefinitely (or twice on the EOF stdin case before
5607 // exiting). Bug #401.
5608 if words.is_empty() {
5609 return Value::Status(0);
5610 }
5611
5612 let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
5613 Some(c) => c,
5614 None => return Value::Status(1),
5615 };
5616
5617 let prompt =
5618 with_executor(|exec| exec.scalar("PROMPT3").unwrap_or_else(|| "?# ".to_string()));
5619
5620 let stdin = std::io::stdin();
5621 let mut reader = stdin.lock();
5622 let mut last_status: i32 = 0;
5623
5624 // c:Src/loop.c:264 — `more = selectlist(args, 0);` renders the menu
5625 // ONCE, BEFORE the selection loop. C reprints it ONLY when the user
5626 // enters an EMPTY line (c:290, inside the inner read loop) — never per
5627 // body iteration. This was a single conflated loop that re-rendered at
5628 // the top of every pass, so `printf "1\n2\n" | select x in a b; do
5629 // print $x; done` redrew the list before each prompt where zsh prints
5630 // it once.
5631 //
5632 // (`selectlist` is also ported at src/ported/loop.rs:127, but that copy
5633 // derives its row budget from adjustlines()/adjustcolumns() — an ioctl
5634 // on fd 1 — and so renders nothing when stdout is not a tty, which is
5635 // exactly this path. Keeping the working inline render here.)
5636 let render_menu = || {
5637 // Direct port of zsh's selectlist from
5638 // src/zsh/Src/loop.c:347-409. Layout is column-major
5639 // ("down columns, then across") — NOT row-major. With
5640 // 6 items in 3 cols zsh produces:
5641 // 1 3 5
5642 // 2 4 6
5643 // The previous Rust impl walked row-major which
5644 // produced 1 2 3 / 4 5 6 (visually similar but wrong
5645 // for prompts that mention ordering and breaks scripts
5646 // that rely on column count == ceil(N/rows)).
5647 //
5648 // C variable mapping:
5649 // ct -> word count (n)
5650 // longest -> max item width + 1, then plus digits-of-ct
5651 // fct -> column count
5652 // fw -> per-column width
5653 // colsz -> row count = ceil(ct / fct)
5654 // t1 -> row index, walks 0..colsz
5655 // ap -> item pointer; advances by colsz to step
5656 // DOWN a column.
5657 let term_width: usize = env::var("COLUMNS")
5658 .ok()
5659 .and_then(|v| v.parse().ok())
5660 .unwrap_or(80);
5661 let ct = words.len();
5662 // loop.c:354-363 — find longest item width.
5663 let mut longest = 1usize;
5664 for w in &words {
5665 let aplen = w.chars().count();
5666 if aplen > longest {
5667 longest = aplen;
5668 }
5669 }
5670 // loop.c:365-367 — `longest++` then add digits of `ct`.
5671 longest += 1;
5672 let mut t0 = ct;
5673 while t0 > 0 {
5674 t0 /= 10;
5675 longest += 1;
5676 }
5677 // loop.c:369-373 — fct = (cols - 1) / (longest + 3); if
5678 // 0, fct = 1; else fw = (cols - 1) / fct.
5679 let raw_fct = (term_width.saturating_sub(1)) / (longest + 3);
5680 let (fct, fw) = if raw_fct == 0 {
5681 (1, longest + 3)
5682 } else {
5683 (raw_fct, (term_width.saturating_sub(1)) / raw_fct)
5684 };
5685 // loop.c:374 — colsz = (ct + fct - 1) / fct.
5686 let colsz = ct.div_ceil(fct);
5687 // loop.c:375-395 — for each row t1, walk down columns.
5688 for t1 in 0..colsz {
5689 let mut ap_idx = t1;
5690 while ap_idx < ct {
5691 let w = &words[ap_idx];
5692 let n = ap_idx + 1;
5693 let _ = write!(std::io::stderr(), "{}) {}", n, w);
5694 let mut t2 = w.chars().count() + 2;
5695 let mut t3 = n;
5696 while t3 > 0 {
5697 t2 += 1;
5698 t3 /= 10;
5699 }
5700 // Pad to fw (loop.c:389-390).
5701 while t2 < fw {
5702 let _ = write!(std::io::stderr(), " ");
5703 t2 += 1;
5704 }
5705 ap_idx += colsz;
5706 }
5707 let _ = writeln!(std::io::stderr());
5708 }
5709 };
5710 render_menu(); // c:264 — once, before the loop
5711
5712 'select: loop {
5713 // c:266-290 — inner read loop: prompt and read until a NON-EMPTY
5714 // line arrives; each empty line reprints the menu and re-reads.
5715 let trimmed = loop {
5716 let _ = write!(std::io::stderr(), "{}", prompt);
5717 let _ = std::io::stderr().flush();
5718
5719 let mut line = String::new();
5720 match reader.read_line(&mut line) {
5721 Ok(0) => {
5722 // c:277-285 — EOF (user pressed Ctrl+D): REPLY="",
5723 // a newline to stderr, then leave the construct.
5724 with_executor(|exec| {
5725 exec.set_scalar("REPLY".to_string(), String::new());
5726 });
5727 let _ = writeln!(std::io::stderr());
5728 let _ = std::io::stderr().flush();
5729 break 'select;
5730 }
5731 Ok(_) => {}
5732 Err(_) => break 'select,
5733 }
5734 let t = line.trim_end_matches(['\n', '\r'][..].as_ref()).to_string();
5735 // c:288-289 — `if (*str) break;`
5736 if !t.is_empty() {
5737 break t;
5738 }
5739 // c:290 — `more = selectlist(args, more);` on an empty line.
5740 render_menu();
5741 };
5742 // c:291 `setsparam("REPLY", ztrdup(str));` — REPLY is set once the
5743 // inner loop yields a non-empty line. An empty line never reaches
5744 // here: c:290 reprints and re-reads instead.
5745 with_executor(|exec| {
5746 exec.set_scalar("REPLY".to_string(), trimmed.clone());
5747 });
5748
5749 // c:293 `i = atoi(str);` — atoi(3) reads a LEADING integer:
5750 // optional blanks, optional sign, then digits, ignoring whatever
5751 // trails, and yields 0 when there are no digits at all.
5752 // `parse::<usize>()` is strict and rejected `1 2` / `1abc` / `+2`
5753 // / ` 1`, so a reply with anything after the number selected
5754 // NOTHING where zsh selects the leading number's item.
5755 let i: i64 = {
5756 let b = trimmed.as_bytes();
5757 let mut p = 0;
5758 while p < b.len() && (b[p] == b' ' || b[p] == b'\t') {
5759 p += 1;
5760 }
5761 let neg = p < b.len() && b[p] == b'-';
5762 if p < b.len() && (b[p] == b'-' || b[p] == b'+') {
5763 p += 1;
5764 }
5765 let mut v: i64 = 0;
5766 while p < b.len() && b[p].is_ascii_digit() {
5767 v = v.saturating_mul(10).saturating_add((b[p] - b'0') as i64);
5768 p += 1;
5769 }
5770 if neg {
5771 -v
5772 } else {
5773 v
5774 }
5775 };
5776 // c:294-301 — `if (!i) str = "";` else walk i-1 nodes and take
5777 // that word; running off the end leaves "". A NEGATIVE i walks
5778 // until the list is exhausted (`n && i`), which also lands on "".
5779 let chosen = if i <= 0 {
5780 String::new()
5781 } else {
5782 words.get((i - 1) as usize).cloned().unwrap_or_default()
5783 };
5784
5785 with_executor(|exec| {
5786 exec.set_scalar(name.clone(), chosen);
5787 });
5788
5789 // Reset canonical BREAKS/CONTFLAG before running the body
5790 // so a stale value from a sibling construct doesn't leak in.
5791 crate::ported::builtin::BREAKS.store(0, SeqCst);
5792 crate::ported::builtin::CONTFLAG.store(0, SeqCst);
5793
5794 // c:Src/loop.c — `select` increments LOOPS for the body so
5795 // `break` / `continue` inside the body see loops > 0 and
5796 // don't emit `not in while, until, select, or repeat loop`.
5797 // Mirrors execwhile/execrepeat's `LOOPS.fetch_add` pattern.
5798 // The decrement happens after the body call so a body that
5799 // explicitly returns / errors still leaves the counter
5800 // balanced for the next iteration.
5801 crate::ported::builtin::LOOPS.fetch_add(1, SeqCst);
5802
5803 crate::fusevm_disasm::maybe_print_stdout("select:body", &chunk);
5804 let mut body_vm = fusevm::VM::new(chunk.clone());
5805 register_builtins(&mut body_vm);
5806 let _ = body_vm.run();
5807 last_status = body_vm.last_status;
5808
5809 crate::ported::builtin::LOOPS.fetch_sub(1, SeqCst);
5810
5811 // Drain the canonical BREAKS/CONTFLAG counters. Mirrors
5812 // loop.c:529-534's `if (breaks) { breaks--; if (breaks ||
5813 // !contflag) break; contflag = 0; }` drain pattern.
5814 // The legacy `BREAK_SELECT=1` env-var sentinel is still
5815 // honored for backward compat.
5816 let break_legacy = with_executor(|exec| {
5817 let v = exec.scalar("BREAK_SELECT");
5818 exec.unset_scalar("BREAK_SELECT");
5819 v.map(|s| s != "0" && !s.is_empty()).unwrap_or(false)
5820 });
5821 use std::sync::atomic::Ordering::SeqCst;
5822 let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
5823 if breaks > 0 {
5824 let cont = crate::ported::builtin::CONTFLAG.load(SeqCst);
5825 crate::ported::builtin::BREAKS.fetch_sub(1, SeqCst);
5826 if breaks - 1 > 0 || cont == 0 {
5827 break;
5828 }
5829 crate::ported::builtin::CONTFLAG.store(0, SeqCst);
5830 continue;
5831 }
5832 if break_legacy {
5833 break;
5834 }
5835 }
5836
5837 Value::Status(last_status)
5838 });
5839
5840 // Magic special-parameter assoc lookup. Synthesizes values from
5841 // shell state for zsh's shell-introspection assocs:
5842 // commands, aliases, galiases, saliases, dis_aliases, dis_galiases,
5843 // dis_saliases, functions, dis_functions, builtins, dis_builtins,
5844 // reswords, options, parameters, jobtexts, jobdirs, jobstates,
5845 // nameddirs, userdirs, modules.
5846 // Returns None if `name` isn't a recognized magic name.
5847
5848 // `${arr[idx]}` — pop name, then idx_str. zsh is 1-based for positive
5849 // indices; we honor that. `@`/`*` return the whole array as Value::Array
5850 // so Op::Exec splice produces N argv slots. For `${foo[key]}` where foo
5851 // is an assoc, the idx is a string key — we check assoc_arrays first
5852 // when the idx isn't `@`/`*` and the name has an assoc binding.
5853 // BUILTIN_ARRAY_INDEX — `${name[idx]}` paramsubst dispatch.
5854 // PURE PASSTHRU: pops the idx + name, hands the canonical
5855 // `${name[idx]}` form to `subst::paramsubst` (C port of
5856 // `Src/subst.c::paramsubst`). All subscript-flag dispatch
5857 // ((I)pat / (R)pat / (i)/(r)/(K)/(k), range slices `[N,M]`,
5858 // negative indices, magic-assoc shape lookup, DQ-join collapse)
5859 // lives inside paramsubst → fetchvalue → getarg in params.rs.
5860 //
5861 // Outer-flag dispatch (`(@)` / `(@k)` / `(v)NAME[(I)pat]` / etc.)
5862 // routes through BUILTIN_BRIDGE_BRACE_ARRAY at the compile path
5863 // (canonical paramsubst flag parser owns dispatch at Src/subst.c:2147+),
5864 // so BUILTIN_ARRAY_INDEX receives clean name+key with no sentinel
5865 // prefixes.
5866 //
5867 // argc contract: `3` means the caller is a `PREFORK_SINGLE` (`ssub`)
5868 // context — a scalar assignment RHS (c:Src/exec.c:2603), a `case` WORD
5869 // (c:Src/loop.c:611-612), a `[[ … ]]` operand (c:Src/cond.c:53) or the
5870 // here-string word (c:Src/exec.c:4711). c:Src/subst.c:4226
5871 // if (isarr && ssub) { val = sepjoin(aval, NULL, 1); isarr = 0; }
5872 // so a RANGE subscript joins on `IFS[0]` and stops. `2` is every other
5873 // caller, unchanged. The stack shape is the same either way (name, key) —
5874 // the VM hands `argc` to the handler untouched and zshrs never sets
5875 // `Chunk::builtin_argc_is_arity`.
5876 vm.register_builtin(BUILTIN_ARRAY_INDEX, |vm, argc| {
5877 let ssub = argc == 3;
5878 let idx = vm.pop().to_str();
5879 let name = vm.pop().to_str();
5880 let v = array_index_lookup(&name, &idx, ssub);
5881 // c:Src/subst.c:3912 — a RANGE subscript is array-shaped, so
5882 // c:2916-2917 leaves `isarr` non-zero and the join-then-split block
5883 // applies to it exactly as it does to a `[@]`/`[*]` splice or a bare
5884 // array read. The single-element, assoc-key and search-flag shapes all
5885 // come back as scalars (`isarr == 0`) and pass straight through.
5886 //
5887 // The gate: `ssub` joins at c:4226 and stops (c:3913 `force_split =
5888 // !ssub && …`); `qt` clears `spbreak` at c:1707 and joins at c:3033;
5889 // `sep` / `spsep` are the `(j:…:)` / `(s:…:)` separators, which take a
5890 // flagged expansion to `paramsubst` rather than this fast path. So the
5891 // only door in from here is SH_WORD_SPLIT, and the handler had no such
5892 // block at all:
5893 // setopt shwordsplit; a=('p:q' r); IFS=:; print -rl -- ${a[1,2]}
5894 // zsh: `p` `q` `r` zshrs: `p:q` `r`
5895 // setopt shwordsplit; a=(x '' y); IFS=:; print -rl -- ${a[1,2]}
5896 // zsh: `x` `` zshrs: `x`
5897 // The second is `sepsplit_c3932`'s `nulstring` rule (c:Src/subst.c:36),
5898 // which c:186 keeps for an IFS-NON-whitespace separator.
5899 if ssub || !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT) {
5900 return v;
5901 }
5902 let (ifs_opt, in_dq) = with_executor(|exec| (exec.scalar("IFS"), exec.in_dq_context > 0));
5903 if in_dq {
5904 return v; // c:1707 `!qt` — `spbreak` is 0, the block never opens
5905 }
5906 let Value::Array(items) = &v else {
5907 // c:2916 `isarr == 0` — the single-element, assoc-key, scalar-slice
5908 // and search-flag shapes. c:3914's join arm does not apply to them,
5909 // but c:3931's `if (force_split && !isarr)` does, and it is the
5910 // SAME `sepsplit`: `setopt shwordsplit; a=('p:q' r); IFS=:;
5911 // print -rl -- ${a[1]}` is `p` `q` in zsh, and
5912 // `typeset -A h=(k 'p:q'); … ${h[k]}` likewise.
5913 return split_words_c3932(&v.to_str());
5914 };
5915 let elems: Vec<String> = items.iter().map(|x| x.to_str()).collect();
5916 match join_c3914(elems, ifs_opt.as_deref()) {
5917 // c:3914-3927 left `isarr` non-zero, so c:3931's `!isarr` keeps the
5918 // split off too and the ORIGINAL elements are the answer.
5919 JoinC3914::Elements(_) => v,
5920 // c:3932 `sepsplit`, then c:3933-3938's 0/1/N result shape.
5921 JoinC3914::Joined(val) => split_words_c3932(&val),
5922 }
5923 });
5924 // BUILTIN_ARRAY_INDEX_UNBRACED — bare `$name[idx]` (no braces).
5925 // Same subscript dispatch as BUILTIN_ARRAY_INDEX when KSHARRAYS
5926 // is unset, but under KSHARRAYS the UNBRACED form does NOT
5927 // subscript at all:
5928 // c:Src/subst.c:2800-2802 — fetchvalue's bracket-parse arg is
5929 // `(unset(KSHARRAYS) || inbrace) ? 1 : -1`; -1 inhibits
5930 // subscript parsing for the bare form under KSHARRAYS.
5931 // c:Src/subst.c:2867 — the bracket-consuming loop only runs
5932 // `while (v || ((inbrace || (unset(KSHARRAYS) && vunset)) &&
5933 // isbrack(*s)))` — bare + KSHARRAYS leaves `[...]` as literal
5934 // trailing text.
5935 // The bare `$name` expands (first element for identifier-named
5936 // arrays per c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`),
5937 // the literal `[idx]` (+ any literal suffix) joins the last word,
5938 // and the word undergoes filename generation: `[...]` is a glob
5939 // char class, so unquoted it hits the c:Src/glob.c:1873-1886
5940 // nomatch/nullglob dispatch (reused via exec.expand_glob).
5941 // Operands: [name, idx, suffix, quoted] — `quoted` set when the
5942 // word carries DQ markers (no filename generation in DQ; zsh 5.9:
5943 // `setopt ksharrays; a=(x y z); print "$a[0]"` → `x[0]`).
5944 // Verbatim zsh 5.9 ground truth for the unquoted form:
5945 // `setopt ksharrays; a=(x y z); print -- $a[0]` →
5946 // stderr `zsh:1: no matches found: x[0]`, rc=1, empty stdout.
5947 vm.register_builtin(BUILTIN_ARRAY_INDEX_UNBRACED, |vm, _argc| {
5948 let quoted = vm.pop().to_str() == "1";
5949 let suffix = vm.pop().to_str();
5950 let idx = vm.pop().to_str();
5951 let name = vm.pop().to_str();
5952 if !crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
5953 // The bare `$name[idx]` word is not itself a `singsub` caller
5954 // shape the compiler tracks — it reaches this builtin from
5955 // `compile_word_str`, which the `PREFORK_SINGLE` callers do not
5956 // use. Pass `false` until a measured divergence says otherwise.
5957 //
5958 // c:Src/subst.c:1625 — paramsubst runs with `qt` set inside `"…"`;
5959 // array_index_lookup reads it from in_dq_context. Without it the
5960 // lookup ran unquoted and elided EMPTY elements before the join
5961 // below: `a=("" b); "$a[1,2]"` gave `b` for ` b`, and a quoted
5962 // SCANPM_ISVAR_AT selection (see isvar_at) lost its empty words
5963 // (`set -- "" b; "$@[1,2]"` is two words).
5964 let isvar_at = name == "@" || idx == "@";
5965 if quoted {
5966 with_executor(|exec| exec.in_dq_context += 1);
5967 }
5968 let v = array_index_lookup(&name, &idx, false);
5969 if quoted {
5970 with_executor(|exec| exec.in_dq_context -= 1);
5971 }
5972 // c:Src/subst.c:3033-3034 — `if (qt && !getlen && isarr > 0)
5973 // { val = sepjoin(aval, sep, 1); isarr = 0; }`. Inside double
5974 // quotes a subscript that selects an array (a range, or nothing
5975 // at all) is joined into ONE word on IFS[0]; an empty selection
5976 // joins to "" and still counts as a word. `array_index_lookup`
5977 // rebuilds a flat `${name[idx]}` that carries no `qt`, so the
5978 // unbraced form returned the raw array: `"$a[1]"` on an empty
5979 // array vanished and `"$n[1,2]"` split into two words.
5980 //
5981 // `nojoin` (isarr = -1, c:3031-3032) spares a SCANPM_ISVAR_AT
5982 // value, which c:Src/params.c:2251/2272 sets when the name is
5983 // `@` and c:2049-2050 when the subscript is `[@]` (c:Src/subst.c:
5984 // 2917 turns it into isarr = -1). So `"$@[2,3]"` stays one word
5985 // per element, as `"${@[2,3]}"` does; `for 2 3 4 in "$@[7,-1]"`
5986 // in Functions/Misc/regexp-replace depends on it.
5987 let v = match v {
5988 Value::Array(items) if quoted && !isvar_at => {
5989 let strs: Vec<String> = items.iter().map(|x| x.to_str()).collect();
5990 Value::str(crate::ported::utils::sepjoin(&strs, None)) // c:3034
5991 }
5992 other => other,
5993 };
5994 if suffix.is_empty() {
5995 return v;
5996 }
5997 // Mirrors the previous compile-shape `ARRAY_INDEX +
5998 // Op::Concat` exactly: Concat stringifies via as_str_cow
5999 // (fusevm value.rs:132-146, arrays join with " ").
6000 if let (true, Value::Array(items)) = (isvar_at, &v) {
6001 let mut items: Vec<Value> = items.iter().cloned().collect();
6002 // An unjoined `"$@[…]"` keeps its elements; the suffix
6003 // glues onto the last one (c:Src/subst.c:4434
6004 // `strcatsub(&y, aptr, aptr, x, xlen, fstr, …)`).
6005 if let Some(last) = items.pop() {
6006 items.push(Value::str(format!("{}{}", last.to_str(), suffix)));
6007 return Value::array(items);
6008 }
6009 }
6010 return Value::str(format!("{}{}", v.to_str(), suffix));
6011 }
6012 // KSHARRAYS bare form: no subscript. Bare-`$name` words +
6013 // literal `[idx]suffix` glued onto the last word.
6014 let mut words = ksharrays_bare_words(&name);
6015 let last = format!("{}[{}]{}", words.pop().unwrap_or_default(), idx, suffix);
6016 if quoted {
6017 // DQ context — no filename generation, bracket text stays
6018 // literal.
6019 words.push(last);
6020 return Value::str(words.join(" "));
6021 }
6022 // c:Src/glob.c:1873-1886 — expand_glob handles nullglob /
6023 // NOMATCH (zerr "no matches found" + errflag + the
6024 // current_command_glob_failed cell consumed at the command
6025 // dispatch boundary) / literal passthrough for glob-free text.
6026 let matches = with_executor(|exec| exec.expand_glob(&last));
6027 let mut out: Vec<Value> = words.into_iter().map(Value::str).collect();
6028 out.extend(matches.into_iter().map(Value::str));
6029 if out.len() == 1 {
6030 return out.pop().unwrap();
6031 }
6032 Value::array(out)
6033 });
6034 // BUILTIN_ASSOC_HAS_KEY — `${(k)assoc[name]}` key-existence query.
6035 // Pops [assoc_name, key]; returns key (Str) if present in the
6036 // assoc, empty Str otherwise. Mirrors zsh's `${(k)h[name]}`
6037 // documented semantics in zshparam(1) "Parameter Expansion Flags".
6038 // Distinct from BUILTIN_ARRAY_INDEX (which returns the VALUE) and
6039 // from `${+h[name]}` (which returns "0"/"1"). Bug #145.
6040 vm.register_builtin(BUILTIN_ASSOC_HAS_KEY, |vm, _argc| {
6041 let key = vm.pop().to_str();
6042 let name = vm.pop().to_str();
6043 // c:Src/params.c getindex — the subscript text is substituted
6044 // (singsub) before the lookup; `${(k)H[$k]}` must resolve $k.
6045 // The compiler hands this opcode the RAW subscript text, so a
6046 // dynamic key arrived literally ("$k") and matched nothing.
6047 // singsub is identity for plain keys.
6048 let key = if key.contains('$') || key.contains('`') || key.contains('\u{e18c}') {
6049 crate::ported::subst::singsub(&key)
6050 } else {
6051 key
6052 };
6053 // c:Src/params.c:3131 gethkparam covers ordinary PM_HASHED
6054 // paramtab entries only. Special/magic hashes (`parameters`,
6055 // `options`, … — the zsh/parameter module's partab-backed
6056 // params, Src/Modules/parameter.c) aren't in that storage, so
6057 // a None here doesn't mean "no such assoc". Route those
6058 // through paramsubst, whose assoc materialization handles the
6059 // magic hashes and whose getarg port (c:Src/params.c:1591 +
6060 // Src/subst.c:2922) returns the KEY for `(k)` on a plain
6061 // subscript. zsh 5.9: `${(k)parameters[PATH]}` → "PATH".
6062 // c:Src/params.c:1602-1612 — on a hash subscript C dispatches
6063 // `ht->getnode(ht, s)`; it never enumerates. For the magic hashes that
6064 // distinction is observable: `getfunction_source` (c:Src/Modules/
6065 // parameter.c:549-566) answers for names its scan never lists, `mapfile`
6066 // answers for any readable file, and the job trio calls `getjob(name,
6067 // NULL)` whose `job not found` diagnostic (Src/jobs.c:2150-2151) must be
6068 // emitted by the read. `gethkparam` answers Some(<enumerated keys>) for
6069 // these names, which shortcut the dispatch entirely — `${(k)jobstates[x]}`
6070 // stayed silent and `${(k)functions_source[x]}` returned "" where zsh
6071 // returns the key. Send PARTAB names to paramsubst, which owns the
6072 // getnode path (c:Src/subst.c:2923-2925 — key when set, "" when unset).
6073 // Keys carrying `]`/`}` can't survive the flat rebuild (see
6074 // array_index_lookup), so those keep the enumeration answer.
6075 let magic_getnode = !key.contains(']')
6076 && !key.contains('}')
6077 && crate::ported::modules::parameter::PARTAB
6078 .iter()
6079 .any(|e_| e_.name == name);
6080 match crate::ported::params::gethkparam(&name) {
6081 Some(keys) if !magic_getnode => {
6082 if keys.iter().any(|k| k == &key) {
6083 Value::str(key)
6084 } else {
6085 Value::str("")
6086 }
6087 }
6088 // c:2048 — `key` is already expanded, so an `@`/`*` here is a key,
6089 // not the splat; see `expanded_subscript_text`.
6090 _ => paramsubst_to_value(&format!(
6091 "${{(k){}[{}]}}",
6092 name,
6093 expanded_subscript_text(&key)
6094 )),
6095 }
6096 });
6097 vm.register_builtin(BUILTIN_BRIDGE_BRACE_ARRAY, |vm, argc| {
6098 // argc 2 = the compiler flagged this expansion as the VALUE of a
6099 // scalar assignment (`x=…` / `local x=…` / `typeset x=…`), which C
6100 // preforks with PREFORK_SINGLE|PREFORK_ASSIGN (c:Src/exec.c:2546).
6101 // PREFORK_SINGLE is paramsubst's `ssub` (c:Src/subst.c:1759) and gates
6102 // off c:3913's `force_split` — without it `r=${(s.c.)v[2,-1]}` split
6103 // and re-joined on IFS (`b def`) where zsh keeps `bcdef`. Same
6104 // operand contract as BUILTIN_PARAM_FLAG's argc-3 ssub. argc 1 =
6105 // ordinary word, no ssub (kept for callers that predate the operand).
6106 let ssub = if argc >= 2 {
6107 vm.pop().to_int() != 0
6108 } else {
6109 false
6110 };
6111 // Inner body of `${(...)...}` (already stripped of `${`/`}` by
6112 // the caller). The compiler optionally prefixes Qstring
6113 // (\u{e18c}) to signal "expanded in DQ context" — strip it
6114 // here and bump in_dq_context for the paramsubst call so the
6115 // SUB_ZIP and other qt-aware paths fire.
6116 let body = vm.pop().to_str();
6117 let (dq, inner) = if let Some(rest) = body.strip_prefix('\u{e18c}') {
6118 (true, rest.to_string())
6119 } else {
6120 (false, body)
6121 };
6122 if dq {
6123 with_executor(|exec| exec.in_dq_context += 1);
6124 }
6125 let pf_flags = if ssub {
6126 crate::ported::zsh_h::PREFORK_SINGLE // c:1759
6127 } else {
6128 0
6129 };
6130 let v = paramsubst_to_value_status(vm, &format!("${{{}}}", inner), pf_flags);
6131 if dq {
6132 with_executor(|exec| exec.in_dq_context -= 1);
6133 }
6134 v
6135 });
6136
6137 // BUILTIN_PARAM_FLAG — `${(flags)name}` paramsubst dispatch.
6138 // PURE PASSTHRU: pops sentinel-tagged flags + name, hands the
6139 // canonical `${(flags)name}` form to `subst::paramsubst` (C port
6140 // of `Src/subst.c::paramsubst`). The bridge does no flag
6141 // walking, no DQ-context branching, no array/scalar shape
6142 // selection — all of that lives inside paramsubst. Compile-time
6143 // context (DQ / scalar-assign-RHS) flows through executor cells
6144 // (in_dq_context, in_scalar_assign) bumped by BUILTIN_EXPAND_TEXT.
6145 vm.register_builtin(BUILTIN_PARAM_FLAG, |vm, argc| {
6146 // argc 3 = the compiler flagged this expansion as the VALUE of a
6147 // scalar assignment (`x=…` / `local x=…`), which C preforks with
6148 // PREFORK_SINGLE|PREFORK_ASSIGN (c:Src/exec.c:2603 / :4239-4241).
6149 // PREFORK_SINGLE is paramsubst's `ssub` (c:Src/subst.c:1761) and
6150 // gates off c:3913's `force_split`, so `(s::)` / `(f)` / `(0)` do
6151 // not split there. argc 2 = ordinary word, no ssub.
6152 let ssub = if argc >= 3 {
6153 vm.pop().to_int() != 0
6154 } else {
6155 false
6156 };
6157 let flags = vm.pop().to_str();
6158 let name = vm.pop().to_str();
6159 let body = format!("${{({}){}}}", flags, name);
6160 let pf_flags = if ssub {
6161 crate::ported::zsh_h::PREFORK_SINGLE
6162 } else {
6163 0
6164 };
6165 paramsubst_to_value_status(vm, &body, pf_flags)
6166 });
6167
6168 // `foo[key]=val` — single-key set on an assoc array. Stack: [name, key, value].
6169 // PURE PASSTHRU: assignsparam with `name[key]` form (C port of
6170 // `Src/params.c::assignsparam` subscript path at c:3210-3231)
6171 // already does the indexed-array vs assoc decision, PM_HASHED
6172 // auto-vivification, numeric-subscript bounds handling, and
6173 // PM_READONLY rejection.
6174 /// Assign `val` to one element of the PM_HASHED parameter `name`.
6175 ///
6176 /// This is the tail of C's `assignsparam` for a subscripted target:
6177 /// c:Src/params.c:3251 `getvalue(&vbuf, &t, 1)` (→ `fetchvalue` →
6178 /// `getindex`) followed by c:3343 `assignstrvalue(v, val, flags)`.
6179 ///
6180 /// C hands `getindex` the FLAT `"name[subscript]"` text, and that is
6181 /// safe there because its subscript is still the SOURCE spelling: the
6182 /// bracket walk at c:2008 `parse_subscript` runs BEFORE the
6183 /// `parsestr`/`singsub` round at c:1585-1592, so a `]` that arrives by
6184 /// expansion can never terminate it. zshrs expands a subscript before
6185 /// this builtin runs, so re-flattening to `name[key]` and re-splitting
6186 /// corrupts any key containing `]` (`k='x]y'; h[$k]=5` stored `x`). The
6187 /// two halves therefore stay separate the whole way down:
6188 ///
6189 /// * `sub` is the EXPANDED subscript — the key text.
6190 /// * `sub_src` is the SOURCE subscript, used for ONE decision, the
6191 /// one C makes at c:1410: `if (v->pm && (*s == '(' || *s == Inpar))`
6192 /// — is there a flag block? Flags can only be literal (they are read
6193 /// at c:1409, before any expansion), so `x='(r)v'; h[$x]=Z` has no
6194 /// flag block and stores the literal key `(r)v`, while `h[(r)$x]=Z`
6195 /// does have one and is a search.
6196 ///
6197 /// With no flag block there is nothing for `getindex` to resolve beyond
6198 /// the exact-key rebind at c:1596-1616, so the key goes straight to the
6199 /// element store and never meets a parser. With one, `getindex` runs on
6200 /// the EXPANDED text: its flag block is byte-identical to the source's
6201 /// (flags are literal) and its pattern is already substituted, which is
6202 /// what c:1585-1592 would have produced anyway.
6203 fn assign_hash_element(name: &str, sub: &str, sub_src: &str, val: &str) -> i32 {
6204 use crate::ported::zsh_h::{Inpar, PM_HASHED, PM_READONLY, SCANPM_ARRONLY};
6205 let pm = crate::ported::params::paramtab()
6206 .read()
6207 .ok()
6208 .and_then(|t| t.get(name).cloned());
6209 // c:3216-3221 — `if (v->pm->node.flags & PM_READONLY)`.
6210 if pm
6211 .as_ref()
6212 .is_some_and(|p| (p.node.flags as u32 & PM_READONLY) != 0)
6213 {
6214 crate::ported::utils::zerr(&format!("read-only variable: {}", name)); // c:3217
6215 return 1; // c:3221
6216 }
6217 // c:3171 — `v->pm->node.flags &= ~PM_DEFAULTED;` on the parameter
6218 // being subscripted: an element store makes a TYPESET_TO_UNSET
6219 // declaration (`typeset -A h; h[k]=v`) a set parameter, so
6220 // `typeset -p h` then prints its value.
6221 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
6222 if let Some(p) = tab.get_mut(name) {
6223 p.node.flags &= !(crate::ported::zsh_h::PM_DEFAULTED as i32); // c:3171
6224 }
6225 }
6226 // c:1410 — `if (v->pm && (*s == '(' || *s == Inpar))`, read off the
6227 // SOURCE spelling.
6228 let has_flags = sub_src.starts_with('(') || sub_src.starts_with(Inpar);
6229 if has_flags {
6230 let mut v = crate::ported::zsh_h::value {
6231 pm,
6232 arr: Vec::new(),
6233 // c:2274-2280 — fetchvalue promotes a PM_ARRAY/PM_HASHED
6234 // value with no caller flags to SCANPM_ARRONLY; assignsparam
6235 // arrives through `getvalue`, i.e. flags 0.
6236 scanflags: SCANPM_ARRONLY as i32,
6237 valflags: 0,
6238 start: 0,
6239 end: -1, // c:2279
6240 };
6241 let bracketed = format!("[{}]", sub); // c:2281 `*s == '['`
6242 let mut sp: &str = &bracketed;
6243 if crate::ported::params::getindex(&mut sp, &mut v, 0) != 0 {
6244 // c:2020-2022 — `zerr("invalid subscript")` already reported.
6245 return 1;
6246 }
6247 let elem_is_hash = v
6248 .pm
6249 .as_ref()
6250 .is_some_and(|p| crate::ported::zsh_h::PM_TYPE(p.node.flags as u32) == PM_HASHED);
6251 if elem_is_hash {
6252 // A search subscript: the c:1596 exact-key rebind did not
6253 // happen, so the value still refers to the whole association
6254 // and c:3343 `assignstrvalue` reports it — either "attempt to
6255 // set slice of associative array" (c:2701-2706, the scanflags
6256 // survived the c:2179 clear) or "attempt to set associative
6257 // array to scalar" (c:2831-2839, they did not and no member
6258 // was found).
6259 let pre = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
6260 crate::ported::params::assignstrvalue(
6261 Some(&mut v),
6262 Some(val.to_string()),
6263 crate::ported::zsh_h::ASSPM_WARN,
6264 ); // c:3343
6265 let post = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
6266 return if post != pre { 1 } else { 0 };
6267 }
6268 // c:1596-1616 — a non-search flag group (`(e)`, `(w)`, `(n:N:)`,
6269 // `(p)`, or an unrecognised one, c:1498 `flagerr`): `v->pm` is now
6270 // the ELEMENT and its name is the subscript with the group already
6271 // consumed. That name IS the key.
6272 let key =
6273 v.pm.as_ref()
6274 .map_or(sub, |p| p.node.nam.as_str())
6275 .to_string();
6276 return store_hash_element(name, &key, val);
6277 }
6278 // c:1596-1616 with no flag group at all — the subscript is the key
6279 // verbatim. Nothing to parse, so an expanded `]` stays intact.
6280 store_hash_element(name, sub, val)
6281 }
6282
6283 /// c:Src/params.c:2841 — `foundparam->gsu.s->setfn(foundparam, val)`,
6284 /// the write to one member of an association. This port keeps assoc
6285 /// members as plain strings in `paramtab_hashed_storage` rather than as
6286 /// Params carrying their own `strsetfn` (the same substitution
6287 /// `arrhashsetfn` makes at c:4113), so the member write is a map insert.
6288 fn store_hash_element(name: &str, key: &str, val: &str) -> i32 {
6289 if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
6290 store
6291 .entry(name.to_string())
6292 .or_default()
6293 .insert(key.to_string(), val.to_string()); // c:2841
6294 }
6295 0
6296 }
6297
6298 vm.register_builtin(BUILTIN_SET_ASSOC, |vm, _argc| {
6299 // `${~spec}` carrier: an assignment statement is a word-
6300 // pipeline boundary too — restore the user's GLOB_SUBST
6301 // before the NEXT word expands (`Z[d]=${~Z[d]}; print
6302 // ${options[globsubst]}` must read the user value).
6303 consume_tilde_globsubst_carrier();
6304 // argc 4 = compile flagged the subscript as DYNAMIC (`H[$k]`):
6305 // an EXPANDED-empty key is then a legal assoc key (C's
6306 // assignsparam isident gate sees the raw `$k` text and the
6307 // empty key stores at getindex time — zinit's
6308 // ZINIT_SICE[$1…$2] relies on it). argc 3 = source-literal
6309 // key; `H[]` stays the "not an identifier" error.
6310 // Stack shapes (compile_zsh::compile_assign):
6311 // argc 4 = [name, key, value, key_src] — literal key
6312 // argc 5 = [name, key, value, key_src, dynamic] — expanded key
6313 // `key_src` is the SOURCE spelling of the subscript, kept beside
6314 // the expanded one so nothing downstream has to re-flatten
6315 // `name[key]` and re-split it (c:Src/params.c:2008 parses the
6316 // subscript BEFORE expansion; see `assign_hash_element`).
6317 // argc 6 = [name, key, value, key_src, dynamic, append] — `+=`;
6318 // value is the pre-concatenated read-modify-write.
6319 let append = if _argc >= 6 {
6320 vm.pop().to_int() != 0
6321 } else {
6322 false
6323 };
6324 let key_is_dynamic = if _argc >= 5 {
6325 vm.pop().to_int() != 0
6326 } else {
6327 false
6328 };
6329 let key_src = if _argc >= 4 {
6330 Some(vm.pop().to_str())
6331 } else {
6332 None
6333 };
6334 let value = vm.pop().to_str();
6335 let key = vm.pop().to_str();
6336 let name = vm.pop().to_str();
6337 // c:Src/exec.c:4475-4478 (54941) — `A[k]=v cmd`: save_params cuts
6338 // the assignment text at the subscript (`itype_end(s, INAMESPC, 0)`
6339 // stopping at `[`) and snapshots the whole base parameter `A`, so
6340 // the element assignment is undone after a builtin or shell
6341 // function exactly as after a forked external (whose `addvars`
6342 // runs in the child and never touches the parent's `A`).
6343 with_executor(|exec| {
6344 if exec
6345 .inline_env_stack
6346 .last()
6347 .is_some_and(|frame| frame.recording)
6348 {
6349 save_inline_prefix_param(exec, &name);
6350 }
6351 });
6352 // c:Src/params.c:3250-3266 — assignsparam ASSPM_AUGMENT on a
6353 // subscripted integer/float:
6354 // case PM_INTEGER: case PM_EFLOAT: case PM_FFLOAT:
6355 // zsfree(val); unqueue_signals();
6356 // zerr("attempt to add to slice of a numeric variable");
6357 // return NULL;
6358 if append {
6359 let numeric = crate::ported::params::paramtab()
6360 .read()
6361 .ok()
6362 .and_then(|t| t.get(&name).map(|pm| crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)))
6363 .is_some_and(|t| {
6364 t == crate::ported::zsh_h::PM_INTEGER
6365 || t == crate::ported::zsh_h::PM_EFLOAT
6366 || t == crate::ported::zsh_h::PM_FFLOAT
6367 });
6368 if numeric {
6369 crate::ported::utils::zerr("attempt to add to slice of a numeric variable"); // c:3264
6370 return Value::Status(1);
6371 }
6372 }
6373 let key_src = key_src.unwrap_or_else(|| key.clone());
6374 // c:Src/params.c:3203-3207 — `if (!isident(s)) { zerr("not an
6375 // identifier: %s", s); errflag |= ERRFLAG_ERROR; return NULL; }`.
6376 // Every subscripted assignment passes through that gate, and isident
6377 // rejects an empty subscript at c:1334 `if (!(ss =
6378 // parse_subscript(++ss, 1, ']'))) return 0;` — the LHS text is
6379 // untokenized by then, so the `]` IS parse_subscript's literal endchar
6380 // and c:Src/lex.c:1748 returns NULL. `m[]=z` / `A[]=z` / `s[]=z` are
6381 // therefore all `not an identifier: NAME[]`, never a store.
6382 // Only the SOURCE-LITERAL empty subscript is affected: with a dynamic
6383 // key (`H[$k]=v`) C's isident sees the unexpanded `$k` text, passes,
6384 // and getindex stores the expanded — possibly empty — key.
6385 // The gate lives here because the PM_HASHED fast path and the numeric
6386 // pre-resolve below both reach the store without calling assignsparam
6387 // (the empty key resolved to "" for a hash and to math-0 for an
6388 // indexed array, so `A[]=z` reported "assignment to invalid subscript
6389 // range" instead). Route through assignsparam so the diagnostic and
6390 // the errflag are the canonical ones.
6391 if !key_is_dynamic && key.is_empty() {
6392 crate::ported::params::assignsparam(
6393 &format!("{}[]", name), // c:3203 — the LHS spelling zsh reports
6394 &value,
6395 crate::ported::zsh_h::ASSPM_WARN,
6396 );
6397 return Value::Status(1);
6398 }
6399 // Bash sparse-array tracking for `a[i]=v` (scalar single-index). A set
6400 // that pads the dense Vec past its old end leaves old_len..i as holes;
6401 // on an undefined array, `a[5]=q` leaves only index 5 (count 1). Only
6402 // for INDEXED arrays (assoc keys are strings), bash mode, numeric key.
6403 // Captured before the assign; applied after (on the array path).
6404 let sparse_track: Option<(String, usize, usize)> =
6405 if crate::dash_mode::sparse_arrays() && !key.contains(',') {
6406 key.trim().parse::<usize>().ok().and_then(|i| {
6407 with_executor(|exec| {
6408 if !exec.has_assoc(&name) {
6409 let old_len = exec.array(&name).map(|a| a.len()).unwrap_or(0);
6410 Some((name.clone(), old_len, i))
6411 } else {
6412 None
6413 }
6414 })
6415 })
6416 } else {
6417 None
6418 };
6419 if key_is_dynamic && key.is_empty() {
6420 with_executor(|exec| {
6421 let _ = exec;
6422 });
6423 // Mirror assignsparam's PM_HASHED tail directly (the
6424 // textual `name[]` reconstruction can't pass isident).
6425 if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
6426 let entry = store.entry(name.clone()).or_default();
6427 let newval = if let Some(old) = entry.get("") {
6428 // `+=` arrives pre-concatenated by the compile
6429 // read-modify-write; plain `=` overwrites.
6430 let _ = old;
6431 value.clone()
6432 } else {
6433 value.clone()
6434 };
6435 entry.insert(String::new(), newval);
6436 }
6437 return Value::Status(0);
6438 }
6439 with_executor(|exec| {
6440 #[cfg(feature = "recorder")]
6441 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
6442 let ctx = exec.recorder_ctx();
6443 let attrs = exec.recorder_attrs_for(&name);
6444 crate::recorder::emit_assoc_assign(
6445 &name,
6446 vec![(key.clone(), value.clone())],
6447 attrs,
6448 true,
6449 ctx,
6450 );
6451 }
6452 let _ = exec;
6453 });
6454 // Build `name[key]=value` shape for assignsparam's subscript
6455 // dispatch. Arith-evaluate numeric subscripts on an existing
6456 // indexed array (`a[i+1]=v` form) before handing off — the
6457 // canonical port currently only handles literal int / string
6458 // keys, so pre-resolve here.
6459 let resolved_key = with_executor(|exec| {
6460 // Existence probes only — use the non-cloning `has_*`
6461 // checks. `exec.assoc()` / `exec.array()` return owned
6462 // clones of the whole map/vector, so probing `.is_some()`
6463 // here copied the entire associative array on every
6464 // `h[k]=v` (O(n) per store → O(n²) for a fill loop). The
6465 // profiler flagged `ShellExecutor::assoc → IndexMap::clone`
6466 // as the dominant cost.
6467 let is_indexed = exec.has_array(&name);
6468 let is_assoc = exec.has_assoc(&name);
6469 let is_scalar = !is_indexed && !is_assoc && exec.has_scalar(&name);
6470 // c:Src/params.c::getindex — `(i)pat` / `(I)pat` / `(R)pat`
6471 // / `(r)pat` subscript flags on an indexed array LHS resolve
6472 // to a numeric index (first / last match of pat). On a
6473 // SCALAR LHS the same flags resolve to a CHAR position
6474 // (1-based first/last match of pat in the scalar string)
6475 // for the c:2748+ char-splice assignment. zshrs's
6476 // read-form `${a[(i)pat]}` already implements both shapes;
6477 // the LHS assignment path silently stored the literal
6478 // "(i)pat" as an assoc key (for scalar: auto-vivified to
6479 // PM_HASHED via the assignsparam unknown-subscript
6480 // fallback). Bug #293 (array) / scalar sibling.
6481 //
6482 // Detect the `(flags)pat` shape and resolve to a numeric
6483 // index before assignsparam.
6484 if is_indexed || is_scalar {
6485 if let Some(rest) = key.strip_prefix('(') {
6486 if let Some(close) = rest.find(')') {
6487 let flags = &rest[..close];
6488 let pat = &rest[close + 1..];
6489 if !flags.is_empty()
6490 && flags
6491 .chars()
6492 .all(|c| matches!(c, 'I' | 'R' | 'i' | 'r' | 'n' | 'e'))
6493 {
6494 // Resolve via the array's contents.
6495 if let Some(arr) = exec.array(&name) {
6496 let return_index = true; // LHS write — index needed
6497 let down = flags.contains('I') || flags.contains('R');
6498 let exact = flags.contains('e');
6499 let iter: Box<dyn Iterator<Item = (usize, &String)>> = if down {
6500 Box::new(arr.iter().enumerate().rev())
6501 } else {
6502 Box::new(arr.iter().enumerate())
6503 };
6504 let mut found: Option<usize> = None;
6505 for (idx, elem) in iter {
6506 let matched = if exact {
6507 elem == pat
6508 } else {
6509 crate::ported::pattern::patcompile(
6510 &{
6511 let mut __pat_tok = (pat).to_string();
6512 crate::ported::glob::tokenize(&mut __pat_tok);
6513 __pat_tok
6514 },
6515 crate::ported::zsh_h::PAT_HEAPDUP as i32,
6516 None,
6517 )
6518 .map_or(false, |p| crate::ported::pattern::pattry(&p, elem))
6519 };
6520 if matched {
6521 found = Some(idx);
6522 break;
6523 }
6524 }
6525 let _ = return_index;
6526 // (i)/(r) return 1-based index of match,
6527 // arr.len()+1 (or 1 for I/R) on miss
6528 // per zsh docs. We mirror the read-form
6529 // semantics from subst.rs.
6530 let idx_1based = match found {
6531 Some(i) => (i + 1) as i64,
6532 None => (arr.len() + 1) as i64,
6533 };
6534 return idx_1based.to_string();
6535 }
6536 // Scalar LHS — resolve to a CHAR position
6537 // (1-based first/last match of pat in the
6538 // string). c:Src/params.c:1411-1418 — the
6539 // scalar path returns the char index from
6540 // sliding-window pattern match against
6541 // pm.u_str. Same algorithm as the read-form
6542 // at subst.rs:5283-5306. Bug (scalar
6543 // sibling of #293): `a=hello; a[(I)l]=X`
6544 // previously auto-vivified `a` into
6545 // PM_HASHED with key "(I)l" instead of
6546 // splicing X at the last 'l' position
6547 // (yielding "helXo").
6548 if is_scalar {
6549 let s = exec.scalar(&name).unwrap_or_default();
6550 let s_chars: Vec<char> = s.chars().collect();
6551 let n = s_chars.len();
6552 let want_last = flags.contains('I') || flags.contains('R');
6553 let exact = flags.contains('e');
6554 let mut found: Option<usize> = None;
6555 'outer: for start in 0..=n {
6556 let lengths: Box<dyn Iterator<Item = usize>> = if want_last {
6557 Box::new((1..=(n - start)).rev())
6558 } else {
6559 Box::new(1..=(n - start))
6560 };
6561 for len in lengths {
6562 let cand: String =
6563 s_chars[start..start + len].iter().collect();
6564 let matched = if exact {
6565 cand == pat
6566 } else {
6567 crate::ported::pattern::patcompile(
6568 &{
6569 let mut __pat_tok = (pat).to_string();
6570 crate::ported::glob::tokenize(&mut __pat_tok);
6571 __pat_tok
6572 },
6573 crate::ported::zsh_h::PAT_HEAPDUP as i32,
6574 None,
6575 )
6576 .map_or(false, |p| {
6577 crate::ported::pattern::pattry(&p, &cand)
6578 })
6579 };
6580 if matched {
6581 found = Some(start);
6582 if !want_last {
6583 break 'outer;
6584 }
6585 break;
6586 }
6587 }
6588 }
6589 // (I)/(R): scan again to find LAST.
6590 if want_last {
6591 let mut last_found: Option<usize> = found;
6592 for start in (0..=n).rev() {
6593 for len in 1..=(n - start) {
6594 let cand: String =
6595 s_chars[start..start + len].iter().collect();
6596 let matched = if exact {
6597 cand == pat
6598 } else {
6599 crate::ported::pattern::patcompile(
6600 &{
6601 let mut __pat_tok = (pat).to_string();
6602 crate::ported::glob::tokenize(
6603 &mut __pat_tok,
6604 );
6605 __pat_tok
6606 },
6607 crate::ported::zsh_h::PAT_HEAPDUP as i32,
6608 None,
6609 )
6610 .map_or(false, |p| {
6611 crate::ported::pattern::pattry(&p, &cand)
6612 })
6613 };
6614 if matched {
6615 last_found = Some(start);
6616 break;
6617 }
6618 }
6619 if last_found.is_some() && last_found.unwrap() >= start {
6620 break;
6621 }
6622 }
6623 found = last_found;
6624 }
6625 let idx_1based = match found {
6626 Some(i) => (i + 1) as i64,
6627 // (i) miss → len+1 (one past end).
6628 None => (n + 1) as i64,
6629 };
6630 return idx_1based.to_string();
6631 }
6632 }
6633 }
6634 }
6635 }
6636 if is_indexed && key.trim().parse::<i64>().is_err() {
6637 crate::ported::math::mathevali(&crate::ported::subst::singsub(&key))
6638 .map(|n| n.to_string())
6639 .unwrap_or(key.clone())
6640 } else {
6641 key.clone()
6642 }
6643 });
6644 // c:Src/params.c getindex — C parses the subscript from the
6645 // TOKENIZED source word, so a `]`/`}` that arrived via `$key`
6646 // expansion is plain data and can never terminate the
6647 // subscript. The textual `name[key]` rebuild below re-parses
6648 // the FLAT string, where an expanded `]` splits the key at the
6649 // first bracket (`c[$k]=5` with k='x]y' stored key "x" and
6650 // spilled junk — zpwr expandstats died on the spill in a later
6651 // math expr). For a PM_HASHED target the compile-time split
6652 // already isolated the exact key: store it directly via the
6653 // canonical hashed storage (same mechanism as the
6654 // dynamic-empty-key arm above / assignsparam's PM_HASHED
6655 // tail), with the readonly guard assignsparam would apply.
6656 let target_flags = with_executor(|exec| exec.param_flags(&name));
6657 // PM_SPECIAL exclusion: the zsh/parameter magic assocs
6658 // (functions / aliases / galiases / saliases / options / …)
6659 // have per-key setfns with SIDE EFFECTS — `functions[x]=body`
6660 // must parse the body into shfunctab (Src/Modules/
6661 // parameter.c:296 setfunction), `aliases[x]=v` must write
6662 // aliastab. The direct hashed-storage store below silently
6663 // swallowed those: zinit's tmp-subst wrappers
6664 // (`functions[autoload]=':zinit-tmp-subst-autoload "$@";'`)
6665 // never became real functions, so every
6666 // `.zinit-tmp-subst-off` spammed `unfunction: no such hash
6667 // table element: autoload/compdef/bindkey/…`. Route specials
6668 // through assignsparam's canonical per-name arms instead.
6669 if (target_flags as u32 & crate::ported::zsh_h::PM_HASHED) != 0
6670 && (target_flags as u32 & crate::ported::zsh_h::PM_SPECIAL) == 0
6671 {
6672 // c:3251 + c:3343 — run the real chain (getindex →
6673 // assignstrvalue) instead of storing the subscript verbatim.
6674 // Storing it verbatim is what made `h[(r)v]=Z` invent a key
6675 // named `(r)v` where zsh reports a slice assignment, and it
6676 // also skipped the c:2701 / c:2831 guards entirely.
6677 let _ = &resolved_key;
6678 return Value::Status(assign_hash_element(&name, &key, &key_src, &value));
6679 }
6680 let subscripted = format!("{}[{}]", name, resolved_key);
6681 crate::ported::params::assignsparam(&subscripted, &value, crate::ported::zsh_h::ASSPM_WARN);
6682 if let Some((nm, old_len, i)) = sparse_track {
6683 crate::bash_arrays::note_subscript_set(&nm, old_len, i);
6684 }
6685 Value::Status(0)
6686 });
6687
6688 // Brace expansion. Routes through executor.xpandbraces (already
6689 // implemented for the pre-fusevm executor). Returns Value::Array.
6690 // BUILTIN_ARRAY_DROP_EMPTY — filter out empty Value::Str entries
6691 // from a Value::Array on the stack. Used by `for x in $@` /
6692 // `for x in $*` unquoted forms which drop empty positionals
6693 // (POSIX-like) but do NOT IFS-split each element internally
6694 // (zsh-specific — scalar word splitting is off by default).
6695 // Distinct from BUILTIN_WORD_SPLIT which routes through
6696 // multsub PREFORK_SPLIT (full IFS-split). Bug #166.
6697 vm.register_builtin(BUILTIN_ARRAY_DROP_EMPTY, |vm, argc| {
6698 let v = vm.pop();
6699 strip_nulstring_markers(drop_word_empties(v, argc))
6700 });
6701 // The c:183-186 decision of BUILTIN_ARRAY_DROP_EMPTY, before c:170's
6702 // remnulargs (strip_nulstring_markers) runs on the survivors.
6703 fn drop_word_empties(v: Value, argc: u8) -> Value {
6704 // End of word: whatever `ARRAY_EMPTIES_ELIDABLE` was carrying is
6705 // spent here even though this builtin drops unconditionally, so a
6706 // following word starts from a clean bit.
6707 let _ = take_array_empties_elidable();
6708 // c:Src/subst.c:4387 / :4404 / :4426 — a QUOTED splat of two or more
6709 // elements stored `nulstring` in its empty nodes, which c:183 finds
6710 // NON-empty, so c:186's `uremnode` skips them and `remnulargs` (c:170)
6711 // restores `""`. `a=(x '' y); PRE"${a[@]}"POST` is three words in zsh.
6712 // See `QUOTED_SPLICE_KEEPS_EMPTIES` for the c:4261 element-count
6713 // cut-off that makes this a run-time decision.
6714 if take_quoted_splice_keeps_empties() {
6715 return v;
6716 }
6717 match v {
6718 Value::Array(items) => {
6719 if argc & (WORD_DROP_KEEPS_FIRST | WORD_DROP_KEEPS_LAST) != 0 && plan9_active() {
6720 // c:4341 — under plan9 the quoted-empty affix is glued to
6721 // every element, so no node of the word is empty.
6722 return Value::Array(items);
6723 }
6724 let last = items.len().saturating_sub(1);
6725 let filtered: Vec<Value> = items
6726 .iter()
6727 .enumerate()
6728 .filter(|(i, x)| word_drop_keeps(argc, *i, last) || !x.to_str().is_empty())
6729 .map(|(_, x)| x.clone())
6730 .collect();
6731 Value::array(filtered)
6732 }
6733 Value::Str(s) if s.is_empty() => Value::array(Vec::new()),
6734 other => other,
6735 }
6736 }
6737
6738 // Opens (argc 0) / closes (argc 1) a word whose literals the compiler
6739 // attaches itself — see `BUILTIN_WORD_DEFER_EMPTIES`. Pushes nothing
6740 // meaningful; the emitter pops the status.
6741 vm.register_builtin(BUILTIN_WORD_DEFER_EMPTIES, |_vm, argc| {
6742 if argc == 0 {
6743 open_deferred_affix_word();
6744 } else {
6745 close_deferred_affix_word();
6746 }
6747 Value::Int(0)
6748 });
6749
6750 // c:Src/subst.c:180-187 — prefork's final loop deletes a list node whose
6751 // text is EMPTY: `} else if (!(flags & PREFORK_SINGLE) &&
6752 // !(*ret_flags & PREFORK_KEY_VALUE) && !keep) uremnode(list, node);`.
6753 // The compiler emits it once the word's segments have been folded, which
6754 // is the ORDER c:183-186 has: the node text tested there is the finished
6755 // word, prefix and suffix already attached by the array emit block
6756 // (c:4245 onward — plan9 cross-product at c:4316-4365, the "simply join
6757 // the first and last values" splice at c:4366-4437). So an element that
6758 // is empty at read time is not yet an empty word: under plan9 a prefixed
6759 // word cross-products (`setopt rcexpandparam; a=('' x);
6760 // print -rl -- p$a` → `p`, `px`), and under the splice an affix lands on
6761 // the boundary element (`a=(x y ""); print -rl -- ${a}POST` → `x`, `y`,
6762 // `POST`).
6763 //
6764 // Applies only to the empties of a genuine ARRAY reference. A split field
6765 // — SH_WORD_SPLIT (c:3919 `sepsplit`) or command substitution — is
6766 // `nulstring` in C (c:36), non-empty at c:183, so it SURVIVES:
6767 // `setopt shwordsplit; IFS=:; s='a::b'; print -rl -- P${s}S` is `Pa`,
6768 // ``, `bS`. zshrs renders both as "", so the discriminator is the
6769 // `ARRAY_EMPTIES_ELIDABLE` bit `get_var_impl` sets on the array read.
6770 vm.register_builtin(BUILTIN_WORD_ELIDE_EMPTY, |vm, argc| {
6771 let v = vm.pop();
6772 strip_nulstring_markers(elide_word_empties(v, argc))
6773 });
6774 // The c:183-186 decision of BUILTIN_WORD_ELIDE_EMPTY, before c:170's
6775 // remnulargs (strip_nulstring_markers) runs on the survivors.
6776 fn elide_word_empties(v: Value, argc: u8) -> Value {
6777 // `PARAMSUBST_EMPTIES_DEFERRED` is the same bit reported from the
6778 // other direction: `paramsubst` skipped c:186 for a genuine array
6779 // reference because this word opened with
6780 // `BUILTIN_WORD_DEFER_EMPTIES`, so the removal is owed here. It is
6781 // cleared by that marker's closing half, emitted just below this op.
6782 let elidable = take_array_empties_elidable()
6783 | crate::ported::subst::PARAMSUBST_EMPTIES_DEFERRED.with(|c| c.get());
6784 // A quoted splat never reaches this arm (the compile site sends every
6785 // splice-shaped word to `BUILTIN_ARRAY_DROP_EMPTY` instead), but the
6786 // bit is word-scoped, so spend it here too rather than let it reach
6787 // the next word's drop. See `QUOTED_SPLICE_KEEPS_EMPTIES`.
6788 let _ = take_quoted_splice_keeps_empties();
6789 // argc == 2 is the compiler's "this word carries a quoted-empty
6790 // literal segment" bit (see the emit site). c:4341 — under plan9 that
6791 // affix is glued to EVERY element, so no node of the word can be
6792 // empty and c:186 removes nothing: `setopt rcexpandparam;
6793 // a=("" y); print -rl -- $a''` is `` `y`.
6794 if argc & 2 != 0 && plan9_active() {
6795 return v;
6796 }
6797 if !elidable {
6798 return v;
6799 }
6800 match v {
6801 Value::Array(items) => {
6802 // `items` is an `Arc<Vec<Value>>`; borrow rather than move.
6803 let last = items.len().saturating_sub(1);
6804 let kept: Vec<Value> = items
6805 .iter()
6806 .enumerate()
6807 .filter(|(i, x)| word_drop_keeps(argc, *i, last) || !x.to_str().is_empty())
6808 .map(|(_, x)| x.clone())
6809 .collect();
6810 // c:4245 — an array reference that ends up empty is still an
6811 // ARRAY for the plan9 word-removal rule, not a scalar.
6812 if kept.is_empty() {
6813 note_empty_is_scalar(false);
6814 }
6815 Value::array(kept)
6816 }
6817 other => other,
6818 }
6819 }
6820
6821 // zsh nofork command substitution (c:Src/subst.c:1904-2100) — and the
6822 // ksh93 funsub / mksh valsub it subsumes. See the BUILTIN_KSH_FUNSUB
6823 // doc comment.
6824 vm.register_builtin(BUILTIN_KSH_FUNSUB, |vm, _argc| {
6825 // 0 = unquoted, 1 = inside `"…"`, 2 = unquoted PREFORK_SINGLE (a
6826 // scalar assignment RHS): trimmed like unquoted, never split.
6827 let qt_code = vm.pop().to_int();
6828 let single = qt_code == 2;
6829 let mut qt = qt_code == 1;
6830 let kind = vm.pop().to_int();
6831 let rplyvar = vm.pop().to_str();
6832 let body = vm.pop().to_str();
6833 // !!! POSIX-FAMILY GATE !!! bash, dash and sh have no nofork
6834 // command substitution — all three answer `bad substitution` and
6835 // fail, measured on this host:
6836 // bash -c 'printf "%s\\n" "${ printf inner; }"' -> rc 1
6837 // dash -c 'printf "%s\\n" "${ printf inner; }"' -> rc 2
6838 // sh -c 'printf "%s\\n" "${ printf inner; }"' -> rc 1
6839 // The Korn family DOES have it (funsub/valsub) and so does zsh
6840 // 5.10 (`${ … }` / `${| … }` / `${{VAR} … }`), so the gate is the
6841 // bare bash/sh/dash drop-in only: `posix_faithful()` without the
6842 // Korn leg. `--zsh` and native zshrs keep the substitution.
6843 if crate::dash_mode::posix_faithful() && !crate::dash_mode::korn_mode() {
6844 crate::ported::utils::zerr("bad substitution");
6845 crate::ported::utils::errflag.fetch_or(
6846 crate::ported::zsh_h::ERRFLAG_ERROR,
6847 std::sync::atomic::Ordering::Relaxed,
6848 );
6849 with_executor(|exec| exec.set_last_status(1));
6850 return Value::str("");
6851 }
6852 let live_status = vm.last_status;
6853 // c:Src/subst.c:1625 — paramsubst's `qt` is "am I inside double
6854 // quotes", which is a LEXICAL property of the whole word. The
6855 // compiler can see it only when the substitution IS the entire
6856 // word; `"${ print INNER } $?"` reaches here as a segment whose own
6857 // text carries no quotes, so pick the enclosing quoting up from the
6858 // executor's live DQ flag as well (D10nofork.ztst "return statement
6859 // inside, part 1+": trim must NOT eat `print`'s newline there).
6860 if !qt && with_executor(|exec| exec.in_dq_context) != 0 {
6861 qt = true;
6862 }
6863 // c:Src/subst.c — the split decision is read BEFORE the body runs.
6864 // D10nofork.ztst "test word splitting on result" pins that
6865 // explicitly ("setting option inside is too late for that
6866 // substitution"): a `setopt shwordsplit` executed by the body
6867 // must not retroactively split the value it produced.
6868 let split = !qt
6869 && !single
6870 && (crate::dash_mode::korn_mode()
6871 || crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT));
6872 let out = with_executor(|exec| {
6873 use std::sync::atomic::Ordering::Relaxed;
6874 exec.set_last_status(live_status);
6875 // c:2044 — `parse_string(cmdarg, 0)`: the body is parsed from the
6876 // running line, not from 1.
6877 let lineno: u64 = exec.scalar("LINENO").and_then(|s| s.parse().ok()).unwrap_or(0);
6878 // c:1908 — `int trim = (!EMULATION(EMULATE_ZSH)) ? 2 : !qt;`
6879 let trim: i32 = if crate::dash_mode::korn_mode()
6880 || !crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_ZSH)
6881 {
6882 2
6883 } else if qt {
6884 0
6885 } else {
6886 1
6887 };
6888 // c:1993-2007 — `${ cmd }` takes advantage of the parameter
6889 // scope and `$(<file)` semantics: the body runs in the CURRENT
6890 // shell as `>| TMP {\nBODY\n;}` and the file is read back. The
6891 // spelling is C's byte for byte; a body parse error's line
6892 // number depends on it.
6893 let rplytmp: Option<String> = if kind == 0 {
6894 crate::ported::utils::gettempname(None, true) // c:1996
6895 } else {
6896 None
6897 };
6898 let cmdarg: String = match (kind, &rplytmp) {
6899 (0, Some(t)) => format!(
6900 ">| {} {{\n{}\n;}}", // c:1993 outfmt
6901 crate::ported::utils::quotestring(t, crate::ported::zsh_h::QT_BACKSLASH),
6902 body
6903 ),
6904 (0, None) => {
6905 // c:2004-2007 — TMPPREFIX not writable: `cmdoutval =
6906 // lastval; cmdarg = NULL;`.
6907 crate::ported::exec::cmdoutval.store(live_status, Relaxed);
6908 String::new()
6909 }
6910 _ => body.clone(),
6911 };
6912 // c:2016 — `startparamscope(); /* "local" behaves as if in a
6913 // function */`, paired with c:2093 `endparamscope()`. All three
6914 // forms take it (the C block is under `if (rplyvar)`), which is
6915 // what makes `outer=GLOBAL; ${| local outer=LOCAL; … }` leave
6916 // the outer value alone (D10nofork.ztst "local declaration
6917 // inside").
6918 crate::ported::utils::inc_locallevel(); // c:2016
6919 // c:2018-2024 — `${| cmd }`: `rplypm = createparam("REPLY",
6920 // PM_LOCAL|PM_UNSET|PM_HIDE)` inside the scope, so the body sees
6921 // NO outer REPLY and the outer value is intact afterwards
6922 // (D10nofork.ztst "Basic substitution and REPLY scoping"). mksh's
6923 // valsub is identical, so one save/clear/restore serves both.
6924 let saved_reply = if kind == 1 {
6925 let saved = crate::ported::params::getsparam(&rplyvar);
6926 crate::ported::params::unsetparam(&rplyvar);
6927 saved
6928 } else {
6929 None
6930 };
6931 // c:2025-2031 — `${ cmd }` scopes its result under the reserved
6932 // `.zsh.cmdsubst`: local, unset, hidden and PM_READONLY_SPECIAL,
6933 // so the body cannot assign it (D10nofork.ztst "reserved
6934 // parameter name").
6935 if kind == 0 {
6936 use crate::ported::zsh_h::{PM_HIDE, PM_LOCAL, PM_READONLY_SPECIAL, PM_UNSET};
6937 crate::ported::params::createparam(
6938 ".zsh.cmdsubst",
6939 (PM_LOCAL | PM_UNSET | PM_HIDE | PM_READONLY_SPECIAL) as i32,
6940 ); // c:2025-2027
6941 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
6942 if let Some(pm) = tab.get_mut(".zsh.cmdsubst") {
6943 pm.level = crate::ported::utils::locallevel(); // c:2029
6944 }
6945 }
6946 }
6947 if !cmdarg.trim().is_empty() {
6948 // c:2039-2053 — `int obreaks = breaks; … cmdprog =
6949 // parse_string(cmdarg, 0); if (cmdprog) { execode(cmdprog, 1,
6950 // 0, "cmdsubst"); cmdoutval = lastval; if (retflag) { retflag
6951 // = 0; breaks = obreaks; } } else errflag |= ERRFLAG_ERROR;`
6952 // — `return` ends the body only; `exit` stays pending for the
6953 // c:2094 zexit below; `break` keeps unwinding the loop around
6954 // the command.
6955 use crate::ported::builtin::{BREAKS, RETFLAG};
6956 let obreaks = BREAKS.load(Relaxed); // c:2041
6957 // The body's commands are not inside the word's quotes.
6958 let saved_dq = std::mem::replace(&mut exec.in_dq_context, 0);
6959 let saved_subexp =
6960 crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.replace(0));
6961 // c:Src/exec.c:4410 save_params records only the names in the
6962 // prefix-assignment list (varspc); an assignment the body
6963 // makes while `X=${| … } cmd` is being evaluated is not one
6964 // of them. The inline-env frame records every SET_VAR while
6965 // open (D10nofork.ztst "environment assignment" left REPLY
6966 // unset), so park it for the body.
6967 let frames = std::mem::take(&mut exec.inline_env_stack);
6968 // c:Src/subst.c:2048 — execode pushes the "cmdsubst" context.
6969 let ctx = crate::ported::exec::EvalContextFrame::push("cmdsubst");
6970 let ran = exec.execute_string_at_lineno(&cmdarg, lineno);
6971 drop(ctx);
6972 exec.inline_env_stack = frames;
6973 match ran {
6974 Ok(st) => {
6975 exec.set_last_status(st);
6976 crate::ported::exec::cmdoutval.store(st, Relaxed); // c:2047
6977 }
6978 Err(_) => {
6979 crate::ported::utils::errflag
6980 .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Relaxed); // c:2053
6981 }
6982 }
6983 crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.set(saved_subexp));
6984 exec.in_dq_context = saved_dq;
6985 if RETFLAG.load(Relaxed) != 0 {
6986 RETFLAG.store(0, Relaxed); // c:2050
6987 BREAKS.store(obreaks, Relaxed); // c:2051
6988 }
6989 }
6990 let val = match kind {
6991 1 => {
6992 // c:2082-2083 — `val = dupstring(getsparam(rplyvar))`.
6993 let reply = crate::ported::params::getsparam(&rplyvar).unwrap_or_default();
6994 match saved_reply {
6995 Some(v) => {
6996 crate::ported::params::setsparam(&rplyvar, &v);
6997 }
6998 None => {
6999 crate::ported::params::unsetparam(&rplyvar);
7000 }
7001 }
7002 Value::str(reply)
7003 }
7004 2 => {
7005 // c:2026-2033 — `${{VAR} cmd }`: VAR is the result
7006 // parameter and gets NO local scope, so an assignment
7007 // inside is global. c:2089-2091 then re-enters the
7008 // ordinary parameter path with `s = dyncat(rplyvar, s)`,
7009 // which is why an ARRAY result stays an array.
7010 match exec.array(&rplyvar) {
7011 Some(items) => {
7012 Value::array(items.into_iter().map(Value::str).collect::<Vec<_>>())
7013 }
7014 None => Value::str(
7015 crate::ported::params::getsparam(&rplyvar).unwrap_or_default(),
7016 ),
7017 }
7018 }
7019 _ => {
7020 // c:2056-2057 — `rplypm->node.flags &= ~PM_READONLY_SPECIAL;`
7021 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
7022 if let Some(pm) = tab.get_mut(".zsh.cmdsubst") {
7023 pm.node.flags &= !(crate::ported::zsh_h::PM_READONLY_SPECIAL as i32);
7024 }
7025 }
7026 // c:2058-2075 — `if (rplytmp && !errflag) { noerrs = 2;
7027 // rplylen = zstuff(&cmdarg, rplytmp); … }`: trim==2 strips
7028 // EVERY trailing newline (ksh/bash), trim==1 exactly one,
7029 // trim==0 none.
7030 let mut b = String::new();
7031 if let Some(t) = &rplytmp {
7032 if crate::ported::utils::errflag.load(Relaxed) == 0 {
7033 if let Ok((txt, _)) = crate::ported::input::zstuff(t) {
7034 b = txt;
7035 }
7036 }
7037 while trim > 0 && b.ends_with('\n') {
7038 b.pop();
7039 if trim == 1 {
7040 break;
7041 }
7042 }
7043 let _ = std::fs::remove_file(t); // c:2079-2080
7044 }
7045 Value::str(b)
7046 }
7047 };
7048 crate::ported::params::endparamscope(); // c:2093
7049 // c:2094-2103 — `if (exit_pending) { … stopmsg = 1; zexit(exit_val,
7050 // ZEXIT_NORMAL); }`: an `exit` in the body ran in the CURRENT shell
7051 // and was only deferred by the parameter scope; it ends the shell
7052 // here, before the command around the substitution runs.
7053 {
7054 use crate::ported::builtin::{zexit, EXIT_PENDING, EXIT_VAL, STOPMSG};
7055 use std::sync::atomic::Ordering::Relaxed;
7056 if EXIT_PENDING.load(Relaxed) != 0 {
7057 STOPMSG.store(1, Relaxed); // c:2100
7058 zexit(EXIT_VAL.load(Relaxed), crate::ported::zsh_h::ZEXIT_NORMAL); // c:2101
7059 // c:2103 `_exit(exit_val)` — back here only when zexit deferred
7060 // for an in-process subshell (SUBSHELL_DEPTH): the forked child
7061 // C runs would already be gone, so abort the command around
7062 // the substitution and let the subshell unwind with the value.
7063 crate::ported::utils::errflag
7064 .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Relaxed);
7065 }
7066 }
7067 val
7068 });
7069 // A nofork substitution IS a command substitution, so it publishes
7070 // the body's exit the way `$( … )` does: `ksh -c 'v=${ false; };
7071 // print "rc=$?"'` → `rc=1`. The body run above leaves it in the
7072 // executor; the VM's own counter
7073 // is what BUILTIN_SET_VAR hands back as the assignment's status
7074 // (c:Src/exec.c:3396 `lastval = cmdoutval`), so mirror it here.
7075 vm.last_status = with_executor(|exec| exec.last_status());
7076 // An UNQUOTED substitution is word-split only when the shell splits
7077 // ordinary expansions: always under ksh/mksh, and under zsh only
7078 // with SH_WORD_SPLIT. `split` was decided above, before the body
7079 // ran — see the comment there.
7080 match out {
7081 Value::Str(ref s) if split => {
7082 let (_joined, parts, _isarr, _flags) =
7083 crate::ported::subst::multsub(s, crate::ported::zsh_h::PREFORK_SPLIT);
7084 Value::array(parts.into_iter().map(Value::str).collect::<Vec<_>>())
7085 }
7086 other => other,
7087 }
7088 });
7089
7090 // c:Src/subst.c:3032 `val = sepjoin(aval, sep, 1)` — see the
7091 // BUILTIN_QUOTED_STAR_ONE_WORD doc comment.
7092 vm.register_builtin(BUILTIN_QUOTED_STAR_ONE_WORD, |vm, _argc| match vm.pop() {
7093 Value::Array(items) if items.is_empty() => Value::str(String::new()),
7094 other => other,
7095 });
7096
7097 // BUILTIN_QUOTEDZPUTS — re-wrap top-of-stack scalar via the
7098 // canonical quotedzputs (Src/utils.c:6464). Non-printable bytes
7099 // come back as `$'…'` C-string form so the cond xtrace prefix
7100 // line preserves the source-quoting form for `[[ -n $'\C-[OP' ]]`
7101 // instead of leaking raw ESC + "OP" bytes through the terminal.
7102 vm.register_builtin(BUILTIN_QUOTEDZPUTS, |vm, _argc| {
7103 let s = vm.pop().to_str();
7104 Value::str(crate::ported::utils::quotedzputs(&s))
7105 });
7106
7107 // BUILTIN_QUOTE_TOKENIZED_OUTPUT — char-aware mirror of
7108 // c:Src/exec.c:2114 `quote_tokenized_output`. The canonical
7109 // port at exec::quote_tokenized_output operates on bytes
7110 // (zsh's metafied encoding); zshrs strings are UTF-8 so
7111 // `\u{e187}` Star is `[0xC2, 0x87]`, and a byte walk writes
7112 // 0xC2 raw (invalid UTF-8 lead → U+FFFD on lossy decode).
7113 // Walk by char and dispatch the same switch the byte port
7114 // uses, but with the token chars matching the UTF-8 form.
7115 vm.register_builtin(BUILTIN_QUOTE_TOKENIZED_OUTPUT, |vm, _argc| {
7116 let s = vm.pop().to_str();
7117 let mut out = String::with_capacity(s.len());
7118 let chars: Vec<char> = s.chars().collect();
7119 let mut i = 0;
7120 while i < chars.len() {
7121 let c = chars[i];
7122 // c:2120 — Meta-quoted byte: emit `*++s ^ 32`.
7123 // In UTF-8 strings Meta is `\u{83}`; the next char is
7124 // the metafied payload.
7125 if c == '\u{83}' {
7126 if let Some(&n) = chars.get(i + 1) {
7127 if (n as u32) < 0x80 {
7128 out.push(((n as u8) ^ 32) as char);
7129 } else {
7130 out.push(n);
7131 }
7132 i += 2;
7133 continue;
7134 }
7135 i += 1;
7136 continue;
7137 }
7138 // c:2124 — Nularg: skip.
7139 if c == '\u{e1a1}' {
7140 i += 1;
7141 continue;
7142 }
7143 // c:2128-2143 — ASCII specials get backslash-prefixed
7144 // then fall through to emit the literal char.
7145 match c {
7146 '\\' | '<' | '>' | '(' | '|' | ')' | '^' | '#' | '~' | '[' | ']' | '*' | '?'
7147 | '$' | ' ' => {
7148 out.push('\\');
7149 out.push(c);
7150 i += 1;
7151 continue;
7152 }
7153 '\t' => {
7154 out.push_str("$'\\t'");
7155 i += 1;
7156 continue;
7157 }
7158 '\n' => {
7159 out.push_str("$'\\n'");
7160 i += 1;
7161 continue;
7162 }
7163 '\r' => {
7164 out.push_str("$'\\r'");
7165 i += 1;
7166 continue;
7167 }
7168 '=' => {
7169 if i == 0 {
7170 out.push('\\');
7171 }
7172 out.push(c);
7173 i += 1;
7174 continue;
7175 }
7176 _ => {}
7177 }
7178 // c:2163 — `if (itok(*s)) putc(ztokens[*s - Pound]);`
7179 // Map zsh token chars (`\u{e184}`..`\u{e1a1}` range, the
7180 // ones the lexer emits for `#$^*()…`) back to their
7181 // source ASCII via the `ztokens` table.
7182 let cp = c as u32;
7183 if (0xe184..=0xe1a1).contains(&cp) {
7184 let idx = (cp - 0xe184) as usize;
7185 let ztokens = crate::ported::lex::ztokens.as_bytes();
7186 if idx < ztokens.len() {
7187 out.push(ztokens[idx] as char);
7188 i += 1;
7189 continue;
7190 }
7191 }
7192 out.push(c);
7193 i += 1;
7194 }
7195 Value::str(out)
7196 });
7197
7198 // BUILTIN_WORD_SPLIT — `${=var}` IFS-split runtime.
7199 // PURE PASSTHRU: route through canonical `subst::multsub` with
7200 // PREFORK_SPLIT flag (C port of `Src/subst.c::multsub` at c:544
7201 // — the IFS-split walker with whitespace-vs-non-whitespace
7202 // gating, quote-aware parsing, and empty-field handling).
7203 vm.register_builtin(BUILTIN_WORD_SPLIT, |vm, _argc| {
7204 let s = vm.pop().to_str();
7205 let (_joined, parts, _isarr, _flags) =
7206 crate::ported::subst::multsub(&s, crate::ported::zsh_h::PREFORK_SPLIT);
7207 // Empty single-string special case → empty Array (drop empty arg).
7208 // The Array is a carrier for "no argv word", not an array-SHAPED
7209 // result: a single empty field came from an empty SCALAR (c:3922-3923
7210 // `if (!aval || !aval[0]) val = dupstring("");`). Record that, or
7211 // under RC_EXPAND_PARAM `concat_plan9` reads a stale bit and applies
7212 // c:4362's `uremnode` to a word zsh keeps — `x$(true)y` and
7213 // `v=""; x${=v}y` are each the single word `xy`, not zero words.
7214 if parts.len() == 1 && parts[0].is_empty() {
7215 note_empty_is_scalar(true);
7216 return Value::array(Vec::new());
7217 }
7218 // Zero parts is the same story reached by a different route: an empty
7219 // command substitution splits to nothing at all rather than to one
7220 // empty field. `to_str()` above means this builtin's input is always
7221 // a scalar, so an empty result here is never array-shaped.
7222 restore_empty_shape(nodes_to_value(parts), true)
7223 });
7224
7225 // BUILTIN_FORCE_SPLIT — `${=name}` / SH_WORD_SPLIT forced split.
7226 // c:Src/subst.c:3920-3928 —
7227 // if (force_split && !isarr) {
7228 // aval = sepsplit(val, spsep, 0, 1);
7229 // if (!aval || !aval[0]) val = dupstring("");
7230 // else if (!aval[1]) val = aval[0];
7231 // else isarr = nojoin ? 1 : 2;
7232 // }
7233 // with spsep == NULL for the `=` flag, so sepsplit falls through to
7234 // Src/utils.c:3711 spacesplit(s, allownull=0). See BUILTIN_FORCE_SPLIT's
7235 // doc comment for the empty-field rule and the argc contract.
7236 vm.register_builtin(BUILTIN_FORCE_SPLIT, |vm, argc| {
7237 // argc is a two-bit field, and there is exactly one emit site
7238 // (compile_zsh.rs, the `${=…}` fast path):
7239 // bit 0 keep_empties — the word has neighbouring text or `Dnull`
7240 // markers to attach the empty leading/trailing fields to.
7241 // bit 1 the operand is a `[@]`/`[*]` SPLICE, so c:3931's `!isarr`
7242 // gate is live (see below).
7243 let keep_empties = argc & 1 != 0;
7244 let splice_operand = argc & 2 != 0;
7245 let raw_val = vm.pop();
7246 // c:Src/subst.c:3931 — `if (force_split && !isarr)`. The `${=NAME[*]}`
7247 // / `${=NAME[@]}` splice fast paths hand this builtin whatever the
7248 // c:3914-3927 join arm produced, and that arm can leave the expansion
7249 // ARRAY-shaped: with `nojoin` set and IFS the EMPTY string, neither
7250 // c:3916 nor c:3919 fires (c:3919 wants IFS UNSET, `!ifs`, not merely
7251 // empty), so `isarr` is still -1 here and the split does not run at
7252 // all. Only c:184-187's empty-word removal still applies.
7253 //
7254 // Gated on the SPLICE bit, not on the value's Rust type: a scalar
7255 // `${=@}` reads through BUILTIN_GET_VAR_DQ, which also hands over a
7256 // Value::Array, but there `isarr` was already collapsed by c:3033's
7257 // quoted join and the split must still run over the joined text
7258 // (`set -- 'a b' 'c d'; print -l $=@` is FOUR words).
7259 if let (true, Value::Array(items)) = (splice_operand, &raw_val) {
7260 let kept: Vec<Value> = items
7261 .iter()
7262 .filter(|x| keep_empties || !x.to_str().is_empty())
7263 .cloned()
7264 .collect();
7265 return Value::array(kept);
7266 }
7267 let s = raw_val.to_str();
7268 // c:3932 — `sepsplit(val, spsep, 0, 1)`; spsep NULL → spacesplit, plus
7269 // c:184-187's empty-word removal. See `sepsplit_c3932` for the
7270 // `nulstring`-vs-`""` rule that decides which empties survive.
7271 // c:36 `nulstring` — inside a word whose end-of-word drop is still to
7272 // run (BUILTIN_WORD_DEFER_EMPTIES opened it), an IFS-non-whitespace
7273 // empty field keeps its Nularg so that drop can tell it from the
7274 // IFS-whitespace edges it must delete (c:183-186), then removes the
7275 // marker (c:170 remnulargs). `IFS=:; s=a::b; print -rl -- x${=s}y`
7276 // is `xa` `` `by`.
7277 let out = if keep_empties
7278 && crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get()) > 0
7279 {
7280 crate::ported::utils::sepsplit(&s, None, false) // c:3932, Nularg kept
7281 } else {
7282 sepsplit_c3932(&s, keep_empties)
7283 };
7284 if out.is_empty() {
7285 // c:3922-3923 — `if (!aval || !aval[0]) val = dupstring("");`:
7286 // the split produced nothing, so the value is the empty SCALAR.
7287 // Quoted, that is one empty word (c:4465 `if (qt && !*y) y =
7288 // dupstring(nulstring);` → `a=( "${=v}" )` has one element);
7289 // unquoted, prefork deletes it and the word vanishes.
7290 if keep_empties {
7291 return Value::str(String::new());
7292 }
7293 note_empty_is_scalar(true);
7294 return Value::array(Vec::new());
7295 }
7296 if out.len() == 1 {
7297 // c:3924 — `else if (!aval[1]) val = aval[0];` — a one-field
7298 // split stays a SCALAR (this is why `${#${(f)v}}` counts
7299 // characters when the split yields a single line).
7300 return Value::str(out.into_iter().next().unwrap());
7301 }
7302 // c:3927 — `isarr = nojoin ? 1 : 2;`
7303 Value::array(out.into_iter().map(Value::str).collect())
7304 });
7305
7306 // See BUILTIN_BARE_SPBREAK's declaration for the argc contract.
7307 vm.register_builtin(BUILTIN_BARE_SPBREAK, |vm, argc| {
7308 // c:2567 / c:2562 — `${=…}` sets spbreak 2, `${==…}` clears it.
7309 let spbreak = if argc & 1 != 0 { 2 } else { 0 };
7310 let qt = argc & 2 != 0; // c:3033 `qt`
7311 let name = vm.pop().to_str();
7312 let (elems, is_arr, ifs_opt, in_dq) = with_executor(|exec| {
7313 // c:Src/params.c:4745 `ifssetfn` — the C `ifs` global is NULL only
7314 // when IFS is genuinely UNSET, and c:3920's `(!ifs && …)` turns on
7315 // exactly that distinction, so carry the Option, not a defaulted
7316 // string.
7317 let ifs_opt = exec.scalar("IFS");
7318 let in_dq = qt || exec.in_dq_context > 0; // c:3033 `qt`
7319 let (elems, is_arr) = param_value_elems_c2916(exec, &name);
7320 (elems, is_arr, ifs_opt, in_dq)
7321 });
7322 // c:3899-3906 — "treat a one-element array as a scalar for purposes of
7323 // concatenation with surrounding text (some${param}thing) and
7324 // rc_expand_param handling":
7325 // } else if (isarr && aval && aval[0] && !aval[1]) {
7326 // val = aval[0];
7327 // isarr = 0;
7328 // An EMPTY array fails `aval[0]` and keeps `isarr`.
7329 let mut isarr: i32 = i32::from(is_arr && elems.len() != 1);
7330 // c:1819 / c:2564 / c:2569 — `nojoin` for this spelling. `${==…}`
7331 // clears it outright; `${=…}` recomputes it as `!(ifs && *ifs)` (and,
7332 // unlike c:1819's SH_WORD_SPLIT form, with no `!qt` term).
7333 let nojoin = spbreak != 0 && !matches!(ifs_opt.as_deref(), Some(s) if !s.is_empty());
7334 // c:3030-3032 — `if (isarr) { if (nojoin) isarr = -1; }`.
7335 if isarr != 0 && nojoin {
7336 isarr = -1;
7337 }
7338 // c:3033-3036 — `if (qt && !getlen && isarr > 0) { val = sepjoin(aval,
7339 // sep, 1); isarr = 0; }`. This is why `"${==a}"` with a non-empty IFS
7340 // is ONE joined word while the unquoted form keeps its elements.
7341 if in_dq && isarr > 0 {
7342 return Value::str(crate::ported::utils::sepjoin(&elems, None));
7343 }
7344 // c:3912-3930 — the gate is open only for `${=…}` here (`ssub`,
7345 // `spsep`, `sep` and the quoted-with-offset case never reach this
7346 // spelling), and `force_split` is 1, so `join_c3914` is c:3916 and
7347 // c:3919 exactly. c:3931's `sepsplit` is BUILTIN_FORCE_SPLIT at the
7348 // compile site, which is why this stops at the join.
7349 if spbreak != 0 && isarr != 0 {
7350 return match join_c3914(elems, ifs_opt.as_deref()) {
7351 JoinC3914::Joined(val) => Value::str(val), // c:3917 / c:3925
7352 // Neither join arm fired (IFS set but EMPTY), so `isarr` is
7353 // still non-zero at c:3931 and the split does not run either —
7354 // BUILTIN_FORCE_SPLIT's splice bit passes the array through.
7355 JoinC3914::Elements(kept) => {
7356 Value::array(kept.into_iter().map(Value::str).collect())
7357 }
7358 };
7359 }
7360 if isarr != 0 {
7361 // `${==NAME}` on an array: the block never ran, so the element
7362 // vector is the answer. c:184-187's empty-word removal is the
7363 // BUILTIN_ARRAY_DROP_EMPTY the compile site emits after this.
7364 return Value::array(elems.into_iter().map(Value::str).collect());
7365 }
7366 // c:2927 / c:3904 — a scalar (or the one-element array c:3899
7367 // collapsed): `isarr == 0`, so no join arm applies and c:3931 splits
7368 // this text directly.
7369 Value::str(elems.into_iter().next().unwrap_or_default())
7370 });
7371
7372 vm.register_builtin(BUILTIN_BRACE_EXPAND, |vm, _argc| {
7373 // c:Src/glob.c::xpandbraces — brace expansion runs per word.
7374 // When the upstream produced an array (e.g. `${a:e}` splat),
7375 // expand braces on each element separately so the splat
7376 // survives. `pop().to_str()` would join with space and lose
7377 // the array shape. Parity bug #28 cousin: the BRACE_EXPAND
7378 // emit always fires for any word containing `{` (including
7379 // `${...}` param-expansion braces), so its collapse hit even
7380 // pure-paramsubst args.
7381 let raw = vm.pop();
7382 // Brace expansion runs BETWEEN the expansion builtin that produced
7383 // this word and the concat that consumes it (`x${${P}}y` compiles to
7384 // EXPAND_TEXT, BRACE_EXPAND, CONCAT_DISTRIBUTE). It cannot change
7385 // whether an empty result was scalar- or array-SHAPED — c:Src/glob.c
7386 // xpandbraces only ever rewrites the text of existing words. But both
7387 // exits below funnel through `nodes_to_value`, which records
7388 // `note_empty_is_scalar(false)` for an empty result, so the shape bit
7389 // its producer set was being overwritten with "array" before
7390 // `concat_plan9` could read it. Under RC_EXPAND_PARAM that deleted
7391 // words zsh keeps: `unset P; x${${P}}y` and `x$(true)y` are each the
7392 // single word `xy` (c:4438-4467 scalar arm), not zero words.
7393 // Carry the incoming bit across an empty→empty pass-through.
7394 let incoming_empty_is_scalar = empty_is_scalar();
7395 // c:Src/options.c — `no_brace_expand` (negated braceexpand)
7396 // disables brace expansion entirely. When set, `{a,b}` stays
7397 // literal. Mirror by short-circuiting xpandbraces; pass the
7398 // input through unchanged.
7399 let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
7400 let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
7401 // c:Src/glob.c xpandbraces rewrites word TEXT; it never turns a
7402 // scalar word into an array one. Remember the incoming shape so a
7403 // scalar that brace-expands to exactly one word stays SCALAR:
7404 // nodes_to_value collapses a lone EMPTY node to zero words unless
7405 // in_dq_context > 0, and BUILTIN_EXPAND_TEXT has already decremented
7406 // that by the time this runs — so a quoted word whose expansion came
7407 // out empty, and which carries the Inbrace token so it reaches this
7408 // builtin at all, lost its argument entirely.
7409 let raw_was_scalar = !matches!(raw, Value::Array(_));
7410 let inputs: Vec<String> = match raw {
7411 Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
7412 other => vec![other.to_str()],
7413 };
7414 if !brace_expand {
7415 return restore_empty_shape(nodes_to_value(inputs), incoming_empty_is_scalar);
7416 }
7417 let mut out: Vec<String> = Vec::with_capacity(inputs.len());
7418 for s in inputs {
7419 for w in crate::ported::glob::xpandbraces(&s, brace_ccl) {
7420 out.push(w);
7421 }
7422 }
7423 if raw_was_scalar && out.len() == 1 {
7424 let mut only = out.into_iter().next().unwrap();
7425 crate::ported::glob::remnulargs(&mut only);
7426 return Value::str(only);
7427 }
7428 restore_empty_shape(nodes_to_value(out), incoming_empty_is_scalar)
7429 });
7430
7431 // `*(qual)` glob qualifier filter. Stack: [pattern, qualifier].
7432 // Pattern is glob-expanded normally, then each result is filtered by the
7433 // qualifier predicate. Common qualifiers:
7434 // . — regular files only
7435 // / — directories only
7436 // @ — symlinks
7437 // x — executable
7438 // r/w/x — readable/writable/executable
7439 // N — nullglob (no error if no match)
7440 // L+N / L-N — size > N / size < N (in bytes)
7441 // mh-N / mh+N — modified within N hours / older than N hours
7442 // md-N / md+N — modified within N days / older than N days
7443 // on/On — sort by name asc/desc (default)
7444 // oL/OL — sort by length
7445 // om/Om — sort by mtime
7446 // Pop a scalar pattern, run expand_glob, push Value::Array. Used
7447 // by the segment-concat compile path for `$D/*`-style words.
7448 vm.register_builtin(BUILTIN_GLOB_EXPAND, |vm, _argc| {
7449 // c:Src/glob.c:1872 — honour `setopt noglob` / `noglob CMD`
7450 // precommand. When the option is on, the word stays literal
7451 // (zsh skips the glob expansion entirely). Without this, the
7452 // segment-fast-path BUILTIN_GLOB_EXPAND fired even after
7453 // `noglob` set the option, so `noglob echo *.xyz` saw the
7454 // NOMATCH error instead of the literal pass-through.
7455 let raw = vm.pop();
7456 let noglob =
7457 opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
7458 glob_word_noting_errflag(raw, noglob)
7459 });
7460 // Redirect-target variant of BUILTIN_GLOB_EXPAND. c:Src/glob.c:
7461 // 2161-2167 xpandredir — `prefork(&fake, isset(MULTIOS) ? 0 :
7462 // PREFORK_SINGLE, NULL)` then "Globbing is only done for
7463 // multios.": a redirect target word is only globbed when the
7464 // MULTIOS option is set. With it unset, `echo hi > *.txt`
7465 // creates the literal file `*.txt`, and `wc -c < *.txt` errors
7466 // "no such file or directory: *.txt". Bug #36 follow-up in
7467 // docs/BUGS.md.
7468 vm.register_builtin(BUILTIN_REDIR_GLOB_EXPAND, |vm, _argc| {
7469 let raw = vm.pop();
7470 let noglob =
7471 opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
7472 let multios = opt_state_get("multios").unwrap_or(true);
7473 // c:Src/glob.c:2164-2166 — `in_expandredir = 1; globlist(&fake, 0);
7474 // in_expandredir = 0;`. The flag is what lets `zglob`'s no-match
7475 // dispatch (c:1888-1894) tell a redirect target apart from an
7476 // ordinary word: under NULL_GLOB an ordinary word is dropped, but
7477 // a redirect still needs exactly one target, so the empty result
7478 // is `redirection failed (no match)` instead.
7479 crate::ported::glob::IN_EXPANDREDIR.store(1, std::sync::atomic::Ordering::SeqCst); // c:2164
7480 let out = glob_expand_word_value(raw, noglob || !multios); // c:2165
7481 crate::ported::glob::IN_EXPANDREDIR.store(0, std::sync::atomic::Ordering::SeqCst); // c:2166
7482 out
7483 });
7484 // Clear the default-word glob-pending carrier before the word's
7485 // expansion runs, so a flag set by a prior word never leaks in.
7486 vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB_RESET, |_vm, _argc| {
7487 crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| c.set(false));
7488 crate::ported::subst::DEFAULT_WORD_GLOBSUBST_OFF.with(|c| c.set(false));
7489 crate::ported::subst::DEFAULT_WORD_GLOB_BARS.with(|b| b.borrow_mut().clear());
7490 Value::Status(0)
7491 });
7492 // After the word is assembled, run filename generation ONLY if the
7493 // default/alternate paramsubst arm flagged a source-glob default
7494 // (DEFAULT_WORD_GLOB_PENDING). Otherwise pass the word through
7495 // literally — a parameter VALUE must not glob. c:Src/subst.c globlist.
7496 vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB, |vm, _argc| {
7497 let raw = vm.pop();
7498 let pending = crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| {
7499 let v = c.get();
7500 c.set(false); // read + clear
7501 v
7502 });
7503 let bars = crate::ported::subst::DEFAULT_WORD_GLOB_BARS.with(|b| std::mem::take(&mut *b.borrow_mut()));
7504 if !pending {
7505 return raw;
7506 }
7507 let noglob =
7508 opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
7509 if bars.is_empty() || noglob {
7510 return glob_expand_word_value(raw, noglob);
7511 }
7512 // c:Src/lex.c:1000-1008 + c:Src/subst.c:3207-3228 — the default word's
7513 // source `|` is a Bar token in the assembled word, so globlist reads a
7514 // top-level alternation (`${x:-b|a}` matches the file `b`;
7515 // `X${x:-b|a}` is the pattern `Xb` | `a`). Re-spell those `|`s from
7516 // the carrier before globbing, and put them back to `|` in a word the
7517 // glob hands back literally.
7518 let rebar = |s: String| -> String {
7519 let mut s = s;
7520 for (plain, barred) in &bars {
7521 if let Some(at) = s.find(plain.as_str()) {
7522 s.replace_range(at..at + plain.len(), barred);
7523 }
7524 }
7525 s
7526 };
7527 let barred = match raw {
7528 Value::Array(items) => {
7529 Value::array(items.iter().map(|v| Value::str(rebar(v.to_str()))).collect())
7530 }
7531 other => Value::str(rebar(other.to_str())),
7532 };
7533 match glob_expand_word_value(barred, false) {
7534 Value::Array(items) => Value::array(
7535 items
7536 .iter()
7537 .map(|v| Value::str(crate::ported::lex::untokenize(&v.to_str())))
7538 .collect(),
7539 ),
7540 other => Value::str(crate::ported::lex::untokenize(&other.to_str())),
7541 }
7542 });
7543
7544 // `break`/`continue` from a sub-VM body. The compile path emits
7545 // these when the keyword appears at chunk top-level (no enclosing
7546 // for/while in the current chunk's patch lists). Outer-loop
7547 // builtins (BUILTIN_RUN_SELECT and any future loop-via-builtin
7548 // construct) drain canonical BREAKS/CONTFLAG after each iteration.
7549 //
7550 // Writes match `bin_break`'s c:5836+ pattern:
7551 // continue: contflag = 1; breaks++ (Src/builtin.c::bin_break)
7552 // break: breaks++
7553 vm.register_builtin(BUILTIN_SET_BREAK, |_vm, _argc| {
7554 use std::sync::atomic::Ordering::SeqCst;
7555 crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
7556 Value::Status(0)
7557 });
7558 vm.register_builtin(BUILTIN_SET_CONTINUE, |_vm, _argc| {
7559 use std::sync::atomic::Ordering::SeqCst;
7560 crate::ported::builtin::CONTFLAG.store(1, SeqCst);
7561 crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
7562 Value::Status(0)
7563 });
7564
7565 // `break N`/`continue N` with a RUNTIME level count. Pops [count,
7566 // name]; math-evaluates count (c:builtin.c:5811 `mathevali`); on
7567 // count <= 0 emits `argument is not positive: N` via zerrnam (sets
7568 // errflag → abort, c:5813) and pushes Int(0) (matches no jump-table
7569 // entry → control falls through to the errflag abort). Otherwise
7570 // pushes Int(count) for the compiled jump table to dispatch on.
7571 vm.register_builtin(BUILTIN_BREAK_COUNT_VALIDATE, |vm, _argc| {
7572 let name = vm.pop().to_str();
7573 let count_s = vm.pop().to_str();
7574 // c:5816 — mathevali zerr's a bad expression itself; its errflag
7575 // then suppresses the zerrnam below.
7576 let count = match crate::ported::math::mathevali(&count_s) {
7577 Ok(n) => n,
7578 Err(msg) => {
7579 crate::ported::utils::zerr(&msg);
7580 0
7581 }
7582 };
7583 if count <= 0 {
7584 crate::ported::utils::zerrnam(&name, &format!("argument is not positive: {count}"));
7585 return Value::Int(0);
7586 }
7587 Value::Int(count)
7588 });
7589
7590 // `${arr[*]}` — join array elements with the first IFS char into
7591 // a single string. Matches zsh: in DQ context this preserves the
7592 // join; in array context too the result is one Value::Str.
7593 // Set or clear a shell option directly. Used by `noglob CMD ...`
7594 // precommand wrapping — the compiler emits SET_RAW_OPT to flip the
7595 // option ON before compiling the inner words and OFF after, so glob
7596 // expansion of the inner args sees the temporary state.
7597 vm.register_builtin(BUILTIN_SET_RAW_OPT, |vm, _argc| {
7598 let on = vm.pop().to_int() != 0;
7599 let opt = vm.pop().to_str();
7600 // Pure passthru: canonical port lives in
7601 // src/ported/options.rs::opt_state_set_via_alias and
7602 // handles negation-alias resolution per c:Src/options.c.
7603 crate::ported::options::opt_state_set_via_alias(&opt, on);
7604 Value::Status(0)
7605 });
7606
7607 // c:Src/options.c GLOB_SUBST — runtime glob expansion of
7608 // substituted words. Pop a Value (Str or Array); when
7609 // GLOB_SUBST is ON, run expand_glob on each string element;
7610 // when OFF, pass through unchanged. Bug #119 in docs/BUGS.md.
7611 vm.register_builtin(BUILTIN_GLOB_SUBST_EXPAND, |vm, _argc| {
7612 let raw = vm.pop();
7613 // c:Src/subst.c:3231-3233 — after a default / alternate word C runs
7614 // `if (globsubst != 2) globsubst = 0;`, so the finished expansion is
7615 // not shtokenized and only glob tokens an UNQUOTED inner substitution
7616 // produced survive into it. The paramsubst arm reports both halves
7617 // (subst::DEFAULT_WORD_GLOBSUBST_OFF and DEFAULT_WORD_GLOB_PENDING);
7618 // take them for this word: `setopt globsubst; foo='boring*'`
7619 // `${foo+"$foo"}` stays `boring*`, `${foo+$foo}` still globs.
7620 let default_off = crate::ported::subst::DEFAULT_WORD_GLOBSUBST_OFF.with(|c| c.replace(false));
7621 let default_pending = default_off
7622 && crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| c.replace(false));
7623 let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
7624 if !glob_subst || (default_off && !default_pending) {
7625 return raw;
7626 }
7627 // Collect input strings (Str → vec![s]; Array → multiple).
7628 let inputs: Vec<String> = match raw {
7629 Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
7630 other => vec![other.to_str()],
7631 };
7632 // Run expand_glob on each. Empty matches collapse to a
7633 // single literal pass-through to mirror nullglob-off default.
7634 let mut out: Vec<String> = Vec::with_capacity(inputs.len());
7635 for pattern in inputs {
7636 // c:Src/subst.c — GLOB_SUBST subjects the value to the FULL
7637 // filename-generation pipeline: `filesub` (tilde/`=` expansion)
7638 // BEFORE globbing (prefork runs filesub then globlist). zshrs
7639 // globbed but skipped filesub, so `${~x}` / `setopt globsubst`
7640 // left `~/foo` un-expanded. filesubstr matches the Tilde TOKEN,
7641 // so shtokenize the value first (`~`→Tilde, glob metas active),
7642 // run filesub, then untokenize the surviving glob metas back to
7643 // raw for expand_glob (which re-tokenizes internally).
7644 // c:178 — that filesub is prefork's SECOND pass, gated on
7645 // `unset(SHFILEEXPANSION)`: with SH_FILE_EXPANSION the file
7646 // expansion ran before substitution, so a substituted `~`/`=`
7647 // stays literal.
7648 let pattern = if (pattern.contains('~') || pattern.contains('='))
7649 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHFILEEXPANSION)
7650 {
7651 let mut tok = pattern.clone();
7652 crate::ported::glob::shtokenize(&mut tok);
7653 let fs = crate::ported::subst::filesub(&tok, 0);
7654 crate::ported::lex::untokenize(&fs)
7655 } else {
7656 pattern
7657 };
7658 // c:Src/glob.c:1872-1886 — `expand_glob` already owns the whole
7659 // no-match dispatch: NULL_GLOB drops the word, NOMATCH errors, and
7660 // only with both off does it hand the literal back. Pushing the
7661 // literal again on an empty result undid NULL_GLOB and kept a
7662 // word whose filesub had failed, so under `_expand`'s options
7663 // `${~exp//…}` of `~[]` "expanded" to itself (Y01completion #10).
7664 out.extend(with_executor(|exec| exec.expand_glob(&pattern)));
7665 }
7666 if out.len() == 1 {
7667 Value::str(out.into_iter().next().unwrap())
7668 } else {
7669 Value::array(out.into_iter().map(Value::str).collect())
7670 }
7671 });
7672
7673 // c:Src/math.c:336-364 — `getmathparam` for ArithCompiler pre-load.
7674 // Pop a variable name, return its math value.
7675 //
7676 // This used to be a second, smaller getmathparam: `getsparam` then
7677 // `parse::<i64>` / `parse::<f64>` / `mathevali`. Two consequences,
7678 // both invisible until you compared the two arithmetic backends on
7679 // the same expression. It had no FORCEFLOAT coercion (c:359-362) and
7680 // no `unset(UNSET)` diagnostic (c:345-346), so which of `setopt
7681 // force_float` and `set -u` applied to `$(( x ))` depended on
7682 // whether `compile_arith` had routed the expression to the
7683 // ArithCompiler or to BUILTIN_ARITH_EVAL. And it re-derived a
7684 // typed param's value from `getsparam`'s printed form, the same
7685 // convbase round-trip c:2641 exists to avoid.
7686 //
7687 // There is one `getmathparam` in C; there is one here now.
7688 vm.register_builtin(BUILTIN_GET_MATH_VAR, |vm, _argc| {
7689 let name = vm.pop().to_str();
7690 let n = crate::ported::math::getmathparam(&name); // c:337
7691 if n.type_ == crate::ported::zsh_h::MN_FLOAT {
7692 Value::Float(n.d)
7693 } else {
7694 Value::Int(n.l)
7695 }
7696 });
7697
7698 // ── Compiled-arithmetic runtime ops ───────────────────────────────
7699 //
7700 // c:Src/math.c:1156 `op(int what)` — the arms `Op::Div` / `Op::Mod` /
7701 // `Op::Pow` cannot express, plus the two write-back / finish helpers
7702 // the compiled `(( … ))` path needs. Each pops its operands in C's
7703 // order: for `a OP b` the stack holds \[a, b\], so `b` pops first.
7704
7705 vm.register_builtin(BUILTIN_ARITH_DIV, |vm, _argc| {
7706 let b = arith_pop_mnumber(vm);
7707 let a = arith_pop_mnumber(vm);
7708 arith_push(arith_div(a, b)) // c:1237-1257
7709 });
7710
7711 vm.register_builtin(BUILTIN_ARITH_MOD, |vm, _argc| {
7712 let b = arith_pop_mnumber(vm);
7713 let a = arith_pop_mnumber(vm);
7714 arith_push(arith_mod(a, b)) // c:1258-1272
7715 });
7716
7717 vm.register_builtin(BUILTIN_ARITH_POW, |vm, _argc| {
7718 let b = arith_pop_mnumber(vm);
7719 let a = arith_pop_mnumber(vm);
7720 arith_push(arith_pow(a, b)) // c:1335-1358
7721 });
7722
7723 // c:Src/math.c:1200 — the `BOOL` operators yield an INTEGER 1/0
7724 // whatever their operands were.
7725 vm.register_builtin(BUILTIN_ARITH_LOGAND, |vm, _argc| {
7726 let b = arith_pop_mnumber(vm);
7727 let a = arith_pop_mnumber(vm);
7728 Value::Int(i64::from(mn_is_true(a) && mn_is_true(b)))
7729 });
7730 vm.register_builtin(BUILTIN_ARITH_LOGOR, |vm, _argc| {
7731 let b = arith_pop_mnumber(vm);
7732 let a = arith_pop_mnumber(vm);
7733 Value::Int(i64::from(mn_is_true(a) || mn_is_true(b)))
7734 });
7735 vm.register_builtin(BUILTIN_ARITH_LOGXOR, |vm, _argc| {
7736 let b = arith_pop_mnumber(vm);
7737 let a = arith_pop_mnumber(vm);
7738 Value::Int(i64::from(mn_is_true(a) != mn_is_true(b)))
7739 });
7740
7741 // c:Src/math.c:972 setmathvar — stack is \[name, value, pval\]; pval
7742 // (argc 3) says the operator read the lvalue first (c:975-993).
7743 vm.register_builtin(BUILTIN_SET_MATH_VAR, |vm, argc| {
7744 let pval = argc >= 3 && vm.pop().to_int() != 0;
7745 let value = arith_pop_mnumber(vm);
7746 let name = vm.pop().to_str();
7747 // c:Src/math.c:1161 — `op()` begins `if (errflag) return;`, so an
7748 // assignment operator reached after a math error (`x %= 0`, the
7749 // `y = 3` of `(( x = 5/0, y = 3 ))`) stores nothing.
7750 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
7751 return arith_push(value);
7752 }
7753 // c:Src/math.c:395 `xstack = stack` / c:455 `stack = xstack` —
7754 // `setmathvar` also memoises the value in the math-local read
7755 // cache (`mptr->pval`, c:340-343), which in C belongs to the
7756 // evaluation FRAME and dies with it. The compiled path has no
7757 // such frame, so without this save/restore the entry outlives
7758 // the expression: a later `sum=0` would update the parameter
7759 // while `getmathparam` kept answering from the cache, and the
7760 // next `(( sum += n ))` would resume from the stale total.
7761 // Every setmathvar write goes through to the parameter table
7762 // (`setnparam`, c:1005), so dropping the memo loses nothing but
7763 // one paramtab read. Saving rather than clearing keeps the
7764 // entries of an enclosing `matheval` frame intact, which is
7765 // what c:455 restores.
7766 let saved = crate::ported::math::m_variables_clone();
7767 // c:Src/math.c:1370-1371 — `c = setmathvar(mvp, c); push(c, …)`:
7768 // the stored value, converted to the parameter's type
7769 // (c:1014-1030), is the value of the assignment.
7770 let stored = crate::ported::math::setmathvar(&name, value, pval);
7771 crate::ported::math::m_variables_set(saved);
7772 arith_push(stored)
7773 });
7774
7775 // c:Src/exec.c:5267 execarith tail + c:Src/math.c:1500 lastmathval.
7776 vm.register_builtin(BUILTIN_ARITH_CMD_FINISH_VAL, |vm, _argc| {
7777 use std::sync::atomic::Ordering;
7778 let val = arith_pop_mnumber(vm);
7779 // c:1500 `lastmathval = z` — recorded even when the value is
7780 // only used for the exit status, because a `functions -M` math
7781 // function returns whatever its last `(( … ))` left here
7782 // (c:1115).
7783 crate::ported::math::M_LASTMATHVAL.with(|c| c.set(val));
7784 let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
7785 if live & crate::ported::zsh_h::ERRFLAG_ERROR != 0 {
7786 // Same split as BUILTIN_ARITH_CMD_FINISH: a HARD error
7787 // (`${v:?msg}`) must keep aborting the script, a soft math
7788 // error is consumed here so the next statement still runs.
7789 if live & crate::ported::zsh_h::ERRFLAG_HARD == 0 {
7790 crate::ported::utils::errflag
7791 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
7792 }
7793 vm.last_status = 2;
7794 return Value::Status(2);
7795 }
7796 // c:5267 — `(( 0 ))` is false, anything else is true.
7797 let status = i32::from(!mn_is_true(val));
7798 vm.last_status = status;
7799 Value::Status(status)
7800 });
7801
7802 // c:Src/subst.c:822/830 `if (glbsub) shtokenize(dest)` for the
7803 // `${~spec}` / `$~spec` FLAG, where the compiler emits no
7804 // GLOB_SUBST guard because the metas are meant to be active. Only
7805 // the value's backslashes still need settling — c:Src/glob.c:3651
7806 // leaves the ones before a non-`ztokens` char as data.
7807 // See BUILTIN_PAT_DATA_BACKSLASH docs below for full rationale.
7808 vm.register_builtin(BUILTIN_PAT_DATA_BACKSLASH, |vm, _argc| {
7809 let p = vm.pop().to_str();
7810 // The word arrives in EXPAND_TEXT mode 12's tokenized form (see the
7811 // untokenize there). This leg wants every metacharacter ACTIVE anyway
7812 // — `${~spec}` forced them — so both spellings collapse to the same
7813 // raw character here, exactly as they did when mode 9 untokenized one
7814 // step earlier.
7815 let p = crate::lex::untokenize(&p);
7816 let p = crate::pattern_data_escape::escape_data_backslashes(&p);
7817 // c:Src/glob.c:3575-3580 + 3617-3620 — the `shtokenize` this
7818 // builtin stands in for adds ZSHTOK_SHGLOB under SH_GLOB, and
7819 // that flag makes `(`, `|` and `)` of the VALUE stay ordinary
7820 // characters. Without this the value's parens reach patcompile
7821 // as live metacharacters and `emulate ksh; p='-([AMO]*|[0CRSWnsw])';
7822 // [[ "-s" = $~p ]]` becomes a bad pattern where zsh just fails
7823 // to match.
7824 if crate::pattern_data_escape::shglob_hides_parens() {
7825 return Value::str(crate::pattern_data_escape::escape_shglob_parens(
7826 &p,
7827 crate::pattern_data_escape::dropin_keeps_ksh_groups(),
7828 ));
7829 }
7830 Value::str(p)
7831 });
7832
7833 // c:Src/options.c GLOB_SUBST + Src/cond.c:552 cond_match.
7834 // Pop pattern string; when GLOB_SUBST is OFF, escape every glob
7835 // metachar with `\` so the downstream StrMatch + patcompile
7836 // treat them as literals (matching C's tokenization-based
7837 // gate). When GLOB_SUBST is ON, only the data-backslash respelling
7838 // runs and the metas stay active.
7839 // See BUILTIN_GLOB_SUBST_GUARD docs below for full rationale.
7840 vm.register_builtin(BUILTIN_GLOB_SUBST_GUARD, |vm, _argc| {
7841 let p = vm.pop().to_str();
7842 let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
7843 if glob_subst {
7844 // c:Src/subst.c:822/830 `if (glbsub) shtokenize(dest)` — the
7845 // value's metas go ACTIVE, but c:Src/glob.c:3651 still leaves a
7846 // backslash before a non-`ztokens` char as ordinary data. Respell
7847 // those in the normalizer's literal-backslash form; the ones
7848 // `zshtokenize` WOULD fold into a quote are left alone.
7849 // docs/BUGS.md #1090.
7850 let p = crate::pattern_data_escape::escape_data_backslashes(&p);
7851 // c:Src/glob.c:3575-3580 + 3617-3620 — same `shtokenize` call,
7852 // so the ZSHTOK_SHGLOB suppression of `(` / `|` / `)` applies
7853 // to the GLOB_SUBST leg exactly as it does to the `${~spec}`
7854 // leg above.
7855 if crate::pattern_data_escape::shglob_hides_parens() {
7856 return Value::str(crate::pattern_data_escape::escape_shglob_parens(
7857 &p,
7858 crate::pattern_data_escape::dropin_keeps_ksh_groups(),
7859 ));
7860 }
7861 return Value::str(p);
7862 }
7863 // The metacharacters that have to be spelled `\X` to reach
7864 // `ported::pattern`'s normalizer as ordinary characters.
7865 //
7866 // c:Src/lex.c:1390-1404 — `-` / `!` are Dash / Bang TOKENS only when
7867 // the LEXER sees them unquoted; pattern.c's range parser (c:1483) and
7868 // negation test look for the tokens, so a SUBSTITUTED `-` / `!` must
7869 // stay an ordinary character with GLOB_SUBST off. Without those two,
7870 // `cset='^a-z'; [[ - = ["$cset"] ]]` built a live `a-z` range out of
7871 // substituted text.
7872 fn is_pattern_meta(c: char) -> bool {
7873 matches!(
7874 c,
7875 '*' | '?'
7876 | '['
7877 | ']'
7878 | '('
7879 | ')'
7880 | '|'
7881 | '<'
7882 | '>'
7883 | '#'
7884 | '^'
7885 | '~'
7886 | '-'
7887 | '!'
7888 | '\\'
7889 )
7890 }
7891 // The character an ACTIVE metacharacter token stands for, or None for
7892 // anything that is not one.
7893 //
7894 // c:Src/glob.c:3640-3645 — `shtokenize` arms a metacharacter by
7895 // rewriting it to `(t - ztokens) + Pound`; c:Src/lex.c:38 `ztokens` is
7896 // the table that maps back. c:Src/zsh.h:159-183 is the metacharacter
7897 // range (Pound 0x84 … Bang 0x9c); c:Src/zsh.h:193-206's Snull / Dnull /
7898 // Bnull / Bnullkeep / Nularg are quote and null MARKERS, never
7899 // metacharacters, so they are excluded here and travel with the
7900 // literal run below — where `untokenize` gives them the same reading
7901 // they had when it ran one step earlier (Bnull → `\`, which this pass
7902 // then escapes to the normalizer's literal-backslash `\\`).
7903 fn active_pattern_token(c: char) -> Option<char> {
7904 let cu = c as u32;
7905 if !(0xe184..=0xe19c).contains(&cu) {
7906 return None;
7907 }
7908 let m = crate::ported::lex::ztokens
7909 .chars()
7910 .nth((cu - 0xe184) as usize)?;
7911 is_pattern_meta(m).then_some(m)
7912 }
7913 // !!! RUST-ONLY HELPER — no C counterpart. C needs no such pass: its
7914 // `patcompile` consumes the token encoding itself (c:Src/pattern.c:248
7915 // — `zpc_chars` holds Star / Quest / Inbrack / …, so a RAW `*` never
7916 // dispatches as ZPC_STAR and is literal by construction). zshrs's
7917 // `ported::pattern` normalizer uses a raw-ASCII encoding whose literal
7918 // form is `\X`, so C's one-byte ACTIVE-vs-DATA distinction has to be
7919 // re-SPELLED: a metacharacter `shtokenize` armed becomes the bare
7920 // character, a metacharacter that is a raw byte of a VALUE becomes
7921 // `\X`. A pattern word holds both at once — c:Src/subst.c:1551-1552
7922 // tokenizes the `(~j)` join SEPARATOR while the array ELEMENTS it joins
7923 // keep their raw bytes — which is why escaping the word wholesale (or
7924 // not at all) is wrong in both directions.
7925 //
7926 // Non-token text is untokenized in RUNS rather than per character, so
7927 // `untokenize`'s multi-character decodes still see their whole input:
7928 // the `Meta` pair (lex.rs's metafied high byte) and the
7929 // `Qstring Snull` … `Snull` span of a `$'…'` literal
7930 // (c:Src/subst.c:301-304).
7931 fn flush_literal_run(lit: &mut String, out: &mut String) {
7932 if lit.is_empty() {
7933 return;
7934 }
7935 for c in crate::lex::untokenize(lit).chars() {
7936 if is_pattern_meta(c) {
7937 out.push('\\');
7938 }
7939 out.push(c);
7940 }
7941 lit.clear();
7942 }
7943 let mut out = String::with_capacity(p.len() * 2);
7944 let mut lit = String::new();
7945 let cs: Vec<char> = p.chars().collect();
7946 let mut i = 0;
7947 while i < cs.len() {
7948 let c = cs[i];
7949 // c:Src/glob.c:3593-3596 `case Meta: s++;` — `Meta` and the
7950 // character after it are ONE unit and neither is a token.
7951 if c as u32 == crate::ported::zsh_h::Meta as u32 && i + 1 < cs.len() {
7952 lit.push(c);
7953 lit.push(cs[i + 1]);
7954 i += 2;
7955 continue;
7956 }
7957 match active_pattern_token(c) {
7958 Some(m) => {
7959 flush_literal_run(&mut lit, &mut out);
7960 out.push(m);
7961 }
7962 None => lit.push(c),
7963 }
7964 i += 1;
7965 }
7966 flush_literal_run(&mut lit, &mut out);
7967 Value::str(out)
7968 });
7969
7970 // argc contract — the caller names the `spbreak` (c:Src/subst.c:1707,
7971 // :2563, :2567) its spelling implies, because c:3912's whole join/split
7972 // block is gated on it:
7973 //
7974 // 0 plain `${NAME[*]}`, unquoted and not `ssub`. `spbreak` is
7975 // SH_WORD_SPLIT, read at RUN time (the option can be set by an
7976 // earlier statement in the same compiled unit).
7977 // 1 "JOIN and STOP" — a PREFORK_SINGLE (`ssub`) context: a scalar
7978 // assignment RHS (c:Src/exec.c:2603), a `typeset NAME=…` argument, a
7979 // `case` word (c:Src/loop.c:611), a `[[ … ]]` operand
7980 // (c:Src/cond.c:53) or the here-string word (c:Src/exec.c:4711).
7981 // c:Src/subst.c:4226
7982 // if (isarr && ssub) { val = sepjoin(aval, NULL, 1); isarr = 0; }
7983 // "prefork() wants a scalar, so join no matter what else" — the join
7984 // is the LAST thing that happens to the value, and `singsub`
7985 // (c:Src/subst.c:514-525) never reaches `globlist`, so nothing splits
7986 // it afterwards. c:3913's `force_split = !ssub && …` says the same.
7987 // 2 `${==NAME[*]}` — c:2563 clears `spbreak`, so the gate stays SHUT
7988 // whatever SH_WORD_SPLIT says and the element vector survives.
7989 // 3 `${=NAME[*]}` / `${=NAME[@]}` — c:2567 sets `spbreak = 2`. c:3917
7990 // joins and c:3932 `sepsplit` splits, but that split is
7991 // BUILTIN_FORCE_SPLIT at the compile site, so this handler stops at
7992 // the join rather than splitting twice. When c:3914-3927 leaves the
7993 // value ARRAY-shaped it returns the array and FORCE_SPLIT's c:3931
7994 // `!isarr` gate passes it through.
7995 vm.register_builtin(BUILTIN_ARRAY_JOIN_STAR, |vm, argc| {
7996 let join_no_split = argc == 1;
7997 let name = vm.pop().to_str();
7998 let (elems, joined, ifs_opt, in_dq) = with_executor(|exec| {
7999 // c:Src/params.c — `"$*"` joins by IFS[0]. zsh
8000 // distinguishes IFS=unset (→ default `" "`) from
8001 // IFS="" (→ EMPTY separator → fields concatenate).
8002 // chars().next() collapsed both into the default, so
8003 // IFS="" was treated as IFS=" ".
8004 // c:Src/params.c:4745 `ifssetfn` — the C `ifs` global is NULL only
8005 // when IFS is genuinely UNSET, and c:3919's `(!ifs && …)` turns on
8006 // exactly that distinction, so carry the Option, not a defaulted
8007 // string.
8008 let ifs_opt = exec.scalar("IFS");
8009 let ifs_full = ifs_opt
8010 .clone()
8011 .unwrap_or_else(|| " \t\n".to_string());
8012 let sep = ifs_full
8013 .chars()
8014 .next()
8015 .map(|c| c.to_string())
8016 .unwrap_or_default();
8017 let in_dq = exec.in_dq_context > 0;
8018 // c:Src/subst.c:3914 — the join at c:3917 is `sepjoin(aval, …)`,
8019 // i.e. it runs on the ELEMENT vector, and c:3912's gate can leave
8020 // that vector untouched. Keep both shapes: the joined scalar for
8021 // the quoted / join-and-stop arms, and the elements for the plain
8022 // command-argument arm that must not join at all.
8023 let (elems, _) = param_value_elems_c2916(exec, &name);
8024 let joined = elems.join(&sep);
8025 (elems, joined, ifs_opt, in_dq)
8026 });
8027 // In QUOTED (`"${name[*]}"`) context the result IS a single scalar —
8028 // c:Src/subst.c:3033 `if (qt && !getlen && isarr > 0) { val =
8029 // sepjoin(aval, sep, 1); isarr = 0; }`. And argc 1 is the caller
8030 // saying it wants the join without the split (see the contract above).
8031 if in_dq || join_no_split {
8032 return Value::str(joined);
8033 }
8034 // c:Src/subst.c:3912 — the join-then-split block is gated:
8035 //
8036 // if (ssub || spbreak || spsep || sep || quoted_array_with_offset) {
8037 // int force_split = !ssub && (spbreak || spsep);
8038 //
8039 // `sep` / `spsep` are the `(j:…:)` / `(s:…:)` separators, which never
8040 // reach this fast path (a flagged expansion goes to paramsubst), and
8041 // the join-and-stop and quoted arms returned above. So the only way in
8042 // left from here is `spbreak`, c:Src/subst.c:1707
8043 // int spbreak = (pf_flags & PREFORK_SHWORDSPLIT)
8044 // && !(pf_flags & PREFORK_SINGLE) && !qt;
8045 // i.e. SH_WORD_SPLIT — or an explicit `${=…}` / `${==…}`, which the
8046 // argc contract above hands down because c:2563/:2567 override the
8047 // option. Without `spbreak` the block does not run at all and the
8048 // expansion keeps its ARRAY shape untouched — `${a[*]}` unquoted is the
8049 // same splat as `${a[@]}`, elements intact:
8050 // a=("p:q" r); IFS=:; print -rl -- ${a[*]} -> `p:q` `r`
8051 // a=("p q" r); print -rl -- ${a[*]} -> `p q` `r`
8052 // a=(x y); IFS=; print -rl -- ${a[*]} -> `x` `y`
8053 // This handler ran the join and the split unconditionally, so the
8054 // first two came back as three words and the third — an EMPTY IFS
8055 // joins with "" and then has no separator left to split on — as the
8056 // single word `xy`.
8057 //
8058 // The empty-word removal below is c:Src/subst.c:184-187 `uremnode`,
8059 // the same prefork pass the `[@]` splat gets from
8060 // BUILTIN_ARRAY_DROP_EMPTY at its compile site; the joined+split path
8061 // used to supply it as a side effect of its `filter`.
8062 let spbreak = match argc {
8063 // c:2563 — `${==NAME[*]}` clears spbreak whatever the option says.
8064 2 => 0,
8065 // c:2567 — `${=NAME[*]}` / `${=NAME[@]}` force it to 2.
8066 3 => 2,
8067 _ => i32::from(crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT)),
8068 };
8069 if spbreak == 0 {
8070 return splice_words_value(prefork_c184_drop_empty(elems));
8071 }
8072 match join_c3914(elems, ifs_opt.as_deref()) {
8073 // c:3914-3927 left `isarr` non-zero, so c:3931's `!isarr` keeps the
8074 // split off as well and the ORIGINAL elements are the answer.
8075 JoinC3914::Elements(kept) => {
8076 if argc == 3 {
8077 // BUILTIN_FORCE_SPLIT runs next; its c:3931 gate passes an
8078 // array through and applies the empty-word removal itself.
8079 return Value::array(kept.into_iter().map(Value::str).collect());
8080 }
8081 splice_words_value(prefork_c184_drop_empty(kept))
8082 }
8083 JoinC3914::Joined(val) => {
8084 if argc == 3 {
8085 // c:3932's `sepsplit` is BUILTIN_FORCE_SPLIT at the compile
8086 // site — hand it the joined scalar rather than split twice.
8087 return Value::str(val);
8088 }
8089 split_words_c3932(&val)
8090 }
8091 }
8092 });
8093
8094 // argc contract — `0` and `1` are the historical callers (the bare
8095 // `$@`/`$*`/`$argv` read, `${^NAME}`, a for-list `$NAME`, a QUOTED
8096 // `"${NAME[@]}"`), which take the splat exactly as read. `2` and `3` are
8097 // the `${NAME[@]}` / `${=NAME[@]}` splice fast paths asking for
8098 // c:Src/subst.c:3912-3939 to run over the result — see
8099 // BUILTIN_ARRAY_JOIN_STAR's contract, whose `0` and `3` these mirror.
8100 // c:3030-3032 forces `isarr = -1` for a `[*]` splice whenever `nojoin` is
8101 // set, so `[@]` and `[*]` need the SAME answer here and share the code.
8102 vm.register_builtin(BUILTIN_ARRAY_ALL, |vm, argc| {
8103 let name = vm.pop().to_str();
8104 // c:Src/params.c:2027-2029 — a `[@]`/`[*]` subscript sets
8105 // SCANPM_ISVAR_AT, i.e. `isarr != 0` (c:2915). An empty result is
8106 // therefore an empty ARRAY, and plan9 deletes the whole word
8107 // (c:4362) rather than keeping the surrounding text.
8108 //
8109 // The note describes the EMPTY value this expansion produces, so it
8110 // must not survive a NON-empty one: the bit is read by concat_plan9
8111 // when the word folds left-associatively, and a later non-empty
8112 // segment overwriting it made `setopt rcexpandparam; e=''; a=(x y);
8113 // print -rl -- ${e}${a}` delete the whole word instead of printing
8114 // `x` and `y` (c:4437 keeps the surrounding text for a scalar
8115 // empty; only c:4362's empty ARRAY deletes it). Restore the
8116 // incoming bit whenever the result is not an empty array.
8117 let saved_empty_is_scalar = empty_is_scalar();
8118 note_empty_is_scalar(false);
8119 let array_all = |vm: &mut fusevm::VM| -> Value {
8120 let _ = &vm;
8121 // bash `"${PIPESTATUS[@]}"` / `"${FUNCNAME[@]}"` / `"${BASH_VERSINFO[@]}"`
8122 // splat — alias the zsh-native special. No-op in --zsh.
8123 if let Some(v) = crate::dash_mode::bash_special_array(&name) {
8124 return Value::array(v.into_iter().map(Value::str).collect());
8125 }
8126 with_executor(|exec| {
8127 // Special positional names — splice the positional list.
8128 if name == "@" || name == "*" || name == "argv" {
8129 return Value::array(exec.pparams().iter().map(Value::str).collect());
8130 }
8131 // c:Src/Modules/parameter.c — funcstack/funcfiletrace/
8132 // funcsourcetrace/functrace are PM_ARRAY|PM_READONLY
8133 // specials backed by the canonical FUNCSTACK Vec.
8134 // `${funcstack[@]}` inside a function call should splat
8135 // the innermost-first names; without this branch the
8136 // runtime fell to the scalar fallback (get_variable
8137 // returns empty for these specials) and `[@]` came out
8138 // empty. Bug #276 in docs/BUGS.md. Mirrors the parallel
8139 // arrays_get handler at src/ported/subst.rs ~10685.
8140 // c:Src/Modules/datetime.c:256 — `epochtime` PM_ARRAY|
8141 // PM_READONLY backed by getcurrenttime(). Same parallel
8142 // arrangement as the FUNCSTACK-backed specials below.
8143 if name == "epochtime" {
8144 // c:Src/params.c:589-594 getparamnode → c:563-585 loadparamnode —
8145 // the `[@]` splat resolves the NAME, clearing PM_AUTOLOAD so
8146 // paramtypestr (c:Src/Modules/parameter.c:48-50) reports the real
8147 // type. Mirrors the arrays_get arm in src/ported/subst.rs.
8148 if !crate::vm_helper::magic_special_shadowed(&name) {
8149 crate::vm_helper::mark_module_param_used(&name);
8150 }
8151 let arr = crate::ported::modules::datetime::getcurrenttime();
8152 return Value::array(arr.into_iter().map(Value::str).collect());
8153 }
8154 if matches!(
8155 name.as_str(),
8156 "funcstack" | "funcfiletrace" | "funcsourcetrace" | "functrace"
8157 ) {
8158 // c:Src/params.c:589-594 — see the epochtime arm above.
8159 if !crate::vm_helper::magic_special_shadowed(&name) {
8160 crate::vm_helper::mark_module_param_used(&name);
8161 }
8162 // Route the three trace arrays through the canonical
8163 // ported getfns (Src/Modules/parameter.c:648/:679/:711)
8164 // — the previous inline copy emitted wrong shapes
8165 // (bare filename for funcfiletrace, `name:lineno` for
8166 // functrace instead of `caller:lineno`); same dedup as
8167 // the parallel arrays_get handler in subst.rs.
8168 let vals: Vec<String> = match name.as_str() {
8169 "funcstack" => crate::ported::modules::parameter::FUNCSTACK
8170 .lock()
8171 .map(|f| f.iter().rev().map(|fs| fs.name.clone()).collect())
8172 .unwrap_or_default(),
8173 "funcfiletrace" => crate::ported::modules::parameter::funcfiletracegetfn(
8174 std::ptr::null_mut(),
8175 ),
8176 "funcsourcetrace" => {
8177 crate::ported::modules::parameter::funcsourcetracegetfn(
8178 std::ptr::null_mut(),
8179 )
8180 }
8181 _ => {
8182 crate::ported::modules::parameter::functracegetfn(std::ptr::null_mut())
8183 }
8184 };
8185 return Value::array(vals.into_iter().map(Value::str).collect());
8186 }
8187 // c:Src/params.c — `${assoc[@]}` enumerates VALUES (per
8188 // params.c:1696-1750 hashparam splat). Check assoc
8189 // storage BEFORE the scalar fallback so an associative
8190 // array named X resolves `${X[@]}` to the values, not
8191 // empty. Bug #109 in docs/BUGS.md: `${h[@]}` on an
8192 // assoc routed through BUILTIN_ARRAY_ALL, which only
8193 // consulted `exec.array(name)` (the indexed-array map)
8194 // — that lookup missed for assocs, fell through to
8195 // `get_variable("h")` (also empty for an assoc-only
8196 // name), and returned `Array(vec![])`. zsh's expected
8197 // behavior is to enumerate values.
8198 if let Some(assoc_map) = exec.assoc(&name) {
8199 return Value::array(assoc_map.values().cloned().map(Value::str).collect());
8200 }
8201 match exec.array(&name) {
8202 Some(v) => {
8203 // bash sparse arrays: `"${a[@]}"` splats only LIVE
8204 // elements, dropping hole slots (`a[5]=q` padding,
8205 // `unset a[i]`). No-op in --zsh (no holes tracked).
8206 let v = crate::bash_arrays::compact(&name, v);
8207 Value::array(v.iter().map(Value::str).collect())
8208 }
8209 None => {
8210 // c:Src/Modules/parameter.c:2235-2298 partab[] — the
8211 // PM_HASHED magic assocs (aliases/functions/parameters/
8212 // options/commands/builtins/modules/widgets/nameddirs/…)
8213 // are real hash params in C, so `${aliases[@]}` takes the
8214 // ordinary getvaluearr path and enumerates their VALUES.
8215 // zshrs keeps them OUT of the executor's assoc storage
8216 // (they are synthesized on demand by `subst::assoc_get`),
8217 // so the `exec.assoc` probe above missed and this arm fell
8218 // through to the scalar fallback, which returned an EMPTY
8219 // array: `alias foo=bar; print -r -- "${aliases[@]}"` gave
8220 // nothing where zsh gives `bar man whence`. Every other
8221 // form already routed through paramsubst's own magic-assoc
8222 // arms; only the flagless `[@]`/`[*]` splat compiles to
8223 // BUILTIN_ARRAY_ALL and reached here.
8224 //
8225 // Placed in the `None` arm so a real indexed array or a
8226 // user-defined assoc of the same name still wins, and so
8227 // no ordinary array read pays for the PARTAB scan.
8228 //
8229 // Same gap on the PM_ARRAY side (c:2239-2291 partab[]
8230 // rows: reswords/dis_reswords/patchars/dis_patchars/…):
8231 // `getaparam` reads `pm->u.arr`, which is NULL on the
8232 // placeholder node zshrs installs for a getfn-backed
8233 // special, so `${reswords[@]}` splatted nothing. Route
8234 // through the canonical `arrays_get` getfn dispatch.
8235 if let Some(arr) = crate::ported::subst::arrays_get(&name) {
8236 return Value::array(arr.into_iter().map(Value::str).collect());
8237 }
8238 if let Some(map) = crate::ported::subst::assoc_get(&name) {
8239 return Value::array(map.values().cloned().map(Value::str).collect());
8240 }
8241 // Fall back to scalar lookup. zsh (unlike bash)
8242 // does NOT IFS-split a scalar variable in a for
8243 // list — `for w in $scalar` iterates ONCE with the
8244 // scalar value. Word-splitting requires either
8245 // sh_word_split option or explicit `${(s.,.)scalar}`.
8246 let val = exec.get_variable(&name);
8247 if val.is_empty() && !exec.has_scalar(&name) && env::var(&name).is_err() {
8248 // c:Src/subst.c:3480-3485 — `${arr[@]}` on a genuinely
8249 // UNSET parameter under NO_UNSET is a "parameter not set"
8250 // error (vunset > 0 && unset(UNSET)), exactly like the
8251 // scalar `$arr`, the `${arr[*]}` splat, and `${arr[1]}`
8252 // — all of which already fire it via GET_VAR. The `[@]`
8253 // splat path returned an empty array silently, so
8254 // `setopt NO_UNSET; print "${arr[@]}"` exited 0 where zsh
8255 // exits 1. A DECLARED-but-empty array (`arr=()`) resolves
8256 // to `Some(vec![])` above and never reaches here, so it
8257 // still splats to nothing without erroring — matching zsh.
8258 if opt_state_get("nounset").unwrap_or(false) {
8259 crate::ported::utils::zerr(&format!("{}: parameter not set", name));
8260 crate::ported::utils::errflag.fetch_or(
8261 crate::ported::zsh_h::ERRFLAG_ERROR,
8262 std::sync::atomic::Ordering::Relaxed,
8263 );
8264 exec.set_last_status(1);
8265 }
8266 // c:Src/subst.c:3480-3485 — an UNSET parameter takes the
8267 // `vunset` arm: `val = dupstring("")` with isarr left at
8268 // 0. That is a SCALAR empty, so plan9 keeps the
8269 // surrounding text (`setopt rcexpandparam;
8270 // print -r -- "[${unset[@]}]"` → `[]`), unlike a
8271 // DECLARED-but-empty array (`arr=()`, matched by the
8272 // `Some(vec![])` arm above), which sets isarr and gets
8273 // the word deleted at c:4362.
8274 note_empty_is_scalar(true);
8275 // c:Src/subst.c:3603-3610 — an UNSET parameter leaves `isarr` at 0
8276 // and yields `val = ""`, i.e. a SCALAR empty, so a quoted
8277 // `"${u[@]}"` is ONE empty word (`f "${u[@]}"` → $# == 1) while
8278 // `"${empty_array[@]}"` is zero. Returning an empty ARRAY here
8279 // collapsed both to zero words. The unquoted form still drops it:
8280 // the compiler emits BUILTIN_ARRAY_DROP_EMPTY after this call for
8281 // non-DQ splices (compile_zsh.rs:6149), and that builtin maps an
8282 // empty Str to an empty array.
8283 Value::str(String::new())
8284 } else if opt_state_get("shwordsplit").unwrap_or(false)
8285 // c:Src/subst.c:1707-1708 — `spbreak = (pf_flags &
8286 // PREFORK_SHWORDSPLIT) && … && !qt`: a quoted
8287 // `"${scalar[@]}"` (argc 4, see below) is never split.
8288 && argc != 4
8289 {
8290 // c:3921 `aval = sepsplit(val, spsep, 0, 1)` — same
8291 // splitter as `${=name}` (Src/utils.c:3711 spacesplit),
8292 // not a naive `split().filter(non-empty)`: only the
8293 // IFS-WHITESPACE-derived empty fields are elided; the
8294 // `nulstring` ones an IFS-NON-whitespace separator makes
8295 // survive (c:Src/subst.c:36).
8296 let nulstring = crate::ported::zsh_h::Nularg.to_string();
8297 let parts: Vec<Value> =
8298 crate::ported::utils::sepsplit(&val, None, false)
8299 .into_iter()
8300 .filter_map(|w| {
8301 if w == nulstring {
8302 Some(Value::str(String::new()))
8303 } else if w.is_empty() {
8304 None // c:184-187 prefork uremnode
8305 } else {
8306 Some(Value::str(w))
8307 }
8308 })
8309 .collect();
8310 Value::array(parts)
8311 } else {
8312 Value::array(vec![Value::str(val)])
8313 }
8314 }
8315 }
8316 })
8317 };
8318 let result = array_all(vm);
8319 if !matches!(&result, Value::Array(a) if a.is_empty()) {
8320 note_empty_is_scalar(saved_empty_is_scalar);
8321 }
8322 // argc 4 is the compile site's "this `[@]` splat is QUOTED" bit
8323 // (`compile_zsh.rs`, the `${NAME[@]}` fast path). c:Src/subst.c:4387
8324 // `if (qt && !*y && isarr != 2) y = dupstring(nulstring)` then keeps
8325 // this read's empty elements alive through c:186 — but only above
8326 // c:4261's two-element cut-off, which is why the count is applied here
8327 // and not at the compile site. See `QUOTED_SPLICE_KEEPS_EMPTIES`.
8328 // Every other argc is an UNQUOTED (or `ssub`-joined) read, so it must
8329 // CLEAR the bit rather than leave a previous word's set: a whole-word
8330 // `"${a[@]}"` emits no end-of-word drop, so nothing else spends it.
8331 let quoted_splat_elems = match &result {
8332 Value::Array(items) if argc == 4 => items.len(),
8333 _ => 0,
8334 };
8335 note_quoted_splice_elems(quoted_splat_elems);
8336 // c:Src/subst.c:3912-3939 for the `${NAME[@]}` splice fast paths. Only
8337 // an ARRAY-shaped result reaches the block (c:3914 `if (isarr || …)`);
8338 // the scalar arms above — an unset parameter (c:3480-3485), the
8339 // scalar-fallback read — leave `isarr` at 0 and pass straight through.
8340 if !matches!(argc, 2 | 3) {
8341 return result;
8342 }
8343 let Value::Array(items) = &result else {
8344 return result;
8345 };
8346 let elems: Vec<String> = items.iter().map(|v| v.to_str()).collect();
8347 // c:1707 / :2567 — `spbreak` is SH_WORD_SPLIT for a plain `${NAME[@]}`
8348 // (argc 2), forced to 2 by the `=` flag (argc 3).
8349 let spbreak = if argc == 3 {
8350 2
8351 } else {
8352 i32::from(crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT))
8353 };
8354 // The gate never opens / neither join arm fires: no join, no split,
8355 // only c:184-187's empty-word removal — which the compile site used to
8356 // append as a separate BUILTIN_ARRAY_DROP_EMPTY, and that builtin
8357 // always returned an ARRAY. Keep that exact shape so nothing
8358 // downstream (concat, plan9, argv splice) sees a one-element splat turn
8359 // into a scalar.
8360 // c:Src/subst.c:4366-4437 then c:183-186 — the removal tests the
8361 // FINISHED node, after the word's text is glued onto the first and
8362 // last elements. When this read is a segment of such a word
8363 // (BUILTIN_WORD_DEFER_EMPTIES opened it) the word's own end-of-word
8364 // drop runs that test; removing the empty elements here, before the
8365 // concat, dropped the ones the affixes land on:
8366 // `a=(x y ""); print -rl -- p${a[@]}q` is `px` `y` `q` in zsh.
8367 let defer_to_word = crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get()) > 0;
8368 let untouched = |e: Vec<String>| {
8369 let e = if defer_to_word { e } else { prefork_c184_drop_empty(e) };
8370 Value::array(e.into_iter().map(Value::str).collect())
8371 };
8372 if spbreak == 0 {
8373 return untouched(elems);
8374 }
8375 let ifs_opt = with_executor(|exec| exec.scalar("IFS"));
8376 match join_c3914(elems, ifs_opt.as_deref()) {
8377 JoinC3914::Elements(kept) => {
8378 if argc == 3 {
8379 Value::array(kept.into_iter().map(Value::str).collect())
8380 } else {
8381 untouched(kept)
8382 }
8383 }
8384 JoinC3914::Joined(val) => {
8385 if argc == 3 {
8386 // BUILTIN_FORCE_SPLIT is c:3932 at the compile site.
8387 Value::str(val)
8388 } else if defer_to_word {
8389 // c:3932 sepsplit, then c:184-187 on the FINISHED word: the
8390 // enclosing word's end-of-word drop removes the truly empty
8391 // edge fields after its text is glued on and strips the c:36
8392 // `nulstring` markers. `setopt shwordsplit; IFS=:; a=(a ""
8393 // b); print -rl -- x${a[@]}y` is `xa` `` `by`.
8394 Value::array(
8395 crate::ported::utils::sepsplit(&val, None, false)
8396 .into_iter()
8397 .map(Value::str)
8398 .collect(),
8399 )
8400 } else {
8401 splice_words_value(sepsplit_c3932(&val, false))
8402 }
8403 }
8404 }
8405 });
8406
8407 // BUILTIN_ARRAY_FLATTEN(N): pops N values, flattens one level of Array
8408 // nesting, pushes the resulting Array AND its length as a separate Int.
8409 // The two-value return shape lets the caller (for-loop compile path)
8410 // SetSlot the length before SetSlot'ing the array, without re-deriving
8411 // the length from the array via a second builtin call.
8412 // `coproc [name] { body }` — bidirectional pipe to backgrounded body.
8413 // Stack discipline (top first): [name (str, "" for default), sub_idx (int)].
8414 // On success: parent's `executor.arrays[name]` becomes [write_fd, read_fd]
8415 // and Status(0) is returned. The caller writes to the child's stdin via
8416 // write_fd, reads its stdout via read_fd, and closes both when done.
8417 //
8418 // Bash's coproc convention is `${NAME[0]}` = read_fd, `${NAME[1]}` =
8419 // write_fd. We follow that: arrays[name] = [read_fd_str, write_fd_str].
8420 vm.register_builtin(BUILTIN_RUN_COPROC, |vm, _argc| {
8421 let sub_idx = vm.pop().to_int() as usize;
8422 let job_text = vm.pop().to_str();
8423 let raw_name = vm.pop().to_str();
8424 let name = if raw_name.is_empty() {
8425 "COPROC".to_string()
8426 } else {
8427 raw_name
8428 };
8429 let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
8430 Some(c) => c,
8431 None => return Value::Status(1),
8432 };
8433
8434 // c:Src/exec.c:1760 — `thisjob = newjob = initjob()` comes BEFORE the
8435 // old coproc's fds are closed (c:1768-1772), so that coproc, which
8436 // only starts exiting once they close, still holds its job slot and
8437 // the new one gets the next number: `coproc cat; …; coproc cat; jobs`
8438 // lists `[2]`. Taking the slot after the fork raced the reaper.
8439 let newjob = {
8440 let table = crate::ported::jobs::JOBTAB
8441 .get_or_init(|| std::sync::Mutex::new(Vec::new()));
8442 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
8443 crate::ported::jobs::initjob(&mut tab)
8444 };
8445 // A coproc that never starts gives the slot back.
8446 let release_newjob = || {
8447 if let Some(table) = crate::ported::jobs::JOBTAB.get() {
8448 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
8449 tab[newjob].stat = 0;
8450 }
8451 };
8452 // c:Src/exec.c:1710-1712 — starting a new coproc closes the
8453 // previous one's fds FIRST:
8454 // if (coprocin >= 0) { zclose(coprocin); zclose(coprocout); }
8455 // The old coproc child then sees EOF on its stdin and exits on
8456 // its own schedule (its job-table entry stays until it's
8457 // reaped) — zsh does NOT deletejob it here. This is also what
8458 // makes the `exec 4<&p; coproc exit; read -u4` EOF idiom work:
8459 // the replacement coproc closes the shell's write end to the
8460 // old one.
8461 {
8462 use std::sync::atomic::Ordering;
8463 let old_in = crate::ported::modules::clone::coprocin.load(Ordering::Relaxed);
8464 if old_in >= 0 {
8465 let old_out = crate::ported::modules::clone::coprocout.load(Ordering::Relaxed);
8466 unsafe {
8467 libc::close(old_in);
8468 if old_out >= 0 {
8469 libc::close(old_out);
8470 }
8471 }
8472 crate::ported::modules::clone::coprocin.store(-1, Ordering::Relaxed);
8473 crate::ported::modules::clone::coprocout.store(-1, Ordering::Relaxed);
8474 }
8475 }
8476
8477 // (parent_read ← child_stdout)
8478 let mut p2c = [0i32; 2]; // parent writes, child reads
8479 let mut c2p = [0i32; 2]; // child writes, parent reads
8480 if unsafe { libc::pipe(p2c.as_mut_ptr()) } < 0 {
8481 release_newjob();
8482 return Value::Status(1);
8483 }
8484 if unsafe { libc::pipe(c2p.as_mut_ptr()) } < 0 {
8485 unsafe {
8486 libc::close(p2c[0]);
8487 libc::close(p2c[1]);
8488 }
8489 release_newjob();
8490 return Value::Status(1);
8491 }
8492 // c:Src/exec.c:5160 mpipe — both pipes' fds are moved above
8493 // the user-visible range (movefd → F_DUPFD ≥ 10) so the
8494 // coproc fds never collide with explicit user fds like
8495 // `exec 3>&p`.
8496 for fd in p2c.iter_mut().chain(c2p.iter_mut()) {
8497 *fd = crate::ported::utils::movefd(*fd);
8498 }
8499
8500 // c:Src/exec.c:1748/1815 — a coproc is spawned by execpline's
8501 // Z_ASYNC arm, inside the same `child_block()` span as `cmd &`.
8502 #[cfg(unix)]
8503 let _child_block = ChildBlockSpan::enter();
8504
8505 match unsafe { libc::fork() } {
8506 -1 => {
8507 unsafe {
8508 libc::close(p2c[0]);
8509 libc::close(p2c[1]);
8510 libc::close(c2p[0]);
8511 libc::close(c2p[1]);
8512 }
8513 release_newjob();
8514 Value::Status(1)
8515 }
8516 0 => {
8517 forked_child_subsh_levels(); // c:Src/exec.c:1221
8518 EXECCMD_FORKED.with(|f| f.set(true)); // c:Src/exec.c:3063 `last1 = forked = 1`
8519 // Child: stdin from p2c[0], stdout to c2p[1]. Close all
8520 // unused fds. setsid so SIGINT to fg doesn't hit us.
8521 unsafe {
8522 libc::dup2(p2c[0], libc::STDIN_FILENO);
8523 libc::dup2(c2p[1], libc::STDOUT_FILENO);
8524 libc::close(p2c[0]);
8525 libc::close(p2c[1]);
8526 libc::close(c2p[0]);
8527 libc::close(c2p[1]);
8528 libc::setsid();
8529 }
8530 crate::fusevm_disasm::maybe_print_stdout("coproc:child", &chunk);
8531 let mut co_vm = fusevm::VM::new(chunk);
8532 register_builtins(&mut co_vm);
8533 let _ = co_vm.run();
8534 let _ = std::io::stdout().flush();
8535 let _ = std::io::stderr().flush();
8536 std::process::exit(co_vm.last_status);
8537 }
8538 pid => {
8539 // Parent: close child ends, store [read_fd, write_fd] in NAME.
8540 unsafe {
8541 libc::close(p2c[0]);
8542 libc::close(c2p[1]);
8543 }
8544 let read_fd = c2p[0];
8545 let write_fd = p2c[1];
8546 with_executor(|exec| {
8547 exec.unset_scalar(&name);
8548 exec.set_array(name, vec![read_fd.to_string(), write_fd.to_string()]);
8549 });
8550 // c:Src/exec.c — `coprocin`/`coprocout` are the
8551 // canonical globals that bin_read's `-p` arm
8552 // (Src/builtin.c:6510) and bin_print's `-p` arm
8553 // (Src/builtin.c:4827) read to find the
8554 // coprocess fds. The Rust port has the atomic
8555 // declarations at src/ported/modules/clone.rs:262
8556 // but the coproc-launch path never updated them,
8557 // so `read -p` / `print -p` always errored with
8558 // "-p: no coprocess" even when a coproc was
8559 // running. Bug #388 in docs/BUGS.md. Update them
8560 // here so the canonical builtins find the live
8561 // pipe.
8562 crate::ported::modules::clone::coprocin
8563 .store(read_fd, std::sync::atomic::Ordering::Relaxed);
8564 crate::ported::modules::clone::coprocout
8565 .store(write_fd, std::sync::atomic::Ordering::Relaxed);
8566 // c:Src/exec.c:1725 — `fdtable[coprocin] =
8567 // fdtable[coprocout] = FDT_UNUSED;`: the two kept ends
8568 // are user-reachable (via `>&p` / `<&p`), so they drop
8569 // the FDT_INTERNAL mark movefd gave them.
8570 crate::ported::utils::fdtable_set(read_fd, crate::ported::zsh_h::FDT_UNUSED);
8571 crate::ported::utils::fdtable_set(write_fd, crate::ported::zsh_h::FDT_UNUSED);
8572 // c:Src/exec.c:2837 — `lastpid = (zlong) pid;`. zsh
8573 // sets the `$!` global to the coproc child's PID so
8574 // subsequent `$!` reads return it. The Rust port at
8575 // exec.rs:6773 mirrors this for regular background
8576 // jobs but the coproc launch path was missing the
8577 // assignment, leaving `$!` at 0 after `coproc cmd`.
8578 crate::ported::modules::clone::lastpid
8579 .store(pid, std::sync::atomic::Ordering::Relaxed);
8580 // c:Src/exec.c:1700-1758 — the coproc rides the SAME
8581 // Z_ASYNC job-table path as `cmd &`: `thisjob = newjob
8582 // = initjob()` (c:1700), addproc hangs the pid+text
8583 // proc entry off the job, `jobtab[thisjob].stat |=
8584 // STAT_NOSTTY` (c:1746), `clearoldjobtab()` (c:1744)
8585 // and `spawnjob()` (c:1758) promote it to curjob. This
8586 // is what makes `jobs` list the coproc as
8587 // `[1] + running cat` and `kill %1` resolve it.
8588 // Mirrors the BUILTIN_RUN_BG parent arm exactly.
8589 {
8590 use crate::ported::jobs;
8591 use std::sync::Mutex;
8592 let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
8593 let idx = {
8594 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
8595 let idx = newjob; // c:exec.c:1760, reserved above
8596 jobs::addproc(
8597 &mut tab[idx],
8598 pid,
8599 &job_text,
8600 false,
8601 Some(std::time::Instant::now()),
8602 -1,
8603 -1,
8604 );
8605 tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
8606 idx
8607 };
8608 jobs::clearoldjobtab(); // c:exec.c:1744
8609 if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
8610 *tj = idx as i32;
8611 }
8612 jobs::spawnjob(); // c:exec.c:1758
8613 }
8614 with_executor(|exec| {
8615 exec.jobs
8616 .add_pid_job(pid, job_text.clone(), JobState::Running);
8617 });
8618 Value::Status(0)
8619 }
8620 }
8621 });
8622
8623 vm.register_builtin(BUILTIN_ARRAY_FLATTEN, |vm, argc| {
8624 // `${~spec}` carrier: a `for`/`select` WORD LIST is a word-
8625 // pipeline boundary too. In C the `globsubst` flag is a
8626 // paramsubst-LOCAL int (Src/subst.c:1671 `int globsubst =
8627 // isset(GLOBSUBST);`, forced to 2 by `${~…}` at
8628 // Src/subst.c:2603) whose only lasting effect is the
8629 // `shtokenize` of that substitution's own result — it never
8630 // reaches the option table, so `execfor`'s list prefork
8631 // (Src/loop.c:196-235) cannot leak it into the loop BODY.
8632 // zshrs carries the flag through the global option table so
8633 // the compile-emitted glob ops of the SAME word can see it
8634 // (documented deviation at subst.rs:3190), and restores it at
8635 // command-dispatch boundaries — but a `for` list has no
8636 // trailing dispatch of its own, so `for i in "${a:#${~p}*}"`
8637 // left GLOB_SUBST ON and filename-generated the FIRST body
8638 // command's words (`_parameters:34` → `ary+=($i:"$v")` glob-
8639 // erroring "bad pattern: HISTCHARS:!^#", which aborted the
8640 // whole `pr<TAB>` completion). This builtin ends EVERY for/
8641 // select list expansion and runs AFTER each word's
8642 // GLOB_SUBST_EXPAND op, so the carrier has been read by then.
8643 consume_tilde_globsubst_carrier();
8644 let n = argc as usize;
8645 let start = vm.stack.len().saturating_sub(n);
8646 let raw: Vec<Value> = vm.stack.drain(start..).collect();
8647 let mut flat: Vec<Value> = Vec::with_capacity(raw.len());
8648 for v in raw {
8649 match v {
8650 Value::Array(items) => flat.extend(items.iter().cloned()),
8651 other => flat.push(other),
8652 }
8653 }
8654 let len = flat.len() as i64;
8655 // Push the array first; the Int(len) becomes the builtin's return
8656 // value (which CallBuiltin already pushes). Caller consumes in
8657 // reverse: SetSlot(len_slot) pops Int, SetSlot(arr_slot) pops Array.
8658 vm.push(Value::array(flat));
8659 Value::Int(len)
8660 });
8661
8662 // Shell variable get/set — routes through executor.variables so nested
8663 // VMs (function calls) and tree-walker callers see the same storage.
8664 // GET_VAR / GET_VAR_DQ share one body via `get_var_impl`; the only
8665 // difference is `force_dq`, which the compiler sets for QUOTED simple
8666 // reads (`"$name"`) so an array's empty elements are preserved (the
8667 // `in_dq_context` runtime flag is 0 for these compiler-direct reads).
8668 fn get_var_impl(vm: &mut fusevm::VM, argc: u8, force_dq: bool) -> Value {
8669 let args = pop_args(vm, argc);
8670 let name = args.into_iter().next().unwrap_or_default();
8671 let live_status = vm.last_status;
8672 // `$@` and `$*` need splice semantics — return Value::Array of
8673 // positional params so for-loop's BUILTIN_ARRAY_FLATTEN spreads them
8674 // and pop_args splits them into argv slots. zsh's `"$@"` bslashquote-each-
8675 // word semantics matches: each pos-param becomes its own arg.
8676 // Same for arrays accessed by name (e.g. `$arr` in some contexts).
8677 //
8678 // vm.last_status is authoritative: `subshell_end` now returns
8679 // Some(status) and fusevm's `Op::SubshellEnd` writes it into
8680 // vm.last_status, so a deferred subshell `exit N` is visible
8681 // here. Suppressing this sync (as an older revision did, back
8682 // when the host hook returned nothing) made LASTVAL win over
8683 // any status the VM set AFTER SubshellEnd — which dropped the
8684 // `!` negation of `Src/exec.c:1979-1980`
8685 // if ((slflags & WC_SUBLIST_NOT) && !errflag && !retflag)
8686 // lastval = !lastval;
8687 // for `! (exit 7)` (emit_negate_status' SetStatus updated
8688 // vm.last_status, then `$?` read the stale LASTVAL=7).
8689 let sync_status = |exec: &mut ShellExecutor| {
8690 exec.set_last_status(live_status);
8691 };
8692 if name == "@" || name == "*" {
8693 // Quoting decides empty-word retention (c:Src/subst.c:
8694 // 184-187): the COMPILE site knows it and emits
8695 // BUILTIN_ARRAY_DROP_EMPTY after this read for the
8696 // unquoted form only — in_dq_context is NOT a valid
8697 // discriminator here (the quoted "$@" fast path emits
8698 // GET_VAR directly without an EXPAND_TEXT wrapper).
8699 let pp = with_executor(|exec| {
8700 sync_status(exec);
8701 exec.pparams()
8702 });
8703 // c:Src/subst.c:1817 — `int nojoin = (pf_flags &
8704 // PREFORK_SHWORDSPLIT) ? !(ifs && *ifs) && !qt : 0;`
8705 // c:Src/subst.c:3908-3911 — `if (nojoin == 0 || sep) { val =
8706 // sepjoin(aval, sep, 1); isarr = 0; }`
8707 // c:Src/subst.c:3919-3921 — `if (force_split && !isarr) { aval =
8708 // sepsplit(val, spsep, 0, 1); … }`
8709 //
8710 // So under SH_WORD_SPLIT an UNQUOTED `$@`/`$*` with a NON-EMPTY
8711 // `$IFS` is first JOINED on `$IFS[1]` and then re-split on `$IFS`
8712 // — which is why `setopt shwordsplit; set -- one:two b:c; IFS=:;
8713 // print -l $@` is four words in zsh (and in bash, and in ksh).
8714 // The port returned the raw positional list, so an element
8715 // carrying an IFS byte was never broken up
8716 // (D04parameter.ztst "Splitting of $@ on IFS: single element";
8717 // `zshrs --bash -c 'set -- "a b" c; printf "[%s]\n" $@'` printed
8718 // `[a b]` where bash prints `[a]` `[b]`).
8719 //
8720 // The gates are C's, verbatim: `!force_dq` is `!qt`, an UNSET or
8721 // EMPTY `$IFS` leaves `nojoin` at 1 (no join, no split), and the
8722 // whole rule is inert without SH_WORD_SPLIT (`nojoin = 0` there,
8723 // but `force_split` at c:3913 is `!ssub && (spbreak || spsep)`,
8724 // all clear, so neither branch runs).
8725 if !force_dq && crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT) {
8726 let ifs = crate::ported::params::getsparam("IFS").unwrap_or_default(); // c:1817
8727 if !ifs.is_empty() {
8728 // c:3909 `sepjoin(aval, sep, 1)` with sep NULL → $IFS[1].
8729 let sep0: String = ifs.chars().next().map(String::from).unwrap_or_default();
8730 let joined = pp.join(&sep0);
8731 // c:3919 `sepsplit(val, spsep, 0, 1)` with spsep NULL →
8732 // spacesplit (c:Src/utils.c:3711), then c:184-187. The
8733 // IFS-whitespace edge fields are truly empty and c:186
8734 // deletes them only from the FINISHED word, after the
8735 // word's text is glued on (c:4366-4437): `setopt
8736 // shwordsplit; set -- "" x ""; print -rl -- p$@q` is `p`
8737 // `x` `q`. So inside a word that defers that removal
8738 // (BUILTIN_WORD_DEFER_EMPTIES) the fields go out raw, the
8739 // c:36 `nulstring` markers included, exactly as
8740 // BUILTIN_FORCE_SPLIT hands them over; a whole word gets
8741 // the removal from the compile site's BUILTIN_ARRAY_DROP_EMPTY,
8742 // which also strips the markers (c:170). multsub's
8743 // PREFORK_SPLIT walker, used before, dropped the edge fields
8744 // outright and folded `nulstring` to "" (`IFS=:; set -- a::b;
8745 // print -rl -- $@` lost its middle field).
8746 let parts = crate::ported::utils::sepsplit(&joined, None, false); // c:3919, Nularg kept
8747 return Value::array(parts.into_iter().map(Value::str).collect());
8748 }
8749 }
8750 // c:Src/subst.c:4387 — `"$@"` is the quoted splat of the positional
8751 // list, so its empty elements become `nulstring` and survive
8752 // c:186: `set -- x '' y; PRE"$@"POST` is three words in zsh. `*` is
8753 // excluded because c:3032's quoted `sepjoin` has already collapsed
8754 // it to one scalar (the compile site routes `"$*"` through
8755 // BUILTIN_EXPAND_TEXT and discards this value). c:4261's
8756 // element-count cut-off is applied by `note_quoted_splice_elems`.
8757 // An UNQUOTED `$@` passes 0, which CLEARS the bit: a preceding
8758 // whole-word `"${a[@]}"` emits no end-of-word drop, so its bit is
8759 // still standing and this word's drop must not honour it.
8760 let quoted_at_elems = if force_dq && name == "@" { pp.len() } else { 0 };
8761 note_quoted_splice_elems(quoted_at_elems);
8762 return Value::array(pp.iter().map(Value::str).collect());
8763 }
8764 // RC_EXPAND_PARAM: when the option is set and `name` refers to
8765 // an array, return Value::Array so the enclosing word's
8766 // BUILTIN_CONCAT_DISTRIBUTE distributes element-wise. Without
8767 // the option, arrays still join to a space-separated scalar
8768 // (zsh's default unquoted-array-as-scalar semantics).
8769 // `isset` is the single atomic load C's `isset(RCEXPANDPARAM)` is
8770 // (c:Src/zsh.h:2557); `opt_state_get` re-resolved the name through the
8771 // option table under a lock on every parameter read.
8772 let rc_expand = isset(crate::ported::zsh_h::RCEXPANDPARAM);
8773 // c:Src/subst.c — under KSHARRAYS a bare `$name` (no [@]/[*] subscript;
8774 // this GET_VAR path only handles the bare form) is element 1 ONLY — a
8775 // scalar. RC_EXPAND_PARAM then has a single value to distribute, so
8776 // `$acc` → "p1", NOT the whole array. Skip the whole-array rc_expand
8777 // shortcut when KSHARRAYS is set and fall through to the normal path,
8778 // which applies the element-1 collapse. Without this gate,
8779 // `setopt KSH_ARRAYS rc_expand_param; print -r -- $acc` splatted every
8780 // element while zsh prints just "p1".
8781 let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
8782 // c:Src/subst.c:4245 `if (isarr)` gates the whole plan9 block
8783 // (c:4316), and TWO earlier arms have already zeroed `isarr` for a
8784 // bare array read that is quoted or scalar-substituted:
8785 // c:3032 `if (qt && !getlen && isarr > 0) { val = sepjoin(aval,
8786 // sep, 1); isarr = 0; }` — DQ context
8787 // c:3905 `if (nojoin == 0 || sep) { val = sepjoin(aval, sep, 1);
8788 // isarr = 0; }` under `if (ssub || …)` at c:3901
8789 // — PREFORK_SINGLE (scalar
8790 // assignment RHS)
8791 // So RC_EXPAND_PARAM never cross-products a plain `"$a"` / `b=$a`;
8792 // the array collapses to one IFS-joined scalar first. `force_dq` is
8793 // exactly the compiler's flag for those two contexts (it is set for
8794 // `in_dq || scalar_assign_depth > 0 || assign_builtin_arg_depth > 0`),
8795 // Without this gate `setopt rcexpandparam; a=(x y); print -rl -- "$a"Z`
8796 // emitted `xZ` / `yZ` instead of zsh's single `x yZ` — which is how
8797 // `_sqlite`'s `"($exclusive)"$^dashes'-header[…]'` reached
8798 // `comparguments` as five words starting `(-noheader`.
8799 //
8800 // Only the compile-time flag is consulted. The runtime
8801 // `in_dq_context` counter stays set while a `$(…)` INSIDE double
8802 // quotes runs its body, so reading it here would join an unquoted
8803 // `$a` in `"$(print -l -- $a)"`.
8804 if rc_expand && !ksh_arrays && !force_dq {
8805 let arr_val = with_executor(|exec| {
8806 sync_status(exec);
8807 exec.array(&name)
8808 });
8809 if let Some(arr) = arr_val {
8810 // c:4245 — a real array reference (`isarr != 0`). An empty one
8811 // takes plan9's word-removal path (c:4362), so clear the
8812 // scalar bit; a preceding empty-SCALAR expansion in the same
8813 // word would otherwise leave it set and keep the word alive
8814 // (`empty=''; e=(); a=("$empty"); print -rl -- x$e y`).
8815 // Only an EMPTY array may clear it: a non-empty one carries
8816 // no emptiness of its own, and clearing on it wiped the bit a
8817 // preceding empty SCALAR had set — `setopt rcexpandparam;
8818 // e=''; a=(x y); print -rl -- $e$a` lost the whole word.
8819 if arr.is_empty() {
8820 note_empty_is_scalar(false);
8821 }
8822 // A genuine array reference, so an empty element that
8823 // survives the plan9 cross-product IS an empty word:
8824 // `setopt rcexpandparam; a=('' x); print -rl -- ${a}`
8825 // is one word (`x`) in zsh. See `ARRAY_EMPTIES_ELIDABLE`.
8826 note_array_empties_elidable();
8827 return Value::array(arr.into_iter().map(Value::str).collect());
8828 }
8829 }
8830 // Magic-assoc fallback FIRST — `${aliases}` / `${functions}`
8831 // / `${commands}` / etc. should return the value list per
8832 // zsh's bare-assoc semantics. Without this, those names fell
8833 // through to `get_variable` which is empty (they live in
8834 // separate executor tables, not `assoc_arrays`). Return as
8835 // a Value::Array so `arr=(${aliases})` distributes into
8836 // multiple elements, matching zsh's array-context word
8837 // splitting for assoc-bare references.
8838 // Only a name that is a special hash or array row, or a module
8839 // parameter that autoloads, can answer here. For every other name
8840 // `partab_array_get` / `partab_scan_keys` return None after their
8841 // shadow check, module gate and bookkeeping insert — work that cost
8842 // about a third of each parameter read in compinit. The AUTOLOAD
8843 // check keeps `mark_module_param_used`'s first-touch load for those.
8844 let partab_candidate = crate::ported::modules::parameter::PARTAB
8845 .iter()
8846 .any(|e| e.name == name.as_str())
8847 || crate::ported::modules::parameter::PARTAB_ARRAY
8848 .iter()
8849 .any(|e| e.name == name.as_str())
8850 || crate::vm_helper::AUTOLOAD_PARAMS.iter().any(|(p, _)| *p == name.as_str());
8851 let magic_vals = if !partab_candidate {
8852 None
8853 } else {
8854 with_executor(|exec| {
8855 sync_status(exec);
8856 // Canonical PARTAB dispatch (Src/Modules/parameter.c:2235-
8857 // 2298 + SPECIALPMDEFs in mapfile/terminfo/termcap/system/
8858 // zleparameter): PARTAB_ARRAY entries → whole-array getfn;
8859 // PARTAB entries → scan keys + per-key getpm/scanpm fn
8860 // pointers.
8861 let _ = exec;
8862 if let Some(values) = partab_array_get(&name) {
8863 Some(values)
8864 } else if let Some(keys) = partab_scan_keys(&name) {
8865 Some(
8866 keys.iter()
8867 .map(|k| partab_get(&name, k).unwrap_or_default())
8868 .collect::<Vec<_>>(),
8869 )
8870 } else {
8871 None
8872 }
8873 })
8874 };
8875 if let Some(vals) = magic_vals {
8876 // Distinguish "name IS a magic-assoc with no entries"
8877 // (return Array(empty)) from "name is unknown — fall
8878 // through to get_variable".
8879 // c:Src/params.c:2293-2296 — KSHARRAYS bare reference
8880 // collapses to the FIRST element in scan order
8881 // (`v->end = 1, v->isarr = 0`). For `options` the scan
8882 // order is optiontab bucket order (OPTIONTAB), so zsh 5.9
8883 // prints `off` (posixargzero) for
8884 // `setopt ksharrays; print $options`.
8885 if isset(crate::ported::zsh_h::KSHARRAYS) {
8886 // c:Src/params.c:2342-2350 — a PM_HASHED special takes the
8887 // same `!v->scanflags && EMULATION(EMULATE_KSH)` arm as a
8888 // plain association: `s = "[0]"; getindex(...)`, i.e. the
8889 // value under key "0" through the special's getfn, empty
8890 // when absent. PARTAB_ARRAY rows are PM_ARRAY and keep the
8891 // first element.
8892 if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH)
8893 && partab_array_get(&name).is_none()
8894 {
8895 return Value::str(partab_get(&name, "0").unwrap_or_default());
8896 }
8897 return Value::str(vals.into_iter().next().unwrap_or_default());
8898 }
8899 // Same array reference shape as the indexed-array arm below.
8900 if !force_dq {
8901 note_array_empties_elidable();
8902 }
8903 return Value::array(vals.into_iter().map(Value::str).collect());
8904 }
8905 // Indexed-array path: return Value::Array so pop_args splats
8906 // each element into its own argv slot. Direct port of zsh's
8907 // unquoted `$arr` semantics — each element becomes a separate
8908 // word in command-arg position.
8909 //
8910 // DQ context exception: inside `"...$arr..."`, zsh joins with
8911 // the first char of $IFS (default space) so the DQ word stays
8912 // a single argv slot. Detect via in_dq_context (bumped by
8913 // BUILTIN_EXPAND_TEXT mode 1) and return the joined scalar.
8914 // Direct port of Src/subst.c:1759-1813 nojoin/sepjoin: in DQ
8915 // (qt=1) without explicit `(@)`, sepjoin runs and the result
8916 // is one word.
8917 let arr_assoc_data = with_executor(|exec| {
8918 sync_status(exec);
8919 let in_dq = force_dq || exec.in_dq_context > 0;
8920 // KSH_ARRAYS: bare `$arr` returns ONLY arr[0] (zero-
8921 // based first-element-only semantics). Direct port of
8922 // Src/params.c getstrvalue's KSH_ARRAYS gate which
8923 // returns aval[0] instead of the whole array.
8924 let ksh_arrays = isset(crate::ported::zsh_h::KSHARRAYS);
8925 if let Some(arr) = exec.array(&name) {
8926 if ksh_arrays {
8927 return Some((vec![arr.first().cloned().unwrap_or_default()], in_dq));
8928 }
8929 return Some((arr, in_dq));
8930 }
8931 // Existence only: `exec.assoc()` cloned the whole map to answer it.
8932 if exec.has_assoc(&name) {
8933 // c:Src/params.c:2351-2358 — under KSH EMULATION a bare
8934 // `$assoc` is `${assoc[0]}` (a KEY-"0" lookup), so it is
8935 // EMPTY unless the hash actually has a key "0". This is
8936 // EMULATION-gated, not KSHARRAYS-option-gated: `emulate -L
8937 // ksh; typeset -A h=(a 1 b 2); print $h` is empty, whereas
8938 // `setopt ksharrays; …; print $h` collapses to the bucket-
8939 // first value below.
8940 if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
8941 let v = crate::ported::subst::assoc_get(&name)
8942 .and_then(|m| m.get("0").cloned())
8943 .unwrap_or_default();
8944 return Some((vec![v], in_dq));
8945 }
8946 // c:Src/hashtable.c scanhashtable — a bare `$assoc` joins its
8947 // VALUES in zsh hash-BUCKET order (the same order `(k)`/`(v)`
8948 // enumerate), NOT sorted or insertion order. `assoc_get`
8949 // rebuilds zsh's bucket layout; use it so `$as` matches
8950 // `${(v)as}` (`as=(zebra 9 apple 1)` → `9 1`, not the
8951 // alphabetical `1 9`). Under KSHARRAYS the bare form collapses
8952 // to the bucket-FIRST value (`9`), matching zsh.
8953 let values: Vec<String> = crate::ported::subst::assoc_get(&name)
8954 .map(|m| m.values().cloned().collect())
8955 .unwrap_or_default();
8956 if ksh_arrays {
8957 return Some((vec![values.into_iter().next().unwrap_or_default()], in_dq));
8958 }
8959 return Some((values, in_dq));
8960 }
8961 None
8962 });
8963 if let Some((items, in_dq)) = arr_assoc_data {
8964 if in_dq {
8965 // c:Src/utils.c:3936-3945 sepjoin default-sep rule:
8966 // set-but-empty IFS joins with "" (`IFS=""; echo
8967 // "$arr"` concatenates); only unset / space-leading
8968 // IFS yields " ". The previous get_variable read
8969 // couldn't distinguish unset from set-empty.
8970 return Value::str(crate::ported::utils::sepjoin(&items, None));
8971 }
8972 // c:Src/subst.c:3912 — the join-then-split block:
8973 //
8974 // if (ssub || spbreak || spsep || sep || quoted_array_with_offset) {
8975 // int force_split = !ssub && (spbreak || spsep);
8976 // if (isarr || quoted_array_with_offset) {
8977 //
8978 // A BARE `$NAME` / `${NAME}` read of an ARRAY is `isarr != 0`, so it
8979 // takes the same block the `${NAME[@]}` / `${NAME[*]}` splice does
8980 // (BUILTIN_ARRAY_ALL / BUILTIN_ARRAY_JOIN_STAR above) — c:3030-3032
8981 // makes the three spellings indistinguishable once inside it. The
8982 // only door in from HERE is `spbreak`, c:Src/subst.c:1707
8983 // int spbreak = (pf_flags & PREFORK_SHWORDSPLIT)
8984 // && !(pf_flags & PREFORK_SINGLE) && !qt;
8985 // — SH_WORD_SPLIT. `qt` and PREFORK_SINGLE both reach this handler
8986 // as `force_dq` (the compile site emits GET_VAR_DQ for either), and
8987 // that arm returned above, so getting here already means `!qt` and
8988 // `!ssub`; the explicit `${=…}` / `${==…}` spellings have their own
8989 // fast path. `sep` / `spsep` are the `(j:…:)` / `(s:…:)` separators,
8990 // which never reach a compiler fast path.
8991 //
8992 // This handler had no such block at all: it returned the element
8993 // vector whatever the option said, so an element carrying an IFS
8994 // byte was never broken up —
8995 // setopt shwordsplit; a=('p:q' r); IFS=:; print -rl -- $a
8996 // zsh: `p` `q` `r` zshrs: `p:q` `r`
8997 // setopt shwordsplit; a=('p q' r); unset IFS; print -rl -- ${a}
8998 // zsh: `p` `q` `r` zshrs: `p q` `r`
8999 // setopt shwordsplit; a=(x '' y); IFS=:; print -rl -- $a
9000 // zsh: `x` `` `y` zshrs: `x` `y`
9001 // The last is `sepsplit_c3932`'s `nulstring` rule: the join makes
9002 // `x::y`, and an empty field delimited by IFS-NON-whitespace comes
9003 // back as c:Src/subst.c:36's one-byte `nulstring`, which c:186 KEEPS.
9004 //
9005 // Only the JOINED outcome changes shape. When c:3914-3927 leaves the
9006 // vector alone (an IFS that is set but EMPTY: c:1819 `nojoin` is 1
9007 // and c:3919's second arm needs `!ifs`, which a set-empty IFS fails)
9008 // c:3931's `!isarr` closes the split too, and the answer is the
9009 // untouched array the code below already returns.
9010 if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT) {
9011 let ifs_opt = with_executor(|exec| exec.scalar("IFS"));
9012 if let JoinC3914::Joined(val) = join_c3914(items.clone(), ifs_opt.as_deref()) {
9013 // c:3932 `aval = sepsplit(val, spsep, 0, 1)`, then
9014 // c:3933-3938's 0/1/N result shape.
9015 return split_words_c3932(&val);
9016 }
9017 }
9018 // c:Src/subst.c:184-187 — prefork's `else if (!keep)
9019 // uremnode(list, node)`: UNQUOTED expansion drops empty
9020 // list nodes before they reach argv, so `a=(y '' x);
9021 // print -- $a` passes TWO args in zsh (`y x`), while the
9022 // quoted "${a[@]}" splat keeps the empty slot.
9023 //
9024 // The removal must NOT happen HERE. c:186 deletes an
9025 // assembled list NODE — it runs after stringsubst has
9026 // concatenated the word's surrounding text onto the
9027 // elements (the array emit block is c:4245 onward, the
9028 // non-plan9 prefix/suffix attach c:4366-4437), so an
9029 // element that is empty at READ time can still finish as a
9030 // non-empty WORD. Filtering on the way out of the read cost
9031 // exactly that: `a=(x y ""); print -rl -- ${a}POST` dropped
9032 // the slot the suffix was going to land on and printed
9033 // `x` `yPOST` where zsh prints `x` `y` `POST`. The elision
9034 // now lives at the end of word assembly
9035 // (`BUILTIN_WORD_ELIDE_EMPTY`, emitted by compile_zsh once
9036 // the word's segments are folded); flag the value so that
9037 // builtin can tell a genuine array's empty element from a
9038 // split field's `nulstring` (see `ARRAY_EMPTIES_ELIDABLE`).
9039 note_array_empties_elidable();
9040 // c:4245 — a real array reference: `isarr != 0`, so an empty
9041 // one takes plan9's word-removal path, not the scalar path.
9042 // Only note it when the array IS empty — see the rc_expand arm
9043 // above for why a non-empty read must not touch the bit.
9044 if items.is_empty() {
9045 note_empty_is_scalar(false);
9046 }
9047 return Value::array(items.into_iter().map(Value::str).collect());
9048 }
9049 let (val, in_dq, is_known) = with_executor(|exec| {
9050 sync_status(exec);
9051 let v = exec.get_variable(&name);
9052 // For nounset detection: a name is "known" when it has a
9053 // paramtab/array/assoc/env entry. Special chars ($?, $#,
9054 // $@, $*, $-, $$, $!, $_, $0) always count as known
9055 // regardless of value. Pure-digit positional params
9056 // count as known iff index <= $# (set -- has populated
9057 // that slot). c:Src/subst.c:1689 — NOUNSET fires on
9058 // unset positional param too: `set --; echo "$1"` with
9059 // nounset must diagnose.
9060 let is_special_single = name.len() == 1
9061 && matches!(
9062 name.chars().next().unwrap(),
9063 '?' | '#' | '@' | '*' | '-' | '$' | '!' | '_' | '0'
9064 );
9065 let is_pure_digit = !name.is_empty() && name.chars().all(|c| c.is_ascii_digit());
9066 let positional_known = if is_pure_digit {
9067 let idx: usize = name.parse().unwrap_or(0);
9068 if idx == 0 {
9069 true // $0 always set
9070 } else {
9071 idx <= exec.pparams().len()
9072 }
9073 } else {
9074 false
9075 };
9076 let known = !v.is_empty()
9077 || name.is_empty()
9078 || is_special_single
9079 || positional_known
9080 || crate::ported::params::paramtab()
9081 .read()
9082 .ok()
9083 .map(|t| t.contains_key(&name))
9084 .unwrap_or(false)
9085 || env::var(&name).is_ok();
9086 (v, force_dq || exec.in_dq_context > 0, known)
9087 });
9088 // c:Src/subst.c:1689 — NO_UNSET / nounset: reading an unset
9089 // parameter fires "parameter not set" diagnostic and aborts
9090 // the substitution. Direct port of the noerrs gate at c:1689
9091 // (zerr + errflag). Matches `set -u` POSIX semantics.
9092 // `nounset` is the negated alias of UNSET (c:Src/options.c optns), so
9093 // this is C's `unset(UNSET)`: one atomic load, not a name lookup.
9094 if !is_known && !isset(crate::ported::zsh_h::UNSET) {
9095 crate::ported::utils::zerr(&format!("{}: parameter not set", name));
9096 crate::ported::utils::errflag.fetch_or(
9097 crate::ported::zsh_h::ERRFLAG_ERROR,
9098 std::sync::atomic::Ordering::Relaxed,
9099 );
9100 with_executor(|exec| exec.set_last_status(1));
9101 return Value::str("");
9102 }
9103 // Empty unquoted scalar → drop the arg (zsh "remove empty
9104 // unquoted words" rule). Returning empty Value::Array makes
9105 // pop_args contribute zero items. DQ context keeps the empty
9106 // string so "$a" stays a single empty arg. Direct port of
9107 // subst.c's elide-empty pass.
9108 if val.is_empty() && !in_dq {
9109 // c:1650-1656 / c:4437 — a SCALAR parameter has `isarr == 0`,
9110 // so it never reaches plan9's word-removal at c:4362. Flag the
9111 // empty Array below as a scalar so `setopt rcexpandparam;
9112 // v=; print -rl -- x$v y` still emits `x` (only an empty
9113 // ARRAY deletes the word).
9114 note_empty_is_scalar(true);
9115 return Value::array(Vec::new());
9116 }
9117 // c:Src/subst.c:1759 SH_WORD_SPLIT — when shwordsplit is set and
9118 // we're in unquoted command-arg position (not DQ), split scalar
9119 // value on IFS into multiple words. Matches BUILTIN_ARRAY_ALL's
9120 // shwordsplit arm (fusevm_bridge.rs:2200). Without this, bare
9121 // `$s` in `print $s` stayed a single arg even with the option
9122 // set, breaking POSIX-style scalar word-splitting.
9123 if !in_dq && isset(crate::ported::zsh_h::SHWORDSPLIT) {
9124 // c:Src/subst.c:318-324 → c:1707 `spbreak`, c:3921 `sepsplit`, then
9125 // c:4366-4437 attach the word's affixes BEFORE c:184-187 deletes the
9126 // empty nodes. `spacesplit` (c:Src/utils.c:3730-3760, allownull 0)
9127 // gives a leading/trailing run of IFS-whitespace a real `""` node,
9128 // which survives only once text is glued onto it:
9129 // setopt shwordsplit; s=' foo bar '; print -rl -- x${s}y
9130 // zsh: x / foo / bar / y (the edges became `x` and `y`)
9131 // while an IFS-non-whitespace empty field is `nulstring` and always
9132 // survives. This read cannot see the affixes, and dropping every
9133 // truly-empty field here made that `xfoo` / `bary`. paramsubst
9134 // already carries both rules (PARAMSUBST_AFFIXES_DEFERRED leaves the
9135 // removal to the word's end-of-word drop), and it is the same
9136 // expansion C runs, so hand it the `${name}` spelling.
9137 return paramsubst_to_value(&format!("${{{}}}", name));
9138 }
9139 Value::str(val)
9140 }
9141 // Provenance: BUILTIN_GET_VAR / _DQ are the bytecode-level parameter
9142 // READ ops, so this is the tap that hands a tracked parameter's
9143 // lineage to the value the read produced. The name is peeked off the
9144 // stack before `get_var_impl` consumes it.
9145 fn get_var_prov(vm: &mut fusevm::VM, argc: u8, force_dq: bool) -> Value {
9146 if !crate::provenance::active() {
9147 return get_var_impl(vm, argc, force_dq);
9148 }
9149 let name = vm.peek().to_str();
9150 let value = get_var_impl(vm, argc, force_dq);
9151 crate::provenance::on_param_read(&name, &value);
9152 value
9153 }
9154 vm.register_builtin(BUILTIN_GET_VAR, |vm, argc| get_var_prov(vm, argc, false));
9155 vm.register_builtin(BUILTIN_GET_VAR_DQ, |vm, argc| get_var_prov(vm, argc, true));
9156
9157 // `name+=val` (no parens) — runtime dispatch:
9158 // - if `name` is in `arrays` → push `val` as new element
9159 // - if `name` is in `assoc_arrays` → refuse (zsh errors here)
9160 // - else → scalar concat (existing behavior)
9161 // Stack: [name, value].
9162 vm.register_builtin(BUILTIN_APPEND_SCALAR_OR_PUSH, |vm, argc| {
9163 let args = pop_args(vm, argc);
9164 let mut iter = args.into_iter();
9165 let name = iter.next().unwrap_or_default();
9166 let value = iter.next().unwrap_or_default();
9167 with_executor(|exec| {
9168 // Array form: `arr+=elem` pushes a single element.
9169 // Routes through canonical assignaparam(name, [value],
9170 // ASSPM_AUGMENT) — Src/params.c:3357 c:3402-3412 augment
9171 // path prepends prior scalar / appends to existing array.
9172 // Existence probe uses the non-cloning `has_array` — the
9173 // owning `exec.array()` clone here made `arr+=x` in a loop
9174 // O(n²) (see the assoc-store fix above).
9175 if exec.has_array(&name) {
9176 // c:Src/params.c — under KSHARRAYS a bare array name
9177 // addresses element 0 (ksh), so `a+=X` (scalar augment)
9178 // CONCATENATES onto the first element ("firstlast second"),
9179 // it does NOT push a new element. C routes scalar `+=`
9180 // through assignsparam (which targets the elem-0 value);
9181 // zshrs's APPEND_SCALAR_OR_PUSH would otherwise push.
9182 if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
9183 let mut arr = exec.array(&name).unwrap_or_default();
9184 if arr.is_empty() {
9185 arr.push(value.clone());
9186 } else {
9187 arr[0] = format!("{}{}", arr[0], value);
9188 }
9189 exec.set_array(name.clone(), arr);
9190 #[cfg(feature = "recorder")]
9191 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
9192 let ctx = exec.recorder_ctx();
9193 let attrs = exec.recorder_attrs_for(&name);
9194 emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
9195 }
9196 return;
9197 }
9198 let _ = crate::ported::params::assignaparam(
9199 &name,
9200 vec![value.clone()],
9201 crate::ported::zsh_h::ASSPM_AUGMENT
9202 | crate::ported::zsh_h::ASSPM_WARN, // c:Src/exec.c:2573
9203 );
9204 #[cfg(feature = "recorder")]
9205 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
9206 let ctx = exec.recorder_ctx();
9207 let attrs = exec.recorder_attrs_for(&name);
9208 emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
9209 }
9210 return;
9211 }
9212 // An association falls through too: c:Src/params.c:3178-3192
9213 // `resetparam(v->pm, PM_SCALAR)`s a non-special PM_HASHED
9214 // target (unset KSHARRAYS) and the augment then appends to the
9215 // fresh empty scalar, so `typeset -A h; h+=x` leaves h="x".
9216 // Scalar / integer / float form: route through canonical
9217 // assignsparam(name, value, ASSPM_AUGMENT) which
9218 // dispatches PM_TYPE — PM_SCALAR concats, PM_INTEGER
9219 // arith-adds (c:2775-2778), PM_FLOAT float-adds.
9220 let _ = crate::ported::params::assignsparam(
9221 &name,
9222 &value,
9223 crate::ported::zsh_h::ASSPM_AUGMENT | crate::ported::zsh_h::ASSPM_WARN, // c:Src/exec.c:2573 addvars
9224 );
9225 #[cfg(feature = "recorder")]
9226 if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
9227 let ctx = exec.recorder_ctx();
9228 let attrs = exec.recorder_attrs_for(&name);
9229 // Re-read the canonical value via get_variable for the
9230 // recorder bundle (assignsparam may have transformed it
9231 // through integer/float arithmetic).
9232 let final_val = exec.get_variable(&name);
9233 let lower = name.to_ascii_lowercase();
9234 if matches!(
9235 lower.as_str(),
9236 "path" | "fpath" | "manpath" | "module_path" | "cdpath"
9237 ) {
9238 emit_path_or_assign(&name, std::slice::from_ref(&final_val), attrs, true, &ctx);
9239 } else {
9240 crate::recorder::emit_assign_typed(&name, &final_val, attrs, ctx);
9241 }
9242 }
9243 });
9244 Value::Status(0)
9245 });
9246
9247 // BUILTIN_SET_VAR — `name=value` runtime scalar assignment.
9248 // PURE PASSTHRU: hand to canonical `setsparam` (C port of
9249 // `Src/params.c::setsparam`). That walks assignsparam →
9250 // assignstrvalue which already does:
9251 // - readonly rejection (zerr + errflag at c:2701)
9252 // - PM_INTEGER math evaluation (mathevali at c:3590)
9253 // - PM_EFLOAT / PM_FFLOAT float coercion (c:3608)
9254 // - PM_LOWER / PM_UPPER case fold (via setstrvalue)
9255 // - GSU special-param dispatch (homesetfn / ifssetfn / etc.)
9256 // - allexport env mirror via the PM_EXPORTED setfn
9257 //
9258 // Bridge-only concerns kept here:
9259 // - inline_env_stack (zsh `X=foo cmd` scoped env)
9260 // - recorder emission (PFA-SMR)
9261 // - vm.last_status propagation for `a=$(cmd)` exit-code chaining
9262 // Sets the GLOB_ASSIGN-eligibility flag consumed by the NEXT BUILTIN_SET_VAR.
9263 // Emitted only when a scalar-assign RHS had an unquoted glob token. Takes no
9264 // args; its pushed return is discarded by a following Op::Pop.
9265 vm.register_builtin(BUILTIN_MARK_GLOB_ELIGIBLE, |_vm, _argc| {
9266 SET_VAR_GLOB_ELIGIBLE.with(|c| c.set(true));
9267 fusevm::Value::Int(0)
9268 });
9269 vm.register_builtin(BUILTIN_SET_VAR, |vm, argc| {
9270 // `${~spec}` carrier: an assignment statement is a word-
9271 // pipeline boundary too — restore the user's GLOB_SUBST
9272 // before the NEXT word expands (`Z[d]=${~Z[d]}; print
9273 // ${options[globsubst]}` must read the user value).
9274 consume_tilde_globsubst_carrier();
9275 // Snapshot the raw Values BEFORE pop_args's to_str
9276 // flattening — needed to distinguish Int (arith assignment,
9277 // integer-typed param) from Str (scalar assignment).
9278 let mut raw_values: Vec<fusevm::Value> = Vec::with_capacity(argc as usize);
9279 for _ in 0..argc {
9280 raw_values.push(vm.pop());
9281 }
9282 raw_values.reverse();
9283 let name = raw_values.first().map(|v| v.to_str()).unwrap_or_default();
9284 let value_raw = raw_values.get(1).cloned();
9285 let value = value_raw.as_ref().map(|v| v.to_str()).unwrap_or_default();
9286 // c:Src/params.c — when the bytecode hands us an Int value
9287 // (only the arith assignment paths emit this — `(( X = N ))`
9288 // is the canonical site), route through setiparam so the
9289 // param ends up PM_INTEGER + inherits the math layer's
9290 // `lastbase` for display formatting (`(( X = 16#ff ));
9291 // echo \$X` → `16#FF`). Scalar `X=val` and `$((expr))`
9292 // assignments still take the setsparam path below.
9293 let int_assign = matches!(value_raw, Some(fusevm::Value::Int(_)));
9294 let float_assign = matches!(value_raw, Some(fusevm::Value::Float(_)));
9295 let mut assign_failed = false;
9296 with_executor(|exec| {
9297 // c:Src/params.c assignsparam — PM_READONLY rejection
9298 // BEFORE any env mutation. The inline-env-prefix path
9299 // (`X=2 env`) called env::set_var unconditionally before
9300 // the readonly check fired in setsparam, so the OS env
9301 // got X=2 even though the assignment errored. env then
9302 // inherited the polluted env from fork, leaking the
9303 // attempted override past the readonly guard. Mirror
9304 // C's order: readonly check → zerr → bail; only mutate
9305 // env when the assignment is admissible. Bug #551
9306 // (security-relevant).
9307 if exec.is_readonly_param(&name) {
9308 crate::ported::utils::zerr(&format!("read-only variable: {}", name));
9309 return;
9310 }
9311 // Inline-assignment frame tracking (`X=foo cmd` reverts on
9312 // command return). Only the PREFIX assignments belong in
9313 // the frame: c:Src/exec.c:4410 save_params snapshots the
9314 // parsed WC_ASSIGN chain and nothing else. The frame stays
9315 // on the stack while the command runs, so gate on
9316 // `recording` (cleared by SEAL_INLINE_ENV once the prefix
9317 // assignments have committed) — otherwise every assignment
9318 // the command itself makes gets recorded and then reverted
9319 // (`X=y . file` wiped every global the file defined).
9320 let prefix_restored = exec
9321 .inline_env_stack
9322 .last()
9323 .is_some_and(|frame| frame.recording);
9324 if prefix_restored {
9325 save_inline_prefix_param(exec, &name);
9326 let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value));
9327 // c:Src/params.c:5354
9328 }
9329 // c:Src/exec.c:2641 —
9330 // if ((addflags & ADDVAR_EXPORT) && !strchr(name, '[')) {
9331 // ...
9332 // allexp = opts[ALLEXPORT];
9333 // opts[ALLEXPORT] = 1;
9334 // if (isset(KSHARRAYS))
9335 // unsetparam(name);
9336 // pm = assignsparam(name, val, myflags);
9337 // opts[ALLEXPORT] = allexp;
9338 // } else
9339 // pm = assignsparam(name, val, myflags);
9340 // The assignment itself is what has to run under ALLEXPORT —
9341 // `createparam` ORs in PM_EXPORTED for a name that did not
9342 // exist (c:Src/params.c:1170-1171) and `assignstrvalue`'s tail
9343 // calls `export_param` for one that did (c:2836-2841) — so the
9344 // flip wraps the setsparam/setiparam/setnparam dispatch and the
9345 // env mirror below, and nothing else. Doing it any other way
9346 // would be special-casing the `${(t)}` readout instead of
9347 // marking the parameter.
9348 //
9349 // !!! NOT PORTED — the STTY leg (c:2642-2645 `STTYval =
9350 // ztrdup(val)`). `execute` consumes STTYval on the next
9351 // external command (exec.rs:4081) and C drops it again at
9352 // c:4310 / c:4400-4401 when the prefixed command finishes;
9353 // the bridge has no counterpart for those clears, so capturing
9354 // it here would apply `STTY=… cmd`'s settings to whatever
9355 // command ran next instead.
9356 let addvar_export = exec
9357 .inline_env_stack
9358 .last()
9359 .is_some_and(|frame| frame.recording && frame.export)
9360 && !name.contains('['); // c:2641
9361 let saved_allexport = if addvar_export {
9362 let allexp = isset(crate::ported::zsh_h::ALLEXPORT); // c:2646
9363 crate::ported::options::opt_state_set("allexport", true); // c:2647
9364 if isset(crate::ported::zsh_h::KSHARRAYS) {
9365 // c:2648 — force the name to be recreated by the scalar
9366 // assignment rather than written into the array that is
9367 // already there: `setopt ksharrays; A=(1 2); A=x f`
9368 // reports `scalar-export` inside the call in zsh.
9369 crate::ported::params::unsetparam(&name); // c:2649
9370 }
9371 Some(allexp)
9372 } else {
9373 None
9374 };
9375 // c:Src/exec.c:2573 — `flags = !(addflags & ADDVAR_RESTORE) ?
9376 // ASSPM_WARN : 0;`, with the comment "Don't do this if there is a
9377 // list of variables marked to be restored after the command, since
9378 // then the assignment is implicitly scoped." A prefix assignment
9379 // recorded into the open inline-env frame is exactly that: it is
9380 // put back by restore_params when the command returns (c:4144-4145
9381 // `if (restorelist) flags |= ADDVAR_RESTORE;`, and c:4346-4347 for
9382 // an external's forked child). The non-recording arms (POSIX
9383 // special builtins, the do_save = 0 case) and plain assignments
9384 // keep the warning.
9385 let asg_flags = if prefix_restored {
9386 0
9387 } else {
9388 crate::ported::zsh_h::ASSPM_WARN as i32
9389 };
9390 // Canonical setsparam handles readonly, integer math, case
9391 // fold, GSU dispatch. For Int values (arith assigns) route
9392 // through setiparam so the param is PM_INTEGER + inherits
9393 // the math layer's lastbase for display formatting. For
9394 // Float (arith assigns producing MN_FLOAT) route through
9395 // setnparam so the param is PM_FFLOAT — `(( b = a * 2 ))`
9396 // with scalar `a="3.14"` should create b as typeset -F,
9397 // not a scalar holding "6.28".
9398 if int_assign {
9399 if let Some(fusevm::Value::Int(i)) = value_raw {
9400 crate::ported::params::assigniparam(&name, i, asg_flags);
9401 } else {
9402 assign_failed = crate::ported::params::assignsparam(&name, &value, asg_flags).is_none();
9403 }
9404 } else if float_assign {
9405 if let Some(fusevm::Value::Float(f)) = value_raw {
9406 // ArithCompiler returns Value::Float whenever any
9407 // operand came through Str (BUILTIN_GET_VAR yields
9408 // Value::Str even for integer-shaped scalars). To
9409 // avoid forcing every `(( b = a + 3 ))` to PM_FFLOAT
9410 // when `a="5"` (integer-shaped), detect integer-
9411 // valued floats and route through setiparam instead.
9412 // True floats (non-integral) reach setnparam →
9413 // PM_FFLOAT so `typeset -p b` shows `typeset -F …`.
9414 if f.fract() == 0.0 && f.is_finite() && f.abs() <= i64::MAX as f64 {
9415 crate::ported::params::assigniparam(&name, f as i64, asg_flags);
9416 } else {
9417 let mnval = crate::ported::math::mnumber {
9418 l: 0,
9419 d: f,
9420 type_: crate::ported::math::MN_FLOAT,
9421 };
9422 crate::ported::params::assignnparam(&name, mnval, asg_flags);
9423 }
9424 } else {
9425 assign_failed = crate::ported::params::assignsparam(&name, &value, asg_flags).is_none();
9426 }
9427 } else {
9428 // c:Src/exec.c:2554-2567 — GLOB_ASSIGN. When the
9429 // `globassign` option is on and the scalar RHS is a glob
9430 // pattern, glob it and recreate the parameter as a scalar
9431 // (≤1 match) or array (>1) — csh-style assignment. The
9432 // bridge hands `value` UNTOKENIZED, so re-tokenize
9433 // (shtokenize) before haswilds/globlist; zsh's wordcode
9434 // value arrives pre-tokenized via `htok`. The
9435 // `isset(GLOBASSIGN)` gate is first and cheap (option off
9436 // by default), so the common path is unchanged.
9437 let mut globbed = false;
9438 // Only glob the RHS when the compiler flagged an UNQUOTED glob
9439 // token in the literal wordcode (SET_VAR_GLOB_ELIGIBLE). zsh's
9440 // GLOB_ASSIGN (Src/exec.c:2554) globs literal patterns only —
9441 // `x="/tmp/*"`, `x='/tmp/*'`, `x=$param`, `x=$(cmd)` all assign
9442 // verbatim. The value arrives here untokenized (DQ-wrapped by
9443 // the compiler), so this compile-time flag is the only surviving
9444 // signal of whether the pattern was quote-protected.
9445 let glob_eligible = SET_VAR_GLOB_ELIGIBLE.with(|c| c.replace(false));
9446 if glob_eligible && crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBASSIGN) {
9447 let mut tv = value.clone();
9448 crate::ported::glob::shtokenize(&mut tv);
9449 if crate::ported::pattern::haswilds(&tv) {
9450 // Committed to the glob path: never fall back to
9451 // assigning the literal pattern (zsh errors on
9452 // no-match instead).
9453 globbed = true;
9454 // globlist tokenizes its input internally (for
9455 // haswilds + glob_path) and prints the ORIGINAL
9456 // string verbatim in its "no matches found" error,
9457 // so feed it the UNtokenized value — passing the
9458 // tokenized form would leak the Star/Quest token
9459 // bytes into the error message.
9460 let mut ll: crate::ported::linklist::LinkList<String> = Default::default();
9461 ll.push_back(value.clone());
9462 crate::ported::subst::globlist(&mut ll, 0); // c:2556
9463 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
9464 == 0
9465 {
9466 let matches: Vec<String> = ll
9467 .nodes
9468 .iter()
9469 .map(|s| crate::ported::lex::untokenize(s).to_string())
9470 .collect();
9471 crate::ported::params::unsetparam(&name); // c:2562
9472 if matches.len() <= 1 {
9473 let v = matches.into_iter().next().unwrap_or_default();
9474 assign_failed =
9475 crate::ported::params::assignsparam(&name, &v, asg_flags).is_none();
9476 } else {
9477 crate::ported::params::assignaparam(&name, matches, asg_flags);
9478 }
9479 }
9480 // errflag set → globlist already reported
9481 // "no matches found"; leave the param unassigned
9482 // to match zsh's abort.
9483 }
9484 }
9485 // c:Src/exec.c addvars — a NULL return from
9486 // assignsparam (e.g. nameref resolving out of scope,
9487 // createparam refusal at c:1108-1118) fails the
9488 // assignment with status 1.
9489 if !globbed {
9490 assign_failed = crate::ported::params::assignsparam(&name, &value, asg_flags).is_none();
9491 }
9492 }
9493 // PM_EXPORTED / allexport env mirror — read AFTER setsparam
9494 // so the flag bit reflects any GSU setfn side-effects.
9495 let allexport = opt_state_get("allexport").unwrap_or(false);
9496 let already_exported =
9497 (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
9498 if allexport || already_exported {
9499 // c:Src/params.c:3024 — the env mirror is `addenv(pm, value)`,
9500 // and addenv builds its string with `mkenvstr(nam, value,
9501 // pm->flags)` (c:5463) so `copyenvstr` (c:5434) can apply the
9502 // PM_LOWER / PM_UPPER fold. Formatting `name=value` by hand
9503 // skipped that: `typeset -lx v; v=HeLLo` exported `HeLLo`
9504 // where zsh exports `hello`. The fold has to happen HERE
9505 // because the paramtab now stores the value verbatim.
9506 let envstr = crate::ported::params::mkenvstr(
9507 &name,
9508 &value,
9509 exec.param_flags(&name), // c:5463 pm->flags
9510 );
9511 let _ = crate::ported::params::zputenv(&envstr); // c:Src/params.c:5354
9512 }
9513 // c:2651 — `opts[ALLEXPORT] = allexp;`
9514 if let Some(allexp) = saved_allexport {
9515 crate::ported::options::opt_state_set("allexport", allexp);
9516 }
9517 #[cfg(feature = "recorder")]
9518 if crate::recorder::is_enabled()
9519 && exec.local_scope_depth == 0
9520 && !matches!(
9521 name.as_str(),
9522 "PPID" | "LINENO" | "ZSH_ARGZERO" | "argv0" | "ARGC" | "?" | "_" | "RANDOM"
9523 )
9524 {
9525 let ctx = exec.recorder_ctx();
9526 let attrs = exec.recorder_attrs_for(&name);
9527 crate::recorder::emit_assign_typed(&name, &value, attrs, ctx);
9528 }
9529 // c:Src/exec.c:1367-1370 — `if (code == WC_ASSIGN) { cmdoutval = 0;
9530 // addvars(state, state->pc - 1, 0); setunderscore(""); … }`. A
9531 // simple command consisting ONLY of scalar assignments clears `$_`;
9532 // it never goes through execcmd's c:3545-3547
9533 // `setunderscore(lastnode(args))`. src/ported/exec.rs:6946-6950
9534 // already ports that arm, but the WC_ASSIGN wordcode never
9535 // executes under fusevm — a bare `x=1` arrives here as
9536 // BUILTIN_SET_VAR, so `$_` kept the PREVIOUS command's last
9537 // argument. Symptom: in the `unset <TAB>` listing (the user's
9538 // `_parameters` override runs `maxLen=50` right before the
9539 // `$parameters` walk) zsh shows `_` empty while zshrs showed the
9540 // completion-internal `^a*` — `_parameters -g '^a*'`'s last arg.
9541 //
9542 // Two exclusions, both verified against zsh 5.9.2
9543 // (`true aa; <form>; print -r -- "[$_]"`):
9544 // * PREFIX assignments (`x=1 true dd` → `dd`) are part of a
9545 // command, so c:3545-3547 owns `$_`. They are exactly the
9546 // assignments recorded into an open inline-env frame above.
9547 // * `(( q = 1 ))` (→ `aa`, unchanged) is WC_ARITH, not
9548 // WC_ASSIGN; the arith paths are the only ones that hand this
9549 // builtin an Int/Float `Value` (see the `int_assign` note).
9550 if !int_assign
9551 && !float_assign
9552 && !exec
9553 .inline_env_stack
9554 .last()
9555 .is_some_and(|frame| frame.recording)
9556 {
9557 // c:1369 — assignment-only command clears `$_`. The
9558 // former DUAL-STATE note here is obsolete: `set_zunderscore`
9559 // and the ported `setunderscore` now write the SAME
9560 // `init::zunderscore` global (params.rs `zunderscore_lock`
9561 // points at it), matching C's single store, so this one call
9562 // is the whole effect.
9563 crate::ported::exec::setunderscore(""); // c:1369
9564 }
9565 });
9566 // c:Src/exec.c:2654-2663 — `if (!pm) { lastval = 1; ... if
9567 // (!cmdoutval) cmdoutval = 1; }`; BUILTIN_ASSIGN_ONLY_STATUS
9568 // reads the flag back as the command's status.
9569 if assign_failed {
9570 ASSIGN_FAILED_FLAG.store(true, std::sync::atomic::Ordering::Relaxed);
9571 }
9572 Value::Status(vm.last_status)
9573 });
9574
9575 // c:Src/exec.c execfor → Src/params.c:6362 setloopvar — the
9576 // for-loop variable bind. Distinct from BUILTIN_SET_VAR because a
9577 // PM_NAMEREF loop variable REBINDS (new refname) instead of
9578 // assigning through the resolved chain.
9579 vm.register_builtin(BUILTIN_SET_LOOP_VAR, |vm, argc| {
9580 let args = pop_args(vm, argc);
9581 let name = args.first().cloned().unwrap_or_default();
9582 let value = args.get(1).cloned().unwrap_or_default();
9583 if crate::vm_helper::is_nameref(&name) {
9584 let ef_before =
9585 crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
9586 crate::ported::params::setloopvar(&name, &value); // c:6362
9587 let ef_after = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
9588 if (ef_after & crate::ported::utils::ERRFLAG_ERROR) != 0 && ef_after != ef_before {
9589 // zerr fired (read-only reference / invalid self
9590 // reference) — abort the loop, status 1 (C errflag).
9591 vm.last_status = 1;
9592 return Value::Bool(false);
9593 }
9594 return Value::Bool(true);
9595 }
9596 // Plain loop var — canonical scalar path (same shape as
9597 // BUILTIN_SET_VAR's setsparam arm).
9598 with_executor(|exec| {
9599 if exec.is_readonly_param(&name) {
9600 crate::ported::utils::zerr(&format!("read-only variable: {}", name));
9601 return;
9602 }
9603 crate::ported::params::setsparam(&name, &value);
9604 let allexport = opt_state_get("allexport").unwrap_or(false);
9605 let already_exported =
9606 (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
9607 if allexport || already_exported {
9608 // c:Src/params.c:3024 — the env mirror is `addenv(pm, value)`,
9609 // and addenv builds its string with `mkenvstr(nam, value,
9610 // pm->flags)` (c:5463) so `copyenvstr` (c:5434) can apply the
9611 // PM_LOWER / PM_UPPER fold. Formatting `name=value` by hand
9612 // skipped that: `typeset -lx v; v=HeLLo` exported `HeLLo`
9613 // where zsh exports `hello`. The fold has to happen HERE
9614 // because the paramtab now stores the value verbatim.
9615 let envstr = crate::ported::params::mkenvstr(
9616 &name,
9617 &value,
9618 exec.param_flags(&name), // c:5463 pm->flags
9619 );
9620 let _ = crate::ported::params::zputenv(&envstr); // c:Src/params.c:5354
9621 }
9622 });
9623 Value::Bool(true)
9624 });
9625
9626 // Pre-compiled function registration — used by compile_zsh.rs's
9627 // FuncDef path. Stack: [name, base64-bincode-of-Chunk]. We decode
9628 // the base64, deserialize the Chunk, and store directly in
9629 // executor.functions_compiled. Bypasses the ShellCommand JSON layer.
9630 // BUILTIN_VAR_EXISTS — `[[ -v name ]]` set-test.
9631 // PURE PASSTHRU: build `${+name}` and route through canonical
9632 // `subst::paramsubst` which returns "1" for set / "0" for unset
9633 // (C port of `Src/subst.c::paramsubst` plus-prefix arm).
9634 // paramsubst handles all the shapes the 48-line hand-roll did:
9635 // - bare scalar / array / assoc
9636 // - subscripted `a[N]` / `h[key]`
9637 // - positional params (any digit-only name)
9638 // - env-var fallback (`HOME` set via getsparam → lookup_special_var)
9639 vm.register_builtin(BUILTIN_VAR_EXISTS, |vm, _argc| {
9640 let name = vm.pop().to_str();
9641 // c:Src/cond.c:361 `case 'v': return !issetvar(left)`. `-v` is
9642 // NOT `${+name}` — issetvar (params.c:751) additionally rejects
9643 // trailing chars after the parsed name/subscript (`arr[3]extra`,
9644 // nested `arr[2][1]`) and validates array-slice bounds (an
9645 // out-of-range `(i)`-not-found index is "unset"). `${+}` is
9646 // lenient and reported those as set.
9647 Value::Bool(crate::ported::params::issetvar(&name) != 0)
9648 });
9649
9650 // `time { compound; ... }` — runs the sub-chunk and prints elapsed
9651 // wall-clock time. zsh's full `time` also tracks user/system CPU via
9652 // getrusage on the *child*; we approximate via wall-time only since
9653 // the sub-chunk runs in-process (no fork). Output format matches
9654 // `time simple-cmd` (already implemented elsewhere via exectime).
9655 vm.register_builtin(BUILTIN_TIME_SUBLIST, |vm, argc| {
9656 // A negative sub-chunk index is the compiler's marker for the BARE
9657 // `time` keyword, which has no body to run.
9658 // c:Src/exec.c:5331-5334 exectime:
9659 // if (WC_TIMED_TYPE(state->pc[-1]) == WC_TIMED_EMPTY) {
9660 // shelltime(NULL,NULL,NULL,0);
9661 // return 0;
9662 // }
9663 // `shelltime(NULL, NULL, NULL, 0)` is the ONE call that prints the
9664 // shell/children pair: with delta==0 and both pointers NULL, both
9665 // `!delta == !shell` (c:Src/jobs.c:1964) and `!delta == !kids`
9666 // (c:1985) hold. Verified: `zsh -fc 'time'` prints
9667 // shell 0.00s user 0.00s system … / children 0.00s user …
9668 // while every `time <body>` form prints nothing (see the is_cursh
9669 // note below). zshrs previously compiled bare `time` to a plain
9670 // `status = 0` and printed nothing at all.
9671 let sub_idx_raw = vm.pop().to_int();
9672 let sub_idx = sub_idx_raw as usize;
9673 // c:Src/jobs.c:1028-1029 — `pn->text` arg to printtime. argc==2
9674 // means the compiler also pushed a desc string (bug #66 fix);
9675 // older callers with argc==1 push only sub_idx and we synthesize
9676 // an empty desc for backward compat with cached bytecode that
9677 // predates the desc-threading patch.
9678 let desc = if argc >= 2 {
9679 vm.pop().to_str().to_string()
9680 } else {
9681 String::new()
9682 };
9683 // c:Src/exec.c:3536-3538 — `if (!text && (!sfcontext && (jobbing ||
9684 // (how & Z_TIMED)))) text = getjobtext(…)`: inside a shell function
9685 // (sfcontext = SFC_DIRECT, c:5632-5633) no job text is taken, so the
9686 // report's %J is empty: `f(){ time /bin/sleep 0 }; f` prints
9687 // ` 0.00s user …` with no command.
9688 let desc = if crate::ported::exec::sfcontext.load(std::sync::atomic::Ordering::Relaxed)
9689 != crate::ported::zsh_h::SFC_NONE
9690 {
9691 String::new()
9692 } else {
9693 desc
9694 };
9695 // c:Src/exec.c:3690 — the compiler's `is_cursh` verdict for the
9696 // timed body (see compile_zsh.rs `time_cursh_hint`): 1 = current
9697 // shell, 0 = forked job, 2 = decide from the command name below.
9698 // argc < 4 means bytecode cached before this operand pair existed;
9699 // fall back to the old fork-counter heuristic in that case.
9700 let (cursh_hint, cursh_name) = if argc >= 4 {
9701 let hint = vm.pop().to_int();
9702 let name = vm.pop().to_str().to_string();
9703 (hint, name)
9704 } else {
9705 (-1, String::new())
9706 };
9707 // c:Src/jobs.c:1029-1037 dumptime — a timed multi-stage pipeline
9708 // arrives with its per-stage texts and the last stage's is_cursh
9709 // verdict (compile_zsh.rs Time arm): [texts…, last_hint, last_name, n].
9710 let timed_pipeline = if argc > 4 {
9711 let n = vm.pop().to_int().max(0) as usize;
9712 let last_name = vm.pop().to_str().to_string();
9713 let last_hint = vm.pop().to_int();
9714 let mut texts: Vec<String> = (0..n).map(|_| vm.pop().to_str().to_string()).collect();
9715 texts.reverse();
9716 Some(TimedPipeline {
9717 texts,
9718 last_forks: !time_body_is_cursh(last_hint, &last_name),
9719 procs: Vec::new(),
9720 })
9721 } else {
9722 None
9723 };
9724 if sub_idx_raw < 0 {
9725 crate::ported::jobs::shelltime(None, None, None, 0); // c:5333
9726 return Value::Status(0); // c:5334
9727 }
9728 let chunk_opt = vm.chunk.sub_chunks.get(sub_idx).cloned();
9729 let Some(chunk) = chunk_opt else {
9730 return Value::Status(0);
9731 };
9732 // c:Src/jobs.c:1968 — `getrusage(RUSAGE_CHILDREN, &ti)` before
9733 // and after the timed sublist gives accurate per-stage user/sys
9734 // CPU. Wall-time-only approximation (0.7×/0.1× fudge factors)
9735 // produced bogus user/sys columns and ignored TIMEFMT. Bug #66
9736 // in docs/BUGS.md.
9737 let ru_before: libc::rusage = unsafe {
9738 let mut r: libc::rusage = std::mem::zeroed();
9739 libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
9740 r
9741 };
9742 // c:Src/jobs.c — zsh's `time` reports only for JOBS (forked
9743 // work). Builtins/brace-groups/functions run in the shell
9744 // process with no job, so `zsh -fc 'time true'` emits NOTHING.
9745 // Snapshot the fork-event counter; report only if the timed
9746 // body forked (external command or subshell).
9747 let forks_before = crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed);
9748 // c:Src/jobs.c:1943 — `getrusage(RUSAGE_SELF, &ti)` — shelltime's
9749 // "shell" line reports the SHELL PROCESS's own CPU delta, which is
9750 // where a current-shell (`is_cursh`) body's work lands.
9751 let ru_self_before: libc::rusage = unsafe {
9752 let mut r: libc::rusage = std::mem::zeroed();
9753 libc::getrusage(libc::RUSAGE_SELF, &mut r);
9754 r
9755 };
9756 let start = Instant::now();
9757 crate::fusevm_disasm::maybe_print_stdout("time_sublist", &chunk);
9758 let mut sub_vm = fusevm::VM::new(chunk);
9759 register_builtins(&mut sub_vm);
9760 let outer_timed = TIMED_PIPELINE.with(|t| t.replace(timed_pipeline));
9761 let _ = sub_vm.run();
9762 let timed_pipeline = TIMED_PIPELINE.with(|t| t.replace(outer_timed));
9763 let status = sub_vm.last_status;
9764 let elapsed = start.elapsed();
9765 let ru_self_after: libc::rusage = unsafe {
9766 let mut r: libc::rusage = std::mem::zeroed();
9767 libc::getrusage(libc::RUSAGE_SELF, &mut r);
9768 r
9769 };
9770 let ru_after: libc::rusage = unsafe {
9771 let mut r: libc::rusage = std::mem::zeroed();
9772 libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
9773 r
9774 };
9775 // Delta children rusage = timed work's CPU.
9776 let mut delta = ru_after;
9777 let sub = timeval_sub;
9778 delta.ru_utime = sub(ru_after.ru_utime, ru_before.ru_utime);
9779 delta.ru_stime = sub(ru_after.ru_stime, ru_before.ru_stime);
9780 let ti = crate::ported::zsh_h::timeinfo::from_rusage(&delta);
9781 // c:Src/jobs.c:808-809 — `s = getsparam("TIMEFMT"); s ||
9782 // DEFAULT_TIMEFMT`. Honor user-set TIMEFMT, fall back to the
9783 // canonical default.
9784 let fmt = crate::ported::params::getsparam("TIMEFMT")
9785 .unwrap_or_else(|| crate::ported::zsh_system_h::DEFAULT_TIMEFMT.to_string());
9786 // c:Src/jobs.c:768 `desc` arg — for the `time { sublist }` /
9787 // `time simple-cmd` keyword path, zsh passes the sublist's
9788 // source text (used by %J via printtime). The compiler now
9789 // threads the rendered source text through as the desc operand
9790 // (compile_zsh.rs Time arm, argc==2 form). Bug #66.
9791 // c:Src/exec.c:3690 — resolve the compiler's verdict. Hint 2 means
9792 // "a simple command whose head word decides it": `is_builtin ||
9793 // is_shfunc`. A reserved-word / builtin / function head runs in the
9794 // current shell; anything else forks and becomes a job.
9795 let is_cursh = match cursh_hint {
9796 1 => true,
9797 0 => false,
9798 2 => time_body_is_cursh(2, &cursh_name),
9799 // Bytecode cached before the hint operands existed: keep the
9800 // historical fork-counter heuristic (report only if something
9801 // forked) so a stale cache doesn't start emitting shell/children
9802 // lines for every builtin.
9803 _ => {
9804 let forked = crate::vm_helper::FORK_EVENTS
9805 .load(std::sync::atomic::Ordering::Relaxed)
9806 != forks_before;
9807 if forked {
9808 let line =
9809 crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
9810 eprintln!("{}", line);
9811 }
9812 return Value::Status(status);
9813 }
9814 };
9815 let _ = forks_before;
9816 // c:Src/exec.c:1699 — execpline hands `how` (Z_TIMED included) to
9817 // the pipeline's last stage, and a last stage that runs in the
9818 // current shell reaches the c:4443 shelltime call below just as a
9819 // timed builtin does: `time /bin/echo a | read x` prints the shell
9820 // and children lines before dumptime's line for the forked stage.
9821 let last_stage_cursh = timed_pipeline.as_ref().is_some_and(|t| !t.last_forks);
9822 if is_cursh || last_stage_cursh {
9823 // c:Src/exec.c:4443-4444 — `if ((is_cursh || do_exec) && (how &
9824 // Z_TIMED)) shelltime(&shti, &chti, &then, 1);`
9825 //
9826 // c:Src/jobs.c:1933-1993 shelltime(shell, kids, then, delta=1):
9827 // getrusage(RUSAGE_SELF, &ti); dtime_tv(… shell delta …);
9828 // dtime_ts(&dtimespec, then, &now);
9829 // if (!delta == !shell) printtime(&dtimespec, &ti, "shell");
9830 // getrusage(RUSAGE_CHILDREN, &ti); dtime_tv(… kids delta …);
9831 // if (!delta == !kids) printtime(&dtimespec, &ti, "children");
9832 // With delta=1 and both pointers non-NULL, BOTH lines print.
9833 //
9834 // !!! DO NOT "FIX" THIS TO PRINT NOTHING !!!
9835 // The locally-installed `zsh` may print nothing here and look
9836 // like the oracle. It is not: this behaviour was ADDED by
9837 // upstream 53088 (ChangeLog 2024-09-14, Bart Schaefer) —
9838 // "Src/exec.c, Src/jobs.c, Test/A01grammar.ztst,
9839 // Test/A08time.ztst: enable `time' on builtins, assignments,
9840 // and other current-shell actions, including failed commands."
9841 // — which also ADDED Test/A08time.ztst chunks 8-15, the ones
9842 // that assert `shell*` / `children*` for `time x=1`,
9843 // `time echo $(…)`, `time for ((…))`, `time builtin nonesuch`
9844 // and `time false`. zsh 5.9 (2022-05) predates 53088, so a 5.9.x
9845 // binary is silent for every one of those shapes while both
9846 // vendored C trees (src/zsh 5.9.0.3-test and 5.9.999.3-test)
9847 // carry the c:4443 call. Corpus + C source are the spec here.
9848 let mut d_self = ru_self_after;
9849 d_self.ru_utime = sub(ru_self_after.ru_utime, ru_self_before.ru_utime); // c:1954
9850 d_self.ru_stime = sub(ru_self_after.ru_stime, ru_self_before.ru_stime); // c:1955
9851 let ti_self = crate::ported::zsh_h::timeinfo::from_rusage(&d_self);
9852 // c:1972 — `printtime(&dtimespec, &ti, "shell");`
9853 eprintln!(
9854 "{}",
9855 crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti_self, &fmt, "shell")
9856 );
9857 // c:1993 — `printtime(&dtimespec, &ti, "children");`
9858 eprintln!(
9859 "{}",
9860 crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, "children")
9861 );
9862 }
9863 if is_cursh {
9864 // A current-shell body is no job: nothing more to report.
9865 } else if let Some(report) = timed_pipeline.filter(|t| !t.procs.is_empty()).and_then(|t| {
9866 // c:Src/jobs.c:1029-1037 — `for (pn = jn->procs; pn; pn = pn->next)
9867 // printtime(dtime_ts(…, &pn->bgtime, &pn->endtime), &pn->ti,
9868 // pn->text);` — one line per process the pipeline forked.
9869 let jn = crate::ported::zsh_h::job {
9870 procs: t.procs,
9871 ..Default::default()
9872 };
9873 crate::ported::jobs::dumptime(&jn)
9874 }) {
9875 eprintln!("{}", report);
9876 } else if !last_stage_cursh {
9877 // c:Src/jobs.c:1037 — the forked job's own printtime line, with
9878 // `pn->text` (the command source) as %J.
9879 let line = crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
9880 eprintln!("{}", line);
9881 }
9882 Value::Status(status)
9883 });
9884
9885 // `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocator.
9886 // Stack: [path, varid, op_byte]. Opens path with the appropriate mode
9887 // and stores the resulting fd number in $varid as a string. We use
9888 // a high starting fd (10+) by allocating then dup'ing — matches zsh's
9889 // "fresh fd >= 10" promise so subsequent commands don't collide on
9890 // stdin/out/err.
9891 vm.register_builtin(BUILTIN_OPEN_NAMED_FD, |vm, _argc| {
9892 use std::sync::atomic::Ordering;
9893 let op_byte = vm.pop().to_int() as u8;
9894 let varid = vm.pop().to_str();
9895 let path = vm.pop().to_str();
9896 // Param introspection used by both the open and close forms.
9897 // c:Src/exec.c:2188 `getvalue(&vbuf, &s, 0)` reaches the node through
9898 // realparamtab->getnode, which is getprivatenode once zsh/param/private
9899 // is set up (Src/Modules/param_private.c:678): a private from an
9900 // enclosing scope is not this parameter.
9901 let param_flags = crate::ported::params::paramtab().read().ok().and_then(|t| {
9902 t.get(&varid).and_then(|p| {
9903 let vis = crate::ported::modules::param_private::getprivatenode(&**p as *const _);
9904 (!vis.is_null()).then(|| unsafe { (*vis).node.flags })
9905 })
9906 });
9907 let param_readonly = param_flags
9908 .map(|f| (f & crate::ported::zsh_h::PM_READONLY as i32) != 0)
9909 .unwrap_or(false);
9910 // `{varid}>&-` / `{varid}<&-` — REDIR_CLOSE with varid.
9911 // Direct port of Src/exec.c:3805-3850.
9912 if matches!(
9913 op_byte,
9914 b if b == fusevm::op::redirect_op::DUP_WRITE
9915 || b == fusevm::op::redirect_op::DUP_READ
9916 ) {
9917 let n = path.trim_start_matches('&');
9918 if n == "-" {
9919 let val = with_executor(|exec| exec.scalar(&varid)).unwrap_or_default();
9920 let fd1 = val.parse::<i32>();
9921 // c:3811-3816 — bad=1: parameter doesn't contain an fd.
9922 let Ok(fd1) = fd1 else {
9923 crate::ported::utils::zwarn(&format!(
9924 "parameter {} does not contain a file descriptor",
9925 varid
9926 ));
9927 with_executor(|exec| exec.redirect_failed = true);
9928 return Value::Status(1);
9929 };
9930 // c:3813-3814 — bad=2: readonly parameter.
9931 if param_readonly {
9932 crate::ported::utils::zwarn(&format!(
9933 "can't close file descriptor from readonly parameter {}",
9934 varid
9935 ));
9936 with_executor(|exec| exec.redirect_failed = true);
9937 return Value::Status(1);
9938 }
9939 // c:3830-3835 — bad=3: fd >= 10 marked FDT_INTERNAL.
9940 if fd1 >= 10
9941 && fd1 <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
9942 && crate::ported::utils::fdtable_get(fd1) == crate::ported::zsh_h::FDT_INTERNAL
9943 {
9944 crate::ported::utils::zwarn(&format!(
9945 "file descriptor {} used by shell, not closed",
9946 fd1
9947 ));
9948 with_executor(|exec| exec.redirect_failed = true);
9949 return Value::Status(1);
9950 }
9951 // c:3870-3873 — close; report failure (varid form
9952 // always reports, unlike bare `N>&-`).
9953 // In an in-process subshell only the body's copy closes.
9954 crate::ported::exec::SubshFdFrame::touch(fd1);
9955 if crate::ported::utils::zclose(fd1) < 0 {
9956 crate::ported::utils::zwarn(&format!(
9957 "failed to close file descriptor {}: {}",
9958 fd1,
9959 crate::ported::utils::zsh_errno_msg(
9960 std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
9961 ) // c:Src/exec.c:3929 `%e`
9962 ));
9963 return Value::Status(1);
9964 }
9965 return Value::Status(0);
9966 }
9967 // `{varid}>&N` — dup N to a fresh fd >= 10, store in varid.
9968 if let Ok(src) = n.parse::<i32>() {
9969 if param_readonly {
9970 crate::ported::utils::zwarn(&format!(
9971 "can't allocate file descriptor to readonly parameter {}",
9972 varid
9973 ));
9974 with_executor(|exec| exec.redirect_failed = true);
9975 return Value::Status(1);
9976 }
9977 let dup = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
9978 if dup < 0 {
9979 crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src));
9980 with_executor(|exec| exec.redirect_failed = true);
9981 return Value::Status(1);
9982 }
9983 // c:2404-2412 addfd varid arm — movefd + FDT_EXTERNAL.
9984 let final_fd = crate::ported::utils::movefd(dup);
9985 crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
9986 // In an in-process subshell the descriptor dies with the body.
9987 crate::ported::exec::SubshFdFrame::opened(final_fd);
9988 with_executor(|exec| {
9989 #[cfg(feature = "recorder")]
9990 crate::recorder::note_descriptor_param(&varid);
9991 exec.set_scalar(varid, final_fd.to_string());
9992 });
9993 return Value::Status(0);
9994 }
9995 return Value::Status(1);
9996 }
9997 // `{varid}<<HERE` (op byte 255) / `{varid}<<<str` (op byte
9998 // 254) — zshrs-side contract with compile_redir; fusevm's
9999 // redirect_op stops at 8. C path: gethere/getherestr write the
10000 // body to a temp file (Src/exec.c:4660-4682), then addfd's
10001 // varid arm moves the read fd >= 10, marks FDT_EXTERNAL and
10002 // sets the param (c:2402-2412). `path` carries the BODY text
10003 // here.
10004 //
10005 // The two markers ARE the `REDIRF_FROM_HEREDOC` distinction of
10006 // c:Src/exec.c:4671-4672 (flag set at c:Src/parse.c:2970-2971):
10007 // 255 is the here-DOCUMENT spelling, whose body reaches the
10008 // consumer byte-for-byte, and 254 the genuine here-STRING,
10009 // which gains one trailing newline "as if the string given was
10010 // a complete command line" (c:4665-4666).
10011 if op_byte == 255 || op_byte == 254 {
10012 let from_heredoc = op_byte == 255;
10013 if param_readonly {
10014 crate::ported::utils::zwarn(&format!(
10015 "can't allocate file descriptor to readonly parameter {}",
10016 varid
10017 ));
10018 with_executor(|exec| exec.redirect_failed = true);
10019 return Value::Status(1);
10020 }
10021 // c:4671-4672 — `trim_end_matches('\n')` + unconditional
10022 // append was lossy in both directions: `exec {f}<<EOF`
10023 // with two trailing blank lines produced "hello\n" where
10024 // zsh produces "hello\n\n\n".
10025 let body = if from_heredoc {
10026 path
10027 } else {
10028 format!("{}\n", path)
10029 };
10030 let mut tmpl: Vec<u8> = b"/tmp/zshrs_hd_XXXXXX\0".to_vec();
10031 let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
10032 if write_fd < 0 {
10033 crate::ported::utils::zwarn(&format!(
10034 "can't create temp file for here document: {}",
10035 crate::ported::utils::zsh_errno_msg(
10036 std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
10037 ) // c:Src/exec.c:3828 `%e`
10038 ));
10039 return Value::Status(1);
10040 }
10041 let bytes = body.as_bytes();
10042 let mut off = 0;
10043 while off < bytes.len() {
10044 let n = unsafe {
10045 libc::write(
10046 write_fd,
10047 bytes[off..].as_ptr() as *const libc::c_void,
10048 bytes.len() - off,
10049 )
10050 };
10051 if n <= 0 {
10052 unsafe { libc::close(write_fd) };
10053 return Value::Status(1);
10054 }
10055 off += n as usize;
10056 }
10057 unsafe { libc::close(write_fd) };
10058 let read_fd =
10059 unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
10060 unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
10061 if read_fd < 0 {
10062 return Value::Status(1);
10063 }
10064 let final_fd = crate::ported::utils::movefd(read_fd);
10065 if final_fd < 0 {
10066 return Value::Status(1);
10067 }
10068 crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
10069 // In an in-process subshell the descriptor dies with the body.
10070 crate::ported::exec::SubshFdFrame::opened(final_fd);
10071 with_executor(|exec| {
10072 #[cfg(feature = "recorder")]
10073 crate::recorder::note_descriptor_param(&varid);
10074 exec.set_scalar(varid, final_fd.to_string());
10075 });
10076 return Value::Status(0);
10077 }
10078 // Open form: `{varid}>file` etc.
10079 // c:Src/exec.c:2177-2215 checkclobberparam — gate BEFORE open.
10080 if param_readonly {
10081 // c:2191-2197
10082 crate::ported::utils::zwarn(&format!(
10083 "can't allocate file descriptor to readonly parameter {}",
10084 varid
10085 ));
10086 with_executor(|exec| exec.redirect_failed = true);
10087 return Value::Status(1);
10088 }
10089 // c:2199-2213 — NO_CLOBBER refuses to overwrite a parameter
10090 // already holding an OPEN fd (decimal value, fdtable says
10091 // FDT_EXTERNAL).
10092 if !isset(crate::ported::zsh_h::CLOBBER) && op_byte != fusevm::op::redirect_op::CLOBBER {
10093 if let Some(val) = with_executor(|exec| exec.scalar(&varid)) {
10094 if let Ok(fd) = val.parse::<i32>() {
10095 if fd >= 0
10096 && fd <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
10097 && crate::ported::utils::fdtable_get(fd)
10098 == crate::ported::zsh_h::FDT_EXTERNAL
10099 {
10100 crate::ported::utils::zwarn(&format!(
10101 "can't clobber parameter {} containing file descriptor {}",
10102 varid, fd
10103 ));
10104 with_executor(|exec| exec.redirect_failed = true);
10105 return Value::Status(1);
10106 }
10107 }
10108 }
10109 }
10110 let path_c = match CString::new(path.clone()) {
10111 Ok(c) => c,
10112 Err(_) => return Value::Status(1),
10113 };
10114 let flags = match op_byte {
10115 b if b == fusevm::op::redirect_op::READ => libc::O_RDONLY,
10116 b if b == fusevm::op::redirect_op::WRITE || b == fusevm::op::redirect_op::CLOBBER => {
10117 libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC
10118 }
10119 b if b == fusevm::op::redirect_op::APPEND => {
10120 libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND
10121 }
10122 b if b == fusevm::op::redirect_op::READ_WRITE => libc::O_RDWR | libc::O_CREAT,
10123 _ => return Value::Status(1),
10124 };
10125 let fd = unsafe { libc::open(path_c.as_ptr(), flags, 0o666) };
10126 if fd < 0 {
10127 // c:Src/exec.c:3790-3795 — report the open failure and mark the
10128 // redirect failed so the command is SKIPPED, matching the numeric-fd
10129 // (`3< file`) and non-varid (`< file`) paths. Previously this
10130 // silently returned Status(1) with no diagnostic and no
10131 // redirect_failed flag, so `{fd}< /nonexistent` ran the command
10132 // anyway with exit 0 (zsh errors "no such file or directory" +
10133 // skips the command). `%e: %s` = strerror(errno) : filename.
10134 let e = std::io::Error::last_os_error();
10135 let msg = redir_errno_msg(&e);
10136 crate::ported::utils::zwarn(&format!("{}: {}", msg, path));
10137 with_executor(|exec| exec.redirect_failed = true);
10138 return Value::Status(1);
10139 }
10140 // c:2404-2412 addfd varid arm — `fd1 = movefd(fd2);
10141 // fdtable[fd1] = FDT_EXTERNAL; setiparam(varid, fd1);`.
10142 // FDT_EXTERNAL (not INTERNAL): the user owns this fd — the
10143 // NO_CLOBBER gate above and `{fd}>&-` close both key off it.
10144 let final_fd = crate::ported::utils::movefd(fd);
10145 if final_fd < 0 {
10146 crate::ported::utils::zerr(&format!(
10147 "cannot move fd {}: {}",
10148 fd,
10149 crate::ported::utils::zsh_errno_msg(
10150 std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
10151 ) // c:Src/exec.c:2463 `%e`
10152 ));
10153 return Value::Status(1);
10154 }
10155 crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
10156 // In an in-process subshell the descriptor dies with the body.
10157 crate::ported::exec::SubshFdFrame::opened(final_fd);
10158 let _ = Ordering::Relaxed;
10159 with_executor(|exec| {
10160 #[cfg(feature = "recorder")]
10161 crate::recorder::note_descriptor_param(&varid);
10162 exec.set_scalar(varid, final_fd.to_string());
10163 });
10164 Value::Status(0)
10165 });
10166
10167 // BUILTIN_SET_TRY_BLOCK_ERROR — capture the try-block's exit
10168 // status into `__zshrs_try_block_saved_status` (a scratch
10169 // scalar) so the always-arm can later restore it. Also set
10170 // `TRY_BLOCK_ERROR` per zsh semantics: it stays at -1 unless
10171 // the try-block fired an explicit error (errflag), per
10172 // c:Src/exec.c execlist's WC_TRYBLOCK arm.
10173 vm.register_builtin(BUILTIN_SET_TRY_BLOCK_ERROR, |vm, _argc| {
10174 use std::sync::atomic::Ordering;
10175 let vm_status = vm.last_status;
10176 // c:Src/exec.c WC_TRYBLOCK — the always-arm runs with a
10177 // clean escape state. Snapshot RETFLAG / BREAKS / CONTFLAG /
10178 // EXIT_PENDING here and clear them; RESTORE_TRY_BLOCK_STATUS
10179 // re-applies them at always-arm exit so the propagation jump
10180 // emitted by compile_zsh fires correctly.
10181 let ret_save = crate::ported::builtin::RETFLAG.swap(0, Ordering::Relaxed); // c:769-770
10182 let brk_save = crate::ported::builtin::BREAKS.swap(0, Ordering::Relaxed); // c:771-772
10183 let cont_save = crate::ported::builtin::CONTFLAG.swap(0, Ordering::Relaxed); // c:773-774
10184 let exit_save = crate::ported::builtin::EXIT_PENDING.swap(0, Ordering::Relaxed);
10185 // c:Src/loop.c:762-763 — `save_try_errflag = try_errflag;
10186 // save_try_interrupt = try_interrupt;`. Restored at c:778-779
10187 // by RESTORE_TRY_BLOCK_STATUS so a nested try block doesn't
10188 // clobber the enclosing one's `$TRY_BLOCK_ERROR`.
10189 let try_err_save = crate::ported::r#loop::try_errflag.load(Ordering::Relaxed); // c:762
10190 let try_int_save = crate::ported::r#loop::try_interrupt.load(Ordering::Relaxed); // c:763
10191 TRY_ESCAPE_SAVE.with(|s| {
10192 s.borrow_mut().push((
10193 ret_save,
10194 brk_save,
10195 cont_save,
10196 exit_save,
10197 try_err_save,
10198 try_int_save,
10199 ));
10200 });
10201 // c:Src/loop.c:764-766 — `try_errflag = (zlong)(errflag &
10202 // ERRFLAG_ERROR); try_interrupt = (zlong)((errflag &
10203 // ERRFLAG_INT) ? 1 : 0);`. Both are the RAW FLAG BITS, not the
10204 // try-list's exit status: ERRFLAG_ERROR is 1 (zsh.h:2972), so
10205 // `$TRY_BLOCK_ERROR` is 1-or-0 in zsh regardless of what the
10206 // failing command's `$?` was. The try-list's status is carried
10207 // separately in `__zshrs_try_block_saved_status`.
10208 let live_errflag = crate::ported::utils::errflag.load(Ordering::Relaxed);
10209 let try_err = (live_errflag & crate::ported::zsh_h::ERRFLAG_ERROR) as i64; // c:765
10210 let try_int = if (live_errflag & crate::ported::zsh_h::ERRFLAG_INT) != 0 {
10211 1i64
10212 } else {
10213 0i64
10214 }; // c:766
10215 crate::ported::r#loop::try_errflag.store(try_err, Ordering::Relaxed); // c:765
10216 crate::ported::r#loop::try_interrupt.store(try_int, Ordering::Relaxed); // c:766
10217 // c:Src/loop.c:755 — `endval = lastval ? lastval : errflag;`.
10218 // The status of the WHOLE `{…} always {…}` construct, captured
10219 // BEFORE the always-list runs (exectry returns it at c:801) and
10220 // deliberately including the errflag fallback: a try-list that
10221 // failed with `lastval == 0` but raised errflag still reports 1.
10222 let endval = if vm_status != 0 {
10223 vm_status
10224 } else {
10225 live_errflag
10226 }; // c:755
10227 with_executor(|exec| {
10228 // flags=0 (not setsparam's ASSPM_WARN): VM-internal scratch —
10229 // must never surface as a WARN_CREATE_GLOBAL diagnostic inside
10230 // a user function running `{...} always {...}` (f-sy-h's
10231 // `_zsh_highlight` does exactly that under warncreateglobal).
10232 crate::ported::params::assignsparam(
10233 "__zshrs_try_block_saved_status",
10234 &endval.to_string(),
10235 0,
10236 );
10237 let _ = exec;
10238 // Mirror into paramtab so `${parameters[TRY_BLOCK_ERROR]}`
10239 // and the PM_INTEGER `u_val` shadow agree with the atomic
10240 // the special-var getter reads. (setsparam → intsetfn's
10241 // TRY_BLOCK_ERROR arm re-stores the same value.)
10242 exec.set_scalar("TRY_BLOCK_ERROR".to_string(), try_err.to_string());
10243 exec.set_scalar("TRY_BLOCK_INTERRUPT".to_string(), try_int.to_string());
10244 });
10245 // c:Src/loop.c:768 — `errflag = 0;` ("We need to reset all
10246 // errors to allow the block to execute"). C clears the WHOLE
10247 // word, not just ERRFLAG_ERROR.
10248 crate::ported::utils::errflag.store(0, Ordering::Relaxed); // c:768
10249 Value::Status(0)
10250 });
10251
10252 // BUILTIN_BEGIN_INLINE_ENV / END_INLINE_ENV — wrap an
10253 // inline-assignment-prefixed command (`X=foo Y=bar cmd`):
10254 // BEGIN pushes a save frame; SET_VAR fires for each assign and
10255 // ALSO env::set_var's the value (visible to cmd's child); the
10256 // command runs; END pops the frame and restores both shell-var
10257 // and process-env state. Direct port of zsh's addvars() →
10258 // execute_simple → restore-after-exec contract.
10259 vm.register_builtin(BUILTIN_BEGIN_INLINE_ENV, |vm, argc| {
10260 // c:Src/exec.c:4114-4126 — whether the frame RECORDS anything is
10261 // `do_save`:
10262 // if (isset(POSIXBUILTINS)) {
10263 // if (is_shfunc || (hn->flags & (BINF_PSPECIAL|BINF_ASSIGN)))
10264 // do_save = (orig_cflags & BINF_COMMAND);
10265 // else
10266 // do_save = 1;
10267 // } else { ... }
10268 // if (do_save && varspc) save_params(...);
10269 // A frame is pushed either way so BEGIN/END stay balanced; an
10270 // empty save list simply restores nothing, which IS the
10271 // assignment persisting. POSIX.1-2017 XCU 2.9.1: "If the command
10272 // name is a special built-in utility, variable assignments shall
10273 // affect the current execution environment." Verified:
10274 // `dash|ksh|mksh -c 'v=0; v=1 :; printf "[%s]\n" "$v"'` → `[1]`,
10275 // and `v=2 true` → `[1]` because `true` is NOT special.
10276 //
10277 // The name arrives as a compile-time constant (empty when the
10278 // command word is an expansion, which takes the save arm).
10279 let name = if argc >= 1 {
10280 vm.pop().to_str()
10281 } else {
10282 String::new()
10283 };
10284 let mut frame = crate::vm_helper::InlineEnvFrame::new();
10285 // !!! bash EXCEPTION — bash(1), "POSIX Mode": "Assignment
10286 // statements preceding POSIX special builtins persist in the shell
10287 // environment after the builtin completes." bash does this ONLY in
10288 // posix mode, so default `--bash` keeps zsh's save/restore
10289 // (`bash -c 'v=0; v=1 :; printf "[%s]\n" "$v"'` → `[0]`, while
10290 // dash / ksh93 / mksh / bash-as-sh all print `[1]`). zshrs tracks
10291 // bash's `set -o posix` in dash_mode::BASH_ONLY_OPTS, so honor it.
10292 let bash_suppresses =
10293 crate::dash_mode::bash_mode() && !crate::dash_mode::bash_set_o_get("posix");
10294 if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
10295 && !name.is_empty()
10296 && !bash_suppresses
10297 {
10298 // c:4123 `do_save = (orig_cflags & BINF_COMMAND)` — the
10299 // `command` prefix is BINF_COMMAND (c:Src/builtin.c:44
10300 // `BIN_PREFIX("command", BINF_COMMAND)`) and resets the
10301 // behavior, so it keeps the save.
10302 let is_command_prefix = name == "command";
10303 // !!! dash / pdksh EXCEPTION — C's `is_shfunc` leg is not
10304 // universal. `f(){ :; }; v=0; v=4 f` leaves `v` at 0 in dash,
10305 // ash AND mksh, while ksh93 and bash-as-sh (the `--sh`
10306 // reference) leave it at 4. Only the builtin legs are shared.
10307 let is_shfunc = !crate::dash_mode::dash_strict()
10308 && !crate::dash_mode::pdksh_family()
10309 && crate::ported::hashtable::shfunctab_lock()
10310 .read()
10311 .map(|t| t.get(&name).is_some())
10312 .unwrap_or(false);
10313 if !is_command_prefix
10314 && (is_shfunc || builtin_is_pspecial(&name) || builtin_is_assign_family(&name))
10315 {
10316 frame.recording = false; // c:4122-4123 do_save = 0
10317 }
10318 }
10319 // c:4137-4147 — the OTHER decision the command word drives, and the
10320 // one that has nothing to do with POSIX_BUILTINS:
10321 // /* Export this if the command is a shell function,
10322 // * but not if it's a builtin.
10323 // */
10324 // int flags = 0;
10325 // if (is_shfunc)
10326 // flags |= ADDVAR_EXPORT;
10327 // ...
10328 // addvars(state, varspc, flags);
10329 // `addvars` turns that bit into `opts[ALLEXPORT] = 1` around the
10330 // assignment (c:2641-2651), so `X=y shellfn` leaves `X` EXPORTED —
10331 // `${(t)X}` reads `scalar-export` inside the call and `export -p`
10332 // lists it — while `X=y eval …` leaves a plain `scalar`. Verified
10333 // against /opt/homebrew/bin/zsh: `f(){ print ${(t)Q} }; Q=y f` →
10334 // `scalar-export`, `Q=y eval 'print ${(t)Q}'` → `scalar`.
10335 //
10336 // The external-command leg (c:4343-4348 sets ADDVAR_EXPORT
10337 // unconditionally) runs in the FORKED child, after which nothing
10338 // reads the child's parameter table; the value reaches the child's
10339 // environment through the `zputenv` the set-var builtin already
10340 // does either way, so only the shell-function leg is observable and
10341 // only it is mirrored here.
10342 //
10343 // !!! KNOWN GAP — an EXPANDED command word (`c=f; X=y $c`) has no
10344 // compile-time literal, so `name` is the unexpanded text and the
10345 // lookup misses: the prefix assignment stays unexported where zsh
10346 // exports it. Same limitation, same cause as the POSIX_BUILTINS
10347 // `do_save` decision above (compile_zsh.rs:2133-2137) — C resolves
10348 // `hn` at runtime from the expanded word and the bridge is handed a
10349 // constant.
10350 frame.export = crate::ported::hashtable::shfunctab_lock()
10351 .read()
10352 .map(|t| t.get(&name).is_some())
10353 .unwrap_or(false); // c:4142-4143
10354 with_executor(|exec| {
10355 exec.inline_env_stack.push(frame);
10356 });
10357 Value::Status(0)
10358 });
10359 // Closes the frame's save list — see BUILTIN_SEAL_INLINE_ENV.
10360 vm.register_builtin(BUILTIN_SEAL_INLINE_ENV, |_vm, _argc| {
10361 with_executor(|exec| {
10362 if let Some(frame) = exec.inline_env_stack.last_mut() {
10363 frame.recording = false;
10364 }
10365 });
10366 // c:Src/exec.c:4147-4148 — `addvars(state, varspc, flags); if
10367 // (errflag) {` — see PREFIX_ASSIGN_FAILED.
10368 let failed = (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
10369 & crate::ported::zsh_h::ERRFLAG_ERROR)
10370 != 0;
10371 PREFIX_ASSIGN_FAILED.with(|c| c.set(failed));
10372 // The command word is known now unless it is an expansion, which
10373 // BUILTIN_EXEC_DYNAMIC settles.
10374 if let Some(name) = PREFIX_ASSIGN_CMD.with(|c| c.borrow_mut().take()) {
10375 if !name.is_empty() {
10376 settle_prefix_assign_exit(&name);
10377 }
10378 }
10379 Value::Status(0)
10380 });
10381 // See BUILTIN_PREFIX_ASSIGNS_START.
10382 vm.register_builtin(BUILTIN_PREFIX_ASSIGNS_START, |vm, argc| {
10383 let name = if argc >= 1 { vm.pop().to_str() } else { String::new() };
10384 PREFIX_ASSIGN_CMD.with(|c| *c.borrow_mut() = Some(name));
10385 PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.set(false));
10386 Value::Status(0)
10387 });
10388 vm.register_builtin(BUILTIN_END_INLINE_ENV, |_vm, _argc| {
10389 PREFIX_ASSIGN_FAILED.with(|c| c.set(false));
10390 // No dispatch resolved the command: the assignments ran in the shell.
10391 if PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.get()) {
10392 settle_prefix_assign_exit("");
10393 }
10394 with_executor(|exec| {
10395 if let Some(frame) = exec.inline_env_stack.pop() {
10396 frame.restore(); // c:Src/exec.c:4519 restore_params
10397 }
10398 });
10399 Value::Status(0)
10400 });
10401 // c:Src/exec.c:3969-3976 — bare-exec assignment epilogue: see the
10402 // const's doc block. POSIX_BUILTINS → assignments persist (pop the
10403 // frame, discard the saved state); otherwise → restore_params
10404 // (same walk as END_INLINE_ENV).
10405 vm.register_builtin(BUILTIN_EXEC_INLINE_ENV_DONE, |_vm, _argc| {
10406 let persist = isset(crate::ported::zsh_h::POSIXBUILTINS);
10407 with_executor(|exec| {
10408 if let Some(frame) = exec.inline_env_stack.pop() {
10409 if persist {
10410 return; // c:3971 — no save/restore under POSIX_BUILTINS
10411 }
10412 frame.restore(); // c:Src/exec.c:4519 restore_params
10413 }
10414 });
10415 Value::Status(0)
10416 });
10417
10418 // BUILTIN_RESTORE_TRY_BLOCK_STATUS — emitted at the end of an
10419 // `always` arm. Per zshmisc, the exit status of the entire
10420 // `{ try } always { finally }` construct is the try-list's
10421 // status, regardless of what happens in the always-list (the
10422 // exception is `return`/`exit` inside always, which short-
10423 // circuits and the cleanup is the only thing that runs). So
10424 // restore TRY_BLOCK_ERROR unconditionally — the always-list's
10425 // exit status is discarded for the construct.
10426 vm.register_builtin(BUILTIN_RESTORE_TRY_BLOCK_STATUS, |_vm, _argc| {
10427 use std::sync::atomic::Ordering;
10428 // c:Src/loop.c:801 — `return endval;`. The construct's exit
10429 // status is the try-list's (captured at c:755 by
10430 // SET_TRY_BLOCK_ERROR), never the always-list's.
10431 let saved = with_executor(|exec| {
10432 exec.scalar("__zshrs_try_block_saved_status")
10433 .and_then(|s| s.parse::<i32>().ok())
10434 .unwrap_or(0)
10435 });
10436 // c:Src/exec.c:1375 — `lastval = lv;` on the exectry return.
10437 // The always-list's own commands left their status in LASTVAL
10438 // (`always { : }` → 0); without this store the errflag re-raise
10439 // below aborts the shell with the always-list's 0 instead of
10440 // the try-list's failure status.
10441 crate::ported::builtin::LASTVAL.store(saved, Ordering::Relaxed); // c:1375
10442 // c:Src/loop.c:774-777 — the error RE-RAISE. This is the
10443 // whole point of TRY_BLOCK_ERROR being writable:
10444 //
10445 // if (try_errflag) errflag |= ERRFLAG_ERROR;
10446 // else errflag &= ~ERRFLAG_ERROR;
10447 // if (try_interrupt) errflag |= ERRFLAG_INT;
10448 // else errflag &= ~ERRFLAG_INT;
10449 //
10450 // SET_TRY_BLOCK_ERROR cleared errflag (c:768) so the always-arm
10451 // could run; the try-block's error is PARKED in `try_errflag`
10452 // and re-raised HERE unless the always-arm zeroed it
10453 // (`TRY_BLOCK_ERROR=0`, the documented swallow idiom — routed
10454 // to the atomic by intsetfn's IPDEF6 arm, params.rs).
10455 //
10456 // zshrs used to just drop the parked error, so
10457 // `f() { { typeset -r ro=1; ro=2 } always { … }; print reached }`
10458 // kept running and exited 0, where zsh aborts f with status 1.
10459 let te = crate::ported::r#loop::try_errflag.load(Ordering::Relaxed); // c:774
10460 let ti = crate::ported::r#loop::try_interrupt.load(Ordering::Relaxed); // c:776
10461 if te != 0 {
10462 crate::ported::utils::errflag
10463 .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
10464 // c:775
10465 } else {
10466 crate::ported::utils::errflag
10467 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
10468 // c:777
10469 }
10470 if ti != 0 {
10471 crate::ported::utils::errflag
10472 .fetch_or(crate::ported::zsh_h::ERRFLAG_INT, Ordering::Relaxed);
10473 // c:779
10474 } else {
10475 crate::ported::utils::errflag
10476 .fetch_and(!crate::ported::zsh_h::ERRFLAG_INT, Ordering::Relaxed);
10477 // c:781
10478 }
10479 // Re-apply the escape flags captured by SET_TRY_BLOCK_ERROR.
10480 // If the always-arm itself fired return/break/continue/exit,
10481 // its handler already overwrote the canonical atomics; let
10482 // those win — the always-arm's own escape always takes
10483 // priority over the try-block's deferred one.
10484 if let Some((ret, brk, cont, exit_p, try_err_save, try_int_save)) =
10485 TRY_ESCAPE_SAVE.with(|s| s.borrow_mut().pop())
10486 {
10487 // c:Src/loop.c:782-783 — `try_errflag = save_try_errflag;
10488 // try_interrupt = save_try_interrupt;`
10489 crate::ported::r#loop::try_errflag.store(try_err_save, Ordering::Relaxed); // c:782
10490 crate::ported::r#loop::try_interrupt.store(try_int_save, Ordering::Relaxed);
10491 // c:783
10492 if crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) == 0 {
10493 crate::ported::builtin::RETFLAG.store(ret, Ordering::Relaxed);
10494 }
10495 if crate::ported::builtin::BREAKS.load(Ordering::Relaxed) == 0 {
10496 crate::ported::builtin::BREAKS.store(brk, Ordering::Relaxed);
10497 }
10498 if crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed) == 0 {
10499 crate::ported::builtin::CONTFLAG.store(cont, Ordering::Relaxed);
10500 }
10501 if crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) == 0 {
10502 crate::ported::builtin::EXIT_PENDING.store(exit_p, Ordering::Relaxed);
10503 }
10504 }
10505 Value::Status(saved)
10506 });
10507
10508 // `[[ -r/-w/-x file ]]` — the cond path must use access(2) (the
10509 // C-faithful doaccess), NOT fusevm's generic Op::TestFile which only
10510 // checks existence for -r/-w (so a `chmod 000` file read as readable;
10511 // C02cond.ztst:13). Stack: [path, mode]; mode is the access(2) bit
10512 // (R_OK=4, W_OK=2, X_OK=1). Mirrors cond.rs:232/238/267 `doaccess`.
10513 vm.register_builtin(BUILTIN_COND_ACCESS, |vm, _argc| {
10514 let mode = vm.pop().to_int() as i32;
10515 let path = vm.pop().to_str();
10516 Value::Bool(crate::ported::cond::doaccess(&path, mode) != 0)
10517 });
10518
10519 // `[[ -prefix PAT ]]` / `-suffix` / `-after` / `-between` module condition.
10520 // Stack (pushed by the ModCond compile arm): arg0 … argN-1, then the
10521 // operator word last. argc = N+1.
10522 // c:Src/subst.c:4419-4420 `if (globsubst) shtokenize(y)` — see the
10523 // BUILTIN_COND_SHTOKENIZE doc for why a module condition needs it.
10524 vm.register_builtin(BUILTIN_COND_SHTOKENIZE, |vm, _argc| {
10525 let mut s = vm.pop().to_str();
10526 crate::ported::glob::shtokenize(&mut s);
10527 Value::str(s)
10528 });
10529
10530 vm.register_builtin(BUILTIN_COND_MOD, |vm, argc| {
10531 use crate::ported::zle::complete::{cond_psfix, cond_range, CVT_PREPAT, CVT_SUFPAT};
10532 let op = vm.pop().to_str(); // operator word (pushed last → popped first)
10533 let n = (argc as usize).saturating_sub(1);
10534 let mut args: Vec<String> = Vec::with_capacity(n);
10535 for _ in 0..n {
10536 args.push(vm.pop().to_str());
10537 }
10538 args.reverse(); // restore arg0 … argN-1 order
10539 // Dispatch the module/completion condition (C evalcond COND_MOD path:
10540 // condtab lookup + arity check, cond.c:149-185, over the four cotab[]
10541 // entries at complete.c:1697-1702). Handlers return 1=match/true.
10542 let name: String = op
10543 .trim_start_matches(|c: char| c == '-' || c == '\u{e19b}')
10544 .to_string();
10545 // c:Src/cond.c:149-150 — `cd = getconddef((ctype == COND_MODI),
10546 // name + 1, 1)`. The `autol = 1` argument is what makes an
10547 // autoloadable condition LOAD its module: `getconddef`
10548 // (Src/module.c:647) sees the `c:`-stub's `p->module` and calls
10549 // `ensurefeature(p->module, "c:", name)`, then re-looks-up the
10550 // now-real definition that `zsh/complete`'s cotab installed.
10551 // Without this call zshrs answered `[[ -prefix … ]]` straight
10552 // from the compiled-in handler table and left `zsh/complete`
10553 // unloaded, where `zsh -f` reports it loaded after the first use.
10554 // `try_lock`: every other MODULESTAB caller holds the same mutex
10555 // for a moment and the load chain re-enters it; falling through
10556 // to the compiled-in table is the safe outcome, not a deadlock.
10557 let cd = match crate::ported::module::MODULESTAB.try_lock() {
10558 Ok(mut tab) => crate::ported::module::getconddef(0, &name, 1, &mut tab), // c:150
10559 Err(_) => None,
10560 };
10561 // c:151-155 — arity check against the conddef's own min/max
10562 // (`if (l < cd->min || (cd->max >= 0 && l > cd->max))`). The
10563 // fallback pins the same numbers the cotab rows carry
10564 // (complete.c:1698-1701) for the window before zsh/complete is
10565 // loaded, when `getconddef` has only the module-less stub.
10566 let (min, max): (usize, usize) = match cd.as_ref() {
10567 Some(c) if c.max >= 0 => (c.min.max(0) as usize, c.max as usize), // c:152
10568 _ => match name.as_str() {
10569 "prefix" | "suffix" => (1, 2), // c:1700-1701
10570 "after" => (1, 1), // c:1698
10571 "between" => (2, 2), // c:1699
10572 _ => {
10573 // c:Src/cond.c:186-193 — no conddef matched, so C falls out
10574 // of both `getconddef` arms to `zwarnnam(fromtest, "unknown
10575 // condition: %s", errname)` and then `return 2;` (the
10576 // "module not found, error" exit). Status 2 — not 1 — is
10577 // what `evalcond` hands back, and c:Src/exec.c:5216-5221
10578 // turns a 2 into a shell error. Arm the same carrier
10579 // BUILTIN_COND_UNKNOWN uses so the shared
10580 // BUILTIN_COND_STATUS_FROM_BOOL tail emits 2 and aborts;
10581 // returning a bare Bool(false) collapsed it to 1, so
10582 // `[[ -zz a ]]` exited 1 where zsh exits 2.
10583 COND_BAD_PATTERN.with(|c| c.set(true)); // c:193
10584 crate::ported::utils::zerr(&format!(
10585 "unknown condition: {}",
10586 op.replace('\u{e19b}', "-")
10587 ));
10588 return Value::Bool(false);
10589 }
10590 },
10591 };
10592 if args.len() < min || args.len() > max {
10593 // c:Src/cond.c:177-181 — `if (l < cd->min || (cd->max >= 0 &&
10594 // l > cd->max)) { zwarnnam(fromtest, "unknown condition: %s",
10595 // errname); return 2; }`. Status 2, same as the module-not-found
10596 // arm above.
10597 //
10598 // This arm previously refused to arm the carrier because the
10599 // PARSER turned a zero-operand `-word` into a ModCond, so
10600 // `[[ -prefix ]]` would have exited 2 where zsh exits 0. That
10601 // parser bug is fixed (src/ported/parse.rs par_cond_2: a
10602 // multi-char `-word` with no operand now takes c:2590's
10603 // `par_cond_double("-n", s1)` string-test arm, and a two-char
10604 // one takes c:2592's `par_cond_multi(s1, newlinklist())`), so
10605 // the only way to reach here is a genuine arity violation —
10606 // `[[ -between a ]]`, which zsh answers 2.
10607 COND_BAD_PATTERN.with(|c| c.set(true)); // c:180
10608 crate::ported::utils::zerr(&format!(
10609 "unknown condition: {}",
10610 op.replace('\u{e19b}', "-")
10611 ));
10612 return Value::Bool(false);
10613 }
10614 // c:158 — `return !cd->handler(strs, cd->condid);`
10615 let r = match cd.as_ref().and_then(|c| c.handler.map(|h| (h, c.condid))) {
10616 Some((handler, condid)) => handler(&args, condid),
10617 // Pre-load window: zsh/complete's cotab is not installed yet,
10618 // so dispatch through the compiled-in handlers directly.
10619 None => match name.as_str() {
10620 "prefix" => cond_psfix(&args, CVT_PREPAT),
10621 "suffix" => cond_psfix(&args, CVT_SUFPAT),
10622 "after" => cond_range(&args, 0),
10623 "between" => cond_range(&args, 1),
10624 _ => 0,
10625 },
10626 };
10627 Value::Bool(r == 1)
10628 });
10629
10630 vm.register_builtin(BUILTIN_IS_TTY, |vm, _argc| {
10631 let fd_str = vm.pop().to_str();
10632 let fd: i32 = fd_str.trim().parse().unwrap_or(-1);
10633 let is_tty = if fd < 0 {
10634 false
10635 } else {
10636 unsafe { libc::isatty(fd) != 0 }
10637 };
10638 Value::Bool(is_tty)
10639 });
10640
10641 // c:Src/exec.c:4918/5040/5069 — a process substitution used inside a
10642 // `[[ … ]]` cond operand errors "process substitution %s cannot be
10643 // used here" (getoutputfile/getproc run with thisjob == -1). Emitted
10644 // by the compiler in place of ProcessSubIn/Out when in_cond_operand.
10645 vm.register_builtin(BUILTIN_PROCSUB_COND_ERROR, |_vm, _argc| {
10646 let cmd = _vm.pop().to_str();
10647 crate::ported::utils::zerr(&format!("process substitution {} cannot be used here", cmd));
10648 // c:getoutputfile returns NULL with errflag set → the enclosing
10649 // statement aborts (empty stdout, exit 1), rather than the cond
10650 // merely evaluating false.
10651 crate::ported::utils::errflag.fetch_or(
10652 crate::ported::zsh_h::ERRFLAG_ERROR,
10653 std::sync::atomic::Ordering::Relaxed,
10654 );
10655 with_executor(|exec| exec.set_last_status(1));
10656 _vm.last_status = 1;
10657 Value::str("")
10658 });
10659
10660 // Set $LINENO before executing the next statement. Direct
10661 // port of zsh's `lineno` global tracking from Src/input.c
10662 // (`if ((inbufflags & INP_LINENO) || !strin) && c == '\n')
10663 // lineno++;`). The compiler emits one of these before each
10664 // top-level pipe in `compile_sublist`, carrying the line
10665 // number captured by the parser at `ZshPipe.lineno`. Pops
10666 // [n], updates `$LINENO` in the variable table.
10667 vm.register_builtin(BUILTIN_SET_LINENO, |vm, _argc| {
10668 let n = vm.pop().to_int();
10669 set_lineno_impl(n)
10670 });
10671
10672 // Direct port of Src/prompt.c:1623 cmdpush. Token is a `CS_*`
10673 // value (zsh.h:2775-2806) emitted by compile_zsh around each
10674 // compound command (if/while/[[…]]/((…))/$(…)) and consumed by
10675 // `%_` in PS4 / prompt expansion.
10676 vm.register_builtin(BUILTIN_CMD_PUSH, |vm, _argc| {
10677 let token = vm.pop().to_int() as u8;
10678 // Route through canonical cmdpush (Src/prompt.c:1623). The
10679 // prompt expander reads from the file-static `CMDSTACK` at
10680 // `prompt.rs:2006`, not `exec.cmd_stack` — without this,
10681 // `%_` in PS4 saw an empty stack during xtrace.
10682 if (token as i32) < crate::ported::zsh_h::CS_COUNT {
10683 crate::ported::prompt::cmdpush(token);
10684 }
10685 Value::Status(0)
10686 });
10687
10688 // Direct port of Src/prompt.c:1631 cmdpop.
10689 vm.register_builtin(BUILTIN_CMD_POP, |_vm, _argc| {
10690 crate::ported::prompt::cmdpop();
10691 Value::Status(0)
10692 });
10693
10694 vm.register_builtin(BUILTIN_OPTION_SET, |vm, _argc| {
10695 let name = vm.pop().to_str();
10696 // Direct port of `optison(char *name, char *s)` at Src/cond.c:502 — `[[ -o NAME ]]`
10697 // reads through the same `opts[]` array that `setopt NAME`
10698 // writes via `dosetopt`. Earlier code read a duplicate Executor
10699 // HashMap which never saw `bin_setopt`'s writes (those land in
10700 // `OPTS_LIVE` via `opt_state_set`). Routing through the canonical
10701 // C port restores the single-store invariant: one `opts[]`,
10702 // shared between setopt/unsetopt and `[[ -o ]]`.
10703 let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
10704 match r {
10705 0 => Value::Bool(true), // c:cond.c:520 set
10706 1 => Value::Bool(false), // c:cond.c:518/520 unset
10707 _ => {
10708 // c:cond.c:514 — unknown option. optison already emitted the
10709 // diagnostic (via zwarn, now that fromtest=NULL for
10710 // `[[ -o ]]`); re-printing here double-emitted for
10711 // `[[ ! -o bad ]]` / `[[ -o a || -o b ]]`.
10712 Value::Bool(false)
10713 }
10714 }
10715 });
10716 // Tri-state `-o` for compile_cond's direct status path. Returns
10717 // 0 / 1 / 3 as a Value::Int that compile_cond consumes via
10718 // Op::SetStatus. Mirrors zsh's `[[ -o invalid ]]` returning $?=3.
10719 vm.register_builtin(BUILTIN_OPTION_CHECK_TRISTATE, |vm, _argc| {
10720 let name = vm.pop().to_str();
10721 let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
10722 // optison itself prints the diagnostic via zwarnnam when r=3
10723 // and POSIXBUILTINS is unset (the canonical path). Don't
10724 // double-emit here. r is already 0/1/3.
10725 Value::Int(r as i64)
10726 });
10727
10728 // BUILTIN_PARAM_FILTER — `${var:#pat}` / `${var:|name}` etc.
10729 // PURE PASSTHRU: rebuild `${name:#pat}` and route to paramsubst.
10730 vm.register_builtin(BUILTIN_PARAM_FILTER, |vm, argc| {
10731 // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
10732 // compile_zsh::emit_param_modifier (see the note there).
10733 let pf_flags = if argc as usize > 2 && vm.pop().to_int() != 0 {
10734 crate::ported::zsh_h::PREFORK_SINGLE
10735 } else {
10736 0
10737 };
10738 let pattern = vm.pop().to_str();
10739 let name = vm.pop().to_str();
10740 let body = format!("${{{}:#{}}}", name, pattern);
10741 paramsubst_to_value_status(vm, &body, pf_flags)
10742 });
10743
10744 // `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()`
10745 // — subscripted-array assign with array RHS. Stack pushed by
10746 // compile_assign as: [elem0, elem1, …, elemN-1, name, key].
10747 vm.register_builtin(BUILTIN_SET_SUBSCRIPT_RANGE, |vm, argc| {
10748 let n = argc as usize;
10749 let mut popped: Vec<Value> = Vec::with_capacity(n);
10750 for _ in 0..n {
10751 popped.push(vm.pop());
10752 }
10753 popped.reverse();
10754 if popped.len() < 3 {
10755 return Value::Status(1);
10756 }
10757 // c:Src/params.c:3511-3526 — trailing append flag. The ARRAY
10758 // subscript path (`a[N]+=(v)` / `a[lo,hi]+=(v)`) sets it so the
10759 // AUGMENT transform below collapses the range to an empty range
10760 // after the slice end and inserts ONLY the new value. The scalar
10761 // path pre-concats the old slice (ARRAY_INDEX+Concat) and passes
10762 // 0, so it keeps plain-replace semantics.
10763 // The trailing marker is 0/1 for the ARRAY-RHS emitter and 2 for
10764 // the SCALAR-RHS comma emitter (compile_zsh::compile_assign),
10765 // which also pushes the SOURCE subscript just below it. The
10766 // scalar form is the one C does NOT necessarily treat as a
10767 // range: c:Src/params.c:1515 `(c != Outbrack && (ishash || c !=
10768 // ','))` stops the comma from separating subscripts when the
10769 // parameter is a hash, so `h[1,2]=Z` is the ordinary key `1,2`.
10770 // The compile-time split cannot know the type, so it defers here.
10771 let marker = popped.pop().map_or(String::new(), |v| v.to_str());
10772 // Marker 3 is the scalar-RHS form of `+=` (old slice already
10773 // concatenated in, like 2); it differs only for a numeric target.
10774 let scalar_append = marker == "3";
10775 let scalar_rhs = marker == "2" || scalar_append; // c:1515 deferral
10776 let key_src = if scalar_rhs {
10777 popped.pop().map(|v| v.to_str())
10778 } else {
10779 None
10780 };
10781 let append = marker == "1";
10782 let key = popped.pop().unwrap().to_str();
10783 let name = popped.pop().unwrap().to_str();
10784 // c:Src/exec.c:4475-4478 (54941) — a subscripted prefix assignment
10785 // (`H[2,4]=oo f`) saves its base parameter for restore_params.
10786 with_executor(|exec| {
10787 if exec
10788 .inline_env_stack
10789 .last()
10790 .is_some_and(|frame| frame.recording)
10791 {
10792 save_inline_prefix_param(exec, &name);
10793 }
10794 });
10795 // c:Src/params.c:1585-1592 — `if (needtok) { parsestr(&s);
10796 // singsub(&s); }`: the subscript body is parameter-substituted
10797 // BEFORE it is read, whether it goes on to `mathevalarg`
10798 // (c:1601, the array/scalar range bounds) or straight into the
10799 // hash as a key (c:1596-1616). The ARRAY-RHS emitter pre-expands
10800 // its subscript at word-compile time, but the SCALAR-RHS comma
10801 // emitter hands over the raw source, so this round has to happen
10802 // here: `mathevali("$n")` is 0, which silently turned
10803 // `a=abcdef; n=3; a[$n,-1]=X` into a whole-string overwrite
10804 // (`X` instead of `abX`), and it is why the fzf-tab
10805 // `t[$#MATCH/2+1,-1]=""` form still lost its bound.
10806 //
10807 // Only for a source-level LIVE expansion: an escaped `\$`
10808 // reached `parsestr` as the Bnull marker and `singsub` leaves it
10809 // alone, so `h[\$x,y]` keys on the literal `$x,y`.
10810 let key = if scalar_rhs
10811 && key_src.as_deref().is_some_and(|src| {
10812 let b = src.as_bytes();
10813 (0..b.len())
10814 .any(|i| (b[i] == b'$' || b[i] == b'`') && (i == 0 || b[i - 1] != b'\\'))
10815 }) {
10816 crate::ported::subst::singsub(&key) // c:1592
10817 } else {
10818 key
10819 };
10820 let mut values: Vec<String> = Vec::new();
10821 for v in popped {
10822 match v {
10823 Value::Array(items) => {
10824 for it in items.iter() {
10825 values.push(it.to_str());
10826 }
10827 }
10828 other => values.push(other.to_str()),
10829 }
10830 }
10831 // c:Src/params.c:3316/3365 — assignaparam's subscript arm finds the
10832 // parameter through getvalue/fetchvalue, whose getparamnode
10833 // (c:570-575) follows a PM_NAMEREF chain to its endpoint, so
10834 // `ptr[2,4]=(x y)` splices the array `ptr` names. Everything below
10835 // looks `name` up directly and spliced the reference itself ("ptr:
10836 // attempt to assign array value to non-array"). A chain ending on a
10837 // visible parameter is that parameter's name; any other outcome
10838 // (subscripted refname, hidden binding, placeholder, loop) goes to
10839 // the ported assignaparam, which resolves the chain itself.
10840 let name = if crate::ported::params::is_nameref(&name) {
10841 use crate::ported::params::nameref_resolution as NR;
10842 let visible = |t: &str, level: i32, has_pm: bool| {
10843 !has_pm
10844 || crate::ported::params::paramtab()
10845 .read()
10846 .ok()
10847 .and_then(|tb| tb.get(t).map(|p| p.level))
10848 == Some(level)
10849 };
10850 match crate::ported::params::resolve_nameref_name(&name, None) {
10851 NR::Target { name: t, subscript: None, pm, level } if visible(&t, level, pm.is_some()) => t,
10852 _ if !scalar_rhs => {
10853 let asspm = if append { crate::ported::zsh_h::ASSPM_AUGMENT } else { 0 };
10854 let done =
10855 crate::ported::params::assignaparam(&format!("{name}[{key}]"), values, asspm);
10856 return Value::Status(if done.is_some() { 0 } else { 1 });
10857 }
10858 _ => name,
10859 }
10860 } else {
10861 name
10862 };
10863 // Bash sparse-array tracking: a single-index `a[i]=v` that pads the
10864 // dense Vec past its old end leaves indices old_len..i as HOLES (not
10865 // real elements), so `${#a[@]}`/`${!a[@]}` skip them like bash. Only
10866 // in bash mode, only for a plain non-append single index (0-based
10867 // under ksharrays). Captured before the assign; applied after.
10868 let sparse_track: Option<(String, usize, usize)> =
10869 if crate::dash_mode::sparse_arrays() && !append && !key.contains(',') {
10870 key.trim().parse::<usize>().ok().map(|i| {
10871 let old_len =
10872 with_executor(|exec| exec.array(&name).map(|a| a.len()).unwrap_or(0));
10873 (name.clone(), old_len, i)
10874 })
10875 } else {
10876 None
10877 };
10878 // c:Src/params.c:3383-3389 — a subscripted ARRAY assignment to an
10879 // associative array is an error, whatever the subscript looks like:
10880 // if (v && PM_TYPE(v->pm->node.flags) == PM_HASHED) {
10881 // unqueue_signals();
10882 // zerr("%s: attempt to set slice of associative array",
10883 // v->pm->node.nam);
10884 // freearray(val);
10885 // errflag |= ERRFLAG_ERROR;
10886 // return NULL;
10887 // }
10888 // assignaparam (params.rs) ports this, but a single-key `h[k]=(1 2)`
10889 // never reaches it: the VM lowers subscripted assignment to this
10890 // builtin instead. The comma form `h[a,b]=(1 2)` DID error — it takes a
10891 // different route — so the gap looked like a subscript-parsing quirk
10892 // when it was really "the check lives on a path this form doesn't
10893 // take". Untreated, the assignment was SILENTLY DISCARDED: rc=0 and
10894 // `${h[k]}` still read its old value.
10895 //
10896 // Only array-valued assignment is rejected; `h[k]=x` is a scalar
10897 // element store and stays legal.
10898 {
10899 let is_hashed = crate::ported::params::paramtab()
10900 .read()
10901 .ok()
10902 .and_then(|t| {
10903 t.get(&name).map(|pm| {
10904 crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
10905 == crate::ported::zsh_h::PM_HASHED
10906 })
10907 })
10908 .unwrap_or(false);
10909 if is_hashed {
10910 if scalar_rhs {
10911 // c:1515 — for a hash the comma is not a subscript
10912 // separator, so this was never a range: hand the
10913 // WHOLE subscript to the element path. `h[1,2]=Z`
10914 // keys on `1,2` in zsh; rejecting it here was the
10915 // compile-time range split leaking through.
10916 let src = key_src.unwrap_or_else(|| key.clone());
10917 let val = values.first().cloned().unwrap_or_default();
10918 return Value::Status(assign_hash_element(&name, &key, &src, &val));
10919 }
10920 crate::ported::utils::zerr(&format!(
10921 "{name}: attempt to set slice of associative array" // c:3385
10922 ));
10923 crate::ported::utils::errflag.fetch_or(
10924 crate::ported::zsh_h::ERRFLAG_ERROR,
10925 std::sync::atomic::Ordering::Relaxed,
10926 ); // c:3387
10927 return Value::Status(1); // c:3388
10928 }
10929 }
10930
10931 with_executor(|exec| {
10932 // Parse subscript: slice `lo,hi` or single index `i`.
10933 // setarrvalue (Src/params.c:2895) expects 1-based start/
10934 // end inclusive where start==end means replace one
10935 // element. Negative bounds translate to len+n+1 (1-based).
10936 //
10937 // c:Src/params.c — the END side accepts 0 as a valid value
10938 // that signals "insert BEFORE start position" (the canonical
10939 // `a[N,N-1]=val` prepend / mid-insert idiom). Bug #275 in
10940 // docs/BUGS.md: the previous Rust port clamped end up to 1,
10941 // collapsing `a[1,0]=(X Y)` into `a[1,1]=(X Y)` which
10942 // OVERWRITES position 1 instead of prepending. Provide two
10943 // translators — start_translate clamps to 1 (1-based);
10944 // end_translate keeps 0 intact so the splice in
10945 // setarrvalue (start_idx=0..end_idx=0) inserts at the front.
10946 // Bug #589: for scalars (no array), use the scalar's char
10947 // count as `len` so negative-index translation (`a[2,-1]`)
10948 // computes against the actual string length, not 0.
10949 let len = exec
10950 .array(&name)
10951 .map(|a| a.len() as i64)
10952 .or_else(|| {
10953 crate::ported::params::paramtab().read().ok().and_then(|t| {
10954 t.get(&name).and_then(|pm| {
10955 if crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
10956 == crate::ported::zsh_h::PM_SCALAR
10957 {
10958 pm.u_str.as_ref().map(|s| s.chars().count() as i64)
10959 } else {
10960 None
10961 }
10962 })
10963 })
10964 })
10965 .unwrap_or(0);
10966 let start_translate = |raw: i64| -> i32 {
10967 if raw < 0 {
10968 (len + raw + 1).max(1) as i32
10969 } else {
10970 raw.max(1) as i32
10971 }
10972 };
10973 let end_translate = |raw: i64| -> i32 {
10974 if raw < 0 {
10975 (len + raw + 1).max(0) as i32
10976 } else {
10977 raw.max(0) as i32
10978 }
10979 };
10980 // c:Src/params.c — KSH_ARRAYS option flips array subscripts
10981 // from 1-based to 0-based. setarrvalue expects 1-based
10982 // inclusive bounds, so under KSH_ARRAYS we shift positive
10983 // inputs by +1 before translation. Negative bounds left
10984 // alone (count from end). Sibling of #610/#611/#612.
10985 // Bug #613.
10986 let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
10987 let ksh_shift = |raw: i64| -> i64 {
10988 if ksh_arrays && raw >= 0 {
10989 raw + 1
10990 } else {
10991 raw
10992 }
10993 };
10994 // c:Src/params.c getindex — subscript bounds are MATH
10995 // expressions, not bare integers: `a[(( ${#a}+1 ))]=(x)`,
10996 // `a[n+1]=(x)`. Plain `parse::<i64>()` returned 0 on any
10997 // arithmetic subscript, which the `i == 0` guard turned into
10998 // a silent no-op (computed-index append never landed). Parse
10999 // the literal fast-path first, then fall back to mathevali
11000 // (which handles `(( ))` grouping, var refs, and operators).
11001 // c:Src/params.c:2036 + c:2118 — getindex resolves EACH range
11002 // bound with `getarg`, not with mathevalarg directly, so a bound
11003 // may be a `(r)`/`(R)`/`(i)`/`(I)`/`(k)`/`(K)` SEARCH rather than
11004 // an arithmetic expression. On an array getarg's search arm
11005 // (c:1672-1719) returns the 1-BASED INDEX `r` of the match — the
11006 // value-vs-index distinction only sets `*inv`, which getindex
11007 // rejects for a range START (c:2121) and otherwise ignores. So map
11008 // the VALUE-returning direction letters onto their INDEX twins and
11009 // read the position back. Without this both bounds evaluated to 0
11010 // through mathevali and `array[(R)nomatch,(r)nomatch]=(…)` became
11011 // a front INSERT instead of the whole-array replace zsh performs.
11012 let cur_arr: Vec<String> = exec.array(&name).unwrap_or_default();
11013 let eval_bound = |s: &str| -> i64 {
11014 let t = s.trim();
11015 if let Some(rest) = t.strip_prefix('(') {
11016 if let Some(close) = rest.find(')') {
11017 let grp = &rest[..close];
11018 if !grp.is_empty()
11019 && grp
11020 .chars()
11021 .all(|c| matches!(c, 'r' | 'R' | 'i' | 'I' | 'k' | 'K' | 'e'))
11022 {
11023 // c:1394-1410 — r/k select the FIRST match, R/K the
11024 // LAST; i/I are the same searches returning the index.
11025 let mapped: String = grp
11026 .chars()
11027 .map(|c| match c {
11028 'r' | 'k' => 'i',
11029 'R' | 'K' => 'I',
11030 other => other,
11031 })
11032 .collect();
11033 let expr = format!("({}){}", mapped, &rest[close + 1..]);
11034 if let Some(crate::ported::params::getarg_out::Value(v)) =
11035 crate::ported::params::getarg(&expr, Some(&cur_arr), None, None)
11036 {
11037 return v.to_str().trim().parse::<i64>().unwrap_or(0);
11038 }
11039 }
11040 }
11041 }
11042 t.parse::<i64>()
11043 .ok()
11044 .or_else(|| crate::ported::math::mathevali(t).ok())
11045 .unwrap_or(0)
11046 };
11047 let (raw_start, raw_end) = if let Some((s_str, e_str)) = key.split_once(',') {
11048 (ksh_shift(eval_bound(s_str)), ksh_shift(eval_bound(e_str)))
11049 } else {
11050 let i = ksh_shift(eval_bound(&key));
11051 (i, i)
11052 };
11053 // c:Src/params.c:2124-2151 (getindex) — `start == 0 && end == 0`
11054 // is the range entirely off the START of the index range. With
11055 // KSH_ZERO_SUBSCRIPT it degrades to "the first element"
11056 // (`end = startnextlen;` c:2140, i.e. the 0-based range [0,1)).
11057 // Without it the range is flagged VALFLAG_EMPTY (c:2148) and
11058 // setarrvalue (c:2910) rejects the assignment with
11059 // "assignment to invalid subscript range". zshrs silently
11060 // returned for `a[0]=(x)` and front-INSERTED for `a[0,0]=(x)`.
11061 let mut valflags = 0i32;
11062 let (raw_start, raw_end) = if raw_start == 0 && raw_end == 0 {
11063 if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT) {
11064 (1, 1) // c:2140 — first element, in the 1-based convention below
11065 } else {
11066 valflags |= crate::ported::zsh_h::VALFLAG_EMPTY; // c:2148
11067 (-1, 0) // c:2149 — bounds unused; setarrvalue errors first
11068 }
11069 } else {
11070 (raw_start, raw_end)
11071 };
11072 // c:Src/params.c:2114 (getindex) — a SINGLE subscript is the
11073 // range `[i, i]`: `end = we ? we : start;` sets BOTH bounds to
11074 // the same RAW user index, and only the START is then shifted
11075 // down by one (`if (start > 0) start -= startprevlen;` c:2120).
11076 // setarrvalue (c:2944-2953) afterwards resolves each bound
11077 // INDEPENDENTLY — `start += len` clamped at 0, `end += len + 1`
11078 // clamped at 0 — so an out-of-range negative index such as
11079 // `a[-10]` on a 3-element array yields start==end==0, an EMPTY
11080 // range at the front that INSERTS. Running the end bound of a
11081 // single subscript through `start_translate` (which floors at 1)
11082 // instead collapsed that to [0,1) and OVERWROTE element 1:
11083 // `a=(some sunny day); a[-10]=(we'll meet again)` dropped "some"
11084 // (Test/D04parameter.ztst:1420 "Out of range negative array
11085 // subscripts"). Both forms therefore share one translator pair.
11086 let (start, end) = (start_translate(raw_start), end_translate(raw_end));
11087 // c:Src/params.c:3518-3520 (assignaparam ASSPM_AUGMENT) — a
11088 // subscripted `+=` to an array does NOT prepend the old slice;
11089 // it collapses the range to an EMPTY range positioned right
11090 // AFTER the slice end (`v->start = v->end--`) and splices in
11091 // ONLY the new value: `a[2]+=(d)` on (a b c) → (a b d c);
11092 // `a[2,3]+=(x)` on (1 2 3 4) → (1 2 3 x 4). In setarrvalue's
11093 // 1-based convention here that means start = end+1 (so
11094 // start_idx == end_idx == end → splice arr[end..end]).
11095 let (start, end) = if append && end > 0 {
11096 (end + 1, end)
11097 } else {
11098 (start, end)
11099 };
11100 // c:Src/params.c:392-430 IPDEF9("argv"/"@"/"*", &pparams) —
11101 // the positional parameters live in the `pparams` vector, NOT
11102 // paramtab, so a subscript splice (`argv[2]=(X Y Z)`,
11103 // `2=(X Y Z)`) must read/write pparams. Splice a synthetic
11104 // array param holding the current positionals via the
11105 // canonical setarrvalue, then store the result back to
11106 // pparams — mirroring assignaparam's argv/@/* special-case
11107 // (params.rs:6937) for the whole-array form.
11108 if name == "argv" || name == "@" || name == "*" {
11109 let mut pm = {
11110 crate::ported::params::createparam(
11111 &name,
11112 crate::ported::zsh_h::PM_ARRAY as i32,
11113 );
11114 crate::ported::params::paramtab()
11115 .write()
11116 .ok()
11117 .and_then(|mut t| t.remove(&name))
11118 };
11119 if let Some(ref mut p) = pm {
11120 p.u_arr = Some(exec.pparams());
11121 }
11122 let mut v = crate::ported::zsh_h::value {
11123 pm,
11124 arr: Vec::new(),
11125 scanflags: 0,
11126 valflags,
11127 start,
11128 end,
11129 };
11130 crate::ported::params::setarrvalue(&mut v, values);
11131 let result = v.pm.and_then(|p| p.u_arr).unwrap_or_default();
11132 exec.set_pparams(result);
11133 return;
11134 }
11135 // Route through canonical setarrvalue (Src/params.c:2895).
11136 // It handles PM_READONLY rejection, PM_HASHED slice-error,
11137 // PM_ARRAY splice + bounds clamp + padding (c:2980+).
11138 let taken = match crate::ported::params::paramtab().write() {
11139 Ok(mut tab) => tab.remove(&name),
11140 Err(_) => None,
11141 };
11142 // c:Src/exec.c:2640 / getvalue(…, 1) — a subscript assignment to
11143 // a NONEXISTENT parameter auto-creates it. getindex/fetchvalue
11144 // with the create flag calls createparam(name, PM_ARRAY) so the
11145 // splice has an array to write into; `unset u; u[1,2]=(a z)`
11146 // then yields the array (a z). Without this, setarrvalue saw
11147 // v.pm == None and silently stored nothing. The single-index
11148 // scalar-value path (SET_ASSOC/SET_ARRAY_AT) already vivifies;
11149 // this brings the range/array-value path to parity.
11150 let taken = taken.or_else(|| {
11151 crate::ported::params::createparam(&name, crate::ported::zsh_h::PM_ARRAY as i32);
11152 crate::ported::params::paramtab()
11153 .write()
11154 .ok()
11155 .and_then(|mut t| t.remove(&name))
11156 });
11157 // c:Src/params.c:2748+ — PM_SCALAR with subscript range
11158 // SPLICES the value into the scalar's char string. Bug
11159 // #589: zshrs's slice handler always called setarrvalue,
11160 // erroring "attempt to assign array value to non-array"
11161 // for `a=hello; a[2,3]=XYZ`. Detect PM_SCALAR and route
11162 // through assignstrvalue (which does scalar splice via
11163 // the PM_SCALAR arm at params.rs:3709-3789).
11164 //
11165 // Only a SCALAR RHS takes that route: it is assignsparam
11166 // (c:Src/params.c:3183), whose assignstrvalue also covers an
11167 // integer/float target (c:2777 — the whole value is replaced,
11168 // `integer i=5; i[1,2]=3` gives 3). An ARRAY RHS is assignaparam
11169 // → setarrvalue, which rejects every non-array target
11170 // (c:2896-2900 "attempt to assign array value to non-array":
11171 // `s=abc; s[2]=(x y)`).
11172 let target_type = taken
11173 .as_ref()
11174 .map(|pm| crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32));
11175 let is_scalar = scalar_rhs
11176 && target_type.is_some_and(|t| {
11177 t & (crate::ported::zsh_h::PM_ARRAY | crate::ported::zsh_h::PM_HASHED) == 0
11178 });
11179 // c:Src/params.c:3250-3266 — `+=` on a subscripted integer/float:
11180 // case PM_INTEGER: case PM_EFLOAT: case PM_FFLOAT:
11181 // zsfree(val); unqueue_signals();
11182 // zerr("attempt to add to slice of a numeric variable");
11183 // return NULL;
11184 if scalar_append
11185 && target_type.is_some_and(|t| {
11186 t == crate::ported::zsh_h::PM_INTEGER
11187 || t == crate::ported::zsh_h::PM_EFLOAT
11188 || t == crate::ported::zsh_h::PM_FFLOAT
11189 })
11190 {
11191 if let (Some(pm), Ok(mut tab)) = (taken, crate::ported::params::paramtab().write()) {
11192 tab.insert(name, pm);
11193 }
11194 crate::ported::utils::zerr("attempt to add to slice of a numeric variable"); // c:3264
11195 return;
11196 }
11197 let mut v = crate::ported::zsh_h::value {
11198 pm: taken,
11199 arr: Vec::new(),
11200 scanflags: 0,
11201 valflags,
11202 start,
11203 end,
11204 };
11205 if is_scalar {
11206 // Scalar splice — concat values, route through
11207 // assignstrvalue which dispatches by PM_TYPE.
11208 // start_translate returns 1-based positions; assignstrvalue's
11209 // PM_SCALAR arm at params.rs:3735+ expects 0-based start
11210 // (chars before start are kept) and 0-based end-exclusive
11211 // (chars from end are kept). Convert: start-=1.
11212 if v.start > 0 {
11213 v.start -= 1;
11214 }
11215 let val: String = values.join("");
11216 crate::ported::params::assignstrvalue(Some(&mut v), Some(val), 0);
11217 } else {
11218 crate::ported::params::setarrvalue(&mut v, values);
11219 }
11220 // Write the mutated Param back to paramtab — setarrvalue
11221 // mutated v.pm in-place; the prior `tab.remove(&name)` at
11222 // the top of this handler took ownership, so we re-insert
11223 // here. setarrvalue + this re-insert IS the canonical
11224 // store (Src/params.c:2895). No further mirror needed.
11225 if let Some(pm) = v.pm {
11226 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
11227 tab.insert(name, pm);
11228 }
11229 }
11230 });
11231 if let Some((nm, old_len, i)) = sparse_track {
11232 crate::bash_arrays::note_subscript_set(&nm, old_len, i);
11233 }
11234 Value::Status(0)
11235 });
11236
11237 // BUILTIN_CONCAT_SPLICE — word-segment concat for an expansion whose
11238 // ARRAY shape survives into the word (`${arr[@]}`, `$@`, `${(@)a}`,
11239 // `${=v}`, slices). c:Src/subst.c:4245 `if (isarr)` gates the two
11240 // emit shapes and c:1663 `int plan9 = isset(RCEXPANDPARAM);` picks
11241 // between them at RUNTIME — so the option, not the compile-time
11242 // segment shape, decides splice-vs-cross-product here.
11243 vm.register_builtin(BUILTIN_CONCAT_SPLICE, |vm, _argc| {
11244 let rhs = vm.pop();
11245 let lhs = vm.pop();
11246 if plan9_active() {
11247 return concat_plan9_prov(lhs, rhs);
11248 }
11249 concat_splice_prov(lhs, rhs)
11250 });
11251
11252 // BUILTIN_CONCAT_DISTRIBUTE — word-segment concat. With
11253 // rcexpandparam (zsh option), distributes element-wise (cartesian
11254 // product). Default mode: joins arrays with IFS first char to a
11255 // single scalar before concat, matching zsh's default unquoted
11256 // and DQ semantics. Direct port of Src/subst.c sepjoin path
11257 // (line ~1813) which gates element-vs-join on the rc_expand_param
11258 // option, defaulting to join.
11259 // BUILTIN_CONCAT_DISTRIBUTE_FORCED — same shape as
11260 // CONCAT_DISTRIBUTE, but always cartesian-distributes when one
11261 // side is Array. Used for compile-time-detected explicit
11262 // distribution forms (`${^arr}` etc.) where the source flag
11263 // overrides the rcexpandparam option default.
11264 // `${^arr}` — RC_EXPAND_PARAM forced on by the flag. concat_plan9 carries
11265 // both halves of C's plan9 block: the c:4316-4350 cartesian emit AND the
11266 // c:4362 `uremnode` word deletion for an empty array. The DISTRIBUTE_FORCED
11267 // handler below cannot be reused: it is shared with `${(@)a}` / `${(f)v}` /
11268 // `${a[@]}`, which KEEP the word on empty (`x${(@)a}y` → `xy`).
11269 vm.register_builtin(BUILTIN_CONCAT_PLAN9, |vm, _argc| {
11270 let rhs = vm.pop();
11271 let lhs = vm.pop();
11272 concat_plan9_prov(lhs, rhs)
11273 });
11274
11275 // `${^^arr}` — RC_EXPAND_PARAM forced OFF (c:2553-2555 `plan9 = 0`). Every
11276 // other concat builtin re-checks plan9_active(), which is the OPTION, so
11277 // under `setopt rcexpandparam` they cross-product regardless of the flag.
11278 // Go straight to concat_splice — C's non-plan9 path (c:4366-4437).
11279 vm.register_builtin(BUILTIN_CONCAT_SPLICE_NOPLAN9, |vm, _argc| {
11280 let rhs = vm.pop();
11281 let lhs = vm.pop();
11282 concat_splice_prov(lhs, rhs)
11283 });
11284
11285 vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE_FORCED, |vm, _argc| {
11286 let rhs = vm.pop();
11287 let lhs = vm.pop();
11288 match (lhs, rhs) {
11289 (Value::Array(la), Value::Array(ra)) => {
11290 if ra.is_empty() {
11291 return Value::Array(la);
11292 }
11293 if la.is_empty() {
11294 return Value::Array(ra);
11295 }
11296 let mut out = Vec::with_capacity(la.len() * ra.len());
11297 for a in la.iter() {
11298 let a_s = a.as_str_cow();
11299 for b in ra.iter() {
11300 let b_s = b.as_str_cow();
11301 let mut s = String::with_capacity(a_s.len() + b_s.len());
11302 s.push_str(&a_s);
11303 s.push_str(&b_s);
11304 out.push(Value::str(s));
11305 }
11306 }
11307 Value::array(out)
11308 }
11309 (Value::Array(la), rhs_scalar) => {
11310 // An EMPTY array contributes nothing to a concatenated
11311 // word — the surrounding scalar text survives. zsh:
11312 // `x${^a}y` (a=()) / `x${(P)scalar-empty}y` → "xy", NOT
11313 // a dropped word. Without this, a `(P)` indirect to an
11314 // unset/empty scalar (which nodes_to_value collapses to
11315 // Value::Array([]) for standalone-removal semantics)
11316 // cartesian-dropped the whole word — p10k's
11317 // `typeset -g _$2=${(P)2}` then arrived as a bare
11318 // `typeset` and dumped every parameter (~217× → 19 MB
11319 // terminal flood → startup hang).
11320 if la.is_empty() {
11321 return rhs_scalar;
11322 }
11323 let r = rhs_scalar.as_str_cow();
11324 let out: Vec<Value> = la
11325 .iter()
11326 .map(|a| {
11327 let a_s = a.as_str_cow();
11328 let mut s = String::with_capacity(a_s.len() + r.len());
11329 s.push_str(&a_s);
11330 s.push_str(&r);
11331 Value::str(s)
11332 })
11333 .collect();
11334 Value::array(out)
11335 }
11336 (lhs_scalar, Value::Array(ra)) => {
11337 // Symmetric empty-array-contributes-nothing rule; see
11338 // the (Array, scalar) arm above.
11339 if ra.is_empty() {
11340 return lhs_scalar;
11341 }
11342 let l = lhs_scalar.as_str_cow();
11343 let out: Vec<Value> = ra
11344 .iter()
11345 .map(|b| {
11346 let b_s = b.as_str_cow();
11347 let mut s = String::with_capacity(l.len() + b_s.len());
11348 s.push_str(&l);
11349 s.push_str(&b_s);
11350 Value::str(s)
11351 })
11352 .collect();
11353 Value::array(out)
11354 }
11355 (lhs_s, rhs_s) => {
11356 let l = lhs_s.as_str_cow();
11357 let r = rhs_s.as_str_cow();
11358 let mut s = String::with_capacity(l.len() + r.len());
11359 s.push_str(&l);
11360 s.push_str(&r);
11361 Value::str(s)
11362 }
11363 }
11364 });
11365
11366 vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE, |vm, argc| {
11367 let rhs = vm.pop();
11368 let lhs = vm.pop();
11369 // c:Src/subst.c:4245 `if (isarr)` — an unquoted array embedded
11370 // in a word ALWAYS emits one word per element, never a scalar
11371 // join. The shape (splice vs plan9 cross-product) is chosen at
11372 // RUNTIME by c:1663 `int plan9 = isset(RCEXPANDPARAM);`, exactly
11373 // as BUILTIN_CONCAT_SPLICE does. The only extra case DISTRIBUTE
11374 // handles is the DQ context: the compiler emits
11375 // CallBuiltin(BUILTIN_CONCAT_DISTRIBUTE, 1) when the parent word
11376 // is DQ-wrapped (compile_zsh.rs parent_is_dq), and inside DQ
11377 // `"pre${arr}post"` joins via $IFS[0] to a single scalar
11378 // regardless of the option (c:Src/subst.c:1650-1656 isarr
11379 // comment). The default UNQUOTED path emits argc=2 (lhs + rhs).
11380 // Bug #246 in docs/BUGS.md.
11381 if argc == 1 {
11382 // DQ context: join any Array side to scalar via sepjoin's
11383 // IFS default. c:Src/utils.c:3936-3945 — set-but-empty IFS
11384 // joins with "" (`IFS=""; echo "x$*y"` → `xabcy`); only
11385 // unset / space-leading IFS yields " ".
11386 let join_arr = |arr: &[Value]| -> String {
11387 let strs: Vec<String> = arr.iter().map(|v| v.as_str_cow().into_owned()).collect();
11388 crate::ported::utils::sepjoin(&strs, None)
11389 };
11390 // Provenance: the DQ-join arm consumes both operands, so
11391 // capture them first (Arc clones, only while armed).
11392 let prov_operands = crate::provenance::active().then(|| (lhs.clone(), rhs.clone()));
11393 let l = match lhs {
11394 Value::Array(a) => join_arr(&a),
11395 other => other.as_str_cow().into_owned(),
11396 };
11397 let r = match rhs {
11398 Value::Array(a) => join_arr(&a),
11399 other => other.as_str_cow().into_owned(),
11400 };
11401 let mut s = String::with_capacity(l.len() + r.len());
11402 s.push_str(&l);
11403 s.push_str(&r);
11404 let out = Value::str(s);
11405 if let Some((pl, pr)) = prov_operands {
11406 crate::provenance::on_concat(&pl, &pr, &out);
11407 }
11408 return out;
11409 }
11410 // Unquoted plain `${arr}`: same runtime dispatch as
11411 // BUILTIN_CONCAT_SPLICE — c:4245 `if (isarr)` always distributes
11412 // one word per element; c:1663 picks splice (default, first/last
11413 // sticking, c:4366-4437) vs plan9 cross-product (c:4316-4365).
11414 // concat_splice / concat_plan9 both honor EMPTY_EXPANSION_IS_SCALAR
11415 // so the p10k `${(P)2}` empty-array word-removal semantics survive.
11416 if plan9_active() {
11417 return concat_plan9_prov(lhs, rhs);
11418 }
11419 concat_splice_prov(lhs, rhs)
11420 });
11421
11422 // See BUILTIN_WORD_ASSEMBLE_PLAN9's doc comment for the stack contract.
11423 vm.register_builtin(BUILTIN_WORD_ASSEMBLE_PLAN9, |vm, argc| {
11424 // Pop argc values: descriptor was pushed FIRST (bottom), segments
11425 // after it, so popping top-first then reversing yields
11426 // [descriptor, seg0, …, seg(n-1)].
11427 let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
11428 for _ in 0..argc {
11429 popped.push(vm.pop());
11430 }
11431 popped.reverse();
11432 let mut it = popped.into_iter();
11433 let descriptor = it.next().map(|v| v.to_str()).unwrap_or_default();
11434 let plan9_flags: Vec<bool> = descriptor.chars().map(|c| c == '1').collect();
11435 let segments: Vec<Value> = it.collect();
11436 word_assemble_plan9(&segments, &plan9_flags)
11437 });
11438
11439 // `[[ a -ef b ]]` — same-inode test. Resolves both paths via fs::metadata
11440 // (follows symlinks the way zsh's -ef does) and compares (dev, inode).
11441 // Returns false on any I/O error (path missing, permission denied, etc.).
11442 vm.register_builtin(BUILTIN_SAME_FILE, |vm, _argc| {
11443 let b = vm.pop().to_str();
11444 let a = vm.pop().to_str();
11445 let same = match (fs::metadata(&a), fs::metadata(&b)) {
11446 (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
11447 _ => false,
11448 };
11449 Value::Bool(same)
11450 });
11451
11452 // `[[ -c path ]]` — character device.
11453 vm.register_builtin(BUILTIN_IS_CHARDEV, |vm, _argc| {
11454 let path = vm.pop().to_str();
11455 let result = fs::metadata(&path)
11456 .map(|m| m.file_type().is_char_device())
11457 .unwrap_or(false);
11458 Value::Bool(result)
11459 });
11460 // `[[ -b path ]]` — block device.
11461 vm.register_builtin(BUILTIN_IS_BLOCKDEV, |vm, _argc| {
11462 let path = vm.pop().to_str();
11463 let result = fs::metadata(&path)
11464 .map(|m| m.file_type().is_block_device())
11465 .unwrap_or(false);
11466 Value::Bool(result)
11467 });
11468 // `[[ -p path ]]` — FIFO (named pipe).
11469 vm.register_builtin(BUILTIN_IS_FIFO, |vm, _argc| {
11470 let path = vm.pop().to_str();
11471 let result = fs::metadata(&path)
11472 .map(|m| m.file_type().is_fifo())
11473 .unwrap_or(false);
11474 Value::Bool(result)
11475 });
11476 // `[[ -S path ]]` — socket.
11477 vm.register_builtin(BUILTIN_IS_SOCKET, |vm, _argc| {
11478 let path = vm.pop().to_str();
11479 let result = fs::symlink_metadata(&path)
11480 .map(|m| m.file_type().is_socket())
11481 .unwrap_or(false);
11482 Value::Bool(result)
11483 });
11484
11485 // `[[ -k path ]]` / `-u` / `-g` — sticky / setuid / setgid bit.
11486 vm.register_builtin(BUILTIN_HAS_STICKY, |vm, _argc| {
11487 let path = vm.pop().to_str();
11488 let result = fs::metadata(&path)
11489 .map(|m| m.permissions().mode() & libc::S_ISVTX as u32 != 0)
11490 .unwrap_or(false);
11491 Value::Bool(result)
11492 });
11493 vm.register_builtin(BUILTIN_HAS_SETUID, |vm, _argc| {
11494 let path = vm.pop().to_str();
11495 let result = fs::metadata(&path)
11496 .map(|m| m.permissions().mode() & libc::S_ISUID as u32 != 0)
11497 .unwrap_or(false);
11498 Value::Bool(result)
11499 });
11500 vm.register_builtin(BUILTIN_HAS_SETGID, |vm, _argc| {
11501 let path = vm.pop().to_str();
11502 let result = fs::metadata(&path)
11503 .map(|m| m.permissions().mode() & libc::S_ISGID as u32 != 0)
11504 .unwrap_or(false);
11505 Value::Bool(result)
11506 });
11507 vm.register_builtin(BUILTIN_OWNED_BY_USER, |vm, _argc| {
11508 let path = vm.pop().to_str();
11509 let euid = unsafe { libc::geteuid() };
11510 let result = fs::metadata(&path)
11511 .map(|m| m.uid() == euid)
11512 .unwrap_or(false);
11513 Value::Bool(result)
11514 });
11515 vm.register_builtin(BUILTIN_OWNED_BY_GROUP, |vm, _argc| {
11516 let path = vm.pop().to_str();
11517 let egid = unsafe { libc::getegid() };
11518 let result = fs::metadata(&path)
11519 .map(|m| m.gid() == egid)
11520 .unwrap_or(false);
11521 Value::Bool(result)
11522 });
11523
11524 // `[[ -N path ]]` — file's access time is NOT newer than its
11525 // modification time (zsh man: "true if file exists and its
11526 // access time is not newer than its modification time"). Used
11527 // by zsh's mailbox-watching code. The semantic is `atime <=
11528 // mtime` (equivalent to `mtime >= atime`) — equal counts as
11529 // true, which a strict `mtime > atime` check missed for newly
11530 // created files where both stamps are identical.
11531 vm.register_builtin(BUILTIN_FILE_MODIFIED_SINCE_ACCESS, |vm, _argc| {
11532 let path = vm.pop().to_str();
11533 let result = fs::metadata(&path)
11534 .map(|m| m.atime() <= m.mtime())
11535 .unwrap_or(false);
11536 Value::Bool(result)
11537 });
11538
11539 // `[[ a -nt b ]]` — true if `a`'s mtime is strictly later than `b`'s.
11540 // BOTH files must exist; if either is missing the result is false.
11541 // (Earlier behavior was bash's "missing == infinitely-old"; zsh
11542 // strictly requires both files to exist.)
11543 vm.register_builtin(BUILTIN_FILE_NEWER, |vm, _argc| {
11544 let b = vm.pop().to_str();
11545 let a = vm.pop().to_str();
11546 // Use SystemTime modified() for nanosecond precision —
11547 // MetadataExt::mtime() returns seconds only, so two files
11548 // touched within the same second compared equal even when
11549 // 500ms apart. zsh tracks ns and uses `>=` for ties (touching
11550 // a then b in quick succession should still report b newer).
11551 let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
11552 let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
11553 let result = match (ta, tb) {
11554 (Some(ta), Some(tb)) => ta > tb,
11555 _ => false,
11556 };
11557 Value::Bool(result)
11558 });
11559
11560 // `[[ a -ot b ]]` — mirror of -nt. Same both-must-exist contract.
11561 vm.register_builtin(BUILTIN_FILE_OLDER, |vm, _argc| {
11562 let b = vm.pop().to_str();
11563 let a = vm.pop().to_str();
11564 let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
11565 let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
11566 let result = match (ta, tb) {
11567 (Some(ta), Some(tb)) => ta < tb,
11568 _ => false,
11569 };
11570 Value::Bool(result)
11571 });
11572
11573 // `set -e` / `setopt errexit` post-command check. Compiler emits
11574 // this after each top-level command's SetStatus (skipped inside
11575 // conditionals/pipelines/&&||/`!`). If errexit is on AND the last
11576 // command exited non-zero AND it's not a `return` from a function,
11577 // exit the shell with that status.
11578 // `set -x` / `setopt xtrace` — print each command before it runs.
11579 // The compiler emits this BEFORE the actual builtin/external call
11580 // with the command's literal text as a single string arg. We
11581 // print to stderr if xtrace is on. Honors `$PS4` (default `+ `).
11582 //
11583 // ── XTRACE flow control ────────────────────────────────────────
11584 // Mirror of C zsh's `doneps4` flag in execcmd_exec (Src/exec.c).
11585 // When an assignment trace fires (XTRACE_ASSIGN), it emits PS4
11586 // and sets this flag so the subsequent XTRACE_ARGS skips its own
11587 // PS4 emission — the assignment + command end up on the SAME
11588 // line: `<PS4>a=1 echo hello\n`. XTRACE_ARGS / XTRACE_NEWLINE
11589 // reset the flag after emitting the trailing `\n`.
11590 vm.register_builtin(BUILTIN_XTRACE_IS_ON, |_vm, _argc| {
11591 // Push live xtrace state. Caller pairs this with JumpIfFalse
11592 // to skip the trace-string-building block when xtrace is off,
11593 // avoiding side-effectful operand re-evaluation. Bug #159 in
11594 // docs/BUGS.md.
11595 let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11596 Value::Int(if on { 1 } else { 0 })
11597 });
11598
11599 vm.register_builtin(BUILTIN_XTRACE_LINE, |vm, _argc| {
11600 // Keep the Value; `to_str()` allocates a String, and this handler
11601 // runs on EVERY `(( … ))` / `[[ … ]]` / loop-head evaluation, where
11602 // xtrace is off essentially always. Defer the allocation to the
11603 // `on` branch below.
11604 let cmd_val = vm.pop();
11605 // Sync exec.last_status with the live vm.last_status BEFORE
11606 // the next command runs. Direct port of the zsh exec.c
11607 // contract — `$?` reads the exit status of the *most recent*
11608 // command. XTRACE_LINE is emitted by the compiler BEFORE
11609 // every simple command, so it's the natural sync point.
11610 let live = vm.last_status;
11611 with_executor(|exec| {
11612 exec.set_last_status(live);
11613 });
11614 // C zsh emits xtrace for `(( … ))` / `[[ … ]]` / `case` /
11615 // `if/while/until/for/repeat` head expressions via
11616 // `printprompt4(); fprintf(xtrerr, "%s\n", expr)` at
11617 // Src/exec.c:5240 (math), c:5286 (cond), c:4117 (for), etc.
11618 // The compiler emits BUILTIN_XTRACE_LINE only at those
11619 // construct boundaries (compile_arith / compile_cond /
11620 // compile_if / compile_while / compile_for / compile_case);
11621 // simple commands route to BUILTIN_XTRACE_ARGS instead. So
11622 // this handler always emits when xtrace is on — no prefix-
11623 // string heuristic.
11624 let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11625 if on {
11626 let already = XTRACE_DONE_PS4.with(|f| f.get());
11627 if !already {
11628 printprompt4();
11629 }
11630 // c:exec.c:5240/5286 — `fprintf(xtrerr, "%s\n", expr)`. Buffer
11631 // the line + newline, flush once (single write).
11632 xtrerr_fputs(&cmd_val.to_str());
11633 xtrerr_fputs("\n");
11634 xtrerr_flush();
11635 XTRACE_DONE_PS4.with(|f| f.set(false));
11636 }
11637 Value::Status(0)
11638 });
11639
11640 // BUILTIN_XTRACE_ARRAY_LINE — xtrace line for an `arr=(...)` / `arr+=(...)`
11641 // assignment. Stack on entry: [array, prefix] (argc = 2); pops prefix
11642 // ("name=( " / "name+=( "), then the whole assembled Value::Array. Direct
11643 // port of c:Src/exec.c::addvars:2624-2632, guarded on the live xtrace
11644 // state like C's `if (xtr)`: prints `prefix qz(e0) qz(e1) … ) ` with each
11645 // element shell-quoted (quotedzputs). Replaces the former one-VM-slot-per-
11646 // element trace, which overflowed next_slot (u16) on large literals.
11647 vm.register_builtin(BUILTIN_XTRACE_ARRAY_LINE, |vm, _argc| {
11648 let prefix = vm.pop().to_str();
11649 let arr = vm.pop();
11650 let live = vm.last_status;
11651 with_executor(|exec| {
11652 exec.set_last_status(live);
11653 });
11654 let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11655 if on {
11656 let already = XTRACE_DONE_PS4.with(|f| f.get());
11657 if !already {
11658 printprompt4();
11659 }
11660 let mut line = String::with_capacity(prefix.len() + 16);
11661 line.push_str(&prefix);
11662 if let Value::Array(items) = arr {
11663 for it in items.iter() {
11664 line.push_str(&crate::ported::utils::quotedzputs(&it.to_str()));
11665 line.push(' ');
11666 }
11667 }
11668 line.push_str(") ");
11669 line.push('\n');
11670 xtrerr_fputs(&line);
11671 xtrerr_flush();
11672 XTRACE_DONE_PS4.with(|f| f.set(false));
11673 }
11674 Value::Status(0)
11675 });
11676
11677 // BUILTIN_MAKE_ARRAY_COUNTED — pop a count Int (top), then pop that many
11678 // values below it, and push them as one Value::Array (bottom-of-group
11679 // first). Same result as Op::MakeArray(N) but N comes from the stack as an
11680 // i64, dodging MakeArray's u16 operand cap. The compiler emits this only
11681 // when a literal `arr=(...)` has more than u16::MAX elements.
11682 vm.register_builtin(BUILTIN_MAKE_ARRAY_COUNTED, |vm, _argc| {
11683 let count = vm.pop().to_int().max(0) as usize;
11684 let mut items: Vec<Value> = Vec::with_capacity(count);
11685 for _ in 0..count {
11686 items.push(vm.pop());
11687 }
11688 items.reverse();
11689 Value::array(items)
11690 });
11691
11692 // BUILTIN_ARGV_RFLATTEN — recursively flatten a MakeArray-packed argv
11693 // bundle so a >255-arg Call/CallFunction/CallBuiltin (dispatched with
11694 // argc=1 over the single packed Array) recovers every positional arg. The
11695 // call ops flatten only one level; a brace/glob/`$arr` word contributes a
11696 // nested Array that would otherwise stringify. See the const doc.
11697 vm.register_builtin(BUILTIN_ARGV_RFLATTEN, |vm, _argc| {
11698 let v = vm.pop();
11699 let mut out: Vec<String> = Vec::new();
11700 flatten_array_value(v, &mut out);
11701 Value::array(out.into_iter().map(Value::str).collect())
11702 });
11703
11704 // Like XTRACE_LINE but reads the top `argc - 1` values from the
11705 // VM stack WITHOUT consuming them (peek), then pops a prefix
11706 // string at the top. Joins prefix + peeked args with spaces using
11707 // zsh's quotedzputs-equivalent quoting. Direct port of
11708 // Src/exec.c:2055-2066 — emit AFTER expansion, with each arg
11709 // shell-quoted, so `for i in a b; echo for $i` traces as
11710 // `echo for a` / `echo for b`, not `echo for $i`.
11711 //
11712 // Stack contract on entry: [arg1, arg2, ..., argN, prefix].
11713 // Pops prefix; peeks argN..arg1 below. argc = N + 1.
11714 vm.register_builtin(BUILTIN_XTRACE_ARGS, |vm, argc| {
11715 let prefix = vm.pop().to_str();
11716 let live = vm.last_status;
11717 with_executor(|exec| {
11718 exec.set_last_status(live);
11719 });
11720 let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11721 if on {
11722 let n_args = argc.saturating_sub(1) as usize;
11723 let len = vm.stack.len();
11724 // c:Src/exec.c:2055 — argv is the POST-expansion word
11725 // list, so an arg that expanded to multiple words splats
11726 // into multiple trace tokens AND an arg that expanded to
11727 // zero words (empty unquoted `${UNSET}`) emits nothing.
11728 // pop_args (line 6243) already does this splat for the
11729 // real handler; mirror the same Array → splat / empty →
11730 // drop logic here so xtrace renders `echo ${UNSET}` as
11731 // `echo` (zsh) instead of `echo ''` (the previous
11732 // single-arg stringify path returned "" and then
11733 // quotedzputs wrapped it in `''`).
11734 let arg_strs: Vec<String> = if n_args > 0 && len >= n_args {
11735 let mut out = Vec::new();
11736 for v in &vm.stack[len - n_args..] {
11737 match v {
11738 Value::Array(items) => {
11739 for item in items.iter() {
11740 out.push(quotedzputs(&item.to_str()));
11741 }
11742 }
11743 other => out.push(quotedzputs(&other.to_str())),
11744 }
11745 }
11746 out
11747 } else {
11748 Vec::new()
11749 };
11750 // Builtins dispatch through `execbuiltin` (Src/builtin.c:442)
11751 // which emits its own PS4 + name + args xtrace. To avoid
11752 // double-emission, skip our emission here when the first
11753 // arg is a known builtin with a registered HandlerFunc —
11754 // those go through execbuiltin and will trace themselves.
11755 // Externals + builtins-not-yet-routed-through-execbuiltin
11756 // keep our emission as a stand-in.
11757 let goes_through_execbuiltin = crate::ported::builtin::BUILTINS
11758 .iter()
11759 .any(|b| b.node.nam == prefix && b.handlerfunc.is_some());
11760 if !goes_through_execbuiltin {
11761 let line = if arg_strs.is_empty() {
11762 prefix
11763 } else {
11764 format!("{} {}", prefix, arg_strs.join(" "))
11765 };
11766 // Mirrors Src/exec.c:2055 xtrace emission. C does:
11767 // if (!doneps4) printprompt4();
11768 // ... emit args + spaces ...
11769 // fputc('\n', xtrerr); fflush(xtrerr);
11770 // printprompt4 + the args + `\n` all land in the xtrerr
11771 // buffer; the single fflush below writes the whole line in
11772 // one syscall so concurrent pipeline stages never
11773 // interleave (c:makecline:2122-2123).
11774 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
11775 if !already_ps4 {
11776 printprompt4();
11777 }
11778 xtrerr_fputs(&line);
11779 xtrerr_fputs("\n"); // c:2122 fputc('\n', xtrerr)
11780 xtrerr_flush(); // c:2123 fflush(xtrerr)
11781 }
11782 XTRACE_DONE_PS4.with(|f| f.set(false));
11783 }
11784 Value::Status(0)
11785 });
11786
11787 // BUILTIN_XTRACE_ASSIGN — direct port of the per-assignment
11788 // trace block at Src/exec.c:2517-2582. C body excerpt:
11789 // xtr = isset(XTRACE);
11790 // if (xtr) { printprompt4(); doneps4 = 1; }
11791 // while (assign) {
11792 // if (xtr) fprintf(xtrerr, "%s+=" or "%s=", name);
11793 // ... eval value into `val` ...
11794 // if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
11795 // ...
11796 // }
11797 //
11798 // Stack on entry: [..., name, value]. PEEKS both (they're left
11799 // on stack for SET_VAR to pop). Emits `name=<quoted-val> ` with
11800 // no newline; trailing `\n` comes from XTRACE_ARGS (cmd path)
11801 // or XTRACE_NEWLINE (assignment-only path).
11802 vm.register_builtin(BUILTIN_XTRACE_ASSIGN, |vm, _argc| {
11803 let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11804 if on {
11805 // PEEK [..., name, value] — argc==2 by contract.
11806 let len = vm.stack.len();
11807 if len >= 2 {
11808 let name = vm.stack[len - 2].to_str();
11809 let value = vm.stack[len - 1].to_str();
11810 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
11811 if !already_ps4 {
11812 printprompt4();
11813 XTRACE_DONE_PS4.with(|f| f.set(true));
11814 }
11815 // C: `fprintf(xtrerr, "%s=", name)` then `quotedzputs
11816 // (val); fputc(' ', xtrerr);`. Append to the xtrerr buffer
11817 // (no newline / no flush — the line continues with the
11818 // command via XTRACE_ARGS, or ends at XTRACE_NEWLINE).
11819 xtrerr_fputs(&format!("{}={} ", name, quotedzputs(&value)));
11820 }
11821 }
11822 Value::Status(0)
11823 });
11824
11825 // BUILTIN_XTRACE_NEWLINE — emit trailing `\n` + flush iff a
11826 // prior XTRACE_ASSIGN this line already emitted PS4. Mirrors
11827 // C's `fputc('\n', xtrerr); fflush(xtrerr);` at exec.c:3398
11828 // (the assignment-only path through execcmd_exec).
11829 vm.register_builtin(BUILTIN_XTRACE_NEWLINE, |_vm, _argc| {
11830 let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
11831 if on {
11832 let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
11833 if already_ps4 {
11834 xtrerr_fputs("\n"); // c:3398 fputc('\n', xtrerr)
11835 xtrerr_flush(); // c:3398 fflush(xtrerr)
11836 XTRACE_DONE_PS4.with(|f| f.set(false));
11837 }
11838 }
11839 Value::Status(0)
11840 });
11841
11842 // c:Src/exec.c WC_TRYBLOCK — post-always re-jump probes. Each
11843 // returns 1 + consumes the atomic when the corresponding
11844 // escape flag is set; the try-block compile pairs each with
11845 // a JumpIfFalse + Jump → outer scope's return / break /
11846 // continue patches.
11847 vm.register_builtin(BUILTIN_RETFLAG_CHECK, |_vm, _argc| {
11848 use std::sync::atomic::Ordering;
11849 let r = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
11850 if r != 0 {
11851 // Don't clear here — doshfunc owns the clear at c:6047
11852 // when the function unwinds. Leaving it set propagates
11853 // through nested `eval`/`source` callers correctly.
11854 Value::Int(1)
11855 } else {
11856 Value::Int(0)
11857 }
11858 });
11859 vm.register_builtin(BUILTIN_BREAKS_CHECK, |_vm, _argc| {
11860 use std::sync::atomic::Ordering;
11861 let b = crate::ported::builtin::BREAKS.load(Ordering::Relaxed);
11862 let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
11863 // `break` sets BREAKS but NOT CONTFLAG; `continue` sets both.
11864 // Filter out the continue path here so the two checks are
11865 // mutually exclusive.
11866 if b != 0 && c == 0 {
11867 // Consume BREAKS so the outer loop's break_patches
11868 // landing doesn't double-decrement.
11869 crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
11870 Value::Int(1)
11871 } else {
11872 Value::Int(0)
11873 }
11874 });
11875 vm.register_builtin(BUILTIN_CONTFLAG_CHECK, |_vm, _argc| {
11876 use std::sync::atomic::Ordering;
11877 let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
11878 if c != 0 {
11879 crate::ported::builtin::CONTFLAG.store(0, Ordering::Relaxed);
11880 crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
11881 Value::Int(1)
11882 } else {
11883 Value::Int(0)
11884 }
11885 });
11886 // c:Src/loop.c — `loops++` / `loops--` bracket every iterative
11887 // construct: execfor c:114/188, execwhile c:427/491, execrepeat
11888 // c:523/546. `loops` is a GLOBAL, not a per-frame counter, and
11889 // `bin_break` reads it (`if (!loops)`) to decide whether `break` /
11890 // `continue` is legal. Because doshfunc does NOT reset it (only
11891 // restores it under LOCAL_LOOPS, c:6104-6112), a function called
11892 // from inside a loop sees the CALLER's count and its `break` ends
11893 // the caller's loop. zshrs's compiled for/while/until/repeat lower
11894 // to raw jumps, so without these two ops the counter stayed 0 and
11895 // every such `break` errored out instead.
11896 vm.register_builtin(BUILTIN_LOOP_ENTER, |_vm, _argc| {
11897 crate::ported::builtin::LOOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); // c:114
11898 Value::Int(0)
11899 });
11900 // c:Src/loop.c:141-145 + :199-203 (execfor), :478-481 (execwhile),
11901 // :534-537 (execrepeat) — every loop that abandons its body because
11902 // `errflag` is set FORCES the escaping status first:
11903 // if (errflag) { if (breaks) breaks--; lastval = 1; break; }
11904 // so a fatal error inside a loop leaves 1, not whatever the failing
11905 // command set. `setopt extendedglob; for i in 1 2; do [[ abc == [ ]]; done`
11906 // exits 1 in zsh while the bare `[[ abc == [ ]]` exits 2; zshrs's compiled
11907 // loops jumped straight to the chunk-end landing and carried the cond's 2
11908 // out. `execselect` has no such assignment (c:217+), which is why
11909 // `compile_select` does not bump `open_loop_depth` and never emits this.
11910 // c:Src/loop.c:479-482 — `if (errflag) { lastval = 1; break; }`, the
11911 // guard every C loop runs AFTER its body. Without it a completer whose
11912 // loop body raises an error spins forever in an interactive shell: the
11913 // errflag makes the prologue skip every statement, but the loop's exit
11914 // test reads a residual `$? == 0` and loops again. Reproduced at 98% CPU
11915 // with `_files -g "*(-.):t:source files" -g "(a:t:directories"` behind a
11916 // `#compdef` — `while _next_label …; do` (Completion/Unix/Type/_files
11917 // sh:121) never terminated, so `<cmd> <TAB>` produced nothing, ever.
11918 // lldb pinned three samples at the same loop head in the `_files` chunk.
11919 //
11920 // C tests the WHOLE errflag word here (ERRFLAG_INT included), exactly as
11921 // the list loop does at c:Src/exec.c:1443.
11922 vm.register_builtin(BUILTIN_LOOP_ERRFLAG_BREAK, |vm, _argc| {
11923 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) == 0 {
11924 return Value::Int(0);
11925 }
11926 vm.last_status = 1; // c:481/201/536
11927 with_executor(|exec| exec.set_last_status(1)); // c:481/201/536
11928 Value::Int(1) // c:482/202/537 — break
11929 });
11930
11931 vm.register_builtin(BUILTIN_LOOP_ERRFLAG_STATUS, |vm, _argc| {
11932 // The `if (errflag)` half of the C guard is re-tested HERE, not at
11933 // compile time: the same abort edge also carries an ERREXIT
11934 // (`set -e`) exit, which in C leaves execlist via `zexit(lastval)`
11935 // and never reaches the loop's `if (errflag)` arm — so that status
11936 // must survive untouched.
11937 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
11938 & crate::ported::zsh_h::ERRFLAG_ERROR)
11939 != 0
11940 {
11941 vm.last_status = 1; // c:144/201/480/536
11942 with_executor(|exec| exec.set_last_status(1)); // c:144/201/480/536
11943 }
11944 Value::Int(0)
11945 });
11946 vm.register_builtin(BUILTIN_LOOP_EXIT, |_vm, _argc| {
11947 use std::sync::atomic::Ordering::SeqCst;
11948 // Saturating: a chunk aborted mid-loop (errflag, `return`)
11949 // unwinds through `run_chunk`'s restore rather than this op, so
11950 // never let a stray decrement drive the count negative.
11951 let _ = crate::ported::builtin::LOOPS
11952 .fetch_update(SeqCst, SeqCst, |n| Some(if n > 0 { n - 1 } else { 0 })); // c:188
11953 Value::Int(0)
11954 });
11955
11956 // c:Src/loop.c:529-534 (execwhile), :180-185 (execfor), :540-545
11957 // (execrepeat) — the identical post-body drain every loop runs:
11958 // if (breaks) {
11959 // breaks--;
11960 // if (breaks || !contflag) break;
11961 // contflag = 0;
11962 // }
11963 // Returns Int(1) when this loop must terminate, Int(0) when it
11964 // should proceed to the next iteration. Only a `break`/`continue`
11965 // executed in a DIFFERENT chunk (a called function, `eval`, a
11966 // sourced file) reaches here — an in-chunk `break` compiles to a
11967 // direct jump and never touches the counter.
11968 vm.register_builtin(BUILTIN_LOOP_BREAK_DRAIN, |_vm, _argc| {
11969 use std::sync::atomic::Ordering::SeqCst;
11970 let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
11971 if breaks == 0 {
11972 return Value::Int(0);
11973 }
11974 let remaining = breaks - 1;
11975 crate::ported::builtin::BREAKS.store(remaining, SeqCst); // c:530
11976 let contflag = crate::ported::builtin::CONTFLAG.load(SeqCst);
11977 if remaining != 0 || contflag == 0 {
11978 return Value::Int(1); // c:532 — `break`
11979 }
11980 crate::ported::builtin::CONTFLAG.store(0, SeqCst); // c:533
11981 Value::Int(0)
11982 });
11983
11984 // c:Src/exec.c:1370 execlist — `while (wc_code(code) == WC_LIST &&
11985 // !breaks && !retflag && !errflag)`. A pending `breaks` stops the
11986 // CURRENT list at the next statement boundary WITHOUT consuming it,
11987 // so the flag keeps travelling outward until a loop's drain eats it.
11988 // Non-consuming by design: the drain above is the only consumer.
11989 vm.register_builtin(BUILTIN_BREAKS_PENDING, |_vm, _argc| {
11990 let b = crate::ported::builtin::BREAKS.load(std::sync::atomic::Ordering::SeqCst);
11991 Value::Int(if b != 0 { 1 } else { 0 })
11992 });
11993
11994 vm.register_builtin(BUILTIN_NOEXEC_CHECK, |vm, _argc| {
11995 // c:Src/exec.c:1390 — `set -n` / `noexec` option: parse but
11996 // don't execute. Returns Int(1) when noexec is set so the
11997 // emit-side JumpIfTrue skips the statement body.
11998 //
11999 // Arg: 1 when every command of the sublist is a simple command
12000 // with a command word. c:Src/parse.c:1928 marks those cmplx, so C
12001 // never takes execsimple's `if (!isset(EXECOPT)) return lastval =
12002 // 0;` (c:Src/exec.c:1351) for them: execcmd_exec expands the words
12003 // first and skips only the execution (c:4051). Those commands
12004 // carry their own BUILTIN_NOEXEC_CMD_GATE, so noexec must not
12005 // skip them here.
12006 let gated_in_command = vm.pop().to_int() != 0;
12007 if !gated_in_command && opt_state_get("noexec").unwrap_or(false) {
12008 return Value::Int(1);
12009 }
12010 // c:Src/exec.c:1390 — execlist's list-loop gate:
12011 // `while (wc_code(code) == WC_LIST && !breaks && !retflag
12012 // && !errflag)`
12013 // — once errflag is set, the NEXT sublist never starts, so
12014 // lastval survives untouched to the shell exit. Without this
12015 // prologue gate the follow-up statement RAN, its dispatch
12016 // saw errflag, returned 1, and SetStatus clobbered lastval —
12017 // `[[ x == [a- ]]; print rc=$?` exited 1 instead of zsh's 2
12018 // (the cond syntax error set lastval=2 per exec.c:5216-5221).
12019 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
12020 & crate::ported::zsh_h::ERRFLAG_ERROR)
12021 != 0
12022 {
12023 return Value::Int(1);
12024 }
12025 Value::Int(0)
12026 });
12027 // See BUILTIN_NOEXEC_CMD_GATE.
12028 vm.register_builtin(BUILTIN_NOEXEC_CMD_GATE, |vm, _argc| {
12029 if !opt_state_get("noexec").unwrap_or(false) {
12030 return Value::Int(0);
12031 }
12032 // c:Src/exec.c:3760-3763 — `if (errflag) { lastval = 1; goto err; }`
12033 // runs after globlist and before the EXECOPT test, so an expansion
12034 // error still sets the status under NO_EXEC.
12035 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
12036 & crate::ported::zsh_h::ERRFLAG_ERROR)
12037 != 0
12038 {
12039 vm.last_status = 1; // c:3761 `lastval = 1;`
12040 crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
12041 }
12042 Value::Int(1) // c:4051 — the execution branch is not taken
12043 });
12044 // c:Src/exec.c:1536-1538 —
12045 // /* suppress errexit for commands before && and || and after ! */
12046 // if (isandor || isnot)
12047 // noerrexit |= NOERREXIT_EXIT | NOERREXIT_RETURN;
12048 // The bits live on the PROCESS-GLOBAL `noerrexit`, so they are still
12049 // in force inside a shell function called from that position (doshfunc
12050 // clears only NOERREXIT_RETURN, c:5930). zshrs suppressed the check
12051 // purely at COMPILE time (`errexit_suppress_depth`), which cannot
12052 // reach a separately-compiled function body — so
12053 // TRAPZERR(){ print E }; f(){ print f; false; }; f && t
12054 // fired the ZERR trap from inside `f` where zsh stays silent
12055 // (C03traps:14, E01options:18,19,21).
12056 vm.register_builtin(BUILTIN_NOERREXIT_SUPPRESS, |_vm, _argc| {
12057 use std::sync::atomic::Ordering;
12058 let old = crate::ported::exec::noerrexit.load(Ordering::Relaxed); // c:1417
12059 NOERREXIT_SAVES.with(|st| st.borrow_mut().push(old));
12060 crate::ported::exec::noerrexit.store(
12061 old | crate::ported::zsh_h::NOERREXIT_EXIT | crate::ported::zsh_h::NOERREXIT_RETURN,
12062 Ordering::Relaxed,
12063 ); // c:1538
12064 Value::Int(0)
12065 });
12066 // c:Src/exec.c:1621 / c:1626 — `noerrexit = oldnoerrexit;`
12067 vm.register_builtin(BUILTIN_NOERREXIT_RESTORE, |_vm, _argc| {
12068 use std::sync::atomic::Ordering;
12069 if let Some(old) = NOERREXIT_SAVES.with(|st| st.borrow_mut().pop()) {
12070 crate::ported::exec::noerrexit.store(old, Ordering::Relaxed); // c:1621
12071 }
12072 Value::Int(0)
12073 });
12074 vm.register_builtin(BUILTIN_DONETRAP_RESET, |_vm, _argc| {
12075 donetrap_reset_impl()
12076 });
12077 // See BUILTIN_FORKED_REDIRS.
12078 vm.register_builtin(BUILTIN_FORKED_REDIRS, |vm, _argc| {
12079 if FORKED_REDIR_VM.with(|f| f.replace(0)) == vm as *const fusevm::VM as usize {
12080 // The next op is the command's WithRedirectsBegin.
12081 let depth = with_executor(|exec| exec.redirect_scope_stack.len()) + 1;
12082 FORKED_REDIR_SCOPE.with(|c| c.set(depth));
12083 }
12084 Value::Int(0)
12085 });
12086 vm.register_builtin(BUILTIN_EXECCMD_FORKED_LEVEL, |vm, _argc| {
12087 let is_subsh = vm.pop().to_int() != 0;
12088 execcmd_forked_level(is_subsh, vm as *const fusevm::VM as usize);
12089 Value::Int(0)
12090 });
12091 vm.register_builtin(BUILTIN_EXITING_EXIT_TRAP, |_vm, _argc| {
12092 use crate::ported::signals_h::SIGEXIT;
12093 use std::sync::atomic::Ordering;
12094 // `trap '…' EXIT` bodies live in traps_table, TRAPEXIT in sigtrapped.
12095 let trapped = crate::ported::signals::sigtrapped.lock().ok().is_some_and(|st| st.get(SIGEXIT as usize).is_some_and(|&s| s != 0))
12096 || crate::ported::builtin::traps_table().lock().ok().is_some_and(|t| t.contains_key("EXIT"));
12097 if trapped {
12098 let eflag = crate::ported::utils::errflag.load(Ordering::Relaxed); // c:1701
12099 crate::ported::utils::errflag.store(0, Ordering::Relaxed); // c:1702
12100 crate::ported::signals::dotrap(SIGEXIT); // c:1703
12101 // c:1705 — `sigtrapped[SIGEXIT] = 0;`
12102 if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
12103 if let Some(slot) = st.get_mut(SIGEXIT as usize) {
12104 *slot = 0;
12105 }
12106 }
12107 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
12108 t.remove("EXIT");
12109 }
12110 crate::ported::utils::errflag.store(eflag, Ordering::Relaxed); // c:1706
12111 }
12112 Value::Int(0)
12113 });
12114
12115 // c:Src/exec.c:1451/1455/1476/1390 — see BUILTIN_STMT_PROLOGUE_FAST.
12116 vm.register_builtin(BUILTIN_STMT_PROLOGUE_FAST, |vm, _argc| {
12117 use std::sync::atomic::Ordering;
12118 let line = vm.pop().to_int();
12119 SUBLIST_SERIAL.with(|c| c.set(c.get().wrapping_add(1)));
12120 GLOB_WORD_ERRFLAG.with(|c| c.set(false));
12121 PREFORK_CUT.with(|c| c.set(false));
12122 if line >= 0 {
12123 set_lineno_impl(line); // c:1451
12124 }
12125 donetrap_reset_impl(); // c:1455
12126 // c:1390 — `set -n`, and execlist's own `!errflag` list gate.
12127 if opt_state_get("noexec").unwrap_or(false)
12128 || (crate::ported::utils::errflag.load(Ordering::Relaxed)
12129 & crate::ported::zsh_h::ERRFLAG_ERROR)
12130 != 0
12131 {
12132 return Value::Bool(false);
12133 }
12134 // c:1423 `sigtrapped[SIGDEBUG]` — checked through both
12135 // registries because `settrap` and `bin_trap` populate
12136 // different ones (signals.rs:1481-1511 dotrap).
12137 let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
12138 let debug_trapped = crate::ported::signals::sigtrapped
12139 .lock()
12140 .map(|v| v.get(sig_debug).copied().unwrap_or(0))
12141 .unwrap_or(0);
12142 if debug_trapped != 0 {
12143 return Value::Bool(false);
12144 }
12145 let debug_in_table = crate::ported::builtin::traps_table()
12146 .lock()
12147 .map(|t| t.contains_key("DEBUG"))
12148 .unwrap_or(false);
12149 Value::Bool(!debug_in_table)
12150 });
12151
12152 vm.register_builtin(BUILTIN_SUBLIST_FINISH, |vm, _argc| {
12153 // c:Src/jobs.c:1754 — `pipestats[0] = lastval;`. C has ONE
12154 // `lastval` global (c:Src/exec.c:120), so `waitonejob` reads
12155 // exactly the status the finished sublist just produced.
12156 //
12157 // zshrs splits that global in two: the fusevm status cell that
12158 // `Op::SetStatus`/`Op::GetStatus` and therefore `$?` use, and
12159 // the `builtin::LASTVAL` mirror that the ported `waitonejob`
12160 // reads. The compound-command compilers (compile_if,
12161 // compile_while, compile_for, compile_case, …) settle their
12162 // result with `Op::SetStatus` alone, so LASTVAL still holds
12163 // whatever the last dispatched BUILTIN returned — the loop
12164 // condition, typically. `if [[ -z x ]]; then :; fi` and
12165 // `while false; do :; done` both end with `$? == 0` and a
12166 // stale LASTVAL of 1.
12167 //
12168 // compile_sublist pushes `Op::GetStatus` ahead of this call so
12169 // the authoritative status arrives as an argument; republish it
12170 // through LASTVAL to reunify the two before the ported
12171 // waitonejob reads it, exactly as the single-command dispatch
12172 // sites at c:Src/exec.c:4367 do.
12173 let status = vm.pop().to_int() as i32;
12174 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
12175 // c:Src/jobs.c:1750-1756 — `compile_sublist` only emits this
12176 // marker for a cmplx sublist element that is not a multi-stage
12177 // pipeline, i.e. exactly the case where C's job carries no
12178 // procs, so drive the canonical port with a procs-less job the
12179 // same way the single-command sites do.
12180 let mut synth = crate::ported::zsh_h::job::default();
12181 crate::ported::jobs::waitonejob(&mut synth);
12182 Value::Status(0)
12183 });
12184
12185 // `[[ -z X ]]` / `[[ -n X ]]` — pop one Value, route through
12186 // canonical `src/ported/cond.rs::evalcond` so the actual
12187 // empty/non-empty test reuses the C-port at `cond.rs:270-271`
12188 // (`'n' => !arg.is_empty()`, `'z' => arg.is_empty()`).
12189 //
12190 // The Array→args conversion lives at the bridge because cond.rs
12191 // expects `&[&str]` (C `cond_str` signature equivalent). For
12192 // `"${arr[@]}"` in DQ context the splice yields `Value::Array`
12193 // — an empty array still expands to one implicit empty word
12194 // (per zsh's "${arr[@]}" splat preserving at least one slot
12195 // in cond context), so:
12196 // - Array(0) → ["-z", ""] → evalcond → 0 (true)
12197 // - Array(1) → ["-z", word] → evalcond → 0/1
12198 // - Array(2+) → ["-z", w1, w2, ...] → evalcond → 2 (parse
12199 // error: too many ops)
12200 // → coerced to false
12201 // - Str(s) → ["-z", s] → evalcond → 0/1
12202 //
12203 // Bug #185 in docs/BUGS.md.
12204 fn run_cond_str_empty(v: Value, op: &str) -> Value {
12205 let words: Vec<String> = match v {
12206 Value::Array(arr) => arr.iter().map(|x| x.to_str()).collect(),
12207 Value::Str(s) => vec![s.to_string()],
12208 other => vec![other.to_str()],
12209 };
12210 let mut args: Vec<&str> = vec![op];
12211 if words.is_empty() {
12212 args.push("");
12213 } else {
12214 args.extend(words.iter().map(|s| s.as_str()));
12215 }
12216 let opts: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
12217 let vars: std::collections::HashMap<String, String> = std::collections::HashMap::new();
12218 // c:Src/cond.c:62-66 — `evalcond` returns 0=true, 1=false,
12219 // 2=syntax-error. Coerce error to false (observable behavior
12220 // in zsh: `[[ -z a b ]]` errors and the test as a whole
12221 // returns non-zero).
12222 // `[[ ]]` dispatch — C's `evalcond(state, NULL)` calling convention.
12223 // `None` for from_test → mathevali integer-compare coercion path.
12224 let ret = crate::ported::cond::evalcond(&args, &opts, &vars, false, None);
12225 Value::Int(if ret == 0 { 1 } else { 0 })
12226 }
12227 vm.register_builtin(BUILTIN_COND_STR_EMPTY, |vm, _argc| {
12228 let v = vm.pop();
12229 run_cond_str_empty(v, "-z")
12230 });
12231 vm.register_builtin(BUILTIN_COND_STR_NONEMPTY, |vm, _argc| {
12232 let v = vm.pop();
12233 run_cond_str_empty(v, "-n")
12234 });
12235
12236 // `exec N<<<"str"` — herestring redirect to explicit fd, applied
12237 // permanently. Direct port of `Src/exec.c:4655 getherestr` +
12238 // `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)` at c:3766-
12239 // 3780 for the nullexec=1 bare-exec-redir path. Bug #205 in
12240 // docs/BUGS.md.
12241 // c:Src/exec.c:4671-4672 — a here-string DERIVED FROM a here-doc
12242 // gets no appended newline. See BUILTIN_HEREDOC_BODY_SINK.
12243 vm.register_builtin(BUILTIN_HEREDOC_BODY_SINK, |vm, _argc| {
12244 let body = vm.pop().to_str();
12245 if crate::provenance::active() {
12246 crate::provenance::on_heredoc("heredoc", &body);
12247 }
12248 with_executor(|exec| exec.host_set_pending_stdin(body));
12249 Value::Int(0)
12250 });
12251
12252 vm.register_builtin(BUILTIN_EXEC_HERESTR_FD, |vm, _argc| {
12253 // Stack (pushed by compile_redir): [content, fd, from_heredoc].
12254 let from_heredoc = vm.pop().to_int() != 0;
12255 let fd = vm.pop().to_int() as i32;
12256 let content = vm.pop().to_str();
12257 // c:Src/exec.c:4671-4672 — `getherestr` appends the newline
12258 // only for a REAL here-string:
12259 // if (!(fn->flags & REDIRF_FROM_HEREDOC))
12260 // t[len++] = '\n';
12261 // The `REDIRF_FROM_HEREDOC` flag is set by
12262 // c:Src/parse.c:2970-2971 when the redirection was written as
12263 // `<<WORD` and the parser turned the collected body into a
12264 // here-string. This helper serves BOTH spellings — `exec
12265 // 3<<<str` (append) and `exec 3<<EOF` (verbatim) — so the flag
12266 // has to be threaded in rather than assumed. It used to
12267 // unconditionally append, and the here-doc call site
12268 // compensated with `trim_end_matches('\n')`, which collapsed
12269 // N trailing blank lines to one and added a newline to a body
12270 // that ended without one.
12271 let body = if from_heredoc {
12272 content
12273 } else {
12274 format!("{}\n", content)
12275 };
12276 // c:Src/exec.c:3779 `addfd(forked, save, mfds, fn->fd1, fil, 0,
12277 // NULL)` → c:2421-2443: park the OLD contents of fd1 so
12278 // `fixfds` (c:4530) can put them back at the end of the
12279 // command; a bare `exec` (nullexec==1, c:3978-3986) skips it
12280 // and the redirection persists. This runs BEFORE the temp
12281 // file is opened on purpose: C reads fd1's pre-redirect state
12282 // too, and taking it afterwards would dup the here-document
12283 // itself and "restore" fd N to the body at scope end.
12284 with_executor(|exec| exec.save_fd_for_scope(fd));
12285 // c:4673-4679 — gettempfile → write_loop → close → reopen
12286 // read-only → unlink. Rust equivalent via tempfile crate or
12287 // explicit O_TMPFILE; use mkstemp + unlink-immediately to
12288 // mirror C exactly.
12289 use std::ffi::CString;
12290 let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
12291 let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
12292 if write_fd < 0 {
12293 crate::ported::utils::zwarn(&format!(
12294 "can't create temp file for here document: {}",
12295 crate::ported::utils::zsh_errno_msg(
12296 std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
12297 ) // c:Src/exec.c:3828 `%e`
12298 ));
12299 return Value::Status(1);
12300 }
12301 // c:4675 — write_loop(fd, t, len)
12302 let bytes = body.as_bytes();
12303 let mut off = 0;
12304 while off < bytes.len() {
12305 let n = unsafe {
12306 libc::write(
12307 write_fd,
12308 bytes[off..].as_ptr() as *const libc::c_void,
12309 bytes.len() - off,
12310 )
12311 };
12312 if n <= 0 {
12313 unsafe { libc::close(write_fd) };
12314 return Value::Status(1);
12315 }
12316 off += n as usize;
12317 }
12318 unsafe { libc::close(write_fd) }; // c:4676
12319 // Path null-terminated by mkstemp; reopen for reading.
12320 let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
12321 // c:4678 — unlink immediately so the file disappears on
12322 // close, leaving only the fd reference.
12323 unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
12324 if read_fd < 0 {
12325 return Value::Status(1);
12326 }
12327 // c:Src/utils.c:2047-2065 `redup(x, y)` — the `zclose(x)` lives
12328 // INSIDE the `else if (x != y)` arm:
12329 // if(x < 0) zclose(y);
12330 // else if (x != y) { dup2(x, y); …; zclose(x); }
12331 // When x == y, redup is a NO-OP. This helper closed `read_fd`
12332 // unconditionally, and `read_fd == fd` is the COMMON case for
12333 // `exec 3<<…`: mkstemp takes the lowest free fd (3), closes it
12334 // at c:4676, then `open` reclaims the same 3, so `dup2(3, 3)`
12335 // returned 3 and the very next `close(3)` threw the redirect
12336 // away. Every `exec N<<…` / `exec N<<<…` left N closed, and
12337 // `cat <&N` reported "N: bad file descriptor".
12338 if read_fd != fd {
12339 let r = unsafe { libc::dup2(read_fd, fd) };
12340 unsafe { libc::close(read_fd) };
12341 if r < 0 {
12342 return Value::Status(1);
12343 }
12344 }
12345 Value::Status(0)
12346 });
12347 // c:Src/exec.c:2418 + addfd splice — MULTIOS fan-out. Stack
12348 // layout pushed by compile_zsh's coalescing pass:
12349 // [target_1, op_byte_1, target_2, op_byte_2, …, target_N,
12350 // op_byte_N, fd]
12351 // argc = 2N + 1. Pops, opens every target, sets up a pipe +
12352 // splitter thread that reads pipe → writes every chunk to
12353 // every opened target, dup2's pipe-write-end onto fd. The
12354 // splitter is closed + joined by host_redirect_scope_end.
12355 // Bug #36 in docs/BUGS.md.
12356 vm.register_builtin(BUILTIN_MULTIOS_REDIRECT, |vm, argc| {
12357 if argc < 3 || argc % 2 == 0 {
12358 // Bad shape — bail.
12359 return Value::Status(1);
12360 }
12361 // Pop fd first (top of stack).
12362 let fd = vm.pop().to_int() as i32;
12363 // Then pop (op, target) pairs in reverse compile order. Keep
12364 // targets as Values — a glob-bearing target arrives as a
12365 // Value::Array of matches.
12366 let n_targets = ((argc - 1) / 2) as usize;
12367 let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_targets);
12368 for _ in 0..n_targets {
12369 let op_byte = vm.pop().to_int() as u8;
12370 let target = vm.pop();
12371 pairs.push((op_byte, target));
12372 }
12373 // Restore compile order (target_1 first).
12374 pairs.reverse();
12375 // c:Src/exec.c:3785-3796 — an errflag from expanding a target fails
12376 // the redirection (see redir_target_expansion_failed).
12377 if with_executor(redir_target_expansion_failed) {
12378 return Value::Status(1);
12379 }
12380
12381 // c:Src/glob.c:2195-2203 xpandredir — "Loop over matches,
12382 // duplicating the redirection for each file found": a glob
12383 // target with N matches becomes N members of the same multio
12384 // (`echo hi > *.txt` with two matches writes both files).
12385 let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
12386 for (op_byte, target) in pairs {
12387 match target {
12388 // c:Src/glob.c:2161 — without MULTIOS the word is preforked
12389 // PREFORK_SINGLE: one word, an array joined by sepjoin.
12390 Value::Array(items) if !opt_state_get("multios").unwrap_or(true) => {
12391 let words: Vec<String> = items.iter().map(|v| v.to_str()).collect();
12392 entries.push((op_byte, crate::ported::utils::sepjoin(&words, None)));
12393 }
12394 Value::Array(items) => {
12395 for item in items.iter() {
12396 entries.push((op_byte, item.to_str()));
12397 }
12398 }
12399 other => entries.push((op_byte, other.to_str())),
12400 }
12401 }
12402 if entries.is_empty() {
12403 return Value::Status(1);
12404 }
12405
12406 // c:Src/exec.c:2418 — `else if (!mfds[fd1] || unset(MULTIOS))`:
12407 // with MULTIOS unset every redirect takes the REPLACE path in
12408 // script order — each target is still opened (created /
12409 // truncated) and dup2'd over the fd, so the LAST one wins and
12410 // earlier files end up empty (`unsetopt multios; print x > a
12411 // > b` leaves `a` empty, `x` in `b`). host_apply_redirect is
12412 // exactly one replace step, noclobber gate included.
12413 let multios_on = opt_state_get("multios").unwrap_or(true);
12414 if !multios_on {
12415 with_executor(|exec| {
12416 for (op_byte, target) in &entries {
12417 exec.host_apply_redirect(fd as u8, *op_byte, target);
12418 if exec.redirect_failed {
12419 // c:Src/exec.c execerr — abort the remaining
12420 // redirect list on failure.
12421 break;
12422 }
12423 }
12424 });
12425 return Value::Status(0);
12426 }
12427
12428 if entries.len() == 1 {
12429 // Single member after splicing — a plain replace
12430 // (c:2418 new-multio arm). Route through
12431 // host_apply_redirect so the noclobber gate, the
12432 // pipeline-output split partial, and error handling all
12433 // apply exactly as for an un-bagged redirect.
12434 let (op_byte, target) = &entries[0];
12435 with_executor(|exec| {
12436 exec.host_apply_redirect(fd as u8, *op_byte, target);
12437 });
12438 return Value::Status(0);
12439 }
12440
12441 // c:Src/exec.c:3722-3724 — when this command's stdout IS the
12442 // pipeline output, C seeds mfds[1] with the pipe BEFORE
12443 // walking the redirect list, so the pipe is the multio's
12444 // first member (`print x >&1 > f | cat` sends `x` down the
12445 // pipe TWICE: once for the seed, once for the `>&1` dup).
12446 let pipe_seed = fd == 1
12447 && with_executor(|exec| {
12448 exec.pipe_output_scope
12449 .is_some_and(|d| d + 1 == exec.redirect_scope_stack.len())
12450 });
12451
12452 // Save current fd state for scope-end restoration — BEFORE
12453 // the first member's replace dup2 below.
12454 // c:Src/exec.c:2425 — `int fdN = movefd(fd1); save[fd1] = fdN;`. A SAVED
12455 // descriptor is shell state and must live above the script's fd range:
12456 // plain dup() returns the LOWEST free fd, which parked the saved stdout
12457 // on fd 3, so `print -u 3 -r -- X 2>/dev/null` wrote into the shell's own
12458 // saved descriptor and reported success where zsh says `bad file number`.
12459 // F_DUPFD with a floor of 10 is exactly what movefd does.
12460 // c:Src/exec.c:3978-3986 — a bare `exec`'s redirections (nullexec==1)
12461 // are permanent: the saved copy is closed, not restored, and the
12462 // tee process outlives this command (see the closemn arm below).
12463 let permanent = with_executor(|exec| exec.exec_redirs_permanent);
12464 if permanent {
12465 // Inside an in-process `( … )` / `$( … )` the change is permanent
12466 // only for the subshell (see SubshFdFrame), as for a single target.
12467 crate::ported::exec::SubshFdFrame::touch(fd);
12468 }
12469 // c:2478 — `if (!forked && save[fd1] == -2)`.
12470 let forked = with_executor(|exec| redir_scope_forked(exec.redirect_scope_stack.len()));
12471 let saved = if forked {
12472 -1
12473 } else {
12474 unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) }
12475 };
12476 // c:2426-2437 — "fd1 may already be closed here, so ignore bad
12477 // file descriptor error": `save[fd1] = fdN` stores -1 for a closed
12478 // fd, and the scope-end restore (c:Src/utils.c:2047 `if (x < 0)
12479 // zclose(y)`) then CLOSES it. Without that slot the splitter's pipe
12480 // write end stayed dup'd on the fd, the splitter never saw EOF and
12481 // the scope-end join hung: `print -u3 x 3>a 3>b` never returned.
12482 // A forked child parks no copy, but the splitter's pipe write end
12483 // on `fd` must still go at scope end or its join never sees EOF;
12484 // the -1 slot closes it there, as the child's exit does in C.
12485 let saved_slot = if forked {
12486 Some(-1)
12487 } else if saved >= 0 {
12488 Some(saved)
12489 } else if std::io::Error::last_os_error().raw_os_error() == Some(libc::EBADF) {
12490 Some(-1)
12491 } else {
12492 None
12493 };
12494 if permanent {
12495 // c:4038-4040 — `for (i = 0; i < 10; i++) if (save[i] != -2)
12496 // zclose(save[i]);`
12497 if saved >= 0 {
12498 unsafe { libc::close(saved) };
12499 }
12500 } else if let Some(saved) = saved_slot {
12501 with_executor(|exec| {
12502 if let Some(top) = exec.redirect_scope_stack.last_mut() {
12503 top.push((fd, saved));
12504 mark_saved_fd(saved);
12505 } else if saved >= 0 {
12506 unsafe { libc::close(saved) };
12507 }
12508 });
12509 }
12510
12511 // Accumulate member fds in redirect order. c:Src/exec.c:
12512 // 2447-2480 addfd — the FIRST member REPLACES the fd
12513 // (c:2448-2450 `mfds[fd1]->ct=1; mfds[fd1]->fds[0]=fd1;`), so
12514 // a later numeric `>&N` self-dup resolves against the fd's
12515 // value at that point in the sequence: `print x > f >&1`
12516 // writes f TWICE; `print x >&1 > f` writes the ORIGINAL
12517 // stdout + f.
12518 let mut target_fds: Vec<i32> = Vec::with_capacity(entries.len() + 1);
12519 if pipe_seed {
12520 let p = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
12521 if p >= 0 {
12522 target_fds.push(p);
12523 }
12524 }
12525 let noclobber = opt_state_get("noclobber").unwrap_or(false)
12526 || !opt_state_get("clobber").unwrap_or(true);
12527 for (i, (op_byte, target)) in entries.iter().enumerate() {
12528 let open_result: std::io::Result<i32> = match *op_byte {
12529 r::DUP_WRITE | r::DUP_READ => {
12530 // Numeric `>&N` — dup the LIVE fd N (after any
12531 // earlier member's replace).
12532 match target.trim_start_matches('&').parse::<i32>() {
12533 Ok(src) => {
12534 let d = unsafe { libc::fcntl(src, libc::F_DUPFD_CLOEXEC, 10) };
12535 if d >= 0 {
12536 Ok(d)
12537 } else {
12538 Err(std::io::Error::last_os_error())
12539 }
12540 }
12541 Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
12542 }
12543 }
12544 r::WRITE => {
12545 // c:Src/exec.c clobber_open — noclobber applies
12546 // to multio file targets too; failure aborts the
12547 // remaining redirect list (execerr), so `setopt
12548 // noclobber; touch a; print x > a > b` errors on
12549 // `a` and never creates `b`.
12550 let target_meta = std::fs::metadata(target).ok();
12551 let target_is_regular_file = target_meta
12552 .as_ref()
12553 .map(|m| m.file_type().is_file())
12554 .unwrap_or(false);
12555 // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY re-uses
12556 // an empty regular file under noclobber (same allowance
12557 // as the single-redirect path).
12558 let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
12559 && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
12560 if noclobber && target_is_regular_file && !clobber_empty_ok {
12561 eprintln!(
12562 "{}:{}: file exists: {}",
12563 shname(),
12564 crate::ported::lex::lineno(),
12565 target
12566 );
12567 for prev in &target_fds {
12568 unsafe {
12569 libc::close(*prev);
12570 }
12571 }
12572 with_executor(|exec| {
12573 exec.redirect_failed = true;
12574 });
12575 // Sink the upcoming command's output (mirrors
12576 // the single-redirect noclobber arm in
12577 // host_apply_redirect).
12578 if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
12579 let new_fd = file.into_raw_fd();
12580 unsafe {
12581 libc::dup2(new_fd, fd);
12582 libc::close(new_fd);
12583 }
12584 }
12585 return Value::Status(1);
12586 }
12587 fs::OpenOptions::new()
12588 .write(true)
12589 .create(true)
12590 .truncate(true)
12591 .open(target)
12592 .map(|f| f.into_raw_fd())
12593 }
12594 r::APPEND => fs::OpenOptions::new()
12595 .write(true)
12596 .create(true)
12597 .append(true)
12598 .open(target)
12599 .map(|f| f.into_raw_fd()),
12600 _ => fs::OpenOptions::new()
12601 .write(true)
12602 .create(true)
12603 .truncate(true)
12604 .open(target)
12605 .map(|f| f.into_raw_fd()),
12606 };
12607 match open_result {
12608 Ok(tfd) => {
12609 // c:2463-2470 / c:2481 — every member of a split stream
12610 // is `movefd`'d above the script's fd range. A file opened
12611 // while the target fd is closed comes back AS that fd
12612 // (`print -u3 x 3>a 3>b`: `a` opens on 3), and the pipe
12613 // dup2 below then replaced the member with the splitter's
12614 // own write end — `a` stayed empty and the splitter fed
12615 // its output back into itself.
12616 let tfd = crate::ported::utils::movefd(tfd);
12617 if i == 0 && !pipe_seed {
12618 // c:2448-2450 — first member replaces the fd.
12619 unsafe {
12620 libc::dup2(tfd, fd);
12621 }
12622 }
12623 target_fds.push(tfd);
12624 }
12625 Err(e) => {
12626 // c:Src/exec.c:3741 — `zwarn("%e: %s", errno, fname)`:
12627 // zwarning supplies the `name:LINE:` prefix with the
12628 // REAL current lineno; redir_errno_msg builds the `%e`
12629 // errno message (was a hardcoded ErrorKind match that
12630 // showed generic "redirect failed" for EROFS/etc.).
12631 let msg = redir_errno_msg(&e);
12632 crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
12633 // Close already-opened fds to avoid leaks.
12634 for prev in &target_fds {
12635 unsafe {
12636 libc::close(*prev);
12637 }
12638 }
12639 with_executor(|exec| {
12640 exec.redirect_failed = true;
12641 });
12642 return Value::Status(1);
12643 }
12644 }
12645 }
12646
12647 // Create the splitter pipe.
12648 let (read_end, write_end) = match os_pipe::pipe() {
12649 Ok(p) => p,
12650 Err(_) => {
12651 for f in &target_fds {
12652 unsafe {
12653 libc::close(*f);
12654 }
12655 }
12656 return Value::Status(1);
12657 }
12658 };
12659 // c:Src/exec.c:5222 — `pp[0] = movefd(pp[0]);` in `mpipe()`.
12660 // c:Src/utils.c:1990-2012 movefd — "if(fd != -1 && fd < 10)"
12661 // dup into the >=10 range and zclose the low copy, then mark
12662 // the result FDT_INTERNAL. Every shell-internal fd goes
12663 // through this so it can never share a number with the
12664 // user-visible `>&N` range that the redirect bookkeeping
12665 // opens/dups/closes. This splitter kept the raw (low) pipe
12666 // read end, so an unrelated close of that number shut it
12667 // under the splitter thread and dropping the owned
12668 // PipeReader aborted the process with std's "IO Safety
12669 // violation: owned file descriptor already closed"
12670 // (E01options.ztst:46 `( echo hello ) >a >b`, ~2 runs in 3).
12671 let read_end = unsafe {
12672 <os_pipe::PipeReader as std::os::unix::io::FromRawFd>::from_raw_fd(
12673 crate::extensions::fds::movefd(read_end.into_raw_fd()),
12674 )
12675 };
12676 let pipe_write_raw = AsRawFd::as_raw_fd(&write_end);
12677 if permanent && (0..10).contains(&fd) {
12678 // c:Src/exec.c:4014-4016 — "We are done with redirection. close
12679 // the mnodes, spawning tee/cat processes as necessary." A bare
12680 // `exec 3>&1 3>&2` keeps the multio for the rest of the shell's
12681 // life, so the scope-bound splitter thread below (drained and
12682 // joined at scope end) cannot serve it: nothing ever ends the
12683 // scope, and the thread dies with the process before it copies
12684 // what the last command wrote. closemn forks the tee process C
12685 // uses for every multio; it reads until the shell's last copy
12686 // of the pipe write end closes, i.e. after the shell exits.
12687 // c:4045 — "We're done with this job, no need to wait for it."
12688 let write_dup = unsafe { libc::fcntl(pipe_write_raw, libc::F_DUPFD_CLOEXEC, 10) };
12689 drop(write_end);
12690 if write_dup < 0 {
12691 for f in &target_fds {
12692 unsafe { libc::close(*f) };
12693 }
12694 return Value::Status(1);
12695 }
12696 unsafe {
12697 libc::dup2(write_dup, fd);
12698 libc::close(write_dup);
12699 }
12700 let mn = crate::ported::zsh_h::multio {
12701 ct: target_fds.len() as i32,
12702 rflag: 1, // c:2462 — output multio: a tee, not a cat
12703 pipe: std::os::unix::io::IntoRawFd::into_raw_fd(read_end),
12704 fds: target_fds,
12705 };
12706 let mut mfds: [Option<Box<crate::ported::zsh_h::multio>>; 10] = Default::default();
12707 mfds[fd as usize] = Some(Box::new(mn));
12708 crate::ported::exec::closemn(&mut mfds, fd, crate::ported::zsh_h::REDIR_CLOSE);
12709 return Value::Status(0);
12710 }
12711 // Spawn the splitter thread: read pipe → write every chunk
12712 // to every target fd. Each write inside the thread uses
12713 // libc::write directly on the raw fd (no Rust File ownership
12714 // so the splitter can close after EOF without racing main).
12715 let target_fds_for_thread = target_fds.clone();
12716 let handle = std::thread::spawn(move || {
12717 let mut r = read_end;
12718 let mut buf = [0u8; 8192];
12719 loop {
12720 match std::io::Read::read(&mut r, &mut buf) {
12721 Ok(0) => break,
12722 Ok(n) => {
12723 for &tfd in &target_fds_for_thread {
12724 let mut off = 0;
12725 while off < n {
12726 let w = unsafe {
12727 libc::write(
12728 tfd,
12729 buf[off..n].as_ptr() as *const libc::c_void,
12730 n - off,
12731 )
12732 };
12733 if w <= 0 {
12734 break;
12735 }
12736 off += w as usize;
12737 }
12738 }
12739 }
12740 Err(_) => break,
12741 }
12742 }
12743 // Close every target so file contents flush.
12744 for tfd in target_fds_for_thread {
12745 unsafe {
12746 libc::close(tfd);
12747 }
12748 }
12749 });
12750
12751 // Dup the pipe write-end onto the target fd; close the
12752 // original write_end so EOF arrives when host_redirect_scope_end
12753 // closes our tracked pipe_write_fd.
12754 let write_dup = unsafe { libc::fcntl(pipe_write_raw, libc::F_DUPFD_CLOEXEC, 10) };
12755 drop(write_end);
12756 if write_dup < 0 {
12757 return Value::Status(1);
12758 }
12759 unsafe {
12760 libc::dup2(write_dup, fd);
12761 libc::close(write_dup);
12762 }
12763 // Track the running splitter so scope-end can drain + join.
12764 // The "write_fd" we store is the user-visible fd (e.g. 1).
12765 // Closing that fd at scope-end isn't quite right; we need a
12766 // way to send EOF. Solution: track the write_dup we just
12767 // closed; instead keep a second dup for the close-on-end.
12768 // Shell-internal bookkeeping fd — above the script's range (movefd).
12769 let close_on_end = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
12770 with_executor(|exec| {
12771 if let Some(top) = exec.multios_scope_stack.last_mut() {
12772 top.push((close_on_end, handle));
12773 } else {
12774 // No scope — leak the dup; thread will keep running
12775 // until process exit. Should not happen because
12776 // host_redirect_scope_begin pushed a frame.
12777 unsafe { libc::close(close_on_end) };
12778 }
12779 });
12780 Value::Status(0)
12781 });
12782 // c:Src/exec.c:2418 input-arm — MULTIOS read fan-in. Stack
12783 // layout pushed by compile_zsh (mirrors the write side):
12784 // [source_1, op_1, source_2, op_2, …, source_N, op_N, fd]
12785 // argc = 2N + 1; op distinguishes file opens (READ) from numeric
12786 // `<&N` dups (DUP_READ); a glob source arrives as Value::Array
12787 // and splices into one member per match (c:Src/glob.c:2195-2203).
12788 // Opens every source, sets up a pipe + producer thread that
12789 // reads each source in order and writes to the pipe write-end,
12790 // then closes its write-end so the consumer gets EOF. dup2 the
12791 // pipe read-end onto fd. Bug #36 input side in docs/BUGS.md.
12792 vm.register_builtin(BUILTIN_MULTIOS_READ, |vm, argc| {
12793 if argc < 3 || argc % 2 == 0 {
12794 return Value::Status(1);
12795 }
12796 let fd = vm.pop().to_int() as i32;
12797 let n_sources = ((argc - 1) / 2) as usize;
12798 let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_sources);
12799 for _ in 0..n_sources {
12800 let op_byte = vm.pop().to_int() as u8;
12801 let source = vm.pop();
12802 pairs.push((op_byte, source));
12803 }
12804 pairs.reverse();
12805 // c:Src/exec.c:3785-3796 — an errflag from expanding a target fails
12806 // the redirection (see redir_target_expansion_failed).
12807 if with_executor(redir_target_expansion_failed) {
12808 return Value::Status(1);
12809 }
12810
12811 // Splice glob match arrays (c:Src/glob.c:2195-2203).
12812 let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
12813 for (op_byte, source) in pairs {
12814 match source {
12815 // c:Src/glob.c:2161 — without MULTIOS the word is preforked
12816 // PREFORK_SINGLE: one word, an array joined by sepjoin.
12817 Value::Array(items) if !opt_state_get("multios").unwrap_or(true) => {
12818 let words: Vec<String> = items.iter().map(|v| v.to_str()).collect();
12819 entries.push((op_byte, crate::ported::utils::sepjoin(&words, None)));
12820 }
12821 Value::Array(items) => {
12822 for item in items.iter() {
12823 entries.push((op_byte, item.to_str()));
12824 }
12825 }
12826 other => entries.push((op_byte, other.to_str())),
12827 }
12828 }
12829 if entries.is_empty() {
12830 return Value::Status(1);
12831 }
12832
12833 // c:Src/exec.c:2418 — `unset(MULTIOS)`: sequential replace,
12834 // last source wins (`unsetopt multios; cat < a < b` reads
12835 // only b; a is still opened — and errors still surface).
12836 let multios_on = opt_state_get("multios").unwrap_or(true);
12837 if !multios_on {
12838 with_executor(|exec| {
12839 for (op_byte, source) in &entries {
12840 multios_apply_member(exec, fd, *op_byte, source);
12841 if exec.redirect_failed {
12842 break;
12843 }
12844 }
12845 });
12846 return Value::Status(0);
12847 }
12848
12849 // c:Src/exec.c:3775-3777 — a pipeline stage's input pipe is already
12850 // mfds[0]'s first member, so even one source concatenates.
12851 let pipe_seed = with_executor(|exec| take_pipe_input_seed(exec, fd));
12852 if entries.len() == 1 && pipe_seed.is_none() {
12853 // Single member after splicing — plain replace.
12854 let (op_byte, source) = &entries[0];
12855 with_executor(|exec| {
12856 multios_apply_member(exec, fd, *op_byte, source);
12857 });
12858 return Value::Status(0);
12859 }
12860
12861 // Save current fd state for scope-end restoration — BEFORE
12862 // the first member's replace dup2 below.
12863 // c:Src/exec.c:2425 — `int fdN = movefd(fd1); save[fd1] = fdN;`. A SAVED
12864 // descriptor is shell state and must live above the script's fd range:
12865 // plain dup() returns the LOWEST free fd, which parked the saved stdout
12866 // on fd 3, so `print -u 3 -r -- X 2>/dev/null` wrote into the shell's own
12867 // saved descriptor and reported success where zsh says `bad file number`.
12868 // F_DUPFD with a floor of 10 is exactly what movefd does.
12869 // c:2478 — `if (!forked && save[fd1] == -2)`.
12870 let forked = with_executor(|exec| redir_scope_forked(exec.redirect_scope_stack.len()));
12871 let saved = if forked {
12872 -1
12873 } else {
12874 unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) }
12875 };
12876 // c:2426-2437 — a closed fd saves -1, restored by closing it (see
12877 // the write-side multio above).
12878 // A forked child parks no copy, but the pipe read end on `fd` must
12879 // still go at scope end or a splitter blocked on a full pipe never
12880 // finishes its join; the -1 slot closes it there, as the child's
12881 // exit does in C.
12882 let saved_slot = if forked {
12883 Some(-1)
12884 } else if saved >= 0 {
12885 Some(saved)
12886 } else if std::io::Error::last_os_error().raw_os_error() == Some(libc::EBADF) {
12887 Some(-1)
12888 } else {
12889 None
12890 };
12891 if let Some(saved) = saved_slot {
12892 with_executor(|exec| {
12893 if let Some(top) = exec.redirect_scope_stack.last_mut() {
12894 top.push((fd, saved));
12895 mark_saved_fd(saved);
12896 } else if saved >= 0 {
12897 unsafe { libc::close(saved) };
12898 }
12899 });
12900 }
12901
12902 // Open every source in redirect order; numeric `<&N` dups
12903 // resolve against the LIVE fd table. First member replaces
12904 // the fd (c:2448-2450) so later self-dups see it.
12905 let mut source_fds: Vec<i32> = Vec::with_capacity(entries.len() + 1);
12906 source_fds.extend(pipe_seed);
12907 for (i, (op_byte, source)) in entries.iter().enumerate() {
12908 let open_result: std::io::Result<i32> = match *op_byte {
12909 r::DUP_READ | r::DUP_WRITE => match source.trim_start_matches('&').parse::<i32>() {
12910 Ok(src) => {
12911 let d = unsafe { libc::fcntl(src, libc::F_DUPFD_CLOEXEC, 10) };
12912 if d >= 0 {
12913 Ok(d)
12914 } else {
12915 Err(std::io::Error::last_os_error())
12916 }
12917 }
12918 Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
12919 },
12920 // c:Src/exec.c:3821-3835 — `fil = getherestr(fn);`
12921 MULTIOS_OP_HERESTR_BODY | MULTIOS_OP_HEREDOC_BODY => {
12922 multios_body_fd(source, *op_byte == MULTIOS_OP_HERESTR_BODY)
12923 }
12924 _ => fs::File::open(source).map(|f| f.into_raw_fd()),
12925 };
12926 match open_result {
12927 Ok(opened) => {
12928 // c:Src/exec.c:2458-2465 — addfd moves every multio member
12929 // out of the script's fd range (`fdN = movefd(fd1)`,
12930 // `fdN = movefd(fd2)`). A member opened by `open` gets the
12931 // LOWEST free descriptor, which for `cat 3<o1 3<o2 <&3` is
12932 // 3 itself: the `dup2` of the concatenator pipe onto fd 3
12933 // below then closed o1, the producer read its own empty
12934 // pipe instead, and the command hung.
12935 let tfd = crate::ported::utils::movefd(opened);
12936 if i == 0 {
12937 unsafe {
12938 libc::dup2(tfd, fd);
12939 }
12940 }
12941 source_fds.push(tfd);
12942 }
12943 Err(e) => {
12944 let msg = match e.kind() {
12945 std::io::ErrorKind::PermissionDenied => "permission denied",
12946 std::io::ErrorKind::NotFound => "no such file or directory",
12947 _ => "open failed",
12948 };
12949 // c:Src/exec.c:3741 — zwarn with real lineno prefix.
12950 crate::ported::utils::zwarn(&format!("{}: {}", msg, source));
12951 for prev in &source_fds {
12952 unsafe {
12953 libc::close(*prev);
12954 }
12955 }
12956 with_executor(|exec| {
12957 exec.redirect_failed = true;
12958 });
12959 return Value::Status(1);
12960 }
12961 }
12962 }
12963
12964 // Create the concatenator pipe.
12965 if !with_executor(|exec| multios_read_concat(exec, fd, source_fds)) {
12966 return Value::Status(1);
12967 }
12968 Value::Status(0)
12969 });
12970 // c:Src/exec.c:3978-3986 — nullexec==1 marker. See the const's
12971 // doc block. Arg: 1 = entering a bare-exec redirect, 0 = leaving.
12972 vm.register_builtin(BUILTIN_EXEC_PERM_REDIRS, |vm, _argc| {
12973 let on = vm.pop().to_int() != 0;
12974 with_executor(|exec| exec.exec_redirs_permanent = on);
12975 Value::Status(0)
12976 });
12977 // Bare-exec redirect epilogue — see the const's doc block.
12978 // c:Src/exec.c:252-259 (execerr) + c:4367-4386 (done: POSIX gate).
12979 vm.register_builtin(BUILTIN_EXEC_REDIR_DONE, |vm, _argc| {
12980 use std::sync::atomic::Ordering;
12981 let failed = with_executor(|exec| {
12982 let f = exec.redirect_failed;
12983 exec.redirect_failed = false;
12984 f
12985 });
12986 if !failed {
12987 // c:Src/exec.c:4032 — `lastval = errflag ? errflag : cmdoutval;`
12988 // cmdoutval is NOT reset by a command without assignments, so
12989 // a `$(...)` from an earlier command (`x=$(false); exec 3>f`)
12990 // still decides this status.
12991 let ef = crate::ported::utils::errflag.load(Ordering::Relaxed);
12992 return Value::Status(if ef != 0 { ef } else { crate::ported::exec::cmdoutval.load(Ordering::Relaxed) });
12993 }
12994 // c:255 — `redir_err = lastval = 1`.
12995 vm.last_status = 1;
12996 if isset(crate::ported::zsh_h::POSIXBUILTINS) && !isset(crate::ported::zsh_h::INTERACTIVE) {
12997 // c:4379-4383 — non-interactive POSIX fatal: exit(1).
12998 // In-process equivalent: arm EXIT_PENDING/EXIT_VAL so the
12999 // next BUILTIN_ERREXIT_CHECK (trigger 2) unwinds the
13000 // script with status 1 — same deferred-exit shape the
13001 // `exit` builtin uses inside subshell contexts.
13002 crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
13003 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
13004 }
13005 Value::Status(1)
13006 });
13007 // c:Src/exec.c:3722-3724 — see the const's doc block. No args.
13008 // BUILTIN_XTRERR_COPY — c:Src/exec.c:3765-3773, emitted right before a
13009 // simple command's WithRedirectsBegin. No args.
13010 vm.register_builtin(BUILTIN_XTRERR_COPY, |_vm, _argc| {
13011 xtrerr_flush(); // c:3766 fflush(xtrerr)
13012 if isset(crate::ported::zsh_h::XTRACE)
13013 && crate::ported::utils::xtrerr.load(std::sync::atomic::Ordering::Relaxed) == libc::STDERR_FILENO
13014 {
13015 // c:3767-3769
13016 let fd = crate::ported::utils::movefd(unsafe { libc::dup(libc::STDERR_FILENO) });
13017 if fd >= 0 {
13018 // c:Src/exec.c:779 `closem(FDT_XTRACE, 0)` closes this fd in a
13019 // child before it execs; zshrs's spawns run no closem, so
13020 // close-on-exec stands in for it.
13021 unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
13022 crate::ported::utils::xtrerr.store(fd, std::sync::atomic::Ordering::Relaxed); // c:3770
13023 crate::ported::utils::fdtable_set(fd, crate::ported::zsh_h::FDT_XTRACE); // c:3771
13024 let depth = with_executor(|exec| exec.redirect_scope_stack.len()) + 1;
13025 XTRERR_COPIES.with(|v| v.borrow_mut().push((depth, fd)));
13026 }
13027 }
13028 Value::Status(0)
13029 });
13030 vm.register_builtin(BUILTIN_PIPE_OUTPUT_MARK, |_vm, _argc| {
13031 with_executor(|exec| exec.pipe_output_pending = true);
13032 Value::Status(0)
13033 });
13034 // c:Src/exec.c:3710-3724 — install this pipeline stage's fds.
13035 // /* Make a copy of stderr for xtrace output before redirecting */
13036 // fflush(xtrerr);
13037 // ...
13038 // /* Add pipeline input/output to mnodes */
13039 // if (input) addfd(forked, save, mfds, 0, input, 0, NULL);
13040 // if (output) addfd(forked, save, mfds, 1, output, 1, NULL);
13041 // Emitted into the stage chunk by compile_zsh.rs (after the arg
13042 // words' expansion ops, before the redirect scope), and fed by
13043 // BUILTIN_RUN_PIPELINE via `stage_fds_park`. Doing the dup2 HERE
13044 // rather than before the chunk runs is what makes a `$(...)` in a
13045 // stage's arguments see the shell's fd 0 instead of the pipe.
13046 vm.register_builtin(BUILTIN_PIPE_FDS_INSTALL, |vm, argc| {
13047 // Arg: `|&` merge-stderr flag (compile_zsh always passes it).
13048 let merge_stderr = pop_args(vm, argc)
13049 .first()
13050 .map(|s| s != "0" && !s.is_empty())
13051 .unwrap_or(false);
13052 let (in_fd, out_fd) = stage_fds_take();
13053 if in_fd < 0 && out_fd < 0 {
13054 return Value::Status(0);
13055 }
13056 // c:3711 `fflush(xtrerr)` — flush before the fds move, so
13057 // anything buffered from the expansion phase lands on the
13058 // ORIGINAL fd, not on the pipe.
13059 let _ = std::io::stdout().flush();
13060 let _ = std::io::stderr().flush();
13061 unsafe {
13062 if in_fd >= 0 {
13063 libc::dup2(in_fd, libc::STDIN_FILENO);
13064 if in_fd != libc::STDIN_FILENO {
13065 libc::close(in_fd);
13066 }
13067 // c:3775-3776 — the pipe is mfds[0]'s first member.
13068 PIPE_INPUT_INSTALLED.with(|c| c.set(Some(SUBLIST_SERIAL.with(|s| s.get()))));
13069 }
13070 if out_fd >= 0 {
13071 libc::dup2(out_fd, libc::STDOUT_FILENO);
13072 if out_fd != libc::STDOUT_FILENO {
13073 libc::close(out_fd);
13074 }
13075 // `cmd |& next`: the `2>&1` C appends to cmd's redirect
13076 // list (walked at c:3730+, i.e. after this addfd), so
13077 // stderr follows the pipe, not the shell's stdout.
13078 if merge_stderr {
13079 libc::dup2(libc::STDOUT_FILENO, libc::STDERR_FILENO);
13080 }
13081 }
13082 }
13083 Value::Status(0)
13084 });
13085 // c:Src/exec.c — block-level redirect-failure gate. When a
13086 // compound command (`{ … } < file`, `( … ) > file`, etc.) has a
13087 // failing redirect (e.g. `< /nonexistent`), zsh skips the entire
13088 // body AND sets lastval to 1. The simple-command path's
13089 // redirect_failed check (line 215-221 above) only catches the
13090 // failure when a builtin dispatches and is consumed by that
13091 // single builtin call — so a multi-statement block kept running
13092 // its remaining statements after the redir error. Emit-side at
13093 // compile_zsh.rs::compile_command's Redirected arm pairs this
13094 // with a JumpIfTrue → WithRedirectsEnd to abandon the body.
13095 vm.register_builtin(BUILTIN_REDIRECT_FAILED_CHECK, |vm, _argc| {
13096 let failed = with_executor(|exec| {
13097 let f = exec.redirect_failed;
13098 exec.redirect_failed = false;
13099 f
13100 });
13101 if failed {
13102 vm.last_status = 1;
13103 Value::Int(1)
13104 } else {
13105 Value::Int(0)
13106 }
13107 });
13108 // c:Src/exec.c — drop-in replacement for fusevm's Op::Exec used by
13109 // the dynamic-first-word path (`$cmd`, `$(cmd)`, glob-named cmds).
13110 // fusevm's Op::Exec returns Value::Status(0) when post-expansion
13111 // argv is empty (vm.rs:1722) — that clobbers \$? for the
13112 // `\$(exit 1); echo \$?` case where the cmd-subst left
13113 // last_status = 1 but the empty expansion gets exec'd to 0.
13114 // Mirror C zsh: when the word list is empty after expansion,
13115 // \$? becomes whatever the inner cmd-subst's last_status is
13116 // (preserved here by returning Value::Status(last_status)).
13117 // c:Src/cond.c:308-316 — `if (!(pprog = patcompile(right, ...)))
13118 // { zwarnnam(fromtest, "bad pattern: %s", right); return 2; }`.
13119 // The cond path must NOT use str_match/glob_match_static: the
13120 // case-statement consumer of those follows Src/loop.c:667 zerr
13121 // semantics (errflag abort), while cond is a zwarn + status-2
13122 // soft failure. COND_BAD_PATTERN carries the 2 across the
13123 // Bool-shaped stack contract (so `!=`'s LogNot can't lose it).
13124 thread_local! {
13125 static COND_BAD_PATTERN: std::cell::Cell<bool> =
13126 const { std::cell::Cell::new(false) };
13127 }
13128 vm.register_builtin(BUILTIN_COND_STRMATCH, |vm, _argc| {
13129 let pat = pattern_filesub(&vm.pop().to_str());
13130 let s = vm.pop().to_str();
13131 // bash `shopt -s nocasematch` → case-insensitive `[[ == ]]` / `[[ != ]]`.
13132 // Lowercase BOTH sides for the match decision (glob metacharacters are
13133 // not letters, so the pattern's `*`/`?`/`[…]` structure is preserved).
13134 // No-op unless the bash shopt is active. --zsh unaffected.
13135 let (s, pat) = if crate::dash_mode::nocasematch() {
13136 (s.to_lowercase(), pat.to_lowercase())
13137 } else {
13138 (s, pat)
13139 };
13140 // A bare POSIX-family drop-in reads a source-level `(` by its own
13141 // rules, not by zsh's parse-time tokenization — see
13142 // `dropin_source_pattern_parens_literal`.
13143 let pat_eff = if crate::pattern_data_escape::dropin_source_pattern_parens_literal() {
13144 crate::pattern_data_escape::escape_shglob_parens(
13145 &pat,
13146 crate::pattern_data_escape::dropin_keeps_ksh_groups(),
13147 )
13148 } else {
13149 pat.clone()
13150 };
13151 let mut pat_tok = pat_eff.clone();
13152 crate::ported::glob::tokenize(&mut pat_tok);
13153 if crate::ported::pattern::patcompile(
13154 &pat_tok,
13155 crate::ported::zsh_h::PAT_STATIC as i32,
13156 None,
13157 )
13158 .is_none()
13159 {
13160 // c:314 — zwarnnam(fromtest, "bad pattern: %s", right).
13161 crate::ported::utils::zwarn(&format!("bad pattern: {}", pat));
13162 COND_BAD_PATTERN.with(|c| c.set(true));
13163 return Value::Bool(false);
13164 }
13165 // Match via the shared engine so `(#b)`/`(#m)` backref and
13166 // MATCH-variable population stays in one place.
13167 Value::Bool(crate::vm_helper::glob_match_static(&s, &pat_eff))
13168 });
13169 // See BUILTIN_RM_STAR_CHECK.
13170 vm.register_builtin(BUILTIN_RM_STAR_CHECK, |vm, argc| {
13171 use crate::ported::zsh_h::{interact, isset, ERRFLAG_ERROR, RMSTARSILENT, SHINSTDIN, Star};
13172 use std::sync::atomic::Ordering;
13173 let mask = vm.pop().to_int() as u64;
13174 let n = (argc as usize).saturating_sub(1);
13175 let mut words: Vec<Value> = (0..n).map(|_| vm.pop()).collect();
13176 words.reverse();
13177 // c:3549-3551 — `type == WC_SIMPLE && interact && unset(RMSTARSILENT)
13178 // && isset(SHINSTDIN)` and at least one argument after `rm`.
13179 if interact() && !isset(RMSTARSILENT) && isset(SHINSTDIN) && n > 0 {
13180 let is_star = |c: char| c == Star || c == '*';
13181 for (i, word) in words.iter().enumerate() {
13182 // c:3554 — `node && !errflag`
13183 if crate::ported::utils::errflag.load(Ordering::Relaxed) != 0 {
13184 break;
13185 }
13186 if i >= 64 || mask & (1u64 << i) == 0 {
13187 continue;
13188 }
13189 let s: Vec<char> = word.to_str().chars().collect();
13190 let l = s.len();
13191 let rmall = if l == 1 && is_star(s[0]) {
13192 // c:3559-3560 — `checkrmall(pwd)`
13193 let pwd = crate::ported::params::getsparam("PWD").unwrap_or_default();
13194 crate::ported::utils::checkrmall(&pwd)
13195 } else if l >= 2 && s[l - 2] == '/' && is_star(s[l - 1]) {
13196 // c:3564-3570 — the word up to the `/`, or `/` itself.
13197 let dir: String = if l == 2 { "/".to_string() } else { s[..l - 2].iter().collect() };
13198 crate::ported::utils::checkrmall(&dir)
13199 } else {
13200 true
13201 };
13202 if !rmall {
13203 crate::ported::utils::errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed); // c:3561 / c:3573
13204 break;
13205 }
13206 }
13207 }
13208 for w in words {
13209 vm.push(w);
13210 }
13211 Value::Int(0)
13212 });
13213 // c:Src/cond.c:122-193, the COND_MODI arm: `[[ a -op b ]]` with an `-op`
13214 // that is not one of get_cond_num's (Src/parse.c:2697-2701). See
13215 // BUILTIN_COND_MODI.
13216 vm.register_builtin(BUILTIN_COND_MODI, |vm, _argc| {
13217 let is_dash = |s: &str| s.starts_with('-') || s.starts_with(crate::ported::zsh_h::Dash);
13218 let mut name = vm.pop().to_str(); // c:130 — the operator word
13219 let right = vm.pop().to_str();
13220 let left = vm.pop().to_str();
13221 let mut strs = vec![left, right]; // c:136-141
13222 let l: i32 = 2; // c:141
13223 // c:143-148
13224 let errname = if is_dash(&name) {
13225 crate::lex::untokenize(&name)
13226 } else if is_dash(&strs[0]) {
13227 crate::lex::untokenize(&strs[0])
13228 } else {
13229 "<null>".to_string()
13230 };
13231 let lookup = |inf: i32, word: &str| {
13232 let cond = word.chars().skip(1).collect::<String>(); // `name + 1`
13233 match crate::ported::module::MODULESTAB.try_lock() {
13234 Ok(mut tab) => crate::ported::module::getconddef(inf, &cond, 1, &mut tab),
13235 Err(_) => None,
13236 }
13237 };
13238 // c:149-150
13239 let mut cd = if is_dash(&name) { lookup(1, &name) } else { None };
13240 if cd.is_none() {
13241 // c:161-174 — retry as a prefix condition on the left word, with
13242 // the operator word moved into its place.
13243 let s = std::mem::replace(&mut strs[0], name); // c:173
13244 name = s; // c:174
13245 cd = if is_dash(&name) { lookup(0, &name) } else { None }; // c:176-177
13246 match cd.as_ref() {
13247 Some(c) if l < c.min || (c.max >= 0 && l > c.max) => {
13248 // c:178-181
13249 crate::ported::utils::zerr(&format!("unknown condition: {}", errname));
13250 COND_BAD_PATTERN.with(|c| c.set(true));
13251 return Value::Bool(false);
13252 }
13253 Some(_) => {}
13254 None => {
13255 // c:186-192 — `zwarnnam(…, "unknown condition: %s",
13256 // errname)` then `return 2;`, the status the shared
13257 // BUILTIN_COND_STATUS_FROM_BOOL tail turns into an error.
13258 crate::ported::utils::zerr(&format!("unknown condition: {}", errname));
13259 COND_BAD_PATTERN.with(|c| c.set(true));
13260 return Value::Bool(false);
13261 }
13262 }
13263 }
13264 // c:158 / c:185 — `return !cd->handler(strs, cd->condid);`
13265 let r = cd
13266 .and_then(|c| c.handler.map(|h| h(&strs, c.condid)))
13267 .unwrap_or(0);
13268 Value::Bool(r != 0)
13269 });
13270
13271 vm.register_builtin(BUILTIN_COND_UNKNOWN, |vm, _argc| {
13272 // c:Src/cond.c:150-188 — `zwarnnam(fromtest, "unknown condition: %s",
13273 // name)` for a `-X` op with no matching cond module. Like a cond
13274 // syntax error it yields status 2 and aborts: arm COND_BAD_PATTERN so
13275 // the downstream BUILTIN_COND_STATUS_FROM_BOOL carries the 2 across the
13276 // Bool-shaped stack and runs the shared errflag+set_last_status(2)+abort
13277 // path (c:Src/exec.c:5216-5221). Returns Bool(false) as the operand.
13278 let op = vm.pop().to_str();
13279 crate::ported::utils::zerr(&format!("unknown condition: {}", op));
13280 COND_BAD_PATTERN.with(|c| c.set(true));
13281 Value::Bool(false)
13282 });
13283 vm.register_builtin(BUILTIN_COND_STATUS_FROM_BOOL, |vm, _argc| {
13284 // `${~pat}` / `${(P)~pat}` inside a `[[ … ]]` operand flips
13285 // GLOB_SUBST on via the tilde carrier so the pattern match sees
13286 // active metacharacters. In C that flag is prefork-scoped and
13287 // gone once the operand is consumed; zshrs restores it at the
13288 // next command-dispatch boundary, but a bare `[[ … ]]` has no
13289 // trailing assignment to trigger that — so globsubst leaked ON
13290 // into the NEXT command's word expansion, filename-generating a
13291 // scalar value it should not (p10k `_p9k_set_prompt`: line 45
13292 // `[[ … != ${(P)~disabled} ]]` leaked into line 46's
13293 // `local val=$arr[idx]`, whose glob-char-laden value then hit
13294 // "no matches found" and aborted the whole prompt build →
13295 // garbled 25-line prompt / interactive hang). Consume the
13296 // carrier here: this builtin ends EVERY `[[ … ]]`, and runs
13297 // after the operands (and their pattern match) are done.
13298 consume_tilde_globsubst_carrier();
13299 let ok = vm.pop().to_int() != 0;
13300 let bad = COND_BAD_PATTERN.with(|c| {
13301 let b = c.get();
13302 c.set(false);
13303 b
13304 });
13305 if bad {
13306 // c:Src/exec.c:5216-5221 — `stat = evalcond(...);
13307 // /* 2 indicates a syntax error. For compatibility,
13308 // turn this into a shell error. */
13309 // if (stat == 2) errflag |= ERRFLAG_ERROR;`
13310 // The errflag abort exits the script with lastval (2),
13311 // matching `zsh -fc '[[ x == [a- ]]; print rc=$?'`
13312 // printing nothing after the diagnostic and exiting 2.
13313 crate::ported::utils::errflag.fetch_or(
13314 crate::ported::zsh_h::ERRFLAG_ERROR,
13315 std::sync::atomic::Ordering::Relaxed,
13316 );
13317 with_executor(|exec| exec.set_last_status(2));
13318 return Value::Int(2); // c:Src/cond.c:316 `return 2;`
13319 }
13320 let status: i32 = if ok { 0 } else { 1 };
13321 // c:Src/exec.c:5216 — `lastval = evalcond(...)`: the conditional's
13322 // result IS the command's lastval, and c:Src/cond.c's evalcond
13323 // never inspects errflag while evaluating. So when a `[[ … ]]`
13324 // operand raised errflag (e.g. a nounset "parameter not set" zerr
13325 // on `${arr[99]}` under NO_UNSET), zsh STILL completes the test and
13326 // exits with the cond result; the errflag only aborts the FOLLOWING
13327 // commands. Sync the result to the executor's live lastval HERE —
13328 // the nounset site left it at a transient 1, and the next
13329 // BUILTIN_ERREXIT_CHECK reads the executor (not vm.last_status), so
13330 // without this sync `setopt NO_UNSET; [[ -z ${arr[99]} ]]` exited 1
13331 // instead of 0. The Op::SetStatus that follows sets vm.last_status;
13332 // this keeps the executor coherent with it before the abort check.
13333 with_executor(|exec| exec.set_last_status(status));
13334 Value::Int(status as i64)
13335 });
13336 // c:Src/exec.c:2796 (execcmd_analyse) / c:1368 (execsimple) — a simple
13337 // command that carries assignments starts with `cmdoutval = 0;`.
13338 vm.register_builtin(BUILTIN_CMDOUTVAL_RESET, |_vm, _argc| {
13339 crate::ported::exec::cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
13340 Value::Status(0)
13341 });
13342 vm.register_builtin(BUILTIN_USE_CMDOUTVAL_RESET, |_vm, _argc| {
13343 crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
13344 Value::Status(0)
13345 });
13346
13347 // See BUILTIN_MARK_CURSH.
13348 vm.register_builtin(BUILTIN_MARK_CURSH, |_vm, _argc| {
13349 crate::exec_jobs::mark_thisjob_cursh(true); // c:Src/exec.c:3674-3682
13350 Value::Int(0)
13351 });
13352 // See BUILTIN_EXEC_DYNAMIC_REDIRS.
13353 vm.register_builtin(BUILTIN_EXEC_DYNAMIC_REDIRS, |_vm, _argc| {
13354 EXEC_DYNAMIC_REDIRS.with(|c| c.set(true));
13355 Value::Int(0)
13356 });
13357 vm.register_builtin(BUILTIN_EXEC_DYNAMIC, |vm, argc| {
13358 let raw = pop_args(vm, argc);
13359 // Taken first so every return path below clears it.
13360 let had_redirs = EXEC_DYNAMIC_REDIRS.with(|c| c.replace(false));
13361 // Flatten Array entries into argv slots (matches fusevm
13362 // Op::Exec's flatten at vm.rs:1660-1665) so `${arr[@]}` /
13363 // splice expansions produce one argv slot per element.
13364 let args: Vec<String> = raw.into_iter().collect();
13365 // c:Src/subst.c paramsubst — when `${var:?msg}` or
13366 // `${var?msg}` set errflag, the expansion may produce empty
13367 // argv[0] which would fall into the EACCES/permission-denied
13368 // path below, masking the real paramsubst diagnostic with a
13369 // spurious "permission denied:" line and rc=126. Honour
13370 // errflag so the simple command ends with the paramsubst
13371 // error as the sole diagnostic, rc=1. Bug #86.
13372 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
13373 & crate::ported::zsh_h::ERRFLAG_ERROR)
13374 != 0
13375 {
13376 if PREFIX_ASSIGN_EXIT_PENDING.with(|c| c.get()) {
13377 settle_prefix_assign_exit(args.first().map(String::as_str).unwrap_or(""));
13378 }
13379 // c:Src/exec.c:4343-4350 — when the error came from the prefix
13380 // assignments and the expanded name is an external command, they
13381 // ran in the forked child (`addvars(…); if (errflag) _exit(1);`):
13382 // the shell sees status 1 and its errflag is untouched.
13383 if PREFIX_ASSIGN_FAILED.with(|c| c.replace(false)) {
13384 if let Some(name) = args.first() {
13385 let is_external = !with_executor(|exec| exec.function_exists(name))
13386 && !crate::ported::builtin::createbuiltintable().contains_key(name.as_str());
13387 if is_external {
13388 // Both bits: `${name?msg}` also sets ERRFLAG_HARD.
13389 crate::ported::utils::errflag.fetch_and(
13390 !(crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
13391 std::sync::atomic::Ordering::Relaxed,
13392 );
13393 with_executor(|exec| exec.set_last_status(1));
13394 }
13395 }
13396 }
13397 // c:Src/exec.c:3719 + 3755-3763 — the argument glob of an
13398 // external runs in the forked child as well (`x=cat; $x
13399 // *nomatch; print after` prints `after`).
13400 if let Some(name) = args.first() {
13401 let is_external = !with_executor(|exec| exec.function_exists(name))
13402 && (files_builtin_runs_from_path(name)
13403 || !crate::ported::builtin::createbuiltintable().contains_key(name.as_str()));
13404 if is_external {
13405 if let Some(status) = external_glob_failure_status() {
13406 return Value::Status(status);
13407 }
13408 }
13409 }
13410 return Value::Status(1);
13411 }
13412 if args.is_empty() {
13413 // c:Src/exec.c:3442 — a command whose words expand to ZERO
13414 // words is a NULL command: `cmdoutval = use_cmdoutval ?
13415 // lastval : 0`. `use_cmdoutval` is set (below, in
13416 // BUILTIN_CMD_SUBST_TEXT) only when a command substitution
13417 // ran during this command's word expansion, so:
13418 // `false; $(exit 5)` → keep the subst status (5)
13419 // `false; $nonexistent` → reset to 0 (null command).
13420 // The previous port unconditionally kept `$?`, so
13421 // `false; $unset` wrongly stayed 1 (A01grammar.ztst:5).
13422 let keep =
13423 crate::ported::exec::use_cmdoutval.load(std::sync::atomic::Ordering::Relaxed) != 0;
13424 let status = if keep { vm.last_status } else { 0 };
13425 crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
13426 return Value::Status(status);
13427 }
13428 if args[0].is_empty() {
13429 // Explicit empty command word — exec returns EACCES.
13430 let script_name =
13431 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
13432 let lineno: u64 = with_executor(|exec| {
13433 exec.scalar("LINENO")
13434 .and_then(|s| s.parse::<u64>().ok())
13435 .unwrap_or(1)
13436 });
13437 eprintln!("{}:{}: permission denied: ", script_name, lineno);
13438 return Value::Status(126);
13439 }
13440 // AOP intercepts (zshrs extension, no C counterpart) — same
13441 // gate as host_exec_external (the static-head path): dynamic
13442 // command names (`cmd=/bin/echo; $cmd payload`) must consult
13443 // registered intercepts before dispatch, else `intercept
13444 // before /bin/echo ...` fires for the literal spelling but
13445 // not the variable one. run_intercepts runs before-advice
13446 // in-place and returns None to continue; Some(status) means
13447 // an around/after advice fully handled the command.
13448 let intercepted = with_executor(|exec| {
13449 if exec.intercepts.is_empty() {
13450 return None;
13451 }
13452 let full_cmd = if args.len() == 1 {
13453 args[0].clone()
13454 } else {
13455 args.join(" ")
13456 };
13457 let rest: Vec<String> = args[1..].to_vec();
13458 exec.run_intercepts(&args[0], &full_cmd, &rest)
13459 });
13460 if let Some(result) = intercepted {
13461 return Value::Status(result.unwrap_or(127));
13462 }
13463 // zshrs-original builtins (async, doctor, peach, …, and the daemon
13464 // z* family) reached via a run-time-resolved head (`$var`): they
13465 // are absent from the static BUILTINS port table / builtintab, so
13466 // execcmd_exec below would treat the head as external and report
13467 // "command not found" — even though `whence` calls it a builtin and
13468 // a literal head runs it via CallBuiltin. Dispatch by name here, but
13469 // ONLY when the head is neither
13470 // a user function nor a ported builtin, so the shell's
13471 // function -> builtin -> external order is preserved.
13472 if let Some(head) = args.first() {
13473 let is_fn = with_executor(|e| e.function_exists(head));
13474 let is_ported =
13475 crate::ported::builtin::createbuiltintable().contains_key(head.as_str());
13476 if !is_fn && !is_ported {
13477 let status = run_daemon_builtin(head, &args[1..])
13478 .or_else(|| try_run_registered_builtin(head, &args[1..]));
13479 if let Some(status) = status {
13480 crate::ported::builtin::LASTVAL
13481 .store(status, std::sync::atomic::Ordering::Relaxed);
13482 return Value::Status(status);
13483 }
13484 }
13485 }
13486 // c:Src/exec.c:2900 execcmd_exec — canonical simple-command
13487 // dispatcher. Runs precmd-modifier walk (c:3013-3091), then
13488 // dispatches to execbuiltin (c:4233) / runshfunc (c:3431+) /
13489 // execute (c:4314) per the resolved head. zshrs's bytecode VM
13490 // expanded the args before reaching here; we feed them in via
13491 // eparams.args and let execcmd_exec do the rest exactly as C
13492 // does for static heads. Without this, `c=builtin; $c source X`
13493 // skipped the precmd walk and emitted "command not found:
13494 // builtin".
13495 let mut state = crate::ported::zsh_h::estate {
13496 prog: Box::<crate::ported::zsh_h::eprog>::default(),
13497 pc: 0,
13498 strs: None,
13499 strs_offset: 0,
13500 };
13501 let mut eparams = crate::ported::zsh_h::execcmd_params {
13502 args: Some(args),
13503 redir: None,
13504 beg: 0,
13505 varspc: None,
13506 assignspc: None,
13507 typ: crate::ported::zsh_h::WC_SIMPLE as i32,
13508 postassigns: 0,
13509 htok: 0,
13510 };
13511 // input/output=0 → no pipe redirection (use shell stdio
13512 // directly); `output != 0` at c:2988 forks immediately. last1=2
13513 // (c:Src/exec.c:2014 `last1 ? 1 : 2`): terminal pipe stage but
13514 // the shell IS needed afterward — the VM keeps executing
13515 // bytecode after this op. last1=1 would arm the fake-exec
13516 // optimization (c:3646-3651, gate at c:3662 `last1 != 1`),
13517 // making `execute()` execve THIS process for external heads:
13518 // `p=/bin/echo; $p hi; echo after` replaced the shell and
13519 // `after` never ran (D04parameter chunk 11 shell-killer).
13520 // c:Src/exec.c:1690-1700 — execpline's job frame: save thisjob
13521 // (`pj = thisjob`) and allocate the jobtab slot that
13522 // execcmd_fork's addproc (c:2853) hangs the child pid off.
13523 // Without a live thisjob, the fork at c:3662 (last1 != 1 →
13524 // external must fork) registers no proc, nothing waits, and
13525 // the child races the rest of the script.
13526 let pj = {
13527 use crate::ported::jobs;
13528 *jobs::THISJOB
13529 .get_or_init(|| std::sync::Mutex::new(-1))
13530 .lock()
13531 .unwrap_or_else(|e| e.into_inner())
13532 };
13533 // The pipeline's job frame (BUILTIN_EXECPLINE_CHILD_BLOCK) already
13534 // holds this command's slot; only a chunk compiled without one
13535 // allocates here.
13536 let held = crate::exec_jobs::execpline_slot_current();
13537 let newjob = held.unwrap_or_else(|| {
13538 use crate::ported::jobs;
13539 let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
13540 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
13541 jobs::initjob(&mut tab) // c:1700 `thisjob = newjob = initjob()`
13542 });
13543 {
13544 use crate::ported::jobs;
13545 *jobs::THISJOB
13546 .get_or_init(|| std::sync::Mutex::new(-1))
13547 .lock()
13548 .unwrap_or_else(|e| e.into_inner()) = newjob as i32;
13549 }
13550 crate::ported::exec::REDIRS_PREAPPLIED.with(|c| c.set(had_redirs));
13551 crate::ported::exec::execcmd_exec(
13552 &mut state,
13553 &mut eparams,
13554 0, // input (c:2989)
13555 0, // output (c:2988)
13556 crate::ported::zsh_h::Z_SYNC as i32, // how
13557 2, // last1=2 — shell continues (c:2014)
13558 -1, // close_if_forked
13559 );
13560 crate::ported::exec::REDIRS_PREAPPLIED.with(|c| c.set(false));
13561 // c:Src/exec.c:1828-1835 — execpline's Z_SYNC tail: waitjobs()
13562 // reaps the forked external. c:Src/jobs.c:487-495 + 551-552 —
13563 // the job's LAST proc sets lastval (0200|sig when signalled,
13564 // else WEXITSTATUS). Builtin/shfunc heads never forked (job
13565 // has no procs) — LASTVAL was already set by execbuiltin /
13566 // doshfunc inside execcmd_exec; skip the wait.
13567 {
13568 use crate::ported::jobs;
13569 let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
13570 let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
13571 if jobs::hasprocs(&tab, newjob) {
13572 jobs::waitjobs(&mut tab, newjob); // c:1835
13573 if let Some(p) = tab[newjob].procs.last() {
13574 let val = if p.is_signaled() {
13575 0o200 | p.term_sig() // c:Src/jobs.c:489-490
13576 } else {
13577 p.exit_status() // c:Src/jobs.c:494
13578 };
13579 crate::ported::builtin::LASTVAL
13580 .store(val, std::sync::atomic::Ordering::Relaxed);
13581 }
13582 }
13583 // c:1977-1979 — `deletejob(jn, 0)` once done; c:1981
13584 // `thisjob = pj` restores the caller's job. A held slot is the
13585 // job frame's to delete when the pipeline closes.
13586 if held.is_none() && newjob < tab.len() {
13587 jobs::deletejob(&mut tab[newjob], false);
13588 }
13589 *jobs::THISJOB
13590 .get_or_init(|| std::sync::Mutex::new(-1))
13591 .lock()
13592 .unwrap_or_else(|e| e.into_inner()) = pj;
13593 }
13594 let status = crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
13595 let mut synth = crate::ported::zsh_h::job::default();
13596 crate::ported::jobs::waitonejob(&mut synth);
13597 Value::Status(status)
13598 });
13599 // c:Src/exec.c:3386-3419 — `< file` / `> file` with no command
13600 // word. Resolves NULLCMD/READNULLCMD at runtime, then dispatches the
13601 // resulting word the way execcmd's fall-through does (shell function →
13602 // builtin → external). Redirects are already applied by the surrounding
13603 // WithRedirectsBegin scope.
13604 vm.register_builtin(BUILTIN_NULLCMD_CHECK, |vm, _argc| {
13605 use std::sync::atomic::Ordering;
13606 let nullcmd = crate::ported::params::getsparam("NULLCMD");
13607 // c:3395 — `!nullcmd || !*nullcmd || opts[CSHNULLCMD]`
13608 if nullcmd.as_deref().map_or(true, str::is_empty)
13609 || crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLCMD)
13610 {
13611 crate::ported::utils::zerr("redirection with no command"); // c:3397
13612 crate::ported::builtin::LASTVAL.store(1, Ordering::Relaxed); // c:3398
13613 vm.last_status = 1;
13614 crate::ported::utils::errflag
13615 .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed); // c:3399
13616 return Value::Bool(false);
13617 }
13618 Value::Bool(true)
13619 });
13620 vm.register_builtin(BUILTIN_NULLCMD_EXEC, |vm, argc| {
13621 let args = pop_args(vm, argc);
13622 let is_single_read = args
13623 .first()
13624 .map(|s| s != "0" && !s.is_empty())
13625 .unwrap_or(false);
13626 // c:Src/exec.c — when the surrounding redir-open failed
13627 // (e.g. `< /nonexistent`), zerr already printed the diag
13628 // and set redirect_failed. Don't invoke NULLCMD — return
13629 // status 1 like the wordcode path does.
13630 let redir_failed = with_executor(|exec| {
13631 let f = exec.redirect_failed;
13632 exec.redirect_failed = false;
13633 f
13634 });
13635 if redir_failed {
13636 crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
13637 return Value::Status(1);
13638 }
13639 // c:3395-3399 — the no-NULLCMD / CSH_NULLCMD refusal already ran in
13640 // BUILTIN_NULLCMD_CHECK, before the redirections opened.
13641 let nullcmd = crate::ported::params::getsparam("NULLCMD");
13642 let nc_str = nullcmd.as_deref().unwrap_or("");
13643 // c:3350 — SHNULLCMD → run `:`.
13644 let cmd: String = if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHNULLCMD) {
13645 ":".to_string()
13646 } else if is_single_read {
13647 // c:3354-3359 — single REDIR_READ + READNULLCMD set → readnullcmd.
13648 let rnc = crate::ported::params::getsparam("READNULLCMD");
13649 let rnc_str = rnc.as_deref().unwrap_or("");
13650 if !rnc_str.is_empty() {
13651 rnc_str.to_string()
13652 } else {
13653 nc_str.to_string() // c:3360-3363 fallback
13654 }
13655 } else {
13656 nc_str.to_string() // c:3360-3363
13657 };
13658 // c:Src/exec.c:3408/3414/3418 — C does not "run NULLCMD" as a special
13659 // case: it APPENDS the word to the command's arg list
13660 // (`addlinknode(args, dupstring(nullcmd))`) and falls through to
13661 // execcmd's ORDINARY dispatch, which resolves that word in this order:
13662 // c:3484-3487 `shfunctab->getnode(shfunctab, cmdarg)` → shell function
13663 // c:3489 `builtintab->getnode(builtintab, cmdarg)` → builtin
13664 // otherwise → external command (PATH lookup).
13665 // `host_exec_external` already implements the shell-function arm and
13666 // the external arm (plus the AOP intercepts and the module-builtin
13667 // name arms), so only the builtintab arm has to be decided here.
13668 //
13669 // The builtintab question must be asked of the TABLE.
13670 // `builtin_in_builtintab` alone is NOT a membership test — it is the
13671 // module *gate*, and `builtin_owning_module` returns None for any name
13672 // it does not know, whose `None => true` arm
13673 // (src/extensions/ext_builtins.rs:179-182) then reports EVERY string as
13674 // an available builtin. With that as the only predicate, the default
13675 // `READNULLCMD=more` (config.h DEFAULT_READNULLCMD), a user's
13676 // `READNULLCMD=less`, `NULLCMD=/bin/cat` and every other external name
13677 // were classified as core builtins and handed to
13678 // `dispatch_builtin_raw("more", vec![])`, which cannot work: plain
13679 // `< file` printed NOTHING and returned 1, and a missing NULLCMD
13680 // returned a silent 1 instead of `command not found` / 127.
13681 //
13682 // Membership first, gate second. The zshrs-original coreutils-shaped
13683 // builtins (`cat`, `basename`, … EXT_BUILTIN_NAMES) are not entries of
13684 // `createbuiltintable()` at all, so the documented `NULLCMD=cat` /
13685 // `READNULLCMD=cat` idioms keep reaching the real `/bin/cat` the way
13686 // `zsh -f` does without needing an explicit exclusion.
13687 //
13688 // `dispatch_builtin` (not `dispatch_builtin_raw`) is the correct entry:
13689 // C's `builtintab->getnode` filters DISABLED nodes, so `disable :;
13690 // NULLCMD=:; > f` must fall through to PATH — the raw dispatcher
13691 // deliberately bypasses that set (it is what `builtin NAME` uses).
13692 let is_shfunc = with_executor(|exec| exec.function_exists(&cmd)); // c:3485
13693 let is_builtin = crate::ported::builtin::createbuiltintable().contains_key(&cmd)
13694 && crate::extensions::ext_builtins::builtin_in_builtintab(&cmd); // c:3489
13695 let status = if !is_shfunc && is_builtin {
13696 dispatch_builtin(&cmd, Vec::new()) // c:3489-3504
13697 } else {
13698 with_executor(|exec| exec.host_exec_external(&[cmd]))
13699 };
13700 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
13701 Value::Status(status)
13702 });
13703 // c:Src/exec.c:3342 — `zerr("redirection with no command")`.
13704 // Bare prefix-keyword (`builtin`, `command`, `exec`, `noglob`,
13705 // `nocorrect`) with a redirect but no command word. Emits the
13706 // canonical diagnostic via zerr (which sets errflag) and
13707 // returns Status(1). Bug #534.
13708 // c:Src/exec.c:3104-3290 then c:3380-3402 — the precommand walk for a
13709 // `builtin`/`command`/`exec` head that carries redirections, run on the
13710 // expanded words BEFORE the redirect scope opens (c:3720), as C orders
13711 // it. Operands: [prefix, words…]. Returns the words without the prefix
13712 // as one array for the prefix opcode; BUILTIN_PRECMD_CHECK_OK then says
13713 // whether the walk let the command run. A failed walk has already
13714 // printed its diagnostic to the still-unredirected stderr.
13715 vm.register_builtin(BUILTIN_PRECMD_CHECK, |vm, argc| {
13716 let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
13717 for _ in 0..argc {
13718 popped.push(vm.pop());
13719 }
13720 popped.reverse();
13721 let mut full: Vec<String> = Vec::with_capacity(popped.len());
13722 for v in popped {
13723 match v {
13724 Value::Array(items) => full.extend(items.iter().map(|i| i.to_str())),
13725 other => full.push(other.to_str()),
13726 }
13727 }
13728 let words: Vec<Value> = full.iter().skip(1).map(|s| Value::str(s.as_str())).collect();
13729 let ok = precmd_walk_ok(&full);
13730 if !ok {
13731 // c:3205 / c:3398 — `lastval = 1;` before `goto done` / return.
13732 vm.last_status = 1;
13733 crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
13734 }
13735 PRECMD_CHECK_PASSED.with(|c| c.set(ok));
13736 Value::array(words)
13737 });
13738 vm.register_builtin(BUILTIN_PRECMD_CHECK_OK, |_vm, _argc| {
13739 Value::Bool(PRECMD_CHECK_PASSED.with(|c| c.replace(true)))
13740 });
13741 vm.register_builtin(BUILTIN_REDIR_NO_CMD, |_vm, _argc| {
13742 crate::ported::utils::zerr("redirection with no command");
13743 Value::Status(1)
13744 });
13745 vm.register_builtin(BUILTIN_DEBUG_TRAP, |vm, _argc| {
13746 // c:Src/signals.c:1245 dotrap(SIGDEBUG) — fires the DEBUG
13747 // trap body once per statement. The body sees the parent
13748 // shell's $? (LASTVAL). Guard against re-entry: commands
13749 // inside the DEBUG trap body would otherwise trigger
13750 // DEBUG_TRAP recursively → stack overflow. zsh guards via
13751 // its in_trap counter; we mirror with a thread-local Cell.
13752 //
13753 // c:Src/exec.c::trapcmd — before dotrap, the C source sets
13754 // `ZSH_DEBUG_CMD` to the about-to-run command text via
13755 // `dupstring(text)`. The trap body reads the parameter;
13756 // C unsets it after the trap returns. compile_list emits
13757 // the rendered statement text as the single arg here so the
13758 // shell-visible parameter reflects the command. Bug #263 in
13759 // docs/BUGS.md.
13760 // Return value: `Int(1)` tells the emit-side JumpIfTrue to SKIP the
13761 // statement this trap ran in front of — C's `donedebug == 2`
13762 // (c:Src/exec.c:1499/1511/1519-1529) plus the forced-return case
13763 // where C's list loop (`while (… && !retflag …)`, c:1443) never
13764 // reaches the command. `Int(0)` = run it.
13765 //
13766 // Two call sites, distinguished by the `mode` operand:
13767 // 0 — c:1476-1502, the DEBUG_BEFORE_CMD block that runs BEFORE the
13768 // sublist (only when the option is set),
13769 // 1 — c:1628-1644, the `sublist_done:` block that runs AFTER it
13770 // (only when the option is NOT set).
13771 // Firing the pre-sublist arm in both modes made the default
13772 // (post-command) DEBUG trap observe the NEXT statement's `$LINENO`
13773 // — A05execution:19 saw "Line 2 / Line 3" for a trap zsh reports as
13774 // "Line 1 / Line 2".
13775 let mode = vm.pop().to_int();
13776 let cmd_text = vm.pop().to_str();
13777 let before = mode == 0;
13778 DEBUG_TRAP_REENTRY.with(|c| {
13779 if c.get() {
13780 return Value::Int(0);
13781 }
13782 // c:1476 `isset(DEBUGBEFORECMD)` / c:1628 `!isset(DEBUGBEFORECMD)`.
13783 if before != isset(crate::ported::zsh_h::DEBUGBEFORECMD) {
13784 return Value::Int(0);
13785 }
13786 // c:Src/exec.c:1423 — `if (sigtrapped[SIGDEBUG] &&
13787 // isset(DEBUGBEFORECMD) && !intrap)`. Bug #573: without
13788 // this gate, every sublist boundary called
13789 // setsparam("ZSH_DEBUG_CMD", ...) even when no DEBUG trap
13790 // was set, polluting the param table and (under
13791 // WARN_CREATE_GLOBAL) emitting a spurious
13792 // `scalar parameter ZSH_DEBUG_CMD created globally`
13793 // warning at every function call.
13794 //
13795 // Two trap registries exist (per signals.rs:1481-1511 dotrap):
13796 // - settrap path → sigtrapped[SIGDEBUG] bits set
13797 // - bin_trap path → traps_table["DEBUG"] populated, sigtrapped untouched
13798 // Mirror the dotrap dispatch decision: skip only when BOTH
13799 // are absent.
13800 let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
13801 let debug_trapped = crate::ported::signals::sigtrapped
13802 .lock()
13803 .map(|v| v.get(sig_debug).copied().unwrap_or(0))
13804 .unwrap_or(0);
13805 let debug_in_table = crate::ported::builtin::traps_table()
13806 .lock()
13807 .map(|t| t.contains_key("DEBUG"))
13808 .unwrap_or(false);
13809 if debug_trapped == 0 && !debug_in_table {
13810 return Value::Int(0);
13811 }
13812 c.set(true);
13813 // c:1478-1481 — `int oerrexit_opt = opts[ERREXIT]; Param pm;
13814 // opts[ERREXIT] = 0; noerrexit |= NOERREXIT_EXIT |
13815 // NOERREXIT_RETURN;`. ERREXIT is forced OFF across the trap
13816 // body so the option can be used as the "skip this command"
13817 // signal (c:1499) without the body's own failing commands
13818 // exiting the shell.
13819 let oerrexit_opt = isset(crate::ported::zsh_h::ERREXIT); // c:1478
13820 crate::ported::options::opt_state_set("errexit", false); // c:1480
13821 let oldnoerrexit =
13822 crate::ported::exec::noerrexit.load(std::sync::atomic::Ordering::Relaxed);
13823 crate::ported::exec::noerrexit.store(
13824 oldnoerrexit
13825 | crate::ported::zsh_h::NOERREXIT_EXIT
13826 | crate::ported::zsh_h::NOERREXIT_RETURN,
13827 std::sync::atomic::Ordering::Relaxed,
13828 ); // c:1481
13829 // c:Src/exec.c:1484 — set ZSH_DEBUG_CMD scalar (PM_READONLY
13830 // is NOT set on ZSH_DEBUG_CMD, so the canonical
13831 // setsparam path is fine here — no direct paramtab
13832 // mutation needed).
13833 // c:1636 — the post-sublist arm has no ZSH_DEBUG_CMD assignment;
13834 // the parameter is a DEBUG_BEFORE_CMD feature only.
13835 if before {
13836 // c:1484-1485 — `getpermtext(state->prog, pc2, 0)`: the
13837 // PERMANENT text, with newlines and tab indents (tnewlins=1,
13838 // c:Src/text.c:279), so `while false; do :; done` reads
13839 // "while false\ndo\n\t:\ndone". The compiled operand is the
13840 // one-line job text; re-parse it and render it through the
13841 // ported getpermtext, as printshfuncnode does for a function
13842 // body (hashtable.rs). The text is already alias-expanded, so
13843 // the re-lex must not expand again, and RCQUOTES must not
13844 // fold a `''` pair the first lex kept as written.
13845 let noalias_save = crate::ported::lex::noaliases();
13846 let rcquotes_save = isset(crate::ported::zsh_h::RCQUOTES);
13847 // A text the wordcode parser rejects falls back to the job
13848 // text silently: C never parses it, so no diagnostic or
13849 // errflag may escape.
13850 let noerrs_save = *crate::ported::utils::noerrs_lock().lock().unwrap();
13851 let errflag_save =
13852 crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
13853 crate::ported::utils::set_noerrs(1);
13854 crate::ported::lex::set_noaliases(true);
13855 crate::ported::options::opt_state_set("rcquotes", false);
13856 let permtext = crate::ported::exec::parse_string(&cmd_text, 0)
13857 .map(|p| crate::ported::text::getpermtext(Box::new(p), None, 0));
13858 crate::ported::options::opt_state_set("rcquotes", rcquotes_save);
13859 crate::ported::lex::set_noaliases(noalias_save);
13860 crate::ported::utils::set_noerrs(noerrs_save);
13861 crate::ported::utils::errflag
13862 .store(errflag_save, std::sync::atomic::Ordering::Relaxed);
13863 let text = permtext.filter(|t| !t.is_empty()).unwrap_or(cmd_text);
13864 crate::ported::params::setsparam("ZSH_DEBUG_CMD", &text);
13865 }
13866 // c:1488/1636 — `exiting = donetrap;` … c:1493/1641 `donetrap = exiting;`
13867 let exiting = crate::ported::exec::DONETRAP.load(std::sync::atomic::Ordering::Relaxed);
13868 let ret = crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed); // c:1489
13869 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGDEBUG);
13870 // c:1491-1492 — `if (!retflag) lastval = ret;`. A trap that
13871 // ran `return N` keeps the forced status; anything else
13872 // leaves the pre-trap `$?` alone.
13873 let retflag =
13874 crate::ported::builtin::RETFLAG.load(std::sync::atomic::Ordering::Relaxed) != 0;
13875 if !retflag {
13876 crate::ported::builtin::LASTVAL.store(ret, std::sync::atomic::Ordering::Relaxed);
13877 // c:1492
13878 }
13879 crate::ported::exec::noerrexit
13880 .store(oldnoerrexit, std::sync::atomic::Ordering::Relaxed); // c:1494
13881 // c:1499 — `donedebug = isset(ERREXIT) ? 2 : 1;`. The trap
13882 // setting ERREXIT is zsh's documented "skip this command"
13883 // signal (zshmisc(1), DEBUG trap).
13884 let donedebug2 = before && isset(crate::ported::zsh_h::ERREXIT); // c:1499
13885 crate::ported::options::opt_state_set("errexit", oerrexit_opt); // c:1500/1643
13886 crate::ported::exec::DONETRAP.store(exiting, std::sync::atomic::Ordering::Relaxed); // c:1493/1641
13887 // c:Src/exec.c:1501-1502 — `if (pm) unsetparam_pm(pm, 0, 1);`
13888 if before {
13889 crate::ported::params::unsetparam("ZSH_DEBUG_CMD");
13890 }
13891 c.set(false);
13892 if retflag {
13893 // c:1443 — the enclosing list loop stops on retflag, so the
13894 // command never runs. Mirror the forced status into the VM's
13895 // own counter (the ported LASTVAL atomic is a separate store)
13896 // and report "skip" so the emit-side jump lands past the
13897 // statement, where the RETFLAG escape unwinds the function.
13898 let forced =
13899 crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
13900 vm.last_status = forced;
13901 with_executor(|exec| exec.set_last_status(forced));
13902 return Value::Int(1);
13903 }
13904 if donedebug2 {
13905 // c:1511 — `if (donedebug != 2) execsimple(state);` and
13906 // c:1519-1529 — the compound form skips the whole sublist and
13907 // sets `donetrap = 1`.
13908 crate::ported::exec::DONETRAP.store(1, std::sync::atomic::Ordering::Relaxed); // c:1527
13909 return Value::Int(1);
13910 }
13911 Value::Int(0)
13912 })
13913 });
13914
13915 // Fatal-only abort check emitted between the pipes of an `&&` / `||`
13916 // chain, where the full errexit check is suppressed. Mirrors ONLY the
13917 // errflag arm of BUILTIN_ERREXIT_CHECK below: an errflag abandons the
13918 // list in zsh, and no connector can consume it.
13919 vm.register_builtin(BUILTIN_FATAL_ABORT_CHECK, |vm, _argc| {
13920 use std::sync::atomic::Ordering;
13921 // c:Src/exec.c:1390 — `while (wc_code(code) == WC_LIST && !breaks &&
13922 // !retflag && !errflag)`: the enclosing list loops test the WHOLE
13923 // errflag. A user interrupt sets ERRFLAG_INT and never ERRFLAG_ERROR
13924 // (signals.c:457), so masking here let the rest of the list run after
13925 // an interrupt:
13926 // TRAPINT() { print T; return 1 }
13927 // f() { print A; kill -INT $$; print C }; f; print B
13928 // zsh: A T zshrs: A T B
13929 //
13930 // The gate is also NOT conditional on the shell being
13931 // non-interactive. C's list loop is the same loop in both cases; an
13932 // interactive shell survives the abort because `loop(toplevel=1)`
13933 // resets the flag before it reads the next command line
13934 // (c:Src/init.c:139/150 `errflag = 0;`, ported at
13935 // ported/init.rs:2384/2394) — not because the list kept running. The
13936 // `isset(INTERACTIVE)` exemption that used to sit here was the reason
13937 // an interrupt was recorded and then ignored:
13938 // kill -INT $$; print survived
13939 // zsh: (nothing, exit 130) zshrs: survived, exit 0
13940 // and it is why installing C's SIGINT handler (`intr()`,
13941 // c:Src/init.c:1442) was unsafe before this — the handler sets
13942 // ERRFLAG_INT and expects THIS gate to end the list.
13943 let errflag_set = crate::ported::utils::errflag.load(Ordering::Relaxed) != 0;
13944 if !errflag_set {
13945 return Value::Int(0);
13946 }
13947 // CONTINUE_ON_ERROR does not reach here: C's list loop
13948 // (c:Src/exec.c:1443) breaks on errflag whatever the option says,
13949 // and only zsh_main's outer do-while re-enters loop() for the NEXT
13950 // top-level line (c:Src/init.c:1960-1968, ported in
13951 // vm_helper.rs execode's toplevel arm).
13952 // Abort the chain with the failing command's own status intact —
13953 // a cond syntax error left lastval=2 (c:Src/exec.c:5216-5221), and
13954 // that 2 is what zsh exits with. Reading the executor's live
13955 // lastval (not forcing 1) is the same rule the full check uses.
13956 //
13957 // An INTERRUPT is the one case where the executor's cell is not the
13958 // live lastval. C has a single `lastval` global, and the SIGINT
13959 // handler writes it directly (`lastval = 128 + SIGINT`,
13960 // c:Src/signals.c:463) — as does a forced-return INT trap
13961 // (`lastval = new_trap_return`, c:Src/signals.c:1202). zshrs splits
13962 // that global in two (see BUILTIN_SUBLIST_FINISH): the handler and
13963 // `dotrapargs` write `builtin::LASTVAL`, while the executor's cell
13964 // holds whatever the interrupted command itself returned — 0 for the
13965 // `kill` that raised the signal. Reading the executor here therefore
13966 // threw the interrupt status away:
13967 // <shell> -f -i -c 'kill -INT $$; print survived'
13968 // zsh exit 130 zshrs exit 0
13969 // and the same for an INT trap that returns non-zero (zsh exit 1).
13970 // Take the handler's write for an interrupt, the executor's for
13971 // everything else.
13972 vm.last_status = if (crate::ported::utils::errflag.load(Ordering::Relaxed)
13973 & crate::ported::zsh_h::ERRFLAG_INT)
13974 != 0
13975 {
13976 crate::ported::builtin::LASTVAL.load(Ordering::Relaxed)
13977 } else {
13978 with_executor(|exec| exec.last_status())
13979 };
13980 Value::Int(1)
13981 });
13982 vm.register_builtin(BUILTIN_PRINT_EXIT_VALUE, |vm, argc| {
13983 // c:Src/exec.c:5498-5505 — the ANONYMOUS-FUNCTION report in
13984 // execfuncdef:
13985 // execshfunc(shf, args);
13986 // ret = lastval;
13987 // if (isset(PRINTEXITVALUE) && isset(SHINSTDIN) && lastval) {
13988 // fprintf(stderr, "zsh: exit %lld\n", lastval);
13989 // It has NO `!subsh` term, unlike execcmd_exec's site at
13990 // c:4308-4309. `argc == 1` marks that call site (the compiler
13991 // pushes a 1 before it); `argc == 0` is the per-command site.
13992 let anon_site = argc >= 1 && vm.pop().to_int() != 0;
13993 // c:Src/exec.c:4308-4316 — `if (isset(PRINTEXITVALUE) &&
13994 // isset(SHINSTDIN) && lastval && !subsh) fprintf(stderr,
13995 // "zsh: exit %lld\n", lastval);`
13996 //
13997 // SHINSTDIN keeps this to a shell reading its program from stdin
13998 // (`zsh -f < script`, the interactive shell) — `-c` and script-file
13999 // runs never report. `subsh` keeps it out of forked pipeline stages
14000 // and `(...)` subshells, which is why zsh prints nothing for
14001 // `false | true` or `(exit 3)`. A function BODY is silent for a
14002 // different reason: c:Src/exec.c:6037 `opts[PRINTEXITVALUE] = 0`
14003 // in doshfunc (ported at exec.rs), restored at c:6158.
14004 let lastval = vm.last_status; // c:4309 lastval
14005 if crate::ported::zsh_h::isset(crate::ported::zsh_h::PRINTEXITVALUE) // c:4308
14006 && crate::ported::zsh_h::isset(crate::ported::zsh_h::SHINSTDIN) // c:4308
14007 && lastval != 0 // c:4309
14008 && (anon_site
14009 || crate::ported::exec::subsh.load(std::sync::atomic::Ordering::Relaxed) == 0)
14010 // c:4309 (`!subsh`; absent at the c:5498 anon-function site)
14011 {
14012 eprintln!("zsh: exit {lastval}"); // c:4311/4313
14013 let _ = std::io::Write::flush(&mut std::io::stderr()); // c:4315 fflush(stderr)
14014 }
14015 Value::Status(0)
14016 });
14017 vm.register_builtin(BUILTIN_ERREXIT_CHECK, |vm, _argc| {
14018 // Returns Value::Int(1) when the caller should jump to the
14019 // current scope's return-patch landing (subshell-end / func-
14020 // end / chunk-end). Returns Value::Int(0) otherwise. Emit
14021 // side at `emit_errexit_check` pairs this with a JumpIfTrue
14022 // → return_patches pattern so the caller can short-circuit.
14023 //
14024 // Four triggers:
14025 // 1. RETFLAG set by a nested `return` / `exit` (eval,
14026 // sourced file, called function). Unwind THIS scope so
14027 // the flag propagates outward until something clears it.
14028 // 2. EXIT_PENDING set (mostly subshell-context exits). Same
14029 // propagation logic.
14030 // 3. `set -e` + nonzero status — the classic errexit path.
14031 // 4. errflag set in non-interactive mode — readonly
14032 // reassign, bad redirect, parse error mid-expansion etc.
14033 // Aborts the script (c:Src/init.c loop()).
14034 use std::sync::atomic::Ordering;
14035 let retflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
14036 // c:Src/exec.c:6198-6201 — "If we are in an exit trap, finish it
14037 // first... we wouldn't set exit_pending if we were already in one."
14038 // C's list loop (c:1443) never consults exit_pending at all;
14039 // EXIT_PENDING is zshrs's own deferred-exit channel, and leaving it
14040 // armed while the EXIT trap body runs made every command after the
14041 // trap's FIRST one get skipped:
14042 // h(){ echo a; echo b }; trap h EXIT; f(){ exit }; f
14043 // zsh: a b zshrs: a
14044 // `in_exit_trap` is the same counter C tests at c:6201.
14045 let exit_pending = if crate::ported::signals::in_exit_trap.load(Ordering::Relaxed) != 0 {
14046 0
14047 } else {
14048 crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed)
14049 };
14050 // c:Src/exec.c:1571-1603 — `sublist_done:` runs the ZERR trap for
14051 // the sublist that just failed. It is NOT gated on retflag: C only
14052 // consults retflag at the TOP of the list loop (c:1370 `while
14053 // (wc_code(code) == WC_LIST && !breaks && !retflag && !errflag)`),
14054 // which stops the NEXT sublist — the current one still completes
14055 // its sublist_done. So `return 5` fires the ERR trap on its way out.
14056 //
14057 // zshrs's escape short-circuit below returns before ever reaching
14058 // the ZERR fire, so a `return N` inside a try-list skipped the trap:
14059 // f() { { return 5 } always { print fin } }; f
14060 // printed `fin / err=5` where zsh prints `err=5 / fin / err=5`.
14061 // (Plain `f() { return 5 }` matched by luck — the inner fire was
14062 // missing but the OUTER sublist fired instead, since doshfunc had
14063 // cleared retflag by then and DONETRAP was still 0.)
14064 //
14065 // `exit` is deliberately excluded: C's `exit` goes zexit() →
14066 // realexit(), leaving the process without ever reaching
14067 // sublist_done. Verified: `zsh -fc 'trap "print err" ERR; f(){ exit
14068 // 5 }; f'` prints nothing.
14069 if retflag != 0 && exit_pending == 0 {
14070 let last = vm.last_status;
14071 // c:1651 — `!donetrap` as it stood when this sublist finished.
14072 let donetrap_at_entry = crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
14073 // c:1598-1603 — same DONETRAP gate as the non-escape path below.
14074 if last != 0 && !donetrap_at_entry && zerr_sigtrapped() {
14075 // c:Src/signals.c:1085-1087 — `int obreaks = breaks; int
14076 // oretflag = retflag; int olastval = lastval;` and c:1220-1222
14077 // — `breaks += obreaks; retflag = oretflag;`. dotrapargs
14078 // brackets EVERY trap dispatch with this save/restore because
14079 // the trap body runs as a normal list and would otherwise
14080 // consume the caller's control-flow flags. That matters
14081 // exactly here: we are firing ZERR while retflag is SET, and a
14082 // FUNCTION-form trap (`TRAPZERR() { … }`) goes through
14083 // doshfunc, whose epilogue eats retflag outright
14084 // (c:Src/exec.c:6047-6052 `if (retflag) { retflag = 0; breaks
14085 // = funcsave->breaks; }`). Without the bracket the pending
14086 // `return 5` was swallowed by its own ERR trap and the
14087 // function ran on:
14088 // TRAPZERR() { print z }; f() { { return 2 } always { : }
14089 // print after }; f
14090 // printed `after`, where zsh returns from f.
14091 //
14092 // zshrs's `dotrap` inlines the dispatch and does not carry
14093 // dotrapargs' save/restore, so the bracket lives at this call
14094 // site. lastval is restored too (c:1087 / c:1213 `lastval =
14095 // olastval`) — the trap body's own commands must not become
14096 // the caller's `$?`.
14097 let obreaks = crate::ported::builtin::BREAKS.load(Ordering::Relaxed); // c:1085
14098 let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed); // c:1086
14099 let olastval = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1087
14100 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1601
14101 crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed); // c:1602
14102 crate::ported::builtin::BREAKS.store(obreaks, Ordering::Relaxed); // c:1220
14103 crate::ported::builtin::RETFLAG.store(oretflag, Ordering::Relaxed); // c:1222
14104 crate::ported::builtin::LASTVAL.store(olastval, Ordering::Relaxed);
14105 // c:1213
14106 }
14107 // c:1660-1680 — the same sublist_done then applies ERR_EXIT /
14108 // ERR_RETURN to the `return N` that ended it: `setopt errexit;
14109 // trap 'echo e' ZERR; return 1` inside a function exits there.
14110 if last != 0 && !donetrap_at_entry {
14111 let _ = errexit_tail(last);
14112 }
14113 }
14114 if retflag != 0 || exit_pending != 0 {
14115 if exit_pending != 0 {
14116 // c:Src/builtin.c zexit — the deferred exit carries its
14117 // status in EXIT_VAL; sync it into the VM counter so
14118 // the top-level unwind reports it as the script's exit
14119 // (run_chunk returns vm.last_status). Without this, a
14120 // POSIX-fatal `.` failure exited 127 (bin_dot's return)
14121 // instead of C's exit(1) at Src/exec.c:4383.
14122 vm.last_status = crate::ported::builtin::EXIT_VAL.load(Ordering::Relaxed) & 0xFF;
14123 }
14124 return Value::Int(1);
14125 }
14126 let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
14127 & crate::ported::zsh_h::ERRFLAG_ERROR)
14128 != 0;
14129 // c:Src/init.c:1960-1968 — CONTINUE_ON_ERROR is decided by
14130 // zsh_main's outer do-while, which re-enters loop() for the next
14131 // top-level LINE; the current list (and every enclosing list,
14132 // loop and function body) still breaks on errflag here. The
14133 // re-entry is ported in vm_helper.rs execode's toplevel arm.
14134 if errflag_set && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE) {
14135 // c:Src/exec.c execlist — every enclosing list loop runs
14136 // `while (... && !errflag)`, so a set errflag breaks the
14137 // CURRENT scope and the check in the enclosing scope
14138 // breaks THAT one, all the way out. Leave errflag SET —
14139 // do NOT convert it to EXIT_PENDING: a process-exit
14140 // signal tunnels through the containment boundaries C
14141 // has, namely eval (Src/builtin.c:6221 `errflag &=
14142 // ~ERRFLAG_ERROR`), source (Src/init.c:1663 same), fork
14143 // boundaries (subshell/cmdsubst — child's errflag dies
14144 // with the child), and the interactive toplevel
14145 // (Src/init.c:139). Those boundaries clear errflag
14146 // themselves and execution continues past them; with
14147 // EXIT_PENDING armed here, `eval 'assoc=(odd)'; echo
14148 // after` aborted the whole script where zsh 5.9 prints
14149 // `after` (eval status 1). Bug #74's function case
14150 // (`f() { local -r x=5; x=10; }; f; echo after`) still
14151 // aborts: the function scope unwinds on THIS check, and
14152 // the caller's next ERREXIT_CHECK sees the still-set
14153 // errflag and unwinds too — exactly C's propagation.
14154 //
14155 // c:Src/init.c:234 — loop() BREAKS on errflag and
14156 // zsh_main exits with the UNTOUCHED lastval, NOT a
14157 // forced 1: `typeset -i x=3#8` (math error during the
14158 // assignment, before typeset sets a status) exits 0 in
14159 // zsh; a cond syntax error set lastval=2 (exec.c:5216-
14160 // 5221) and zsh exits 2; the readonly-reassign case
14161 // exits 1 because ITS lastval is 1. Sync the VM counter
14162 // from the executor's live lastval instead of
14163 // overwriting.
14164 vm.last_status = with_executor(|exec| exec.last_status());
14165 // c:Src/exec.c:1598-1603 — `sublist_done:` runs the ZERR trap
14166 // for the failed sublist BEFORE the enclosing list loop breaks
14167 // on errflag (`while (... && !errflag)` at c:1370). So an
14168 // errflag-setting command (readonly reassign, bad redirect)
14169 // must fire ZERR on its way out, exactly like the retflag
14170 // escape above and the non-escape fall-through below. Without
14171 // this the errflag early-return pre-empted the ZERR block
14172 // further down, so `TRAPZERR() { … }; typeset -r ro=1; ro=2`
14173 // aborted the script (correct) but never fired the trap. Same
14174 // DONETRAP gate + dotrapargs save/restore bracket
14175 // (c:signals.c:1085-1087 / 1213-1222) as the retflag branch:
14176 // a function-form TRAPZERR runs through doshfunc and would
14177 // otherwise consume the caller's breaks/retflag/lastval.
14178 let last = with_executor(|exec| exec.last_status());
14179 if last != 0 && crate::ported::exec::DONETRAP.load(Ordering::Relaxed) == 0 && zerr_sigtrapped() {
14180 let obreaks = crate::ported::builtin::BREAKS.load(Ordering::Relaxed); // c:1085
14181 let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed); // c:1086
14182 let olastval = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1087
14183 // c:Src/signals.c:1101 — dotrapargs returns early if errflag
14184 // is set, and c:1174/1205-1218 brackets the dispatch with
14185 // `traperr = errflag` … restore. The failing assignment left
14186 // errflag SET, so the trap body (`print zerr`) would itself
14187 // bail on the first op. Clear errflag across the dispatch so
14188 // the body runs, then restore it so the script still aborts.
14189 let oerrflag = crate::ported::utils::errflag.load(Ordering::Relaxed); // c:1174
14190 crate::ported::utils::errflag.store(0, Ordering::Relaxed);
14191 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1601
14192 crate::ported::utils::errflag.store(oerrflag, Ordering::Relaxed); // c:1216
14193 crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed); // c:1602
14194 crate::ported::builtin::BREAKS.store(obreaks, Ordering::Relaxed); // c:1220
14195 crate::ported::builtin::RETFLAG.store(oretflag, Ordering::Relaxed); // c:1222
14196 crate::ported::builtin::LASTVAL.store(olastval, Ordering::Relaxed);
14197 // c:1213
14198 }
14199 return Value::Int(1);
14200 }
14201 let last = vm.last_status;
14202 if last == 0 {
14203 return Value::Int(0);
14204 }
14205 // c:Src/exec.c:1598 `if (!this_noerrexit && !donetrap &&
14206 // !this_donetrap)` — gate the ZERR trap fire on DONETRAP so
14207 // an inner sublist (e.g. `false` inside a function) that
14208 // already fired ZERR doesn't fire it AGAIN at the outer
14209 // sublist's post-command check (after the function
14210 // returned non-zero). Bug #303 in docs/BUGS.md. DONETRAP
14211 // is reset at top-level statement boundaries via
14212 // BUILTIN_DONETRAP_RESET (compile_list emit at
14213 // compile_zsh.rs).
14214 let already_done = crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
14215 // c:Src/exec.c:1652-1653 —
14216 // if (sigtrapped[SIGZERR] && lastval &&
14217 // !(noerrexit & NOERREXIT_EXIT)) {
14218 // The ZERR half of the check is gated on the SAME runtime bit as
14219 // the errexit half below. Without this an `&&`/`||` operand — or
14220 // anything it calls — still fired ZERR, so
14221 // TRAPZERR(){ print E }; f(){ print f; false; }; f && t
14222 // printed E where zsh is silent (C03traps:14, E01options:18).
14223 let zerr_suppressed = (crate::ported::exec::noerrexit.load(Ordering::Relaxed)
14224 & crate::ported::zsh_h::NOERREXIT_EXIT)
14225 != 0; // c:1653
14226 // c:1652 — `sigtrapped[SIGZERR] &&`: with no ZERR trap the block
14227 // never runs, so `donetrap` stays 0 and cannot spare a caller's
14228 // errexit / errreturn below.
14229 if !already_done && !zerr_suppressed && zerr_sigtrapped() {
14230 // c:Src/signals.c:1245 dotrap(SIGZERR) — canonical ZERR
14231 // trap dispatch. Fires whenever a command exits
14232 // non-zero.
14233 let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
14234 // c:Src/exec.c:1654-1657 — `int eflag = errflag; errflag = 0;
14235 // dotrap(SIGZERR); errflag = eflag;`: an error raised INSIDE
14236 // the trap body (a `set -u` failure) ends the trap, not the
14237 // list — `set -u; trap 'print t; : $u; print x' ZERR; false;
14238 // print after` prints `after`. Without the restore the
14239 // trap's errflag aborted the enclosing list.
14240 let eflag = crate::ported::utils::errflag.load(Ordering::Relaxed); // c:1654
14241 crate::ported::utils::errflag.store(0, Ordering::Relaxed); // c:1655
14242 let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1656
14243 // c:Src/subst.c:3355-3366 — the one error the restore must not
14244 // drop: `${v?}` in a NON-interactive shell never gets here in C,
14245 // it has already left through `zexit(1)`. The subst port defers
14246 // that exit through ERRFLAG_HARD, so carry the bit across.
14247 let hard = if isset(crate::ported::zsh_h::INTERACTIVE) {
14248 0
14249 } else {
14250 crate::ported::utils::errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_HARD
14251 };
14252 crate::ported::utils::errflag.store(eflag | hard, Ordering::Relaxed); // c:1657
14253 // c:1602 — `donetrap = 1;` after firing.
14254 crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed);
14255 // c:Src/signals.c:1201-1203 — a trap body that ran `return N`
14256 // comes back with `lastval = new_trap_return` and `retflag =
14257 // 1`; C's enclosing `while (wc_code(code) == WC_LIST &&
14258 // !breaks && !retflag && !errflag)` (c:Src/exec.c:1443) then
14259 // abandons the rest of the list and the containing function
14260 // returns that status. zshrs's list loop is the VM, which
14261 // reads `vm.last_status` / the executor's counter rather than
14262 // the ported LASTVAL atomic — so mirror the forced status
14263 // across and report "abort" to the caller. Without this
14264 // fn(){ trap 'print t; return 42' ZERR; false; print B }
14265 // ran `print B` and returned 0.
14266 if oretflag == 0 && crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) != 0 {
14267 let forced = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1201
14268 vm.last_status = forced;
14269 with_executor(|exec| exec.set_last_status(forced));
14270 return Value::Int(1); // c:1443 — list loop stops on retflag
14271 }
14272 }
14273 // c:Src/exec.c:1651 — `if (!this_noerrexit && !donetrap &&
14274 // !this_donetrap)` gates the errexit / errreturn half too, not only
14275 // ZERR: a ZERR trap that already fired inside a function called by
14276 // this sublist also spares the caller the top-level ERR_RETURN-as-
14277 // ERR_EXIT (`setopt errreturn; trap 'echo z' ZERR; f() { false };
14278 // f; echo s` prints `z` then `s`).
14279 if already_done {
14280 return Value::Int(0);
14281 }
14282 errexit_tail(last)
14283 });
14284
14285 // BUILTIN_ASSIGN_ONLY_STATUS — status of an assignment-only
14286 // simple command. c:Src/exec.c:3393-3396 (execcmd_exec, no
14287 // command word + varspc): `if (errflag) lastval = 1; else
14288 // lastval = cmdoutval;`; same shape at c:1322 (execsimple
14289 // WC_ASSIGN: `lv = (errflag ? errflag : cmdoutval)`) and
14290 // c:3977 (nullexec=2 redir variant). cmdoutval is the exit of
14291 // a `$()` that ran in an RHS (already in vm.last_status via
14292 // compile_assign's per-assign SetStatus), 0 otherwise. The
14293 // store goes to the canonical LASTVAL too — that IS C's single
14294 // `lastval` global; without it the errflag-abort path
14295 // (BUILTIN_ERREXIT_CHECK trigger 4) syncs vm.last_status from
14296 // a stale LASTVAL and `readonly r=1; r=2` exited 0, not 1.
14297 vm.register_builtin(BUILTIN_ASSIGN_ONLY_STATUS, |vm, _argc| {
14298 use std::sync::atomic::Ordering;
14299 let had_cmd_subst = vm.pop().to_int() != 0;
14300 let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
14301 & crate::ported::zsh_h::ERRFLAG_ERROR)
14302 != 0;
14303 // c:Src/exec.c addvars — `if (!pm) { lastval = 1; if
14304 // (!cmdoutval) cmdoutval = 1; }` (assignment-failed cheat).
14305 let assign_failed = ASSIGN_FAILED_FLAG.swap(false, std::sync::atomic::Ordering::Relaxed);
14306 let _ = had_cmd_subst;
14307 if assign_failed && crate::ported::exec::cmdoutval.load(Ordering::Relaxed) == 0 {
14308 // c:Src/exec.c:2662-2663 — `if (!cmdoutval) cmdoutval = 1;`
14309 crate::ported::exec::cmdoutval.store(1, Ordering::Relaxed);
14310 }
14311 let status = if errflag_set || assign_failed {
14312 1 // c:Src/exec.c:3394 `lastval = 1` / addvars cmdoutval=1
14313 } else {
14314 // c:3396 `lastval = cmdoutval` — reset to 0 when the command
14315 // started (c:2796 / c:1368, BUILTIN_CMDOUTVAL_RESET), then set by
14316 // any `$(...)` that ran in it, RHS or subscript alike.
14317 crate::ported::exec::cmdoutval.load(Ordering::Relaxed)
14318 };
14319 with_executor(|exec| exec.set_last_status(status));
14320 // c:Src/jobs.c deletefilelist — a `=(cmd)` temp file is bound to the
14321 // JOB of the command that created it and unlinked when that command
14322 // completes (Src/exec.c:5588 for the shfunc case; the simple-command
14323 // job's filelist likewise). An assignment-only command like
14324 // `f==(cmd)` has no consuming builtin/exec, so the PsubFdGuard that
14325 // cleans consuming commands never fires — the temp leaked and a later
14326 // `$(<$f)` / `[[ -f $f ]]` still saw it, where zsh deletes it at the
14327 // end of the assignment (verified: even `f==(x) && cat $f` fails).
14328 // Clean here so the assignment command is the temp's job boundary.
14329 close_pending_psub_fds();
14330 Value::Status(status)
14331 });
14332
14333 // `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
14334 // Pops [name, op_byte, rhs] (rhs popped first). Returns the modified
14335 // value as Value::Str. Handles unset/empty distinction (`:-` etc.
14336 // treat empty same as unset, matching POSIX).
14337 // BUILTIN_PARAM_DEFAULT_FAMILY — `${var-x}` / `${var:-x}` / `${var=x}` /
14338 // `${var:=x}` / `${var?x}` / `${var:?x}` / `${var+x}` / `${var:+x}`.
14339 // PURE PASSTHRU: pop name + op + rhs, reconstruct the canonical
14340 // brace expression, hand to `subst::paramsubst` (C port of
14341 // `Src/subst.c::paramsubst`). All "missing vs empty" gating,
14342 // nounset suppression, default-evaluation, and elide-empty-words
14343 // semantics live inside paramsubst.
14344 vm.register_builtin(BUILTIN_PARAM_DEFAULT_FAMILY, |vm, argc| {
14345 // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
14346 // compile_zsh::emit_param_modifier (see the note there).
14347 let pf_flags = if argc as usize > 3 && vm.pop().to_int() != 0 {
14348 crate::ported::zsh_h::PREFORK_SINGLE
14349 } else {
14350 0
14351 };
14352 let rhs = vm.pop().to_str();
14353 let op = vm.pop().to_int() as u8;
14354 let name = vm.pop().to_str();
14355 // c:Src/params.c:428-430 — `IPDEF9("*", &pparams, …)` and
14356 // `IPDEF9("argv", &pparams, …)` are ONE parameter, and the only shape
14357 // discriminator in the read path is c:Src/params.c:2251
14358 // `isvarat = (t[0] == '@' && !t[1])`, which the name `argv` misses just
14359 // as `*` does. So `${argv:-…}` IS `${*:-…}`. The rebuilt body below
14360 // reaches `paramsubst`, whose "is the parameter set" probe for this
14361 // operator family tests only the literal `@` / `*` names — `argv` fell
14362 // through to an ordinary lookup, came back UNSET, and
14363 // `set -- x y; ${argv:-nope}` took the default where zsh takes the
14364 // positionals. Normalising the spelling reuses every already-correct
14365 // `$*` arm, the same way the compiler's `bare_target` does for `$argv`
14366 // / `${argv}`. Every other modifier family (`#`, `%`, `/`, `:off:len`)
14367 // already resolves `argv` and is left alone.
14368 //
14369 // NOT the assigning ops. c:Src/subst.c:3690-3697 routes `${name:=…}` /
14370 // `${name=…}` through `setsparam`/`setaparam` on the NAME, and zsh
14371 // rejects the `*` spelling there ("not an identifier: *"), while
14372 // `${argv=…}` assigns the positional list. `${+name}` is the same
14373 // story from the other side — zsh answers "bad substitution" for
14374 // `${+*}` but 0/1 for `${+argv}`. Those three keep the literal name.
14375 let name = if name == "argv" && !matches!(op, 1 | 5 | 8) {
14376 "*".to_string()
14377 } else {
14378 name
14379 };
14380 // op=8 is the `${+name}` set-test prefix form (distinct from the
14381 // `${name+rhs}` substitute-if-set suffix form which is op=7).
14382 // Per compile_zsh.rs::parse_param_modifier: the `+` is emitted as
14383 // a leading sigil and `rhs` is empty.
14384 let body = if op == 8 {
14385 format!("${{+{}}}", name)
14386 } else {
14387 let op_str = match op {
14388 0 => ":-",
14389 1 => ":=",
14390 2 => ":?",
14391 3 => ":+",
14392 4 => "-",
14393 5 => "=",
14394 6 => "?",
14395 7 => "+",
14396 _ => "-",
14397 };
14398 format!("${{{}{}{}}}", name, op_str, rhs)
14399 };
14400 paramsubst_to_value_status(vm, &body, pf_flags)
14401 });
14402
14403 // `${var:offset[:length]}` — substring. Pops [name, offset, length].
14404 // length == -1 means "rest of string". Negative offset counts from end.
14405 // BUILTIN_PARAM_SUBSTRING — `${var:offset:length}` literal-int form.
14406 // PURE PASSTHRU: reconstruct `${name:offset:length}` and route
14407 // through `subst::paramsubst`. Length sentinel `i64::MIN` =
14408 // "no length given" (omit the `:length` portion).
14409 //
14410 // c:Src/subst.c:1571,3781 — `${name:-N}` is the colon-default
14411 // operator, NOT a substring with negative offset. zsh's lexical
14412 // rule disambiguates via a literal space: `${name: -N}` (space
14413 // before `-`) is the substring form. The reconstructed body MUST
14414 // preserve that space when offset < 0; otherwise paramsubst's
14415 // `:-` dispatch fires on the synthesized `${name:-N}` body and
14416 // returns N as the unset-default instead of slicing the last N
14417 // chars. Length-form `${name:-N:M}` has the same trap.
14418 vm.register_builtin(BUILTIN_PARAM_SUBSTRING, |vm, argc| {
14419 // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
14420 // compile_zsh::emit_param_modifier (see the note there).
14421 let pf_flags = if argc as usize > 3 && vm.pop().to_int() != 0 {
14422 crate::ported::zsh_h::PREFORK_SINGLE
14423 } else {
14424 0
14425 };
14426 let length = vm.pop().to_int();
14427 let offset = vm.pop().to_int();
14428 let name = vm.pop().to_str();
14429 // !!! DASH-STRICT GATE !!! dash/ash have no `${var:offset:length}`
14430 // substring expansion (it is a "Bad substitution"); bash/ksh/sh do.
14431 if crate::dash_mode::dash_strict() {
14432 crate::ported::utils::zerr("bad substitution");
14433 crate::ported::utils::errflag.fetch_or(
14434 crate::ported::zsh_h::ERRFLAG_ERROR,
14435 std::sync::atomic::Ordering::Relaxed,
14436 );
14437 with_executor(|exec| exec.set_last_status(1));
14438 return Value::str("");
14439 }
14440 let off_sep = if offset < 0 { " " } else { "" };
14441 let body = if length == i64::MIN {
14442 format!("${{{}:{}{}}}", name, off_sep, offset)
14443 } else {
14444 format!("${{{}:{}{}:{}}}", name, off_sep, offset, length)
14445 };
14446 paramsubst_to_value_status(vm, &body, pf_flags)
14447 });
14448
14449 // BUILTIN_PARAM_SUBSTRING_EXPR — `${var:offset_expr[:length_expr]}` form.
14450 // PURE PASSTHRU: rebuild `${name:offset:length}` using the
14451 // expression text verbatim (paramsubst's offset/length
14452 // parser evaluates arith / param refs itself).
14453 //
14454 // c:Src/subst.c:1571,3781 — same `:-` disambiguation trap as
14455 // BUILTIN_PARAM_SUBSTRING. The expression text may itself start
14456 // with `-` (e.g. `${VAR:$((-1))}` arith resolves at the body-
14457 // assembly layer in some upstream paths, leaving `-1` in
14458 // off_expr). Insert a leading space when off_expr starts with
14459 // `-` so paramsubst's check_colon_subscript (subst.c:1571)
14460 // accepts the operand as a math expression instead of the
14461 // `:-` operator catching it.
14462 vm.register_builtin(BUILTIN_PARAM_SUBSTRING_EXPR, |vm, argc| {
14463 // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
14464 // compile_zsh::emit_param_modifier (see the note there).
14465 let pf_flags = if argc as usize > 4 && vm.pop().to_int() != 0 {
14466 crate::ported::zsh_h::PREFORK_SINGLE
14467 } else {
14468 0
14469 };
14470 let has_len = vm.pop().to_int() != 0;
14471 let len_expr = vm.pop().to_str();
14472 let off_expr = vm.pop().to_str();
14473 let name = vm.pop().to_str();
14474 let off_sep = if off_expr.starts_with('-') { " " } else { "" };
14475 let body = if has_len {
14476 format!("${{{}:{}{}:{}}}", name, off_sep, off_expr, len_expr)
14477 } else {
14478 format!("${{{}:{}{}}}", name, off_sep, off_expr)
14479 };
14480 paramsubst_to_value_status(vm, &body, pf_flags)
14481 });
14482
14483 // `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}`
14484 // Pops [name, pattern, op_byte]. op: 0=`#` short-prefix, 1=`##` long,
14485 // 2=`%` short-suffix, 3=`%%` long. Glob-pattern matching via the
14486 // existing glob_match_static helper.
14487 // BUILTIN_PARAM_STRIP — `${var#pat}` / `${var##pat}` / `${var%pat}` /
14488 // `${var%%pat}`. PURE PASSTHRU: reconstruct the brace expression
14489 // and route through `subst::paramsubst`. (M)/(S) flags arrive
14490 // through SUB_FLAGS (already inside paramsubst's scope), so we
14491 // just clear the bridge-side cached read.
14492 vm.register_builtin(BUILTIN_PARAM_STRIP, |vm, argc| {
14493 // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
14494 // compile_zsh::emit_param_modifier (see the note there).
14495 let pf_flags = if argc as usize > 4 && vm.pop().to_int() != 0 {
14496 crate::ported::zsh_h::PREFORK_SINGLE
14497 } else {
14498 0
14499 };
14500 let _dq_flag = vm.pop().to_int() != 0;
14501 let op = vm.pop().to_int() as u8;
14502 let pattern = vm.pop().to_str();
14503 let name = vm.pop().to_str();
14504 let op_str = match op {
14505 0 => "#",
14506 1 => "##",
14507 2 => "%",
14508 3 => "%%",
14509 _ => "#",
14510 };
14511 let body = format!("${{{}{}{}}}", name, op_str, pattern);
14512 paramsubst_to_value_status(vm, &body, pf_flags)
14513 });
14514
14515 // `$((expr))` — pops [expr_string], evaluates via MathEval which
14516 // honors integer-vs-float distinction (zsh-compatible). Returns
14517 // the result as Value::Str so it can be Concat'd into surrounding
14518 // word context.
14519 vm.register_builtin(BUILTIN_ARITH_LASTBASE, |vm, _argc| {
14520 // See [`BUILTIN_ARITH_LASTBASE`]: `lastbase = -1` at the head of an
14521 // evaluation, and the literal's own base where the compiler lexed one.
14522 let base = vm.pop();
14523 let base = match base {
14524 Value::Int(n) => n as i32,
14525 other => other.to_str().parse::<i32>().unwrap_or(-1),
14526 };
14527 crate::ported::math::set_lastbase(base);
14528 Value::Int(i64::from(base))
14529 });
14530 vm.register_builtin(BUILTIN_ARITH_EVAL, |vm, _argc| {
14531 // Pure path: evaluate expr, return string. errflag may be
14532 // set by arithsubst on math error; the caller decides
14533 // whether to clear it. For `(( ... ))` (math command) the
14534 // compile_arith path clears via BUILTIN_ARITH_CMD_FINISH;
14535 // for `$((... ))` (substitution inside another command)
14536 // errflag stays set so the surrounding command aborts —
14537 // matches c:Src/math.c "math errors propagate as errflag
14538 // through the containing word expansion".
14539 let expr = vm.pop().to_str();
14540 let result = crate::ported::subst::arithsubst(&expr, "", "");
14541 let _ = vm; // silence unused warning when no math error path mutates
14542 Value::str(result)
14543 });
14544
14545 // After-call hook used by compile_arith's `(( ... ))` path: when
14546 // arithsubst set errflag (math error), clear it and signal
14547 // status=2 in vm.last_status — matches zsh's c:exec.c arith-
14548 // failure: the math command exits 2 and the script continues.
14549 vm.register_builtin(BUILTIN_ARITH_CMD_FINISH, |vm, _argc| {
14550 use std::sync::atomic::Ordering;
14551 let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
14552 let err = live & crate::ported::zsh_h::ERRFLAG_ERROR;
14553 let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
14554 if err != 0 {
14555 // c:Src/subst.c:3344 — when `${var:?msg}` fires, errflag
14556 // is OR'd with ERRFLAG_HARD to signal a script-abort
14557 // error (vs a recoverable math error like `$((1/0))`).
14558 // Clear only the ERRFLAG_ERROR bit; preserve
14559 // ERRFLAG_HARD so the next ERREXIT_CHECK aborts the
14560 // script. Bug #193 in docs/BUGS.md.
14561 if hard != 0 {
14562 // Keep ERRFLAG_HARD AND ERRFLAG_ERROR set so the
14563 // script-abort gate downstream still fires.
14564 vm.last_status = 2;
14565 Value::Status(2)
14566 } else {
14567 crate::ported::utils::errflag
14568 .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
14569 vm.last_status = 2;
14570 Value::Status(2)
14571 }
14572 } else {
14573 Value::Status(vm.last_status)
14574 }
14575 });
14576
14577 // `$(cmd)` — pops [cmd_string], routes through
14578 // run_command_substitution which performs an in-process pipe-capture.
14579 // Avoids the Op::CmdSubst sub-chunk word-emit bug
14580 // (`printf "a\nb"` produced "anb" via that path). Returns trimmed
14581 // output (trailing newlines stripped per POSIX cmd-sub semantics).
14582 vm.register_builtin(BUILTIN_CMD_SUBST_TEXT, |vm, _argc| {
14583 let cmd = vm.pop().to_str();
14584 // c:Src/exec.c:4727-4729 (getoutput) — `if (!isset(EXECOPT)) return
14585 // newlinklist();`: under NO_EXEC the substitution yields nothing and
14586 // runs nothing, `$(<file)` included (that test is ahead of c:4731).
14587 if !isset(crate::ported::zsh_h::EXECOPT) {
14588 return Value::str(String::new());
14589 }
14590 // The enclosing word's `BUILTIN_WORD_DEFER_EMPTIES` declaration
14591 // covers that word's own segments, not the words of a command run
14592 // inside it (`PRE$(f)${a}POST` — `f`'s own words are finished words
14593 // and owe c:183-186 where they stand). Suspend it for the call.
14594 let saved_defer = crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get());
14595 crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(0));
14596 // Inherit live $? into the inner shell so cmd-subst sees the
14597 // parent's most recent exit. Same rationale as the mode-3
14598 // backtick path above.
14599 let live_status = vm.last_status;
14600 let result = with_executor(|exec| {
14601 exec.set_last_status(live_status);
14602 exec.run_command_substitution(&cmd)
14603 });
14604 // Mirror run_command_substitution's exec.last_status side
14605 // effect into the VM's live counter so a containing
14606 // assignment's BUILTIN_SET_VAR — which reads vm.last_status
14607 // — sees the cmd-subst's exit. Without this, `a=$(false);
14608 // echo $?` reads stale 0 (vm.last_status was zeroed by
14609 // compile_assign's prelude SetStatus, and run_cmd_subst only
14610 // updated exec.last_status). Pull the value back through
14611 // exec since it owns the canonical post-subst record.
14612 let cs_status = with_executor(|exec| exec.last_status());
14613 vm.last_status = cs_status;
14614 // c:Src/exec.c — a command substitution running during a
14615 // command's word expansion makes its exit the status of an
14616 // otherwise-empty command (`$(exit 5)` → 5). Flag it so
14617 // BUILTIN_EXEC_DYNAMIC's null-command branch keeps `$?` instead
14618 // of resetting to 0.
14619 crate::ported::exec::use_cmdoutval.store(1, std::sync::atomic::Ordering::Relaxed);
14620 // Hand the enclosing word's declaration back — its remaining
14621 // segments are still part of that word.
14622 crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(saved_defer));
14623 Value::str(result)
14624 });
14625
14626 // Text-based word expansion. Pops [preserved_text, mode_byte].
14627 // mode_byte:
14628 // 0 = Default — expand_string + xpandbraces + expand_glob
14629 // 1 = DoubleQuoted — strip outer `"…"`, expand_string only
14630 // (no brace, no glob — DQ semantics)
14631 // 2 = SingleQuoted — strip outer `'…'`, no expansion
14632 // (kept for symmetry; Snull early-return covers most SQ)
14633 // 3 = AltBackquote — strip backticks, run as cmd-sub
14634 // 7 = RedirTarget — same as Default but glob gated on MULTIOS
14635 // (c:Src/glob.c:2161-2167 xpandredir)
14636 // 8 = unquoted assignment VALUE — same as 6 plus PREFORK_SINGLE
14637 // (c:Src/exec.c:2603 / :4239-4241)
14638 // 9 = singsub word — mode 0 plus PREFORK_SINGLE and nothing else
14639 // (c:Src/subst.c:514-520 `prefork(&foo, PREFORK_SINGLE, NULL)`:
14640 // the `case` WORD c:Src/loop.c:611, every `[[ … ]]` operand
14641 // c:Src/cond.c:53). No PREFORK_ASSIGN — filesub's colon-walk
14642 // is assignment-only (c:Src/subst.c:689).
14643 // Single result → Value::str; multi → Value::Array.
14644 vm.register_builtin(BUILTIN_EXPAND_TEXT, |vm, _argc| {
14645 let mode = vm.pop().to_int() as u8;
14646 let text = vm.pop().to_str();
14647 // Sync vm.last_status → exec.last_status so cmd-subst (mode 3)
14648 // and any nested $? reads inside singsub see the live `$?`
14649 // from the most recent VM op. Without this, cmd-subst inside
14650 // arg-eval saw a stale exec.last_status that was zeroed at
14651 // the start of the current statement. Direct port of zsh's
14652 // pre-cmdsubst lastval propagation per Src/exec.c:4770.
14653 let live_status = vm.last_status;
14654 with_executor(|exec| exec.set_last_status(live_status));
14655 let result_value = with_executor(|exec| match mode {
14656 // Mode 1 = DoubleQuoted (argument context).
14657 // Mode 5 = DoubleQuoted in scalar-assignment context.
14658 // Both share the same DQ unescape pre-processing; mode 5
14659 // additionally bumps `in_scalar_assign` so subst_port's
14660 // paramsubst sees ssub=true and suppresses split flags
14661 // `(f)` / `(s:STR:)` / `(0)` per Src/subst.c:1759 +
14662 // Src/exec.c::addvars line 2546 (the PREFORK_SINGLE bit
14663 // C zsh sets when prefork-ing the assignment RHS).
14664 1 | 5 => {
14665 // DoubleQuoted: strip outer `"…"` if present. In DQ
14666 // context, `\` escapes the DQ-special chars `$`, `` ` ``,
14667 // `"`, `\`. zsh's expand_string expects the lexer's
14668 // `\0X` literal-marker for an already-escaped char, so
14669 // we pre-process: `\$` → `\0$`, `\\` → `\0\`, etc. Then
14670 // expand_string handles the rest.
14671 let inner = if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
14672 &text[1..text.len() - 1]
14673 } else {
14674 text.as_str()
14675 };
14676 // The lexer's dquote_parse (Src/lex.c) already tokenized
14677 // DQ contents: `$` → Qstring (\u{e18c}), `\$`/`\\`/`\"`/
14678 // `` \` `` → Bnull (\u{e19f}) + literal. Stringsubst /
14679 // multsub recognize these markers natively. We pass
14680 // `inner` through verbatim — no re-tokenization needed.
14681 let prepped: String = inner.to_string();
14682 // Tell parameter-flag application that we're inside
14683 // double quotes — array-only flags ((o), (O), (n),
14684 // (i), (M), (u)) must be no-ops here per zsh.
14685 exec.in_dq_context += 1;
14686 if mode == 5 {
14687 exec.in_scalar_assign += 1;
14688 }
14689 // Mode 1 = argv DQ word; mode 5 = scalar-assign RHS.
14690 // In C zsh, the corresponding prefork-on-list paths
14691 // are: argv → `prefork(argv_list, 0)` returns multi-
14692 // word LinkList (Src/exec.c::execcmd), assignment →
14693 // `prefork(rhs_list, PREFORK_SINGLE|PREFORK_ASSIGN)`
14694 // returns single-word (Src/exec.c::addvars line
14695 // 2546). zshrs's `multsub` (Src/subst.c:544) is the
14696 // multi-result variant; `singsub` (Src/subst.c:514)
14697 // asserts ≤1 node. Mode 5 keeps singsub; mode 1
14698 // switches to multsub so `"${(@)arr}"`/`"$@"`/
14699 // `"${arr[@]}"` in argv context emit multiple words
14700 // as the C path would.
14701 // c:Src/lex.c untokenize — the final argv pass C runs
14702 // on every expanded word (glob.c:1862 / exec.c) drops
14703 // the Nularg empty-word sentinel remnulargs left in
14704 // place and folds any remaining token chars. Without
14705 // it, quoted splits with empty pieces
14706 // ("${(s:|:)x}" on "|a|b|") leak Nularg into argv.
14707 let result_value = if mode == 5 {
14708 let out = crate::ported::subst::singsub(&prepped);
14709 if crate::ported::subst::PREFORK_STOPPED.with(|c| c.get()) {
14710 // Stopped by an `(e)` NULL: the node keeps its quote
14711 // tokens (no remnulargs), rendered by C's untokenize
14712 // (c:Src/exec.c:2134): `v="pre${(e)a}post"` → `"pre`.
14713 Value::str(crate::ported::lex::untokenize_ztokens(&out))
14714 } else {
14715 Value::str(crate::ported::lex::untokenize(&out))
14716 }
14717 } else {
14718 // c:Src/subst.c:3892-3895 — `if (isarr) l->list.flags |=
14719 // LF_ARRAY; else l->list.flags &= ~LF_ARRAY;`. Clear the
14720 // carrier BEFORE the expansion so a word that runs no
14721 // paramsubst at all reads `false` rather than some earlier
14722 // expansion's stamp — the same reset the unquoted arm does
14723 // before its own multsub.
14724 crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.set(false));
14725 let (_first, nodes, _ms_ws, _ret) = crate::ported::subst::multsub(&prepped, 0);
14726 // Read immediately: anything below can re-enter paramsubst
14727 // and overwrite the cell. This is C's `isarr` for the
14728 // OUTERMOST expansion of this word — c:4245 `if (isarr)`.
14729 let seg_is_array = crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.get());
14730 // c:Src/subst.c:655 — multsub returns Vec::new()
14731 // for zero-word results (quoted array splat that
14732 // resolved to empty array). Surface as
14733 // Value::Array(vec![]) so the downstream array
14734 // assignment / argv flattening sees ZERO args.
14735 // Previous Rust port returned Value::str("") which
14736 // surfaced as ONE empty arg. Bug #120 in
14737 // docs/BUGS.md.
14738 if crate::ported::subst::PREFORK_STOPPED.with(|c| c.get()) {
14739 // An `(e)` NULL stopped prefork: every node already in
14740 // the list stays (c:Src/subst.c:4383-4394 inserted the
14741 // elements before the failing one, the first joined to
14742 // the text before the `$`), unexpanded, with its quote
14743 // tokens rendered by untokenize (c:Src/exec.c:2134).
14744 stopped_nodes_value(nodes)
14745 } else if nodes.is_empty() {
14746 // c:Src/subst.c:4245 `if (isarr)` gates the array emit
14747 // block, and c:4362-4365 `if (plan9) { uremnode(l, n);
14748 // return n; }` deletes the WHOLE word from inside it —
14749 // so whether a zero-word expansion removes its word is
14750 // decided by THIS expansion's own `isarr`, never by the
14751 // previous one's. `Value::Array(vec![])` cannot carry
14752 // the bit, so record it here exactly as the unquoted
14753 // arm does (`note_empty_is_scalar(!(seg_zero_words &&
14754 // seg_is_array))`). Without the write the cell still
14755 // held an unrelated earlier expansion's value, and a
14756 // preceding empty-SCALAR read (`local x=$a[2]`, which
14757 // leaves it `true`) resurrected a quoted empty-array
14758 // word as one empty word under RC_EXPAND_PARAM:
14759 // `e=(); r=( q "${e[@]:/A/B}" )` counted 2 where zsh
14760 // counts 1. See EMPTY_EXPANSION_IS_SCALAR.
14761 note_empty_is_scalar(!seg_is_array);
14762 Value::array(Vec::new())
14763 } else if nodes.len() == 1 {
14764 Value::str(crate::ported::lex::untokenize(
14765 &nodes.into_iter().next().unwrap(),
14766 ))
14767 } else {
14768 Value::array(
14769 nodes
14770 .into_iter()
14771 .map(|n| Value::str(crate::ported::lex::untokenize(&n)))
14772 .collect(),
14773 )
14774 }
14775 };
14776 if mode == 5 {
14777 exec.in_scalar_assign -= 1;
14778 }
14779 exec.in_dq_context -= 1;
14780 // c:Src/subst.c:326-327 → prefork c:142-147 — subst.rs's prefork
14781 // stopped on an `(e)` NULL: the word is what preceded the `$`
14782 // (c:1878). The lexer's opening Dnull stays in that node and
14783 // untokenizes to a literal `"` when the word itself opened a
14784 // double quote; this handler stripped those outer quotes above,
14785 // so put it back. Mode 5 also carries UNQUOTED scalar-assignment
14786 // words (`v=${(e)a}`), which have no quote to restore.
14787 // PREFORK_CUT tells the words that follow to stay unexpanded.
14788 if crate::ported::subst::PREFORK_STOPPED.with(|c| c.replace(false)) {
14789 PREFORK_CUT.with(|c| c.set(true));
14790 // The stopped nodes are rendered WITH their quote tokens
14791 // (stopped_nodes_value, or ztokens for mode 5), so a lexer
14792 // Dnull already shows as `"`. Only an ASCII `"` that this
14793 // handler stripped above still has to be put back.
14794 let opened_dq = text.starts_with('"');
14795 let quote = if opened_dq { "\"" } else { "" };
14796 // The quote belongs to the FIRST node, the one holding the
14797 // text before the `$`; later nodes are whole elements.
14798 return match result_value {
14799 Value::Array(items) if !items.is_empty() => {
14800 let mut words: Vec<Value> = items.iter().cloned().collect();
14801 words[0] = Value::str(format!("{}{}", quote, words[0].to_str()));
14802 Value::array(words)
14803 }
14804 other => Value::str(format!("{}{}", quote, other.to_str())),
14805 };
14806 }
14807 result_value
14808 }
14809 2 => {
14810 // SingleQuoted: pure literal, strip outer `'…'`.
14811 let inner = if text.len() >= 2 && text.starts_with('\'') && text.ends_with('\'') {
14812 &text[1..text.len() - 1]
14813 } else {
14814 text.as_str()
14815 };
14816 Value::str(inner.to_string())
14817 }
14818 3 => {
14819 // Backquote command sub: strip outer backticks.
14820 // Word-split the result on IFS when the surrounding
14821 // word is unquoted — zsh: `print -l \`echo a b c\``
14822 // emits one arg per word. The $(…) path applies the
14823 // same split via BUILTIN_WORD_SPLIT after capture; do
14824 // the equivalent here for the `…` form.
14825 let inner = if text.len() >= 2 && text.starts_with('`') && text.ends_with('`') {
14826 &text[1..text.len() - 1]
14827 } else {
14828 text.as_str()
14829 };
14830 // Apply the live VM status before running the inner
14831 // shell so the inherited $? matches zsh's lastval
14832 // propagation.
14833 exec.set_last_status(live_status);
14834 // c:Src/exec.c:4727-4729 — getoutput returns an empty list
14835 // under NO_EXEC.
14836 let captured = if isset(crate::ported::zsh_h::EXECOPT) {
14837 exec.run_command_substitution(inner)
14838 } else {
14839 String::new()
14840 };
14841 let trimmed = captured.trim_end_matches('\n');
14842 if exec.in_dq_context > 0 {
14843 Value::str(trimmed.to_string())
14844 } else {
14845 let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
14846 let parts: Vec<Value> = trimmed
14847 .split(|c: char| ifs.contains(c))
14848 .filter(|s| !s.is_empty())
14849 .map(|s| Value::str(s.to_string()))
14850 .collect();
14851 if parts.is_empty() {
14852 Value::str(String::new())
14853 } else if parts.len() == 1 {
14854 parts.into_iter().next().unwrap()
14855 } else {
14856 Value::array(parts)
14857 }
14858 }
14859 }
14860 4 => {
14861 // HeredocBody: expand variables / command-subst / arith
14862 // but NOT glob or brace. Heredoc lines like `[42]` must
14863 // pass through verbatim — running them through the
14864 // default pipeline triggers NOMATCH on the literal.
14865 Value::str(crate::ported::subst::singsub(&text))
14866 }
14867 _ => {
14868 // Default (unquoted): the lexer's gettokstr already
14869 // tokenized backslash-escapes (`\$` → Bnull+$, etc).
14870 // Pass `text` through verbatim — multsub/stringsubst
14871 // recognize the markers natively. No bridge-side
14872 // re-tokenization needed.
14873 //
14874 // Mode 6 = unquoted RHS in scalar-assign context.
14875 // Pass PREFORK_ASSIGN so prefork's filesub colon-walk
14876 // fires per c:Src/exec.c:2546.
14877 let prepped: String = text.clone();
14878 if std::env::var("ZSHRS_TRACE_DEFP").is_ok() {
14879 eprintln!(
14880 "[TRACE_DEFP] text={:?} prepped={:?} mode={}",
14881 text, prepped, mode
14882 );
14883 }
14884 // Mode 8 = the unquoted VALUE of a `NAME=VALUE` assignment
14885 // (bare statement or typeset-family argument). C preforks
14886 // exactly that with `PREFORK_SINGLE|PREFORK_ASSIGN`
14887 // (c:Src/exec.c:2603 and c:Src/exec.c:4239-4241); the
14888 // PREFORK_SINGLE half is paramsubst's `ssub`
14889 // (c:Src/subst.c:1761), which gates off the forced split at
14890 // c:Src/subst.c:3913.
14891 // Mode 9 = a word C expands with `singsub` (c:Src/subst.c:520
14892 // `prefork(&foo, PREFORK_SINGLE, NULL)`) — the `case` WORD
14893 // (c:Src/loop.c:611) and every `[[ … ]]` operand
14894 // (c:Src/cond.c:53). PREFORK_SINGLE alone: it is paramsubst's
14895 // `ssub` (c:Src/subst.c:1761), which takes the c:4226
14896 // `if (isarr && ssub)` IFS[0] join BEFORE the c:4256 array-emit
14897 // block, so the c:4264 unique fold and the c:4301-4326 sort do
14898 // not run; it also turns off `xpandbraces` (c:Src/subst.c:170)
14899 // and c:3913's `force_split`. NO PREFORK_ASSIGN — a `case`
14900 // word / cond operand gets no `filesub` colon-walk.
14901 // Mode 12 = the same `singsub`, for a `[[ … == pat ]]` RHS or a
14902 // `case` arm. It differs from mode 9 only in what it hands
14903 // BACK: the word keeps its tokens (see the untokenize below),
14904 // because a pattern operand's guard still has to tell a
14905 // metacharacter `shtokenize` armed from a raw byte of a value.
14906 let pf_flags = if mode == 8 {
14907 crate::ported::zsh_h::PREFORK_SINGLE | crate::ported::zsh_h::PREFORK_ASSIGN
14908 } else if mode == 9 || mode == 12 {
14909 crate::ported::zsh_h::PREFORK_SINGLE
14910 } else if mode == 6 {
14911 crate::ported::zsh_h::PREFORK_ASSIGN
14912 } else if mode == 13 {
14913 // Mode 13 = a command argument under MAGIC_EQUAL_SUBST +
14914 // KSH_TYPESET: c:Src/exec.c:3353-3355 preforks it with
14915 // `esprefork = PREFORK_TYPESET`, whose c:Src/subst.c:103
14916 // `asssub` stops word splitting after the first `=`.
14917 crate::ported::zsh_h::PREFORK_TYPESET
14918 } else {
14919 0
14920 };
14921 // c:Src/subst.c:544+ — `multsub(&prepped, 0)` is the
14922 // unquoted-argv equivalent of zsh's `prefork(list,
14923 // 0, NULL)` for a single-element list. Returns the
14924 // post-expansion node list (Vec<String>) so array-
14925 // shape results (e.g. `${a:e}`, `${a[@]}`,
14926 // `${(s::)str}`) splat into multiple argv words.
14927 // singsub() collapses to one string and discards the
14928 // splat — parity bug #28 (whole-array modifier).
14929 // c:Src/subst.c:3929-3932 — `if (isarr) l->list.flags |=
14930 // LF_ARRAY; else l->list.flags &= ~LF_ARRAY;`. C's paramsubst
14931 // holds the LinkList and stamps its `isarr` on it directly;
14932 // the Rust port hands the same bit back through the
14933 // `PARAMSUBST_LF_ARRAY` thread-local (subst.rs:20511) — set at
14934 // subst.rs:17796 (`isarr != 0 && !forced_split_to_one`) and
14935 // reset to false at the top of EVERY paramsubst
14936 // (subst.rs:3891 / subst.rs:17445).
14937 //
14938 // Clear it BEFORE multsub so a segment that runs no paramsubst
14939 // at all reads false instead of some earlier expansion's
14940 // value. `multsub`'s own `isarr` return cannot be used for
14941 // this: an unquoted `$(cmd)` / `` `cmd` `` sets LF_ARRAY
14942 // unconditionally (subst.rs:897 / :1159, c:Src/subst.c:285-286
14943 // `if (!qt) list->list.flags |= LF_ARRAY;` and c:331), so
14944 // `x$(true)y` would look array-shaped when zsh keeps that
14945 // word (verified: it is the single word `xy`).
14946 //
14947 // Every paramsubst re-initialises the cell on entry, so
14948 // clearing it here cannot disturb subst.rs's own readers
14949 // (stringsubst reads it immediately after each paramsubst
14950 // call, subst.rs:1094).
14951 crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.set(false));
14952 // Mode 10 (a deferred command argument): c:Src/subst.c:165-191
14953 // runs filesub in prefork's second pass, after stringsubst has
14954 // run over EVERY word, so `print =nosuchcmd $(cmd)` runs `cmd`
14955 // before `=nosuchcmd` fails. This handler expands one word, so
14956 // the word's filesub is left to BUILTIN_GLOBLIST's all-words
14957 // pass (the word comes back with its Equals/Tilde token).
14958 let saved_skip_filesub = crate::ported::subst::SKIP_FILESUB.with(|c| c.get());
14959 if mode == 10 {
14960 crate::ported::subst::SKIP_FILESUB.with(|c| c.set(true));
14961 }
14962 let (_first, nodes, _ms_ws, _ret) =
14963 crate::ported::subst::multsub(&prepped, pf_flags);
14964 crate::ported::subst::SKIP_FILESUB.with(|c| c.set(saved_skip_filesub));
14965 // c:Src/subst.c:326-327 → prefork c:142-147 — an `(e)` NULL ended
14966 // prefork: the word is the text before the `$` (c:1878), and the
14967 // brace / filesub / remnulargs passes after the substitution loop
14968 // never ran. PREFORK_CUT keeps the later words unexpanded.
14969 if crate::ported::subst::PREFORK_STOPPED.with(|c| c.replace(false)) {
14970 PREFORK_CUT.with(|c| c.set(true));
14971 // remnulargs (c:170) never ran either, so C's nodes still
14972 // hold their quote tokens and untokenize (c:Src/exec.c:2134)
14973 // renders them through ztokens. Every node before the cut
14974 // stays: `(@e)` inserted the earlier elements
14975 // (c:Src/subst.c:4383-4394), so `A${(@e)arr}B` with
14976 // arr=(p x '$(') is `Ap` and `x`.
14977 return stopped_nodes_value(nodes);
14978 }
14979 // Read immediately: brace expansion / filesub / glob below can
14980 // re-enter paramsubst and overwrite the cell. `seg_is_array` is
14981 // the array-ness of the OUTERMOST paramsubst in this segment —
14982 // C's c:4245 `if (isarr)` for the same expansion. Paired with
14983 // `seg_zero_words` it separates the two empty shapes for the
14984 // empty-result arm further down (c:4362 vs c:4464).
14985 let seg_is_array = crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.get());
14986 let seg_zero_words = nodes.is_empty();
14987 if std::env::var("ZSHRS_TRACE_MULTSUB").is_ok() {
14988 eprintln!("[TRACE_MULTSUB] prepped={:?} nodes={:?}", prepped, nodes);
14989 }
14990 // c:Src/subst.c:166 — xpandbraces runs AFTER prefork's
14991 // substitution pass and BEFORE untokenize/glob. Per
14992 // word, scan for Inbrace TOKEN and expand. Words that
14993 // don't contain Inbrace TOKEN pass through unchanged.
14994 // Brace expansion is done here (inside the bridge
14995 // default arm) instead of via a post-EXPAND_TEXT
14996 // BRACE_EXPAND emit because untokenize (line below)
14997 // strips TOKEN bytes, after which the strict-TOKEN
14998 // xpandbraces gate would no longer match.
14999 let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
15000 // c:Src/options.c — `no_brace_expand` (negated
15001 // `braceexpand`) gates brace expansion entirely.
15002 // When off, `{a,b}` stays literal.
15003 // c:Src/subst.c:170 — `if (unset(IGNOREBRACES) && !(flags &
15004 // PREFORK_SINGLE))` guards the `xpandbraces` loop, so a word
15005 // preforked as a scalar (assignment VALUE, mode 8) is NEVER
15006 // brace-expanded: `local x={a,b}` stores the five literal
15007 // characters. This pass stands in for prefork's loop, so it
15008 // owes the same guard.
15009 let brace_expand = opt_state_get("braceexpand").unwrap_or(true)
15010 && (pf_flags & crate::ported::zsh_h::PREFORK_SINGLE) == 0; // c:170
15011 let pre_brace: Vec<String> = if nodes.is_empty() {
15012 vec![String::new()]
15013 } else {
15014 nodes
15015 };
15016 let brace_expanded: Vec<String> = pre_brace
15017 .into_iter()
15018 .flat_map(|w| {
15019 if brace_expand && w.contains('\u{e18f}') {
15020 crate::ported::glob::xpandbraces(&w, brace_ccl)
15021 } else {
15022 vec![w]
15023 }
15024 })
15025 .collect();
15026 // zsh stores the option as `glob` (default ON);
15027 // `setopt noglob` writes `glob=false`. Honor either
15028 // form so the dispatcher behaves the same as zsh.
15029 // Mode 7 = redirect-target word: glob only under
15030 // MULTIOS (c:Src/glob.c:2161-2167 xpandredir,
15031 // "Globbing is only done for multios.").
15032 let noglob = opt_state_get("noglob").unwrap_or(false)
15033 || opt_state_get("GLOB").map(|v| !v).unwrap_or(false)
15034 || !opt_state_get("glob").unwrap_or(true)
15035 || (mode == 7 && !opt_state_get("multios").unwrap_or(true))
15036 // Mode 9 = singsub. c:Src/subst.c:514-525 runs `prefork`
15037 // and NOTHING else — `globlist` is never called, so a
15038 // `case` word (c:Src/loop.c:610-612) and a `[[ … ]]`
15039 // operand (c:Src/cond.c:53) are never filename-generated.
15040 || mode == 9
15041 // Mode 12 = the pattern-operand spelling of mode 9, so it
15042 // owes the same "singsub never reaches globlist" rule.
15043 || mode == 12
15044 // Mode 11 = a segment of a larger word (see compile_zsh's
15045 // text-expansion arm): c:Src/exec.c:3755-3757 globs the
15046 // assembled word, so the segment itself is not globbed.
15047 || mode == 11
15048 // c:Src/exec.c:2603-2613 — a scalar `NAME=VALUE` is
15049 // preforked PREFORK_SINGLE|PREFORK_ASSIGN (mode 8) and
15050 // reaches globlist only under GLOB_ASSIGN; without it the
15051 // value is never globbed. Missing this term, a value the
15052 // compiler did not DQ-wrap globbed here: `x=q(` said "bad
15053 // pattern: q(", `x=q(a)` "number expected", `x=q(|b)` "no
15054 // matches found".
15055 || (mode == 8 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBASSIGN));
15056 // c:Src/subst.c:168 — prefork's `if (*(char *)getdata(node))`
15057 // runs on the node AS IT STANDS, i.e. still carrying the quote
15058 // markers the lexer left and the `Nularg` that c:169's
15059 // `remnulargs` re-inserts for a node made of nothing else
15060 // (c:Src/glob.c:3683-3686). `untokenize` drops that sentinel
15061 // (c:Src/exec.c:2143), but only much later, once the node is
15062 // already a word. Testing emptiness AFTER the untokenize below
15063 // inverted that order and deleted the word C keeps:
15064 // `unset u; r=( q ${u:-""} )` counted 1 where zsh counts 2
15065 // (same for `${u:-''}`, `${u-""}`, `${1:-""}`, `${a[@]:+""}`).
15066 // Record C's answer here and let the empty-result arm consult
15067 // it.
15068 let nonempty_before_untokenize = brace_expanded.iter().any(|w| !w.is_empty());
15069 let parts: Vec<String> = brace_expanded
15070 .into_iter()
15071 .flat_map(|s| {
15072 // The lexer leaves glob metacharacters in their
15073 // META-encoded form: `*` → `\u{e187}`, `?` →
15074 // `\u{e186}`, `[` → `\u{e191}`, etc. expand_string
15075 // doesn't untokenize them, so the literal-char
15076 // checks below (`s.contains('*')`) would miss
15077 // every real glob and skip expand_glob — that
15078 // bug let `echo *.toml` print the literal
15079 // `*.toml` because the META `\u{e187}` never
15080 // matched the literal `*`. Untokenize once so
15081 // the metacharacter checks see the canonical
15082 // form. zsh's pattern.c expects `*` etc. as
15083 // bare chars at the glob layer.
15084 // c:Src/pattern.c:4306 haswilds on the still-
15085 // TOKENIZED word (pre-untokenize), matching C's
15086 // zglob entry gate (Src/glob.c:1230) which runs
15087 // on the lexer-tokenized string. haswilds
15088 // matches ONLY token codes: source-level
15089 // `*.toml` carries Star and fires; bare literal
15090 // `[`/`*`/`?` from `$'...'` decode, `:-`
15091 // default values, or nested-substitution
15092 // results were never shtokenize'd (C
15093 // subst.c:3231 sets globsubst=0 in the `:-`
15094 // arm) and stay literal — bug #625. Plain
15095 // multibyte text (`↔`) never matches a token
15096 // codepoint — bug #627.
15097 let is_glob_pre = !noglob && crate::ported::pattern::haswilds(&s);
15098 // c:Src/glob.c:1230 — zglob receives the word in
15099 // LEXER-TOKENIZED form and only untokenizes it when
15100 // it declines to glob (c:1232) or falls back to the
15101 // literal (c:1884). The token form is what makes a
15102 // QUOTED metachar distinguishable from an active one:
15103 // in `*(.e['[[ $REPLY == a* ]]'])` the body's `]`
15104 // bytes are raw ASCII while the qualifier's real
15105 // closer is `Outbrack`, which is exactly how
15106 // checkglobqual (c:1163/1170, testing Outpar/Inpar)
15107 // and get_strarg's tokenized delimiter half
15108 // (Src/subst.c:1379-1390) find the true end. zshrs
15109 // untokenized here, one line before the glob layer,
15110 // so every quoted metachar became indistinguishable
15111 // from an active one and the qualifier parser closed
15112 // on the first quoted `]`. Keep the tokenized word
15113 // for the glob call; the untokenized form still
15114 // drives the non-glob arms below.
15115 let s_tok = s.clone();
15116 // c:36 `nulstring` — a segment (mode 11) of a word
15117 // whose end-of-word drop is still to run keeps the
15118 // Nularg that marks a split's empty field, as
15119 // `paramsubst_to_value_pf` does; untokenize would fold
15120 // it to "", which that drop cannot tell from a truly
15121 // empty node. The drop removes the marker (c:170).
15122 let nul = crate::ported::zsh_h::Nularg;
15123 let s = if mode == 11
15124 && s.contains(nul)
15125 && crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get()) > 0
15126 {
15127 s.split(nul)
15128 .map(crate::lex::untokenize)
15129 .collect::<Vec<_>>()
15130 .join(&nul.to_string())
15131 } else if mode == 12 {
15132 // c:Src/glob.c:3640-3645 — `shtokenize` says "this
15133 // metacharacter is ACTIVE" by rewriting it to a
15134 // TOKEN and says "this one is data" by leaving the
15135 // raw byte, and C carries both spellings into
15136 // `patcompile` unchanged. Mode 12 is a `[[ … ]]` /
15137 // `case` PATTERN operand, the one word where that
15138 // distinction still has a consumer: untokenizing
15139 // here collapses the two, and the guard that runs
15140 // next can then only escape ALL of the word's metas
15141 // or none of them. c:Src/subst.c:1551-1552 puts
15142 // both halves in one string — the `(~j)` separator
15143 // is tokenized, the elements it joins are not — so
15144 // neither answer is right. Hand the word on
15145 // tokenized; BUILTIN_GLOB_SUBST_GUARD /
15146 // BUILTIN_PAT_DATA_BACKSLASH run the untokenize
15147 // once they have escaped the raw half.
15148 s
15149 } else {
15150 crate::lex::untokenize(&s)
15151 };
15152 // c:Src/exec.c:2603-2613 — only an assignment VALUE
15153 // skips globlist without GLOB_ASSIGN, and that value
15154 // arrives here in mode 8, which `noglob` above already
15155 // covers. The word's text says nothing about its role:
15156 // a text-shape test (`[A-Za-z_][A-Za-z0-9_]*=`) that
15157 // used to skip the glob here also kept `print a=zzq*`,
15158 // `set -- a=zzq*`, `for w in a=zzq*`, `> a=zzq*` and
15159 // the array element `x=(a=zzq*)` from globbing, where
15160 // zsh reports "no matches found".
15161 // Glob-trigger decision: pre-untokenize
15162 // haswilds_tokens_only result (computed above
15163 // before the untokenize that collapses META
15164 // tokens to their ASCII forms). The TOKEN-only
15165 // gate matches C `Src/pattern.c:4306-4376`
15166 // exactly — only Inbrack/Star/Quest/Inpar/Bar/
15167 // Inang/Pound/Hat token codes count as wild,
15168 // not their literal ASCII counterparts. Source-
15169 // level `*.toml` carries Star token so globs;
15170 // `$'…'`-decoded `[abc]` carries bare `[` so
15171 // stays literal. Bug #625.
15172 // c:Src/subst.c:677-678 — `filesub`'s own precondition
15173 // for the PREFORK_TYPESET arm is `(*namptr)[1] &&
15174 // strchr(*namptr + 1, Equals)`: an `=` anywhere but
15175 // position 0. There is NO identifier test. Gating the
15176 // arm below on an assignment-shape test — which demanded a
15177 // leading `[A-Za-z_][A-Za-z0-9_]*` — therefore lost every
15178 // word whose `=` is not preceded by a bare identifier:
15179 // setopt magicequalsubst; print -r -- x:y=~/z
15180 // zsh: x:y=/home/u/z zshrs: x:y=~/z
15181 // Same for `ME:a=~/x`, `1abc=~/x` and `-o=~/x`, the last
15182 // of which is the common `--prefix=~/dir` shape.
15183 // c:Src/subst.c:678 — filesub's own gate is
15184 // `strchr(*namptr + 1, Equals)`: the Equals TOKEN,
15185 // which the lexer emits ONLY for an unquoted `=`.
15186 // Test the still-TOKENIZED word so a quoted `=` does
15187 // not arm the arm below.
15188 let has_nonleading_equals = s_tok
15189 .chars()
15190 .skip(1)
15191 .any(|c| c == crate::ported::zsh_h::Equals);
15192 // c:Src/subst.c:180 `filesub(&cptr, flags & …)` acts on a
15193 // leading Tilde / Equals token; mode 10 deferred it above.
15194 let filesub_deferred = mode == 10
15195 && (s_tok.starts_with(crate::ported::zsh_h::Tilde)
15196 || s_tok.starts_with(crate::ported::zsh_h::Equals));
15197 if (is_glob_pre || filesub_deferred) && mode == 10 {
15198 // Mode 10: the command's BUILTIN_GLOBLIST runs this
15199 // word's filesub and glob after every argument has
15200 // been expanded (c:Src/subst.c:165-191, then
15201 // c:Src/exec.c:3755-3757).
15202 vec![s_tok]
15203 } else if is_glob_pre {
15204 // c:Src/exec.c:3755-3762 — an error raised here is a
15205 // globlist error (`lastval = 1`); see GLOB_WORD_ERRFLAG.
15206 let ef = || {
15207 crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
15208 & crate::ported::zsh_h::ERRFLAG_ERROR
15209 };
15210 let before = ef();
15211 // c:Src/glob.c:2164-2166 — a redirect target
15212 // (mode 7) is globbed with `in_expandredir` set,
15213 // so a NULL_GLOB no-match is `redirection failed
15214 // (no match)` (c:1888-1894), not a dropped word.
15215 let redir = (mode == 7) as i32;
15216 crate::ported::glob::IN_EXPANDREDIR.store(redir, std::sync::atomic::Ordering::SeqCst);
15217 let globbed = exec.expand_glob(&s_tok);
15218 crate::ported::glob::IN_EXPANDREDIR.store(0, std::sync::atomic::Ordering::SeqCst);
15219 if before == 0 && ef() != 0 {
15220 GLOB_WORD_ERRFLAG.with(|c| c.set(true));
15221 }
15222 globbed
15223 } else if has_nonleading_equals
15224 && crate::ported::zsh_h::isset(crate::ported::zsh_h::MAGICEQUALSUBST)
15225 {
15226 // c:Src/exec.c:3353 — when MAGIC_EQUAL_SUBST is set
15227 // on a non-typeset command, esprefork = PREFORK_TYPESET,
15228 // so every arg runs through
15229 // filesub(PREFORK_TYPESET): the `~`/`=` after the
15230 // first `=` (and after each `:`) undergo filename
15231 // expansion. `print foo=~/bar` → `foo=$HOME/bar`.
15232 // filesub (subst.c:667) keys on the Tilde/Equals
15233 // TOKENS, not the literal chars, because that is
15234 // exactly what tells a quoted `~`/`=` from a live
15235 // one. This arm used to `untokenize` the word and
15236 // then `shtokenize` it back, which re-marks EVERY
15237 // literal `~`/`=` as a token — including the ones
15238 // that came out of quotes. With MAGIC_EQUAL_SUBST
15239 // set, `print -r -- --a='b:=c'` then did `=`
15240 // filename expansion on the quoted `=c` and failed
15241 // with `c not found` (zsh prints `--a=b:=c`), and
15242 // `A=( --height='${X:=75%}' )` — fzf-tab.plugin.zsh
15243 // sh:117-128 — errored `75%} not found` at plugin
15244 // load, aborting the rest of the zinit turbo
15245 // `atload` chain. Hand filesub the word C hands it:
15246 // the one the lexer produced.
15247 let exp = crate::ported::subst::filesub(
15248 &s_tok,
15249 crate::ported::zsh_h::PREFORK_TYPESET,
15250 );
15251 vec![crate::lex::untokenize(&exp).to_string()]
15252 } else {
15253 vec![s]
15254 }
15255 })
15256 .collect();
15257 if parts.len() == 1 {
15258 let only = parts.into_iter().next().unwrap_or_default();
15259 // Empty unquoted expansion → drop the arg entirely
15260 // (zsh "remove empty unquoted words" rule). Returning
15261 // an empty Value::Array makes pop_args contribute zero
15262 // items. Direct port of subst.c's empty-elide pass at
15263 // the end of multsub which removes empty linknodes
15264 // from unquoted contexts. Quoted DQ/SQ paths (modes
15265 // 1/2/5) take separate arms above and always emit
15266 // Value::Str so the empty arg survives.
15267 //
15268 // c:Src/subst.c:4437 + 1650-1656 — a word CONTAINING a
15269 // quoted span never drops: `x"${v[-1]}"y` (v empty)
15270 // is the scalar "xy", and a standalone `"${v[-1]}"`
15271 // is ONE empty arg. The lexer marks DQ/SQ spans with
15272 // Dnull(\u{e19e})/Snull(\u{e19d})/Qstring(\u{e18c})/
15273 // Bnull(\u{e19f}); their presence in the SOURCE word
15274 // means qt semantics apply. Without this gate, zpwr's
15275 // global `setopt rc_expand_param` turned autopair's
15276 // `local lchar="${LBUFFER[-1]}"` (empty prompt +
15277 // backspace) into an ARGLESS `local` — the full
15278 // parameter-table dump the user saw per keystroke.
15279 if only.is_empty() && !nonempty_before_untokenize {
15280 // A quote span that WRAPS the expansion keeps the
15281 // empty arg (`"${v[-1]}"` → one empty arg). But a
15282 // quote INSIDE the `${…}` braces — e.g. the alternate
15283 // of `${x:+'q'}` or `${x:-'d'}` — does NOT: when that
15284 // branch isn't taken the result is a plain unquoted
15285 // empty and must ELIDE, matching zsh (`a=(A ${x:+'q'}
15286 // C)` → 2 elements, not 3). So only count quote
15287 // markers at brace-depth 0 (outside `${…}`). Inbrace
15288 // = \u{e18f}, Outbrace = \u{e190}.
15289 let mut depth = 0i32;
15290 let mut word_has_quoted_span = false;
15291 for c in text.chars() {
15292 match c {
15293 '\u{e18f}' => depth += 1,
15294 '\u{e190}' => depth -= 1,
15295 '\u{e19e}' | '\u{e19d}' | '\u{e18c}' | '\u{e19f}' | '"' | '\''
15296 if depth <= 0 =>
15297 {
15298 word_has_quoted_span = true;
15299 break;
15300 }
15301 _ => {}
15302 }
15303 }
15304 if word_has_quoted_span {
15305 // Returns a SCALAR Value, so the empty-Array
15306 // shape bit does not describe it — leave the
15307 // cell alone. Overwriting it here would let a
15308 // trailing quoted-empty segment resurrect a word
15309 // an EARLIER empty array already deleted:
15310 // `setopt rcexpandparam; a=(); x${a}"${P}"y` is
15311 // ZERO words in zsh, and concat_plan9's
15312 // `(Array(empty), scalar)` arm returns the scalar
15313 // when the bit says "scalar".
15314 Value::str(String::new())
15315 } else {
15316 // The empty `Value::Array` below stands for TWO
15317 // different C shapes, and under RC_EXPAND_PARAM
15318 // they behave OPPOSITELY:
15319 //
15320 // c:Src/subst.c:4362-4365 (plan9, empty ARRAY)
15321 // if (plan9) { uremnode(l, n); return n; }
15322 // → the whole word is deleted:
15323 // `a=(); x${a}y` and `x${${a}}y` are 0 words.
15324 //
15325 // c:Src/subst.c:4438-4467 (scalar arm, empty
15326 // SCALAR) — c:4464
15327 // *str = strcatsub(&y, ostr, aptr, x, xlen,
15328 // fstr, globsubst, copied);
15329 // then c:4467 `setdata(n, (void *) y);`
15330 // → the surrounding text survives:
15331 // `unset P; x${P}y` and `x${${P}}y` are the
15332 // single word `xy`.
15333 //
15334 // Nesting is NOT the discriminator — shape is.
15335 // The word is deleted only when the expansion was
15336 // ARRAY-shaped (c:4245 `if (isarr)`) AND produced
15337 // zero words, i.e. C's `while ((x = *aval++))`
15338 // loop at c:4327 never ran and left `plan9`
15339 // non-zero at c:4362. Anything else empty — an
15340 // unset/empty scalar, a nested subexp that
15341 // resolved to a scalar, a command substitution
15342 // with no output — takes c:4438's scalar arm and
15343 // keeps the word.
15344 note_empty_is_scalar(!(seg_zero_words && seg_is_array));
15345 Value::array(Vec::new())
15346 }
15347 } else {
15348 Value::str(only)
15349 }
15350 } else {
15351 Value::array(parts.into_iter().map(Value::str).collect())
15352 }
15353 }
15354 });
15355 // Pull any inner cmd-subst (`` `cmd` `` via mode 3 or via
15356 // mode 0/6 multsub → getoutput, `$(cmd)` via the default
15357 // arm's multsub path, nested `$()`s reached through
15358 // stringsubst) back into vm.last_status so a containing
15359 // assignment's BUILTIN_SET_VAR — which reads vm.last_status —
15360 // sees the cmd-subst's exit. Without this, backtick
15361 // assignments (`a=\`false\`; echo $?`) reported 0 because the
15362 // ported LASTVAL update never reached the VM-side counter.
15363 let cs_status = with_executor(|exec| exec.last_status());
15364 vm.last_status = cs_status;
15365 result_value
15366 });
15367
15368 // `${#name}` — pops [name]. Returns the value's element count for
15369 // arrays (indexed and assoc) or character length for scalars.
15370 // BUILTIN_PARAM_LENGTH — `${#name}`. PURE PASSTHRU.
15371 vm.register_builtin(BUILTIN_PARAM_LENGTH, |vm, _argc| {
15372 let name = vm.pop().to_str();
15373 // PARAM_LENGTH's empty-result semantics differ from
15374 // paramsubst_to_value: 0 nodes → "0" (numeric length), not
15375 // empty array. paramsubst on `${#X}` always returns at least
15376 // one node in practice (the length string); the empty case
15377 // is defensive.
15378 let mut ret_flags: i32 = 0;
15379 let (_full, _pos, nodes) = crate::ported::subst::paramsubst(
15380 &format!("${{#{}}}", name),
15381 0,
15382 false,
15383 0i32,
15384 &mut ret_flags,
15385 );
15386 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
15387 with_executor(|exec| exec.set_last_status(1));
15388 }
15389 if nodes.is_empty() {
15390 Value::str("0")
15391 } else {
15392 nodes_to_value(nodes)
15393 }
15394 });
15395
15396 // `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
15397 // `${var/%pat/repl}` — Pops [name, pattern, replacement, op_byte].
15398 // op: 0=first, 1=all, 2=anchor-prefix (`/#`), 3=anchor-suffix (`/%`).
15399 // BUILTIN_PARAM_REPLACE — `${var/pat/repl}` / `${var//pat/repl}` /
15400 // `${var/#pat/repl}` / `${var/%pat/repl}`. PURE PASSTHRU.
15401 vm.register_builtin(BUILTIN_PARAM_REPLACE, |vm, argc| {
15402 // c:Src/subst.c:1761 — the word's PREFORK_SINGLE bit, pushed last by
15403 // compile_zsh::emit_param_modifier (see the note there).
15404 let pf_flags = if argc as usize > 5 && vm.pop().to_int() != 0 {
15405 crate::ported::zsh_h::PREFORK_SINGLE
15406 } else {
15407 0
15408 };
15409 let dq_flag = vm.pop().to_int() != 0;
15410 let op = vm.pop().to_int() as u8;
15411 let repl = vm.pop().to_str();
15412 let pattern = vm.pop().to_str();
15413 let name = vm.pop().to_str();
15414 // !!! DASH-STRICT GATE !!! dash / ash have no `${var/pat/repl}`
15415 // pattern-replacement expansion — it is a "Bad substitution" error
15416 // (bash/ksh/POSIX-sh support it, so those modes fall through). Raise
15417 // the canonical zsh diagnostic + exit 1 to match /bin/dash's failure.
15418 if crate::dash_mode::dash_strict() {
15419 crate::ported::utils::zerr("bad substitution");
15420 crate::ported::utils::errflag.fetch_or(
15421 crate::ported::zsh_h::ERRFLAG_ERROR,
15422 std::sync::atomic::Ordering::Relaxed,
15423 );
15424 with_executor(|exec| exec.set_last_status(1));
15425 return Value::str("");
15426 }
15427 // DQ context: C's lexer marks every `$` inside double quotes
15428 // as the Qstring token (Src/lex.c dquote_parse) and keeps `'`
15429 // a plain char — so a DQ replacement's `$'…'` is LITERAL in
15430 // C (Src/subst.c:301 decodes only the tokenized Snull form;
15431 // `"${a/X/$'\0'}"` keeps the five chars `$'\0'`). The body
15432 // rebuilt below re-enters stringsubst as raw text, which
15433 // would mis-decode `$'…'` as ANSI-C; stamp the Qstring
15434 // marker on the repl's `$` so stringsubst sees the same DQ
15435 // signal C's tokens carry. The PATTERN side keeps decoding
15436 // (matches observed zsh: the pattern's `$'\0'` matches a
15437 // real NUL while the repl's stays literal).
15438 let repl = if dq_flag {
15439 repl.replace('$', "\u{e18c}")
15440 } else {
15441 repl
15442 };
15443 // op encoding: 0 = first `/`, 1 = all `//`, 2 = anchor-prefix
15444 // `/#`, 3 = anchor-suffix `/%`. The brace form distinguishes
15445 // first-vs-all by single vs doubled slash, and anchored by
15446 // a `#` or `%` immediately after the slash(es).
15447 let body = match op {
15448 0 => format!("${{{}/{}/{}}}", name, pattern, repl),
15449 1 => format!("${{{}//{}/{}}}", name, pattern, repl),
15450 2 => format!("${{{}/#{}/{}}}", name, pattern, repl),
15451 3 => format!("${{{}/%{}/{}}}", name, pattern, repl),
15452 _ => format!("${{{}/{}/{}}}", name, pattern, repl),
15453 };
15454 // c:Src/subst.c:1625 — paramsubst's qt flag. The compiler
15455 // threads the word's DQ context onto the stack; dropping it
15456 // (the old `let _dq_flag`) ran the rebuilt body with qt=false
15457 // whenever the opcode fired outside an EXPAND_TEXT scope, so
15458 // DQ-only semantics inside the replacement (e.g. `$'` staying
15459 // literal per Src/subst.c:301 — `"${a/x/$'\t'q}"`) were lost.
15460 // Bump in_dq_context exactly like EXPAND_TEXT mode 1 so
15461 // paramsubst_to_value's qt probe sees the right context.
15462 if dq_flag {
15463 with_executor(|exec| exec.in_dq_context += 1);
15464 }
15465 let ret = paramsubst_to_value_status(vm, &body, pf_flags);
15466 if dq_flag {
15467 with_executor(|exec| exec.in_dq_context -= 1);
15468 }
15469 ret
15470 });
15471
15472 vm.register_builtin(BUILTIN_REGISTER_COMPILED_FN, |vm, argc| {
15473 let args = pop_args(vm, argc);
15474 // c:Src/exec.c:5389-5394 — `if (htok && names) { execsubst(names);
15475 // if (errflag) { state->pc = end; return 1; } }`: the name words are
15476 // prefork-expanded and globbed (c:2741-2746), so a NOMATCH defines
15477 // nothing and fails the definition.
15478 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
15479 & crate::ported::utils::ERRFLAG_ERROR
15480 != 0
15481 {
15482 return Value::Status(1); // c:5393
15483 }
15484 // c:5399 — `while ((s = (char *) ugetnode(names)))`: one function per
15485 // name the expansion produced (`function a* { … }` defines one per
15486 // matching file). The names lead the argument list; the five
15487 // definition fields follow.
15488 let nnames = args.len().saturating_sub(5);
15489 let (names, fields) = args.split_at(nnames);
15490 let mut status = Value::Status(0);
15491 for name in names {
15492 let mut one = Vec::with_capacity(fields.len() + 1);
15493 one.push(name.clone());
15494 one.extend_from_slice(fields);
15495 status = register_compiled_fn(vm, one);
15496 }
15497 status
15498 });
15499 fn register_compiled_fn(vm: &mut fusevm::VM, args: Vec<String>) -> Value {
15500 let mut iter = args.into_iter();
15501 let name = iter.next().unwrap_or_default();
15502 let body_b64 = iter.next().unwrap_or_default();
15503 let body_source = iter.next().unwrap_or_default();
15504 let line_base_str = iter.next().unwrap_or_default();
15505 let line_base: i64 = line_base_str.parse().unwrap_or(0);
15506 // c:Src/exec.c:5384-5388 — `shf->lineno = (funcstack && (tp == FS_FUNC ||
15507 // tp == FS_EVAL)) ? funcstack->flineno + lineno : lineno;`. Inside an
15508 // `eval` the compiled line is relative to the eval text, so add the
15509 // eval frame's file offset. (A function body is compiled with file
15510 // lines already, so the FS_FUNC arm needs no adjustment here.)
15511 let line_base: i64 = match crate::ported::modules::parameter::FUNCSTACK
15512 .lock()
15513 .ok()
15514 .and_then(|stk| stk.last().map(|top| (top.tp, top.flineno)))
15515 {
15516 Some((tp, flineno)) if tp == crate::ported::zsh_h::FS_EVAL && line_base >= 0 => {
15517 flineno + line_base // c:5387
15518 }
15519 _ => line_base,
15520 };
15521 // c:Src/exec.c:5382 `do_tracing = *state->pc++;` — the `-T` of
15522 // `function -T name { … }`, carried across from compile_funcdef.
15523 let do_tracing = iter.next().map(|s| s == "1").unwrap_or(false); // c:5382
15524 // c:Src/exec.c:5451-5456 — `shf->redir = <redir_prog>`: the rendered
15525 // text of the definition's trailing redirections (empty when there
15526 // were none). See `shfunc::redir_text`.
15527 let redir_text = iter.next().unwrap_or_default(); // c:5453
15528 // c:5387 — `tracing_flags = do_tracing ? PM_TAGGED_LOCAL : 0;`
15529 let tracing_flags: u32 = if do_tracing {
15530 crate::ported::zsh_h::PM_TAGGED_LOCAL
15531 } else {
15532 0
15533 }; // c:5387
15534 // `#<idx>` — the body lives in the ENCLOSING chunk's `sub_chunks` and
15535 // only its index travelled through the constant pool (see
15536 // compile_zsh.rs `compile_funcdef`). `vm.chunk` is the chunk currently
15537 // executing this CallBuiltin, i.e. the one that owns the sub-chunk:
15538 // at top level the script chunk, and inside a function body the body
15539 // chunk that `vm_pool::acquire` (vm_helper.rs:4098) is running.
15540 //
15541 // Anything else is the historical `base64(bincode(chunk))` payload —
15542 // still accepted so a chunk restored from a bytecode cache written by
15543 // an older binary keeps working. `#` is outside the base64 alphabet so
15544 // the two forms cannot be confused.
15545 let sub_chunk = body_b64
15546 .strip_prefix('#')
15547 .and_then(|n| n.parse::<usize>().ok())
15548 .map(|idx| vm.chunk.sub_chunks.get(idx).cloned().ok_or(()));
15549 let decoded = match sub_chunk {
15550 Some(Ok(chunk)) => Ok(chunk),
15551 Some(Err(())) => Err(()),
15552 None => {
15553 let bytes = base64_decode(&body_b64);
15554 bincode::deserialize::<fusevm::Chunk>(&bytes).map_err(|_| ())
15555 }
15556 };
15557 let status = match decoded {
15558 Ok(chunk) => with_executor(|exec| {
15559 // c:Src/exec.c:5383 — `shf->filename =
15560 // ztrdup(scriptfilename);` — the function's
15561 // definition-file is read from the canonical
15562 // file-scope `scriptfilename` global at compile
15563 // time, NOT from a per-executor struct field.
15564 // exec.scriptfilename is seeded once at
15565 // bins/zshrs.rs:1717 to the bin basename ("zsh")
15566 // and never updates on source/dot, so reading from
15567 // it left every user function's def_file as "zsh".
15568 // Route through scriptfilename_get() so source /
15569 // dot's set_scriptfilename calls propagate.
15570 let def_file = crate::ported::utils::scriptfilename_get()
15571 .or_else(|| exec.scriptfilename.clone());
15572 let def_file_for_prov = def_file.clone();
15573 if !body_source.is_empty() {
15574 exec.function_source
15575 .insert(name.clone(), body_source.clone());
15576 }
15577 exec.function_line_base.insert(name.clone(), line_base);
15578 exec.function_def_file.insert(name.clone(), def_file);
15579 // PFA-SMR aspect: every `name() {}` / `function name { }`
15580 // funnels through here at compile time. Emit one record
15581 // with the function name + raw body source.
15582 #[cfg(feature = "recorder")]
15583 if crate::recorder::is_enabled() {
15584 let ctx = exec.recorder_ctx();
15585 let body = if body_source.is_empty() {
15586 None
15587 } else {
15588 Some(body_source.as_str())
15589 };
15590 crate::recorder::emit_function(&name, body, ctx);
15591 }
15592 // c:Src/exec.c:5516-5531 — `TRAP<SIG>() { ... }` is the
15593 // function-named trap install. zsh detects the `TRAP`
15594 // prefix at func-def time and calls
15595 // `settrap(signum, NULL, ZSIG_FUNC)` so the next
15596 // dispatch of that signal routes to the named shfunc.
15597 // Bug #157 in docs/BUGS.md — fusevm_bridge's funcdef
15598 // opcode skipped this dispatch entirely, so TRAPEXIT /
15599 // TRAPUSR1 / TRAPZERR / TRAPDEBUG never fired.
15600 //
15601 // ORDER MATTERS. C runs settrap at c:5518 and
15602 // `shfunctab->addnode` only at c:5539, and dosavetrap
15603 // says why (c:634-637): "Get the old function: this
15604 // assumes we haven't added the new one yet." Running
15605 // the install first made settrap→unsettrap→removetrap→
15606 // dosavetrap snapshot the NEW body, so endtrapscope
15607 // "restored" the inner definition and a nested
15608 // `TRAPEXIT() { … }` permanently clobbered the outer one.
15609 // BUGS.md #1114 — the TRAPxxx() FUNCTION-trap form is zsh-only.
15610 // bash, ksh and dash have no such concept: there `TRAPINT` is an
15611 // ordinary function whose name merely begins with TRAP, and SIGINT
15612 // keeps its default disposition. Recognising it in a drop-in mode
15613 // also SILENTLY DESTROYS an already-installed list trap for that
15614 // signal, since the two forms are alternatives in zsh.
15615 // posix_faithful() is raised only for a bare drop-in flag, so
15616 // --zsh, native zshrs and `emulate sh` are untouched.
15617 if !crate::extensions::dash_mode::posix_faithful() && name.len() > 4 && name.starts_with("TRAP") {
15618 if let Some(sn) = crate::ported::jobs::getsigidx(&name[4..]) {
15619 let _ = crate::ported::signals::settrap(
15620 sn,
15621 None,
15622 crate::ported::zsh_h::ZSIG_FUNC as i32,
15623 );
15624 // c:5530 — `removetrapnode(signum);` "Remove the
15625 // old node explicitly in case it has an
15626 // alternative name". NOT mirrored here: zshrs's
15627 // `jobs::removetrapnode` routes through
15628 // `hashtable::removeshfuncnode`, which calls back
15629 // into `unsettrap` (C explicitly avoids that path
15630 // — see the comment at Src/signals.c:836-838) and
15631 // would tear down the trap `settrap` just armed.
15632 // The `tab.add` below overwrites the canonical
15633 // `TRAP<SIG>` node anyway; only the alt-name case
15634 // (TRAPCLD vs TRAPCHLD) is left unhandled.
15635 }
15636 }
15637 // Mirror into canonical shfunctab so scanfunctions /
15638 // ${(k)functions} / functions builtin see user defs.
15639 // C: exec.c:funcdef → shfunctab->addnode(ztrdup(name),shf).
15640 if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
15641 let mut shf = crate::ported::hashtable::shfunc_with_body(&name, &body_source);
15642 // c:Src/exec.c:5453/5455 — `shf->redir = …`. Stored as
15643 // rendered text (see `shfunc::redir_text`); `None` is C's
15644 // `shf->redir == NULL`.
15645 shf.redir_text = if redir_text.is_empty() {
15646 None
15647 } else {
15648 Some(redir_text.clone())
15649 };
15650 // c:Src/exec.c:5437 — `shf->node.flags = tracing_flags;`
15651 shf.node.flags |= tracing_flags as i32; // c:5437
15652 // c:5532-5538 — /* Is this function traced and redefining
15653 // itself? */
15654 // if (funcstack && funcstack->tp == FS_FUNC &&
15655 // !strcmp(s, funcstack->name)) {
15656 // Shfunc old = ((Shfunc)shfunctab->getnode(shfunctab, s));
15657 // if (old)
15658 // shf->node.flags |= old->node.flags &
15659 // (PM_TAGGED|PM_TAGGED_LOCAL);
15660 // }
15661 // The ported walker does this at exec.rs:7387, but fusevm —
15662 // not that walker — is what registers a `name() { … }`, so a
15663 // `functions -T f`-tagged function that redefined itself
15664 // came back untraced (E02xtrace:6).
15665 if let Ok(stk) = crate::ported::modules::parameter::FUNCSTACK.lock() {
15666 if let Some(top) = stk.last() {
15667 // c:5533
15668 if top.tp == crate::ported::zsh_h::FS_FUNC && top.name == name {
15669 // c:5535 — `Shfunc old = shfunctab->getnode(shfunctab, s);`
15670 // Read through the write guard already held
15671 // above: `shfunctab_lock()` is a std RwLock and
15672 // is NOT reentrant, so re-acquiring it here
15673 // self-deadlocked on exactly the
15674 // self-redefinition case (C04funcdef:30 hung).
15675 if let Some(old) = tab.get(&name) {
15676 // c:5537
15677 shf.node.flags |= old.node.flags
15678 & (crate::ported::zsh_h::PM_TAGGED as i32
15679 | crate::ported::zsh_h::PM_TAGGED_LOCAL as i32);
15680 }
15681 }
15682 }
15683 }
15684 // `shfunc_with_body` stamps the AMBIENT `scriptfilename`
15685 // (c:Src/exec.c:5383). That is right for an ordinary
15686 // definition but wrong twice over for an autoloaded one:
15687 //
15688 // * c:Src/exec.c:5622-5630 — while an autoload file's text
15689 // runs, C sets `scriptfilename = getshfuncfile(shf)`, so a
15690 // `name() { … }` INSIDE it records the fpath file. zshrs
15691 // runs that body on the normal VM path with the CALLER's
15692 // scriptfilename still in place, so a ksh-style autoload
15693 // file — one that defines the function and then calls it —
15694 // got attributed to whoever triggered the load.
15695 // * the function is then re-registered UNCHANGED when its
15696 // chunk is compiled at call time, and that second stamp
15697 // would overwrite the first even if the first were right.
15698 //
15699 // Result before this: `whence -v f` said "from ./caller.zsh"
15700 // where zsh says "from dir/f", and `funcsourcetrace[1]`
15701 // pointed at the caller — which compsys reads directly
15702 // (`_git` locates git-completion.bash through
15703 // `"$(dirname ${funcsourcetrace[1]%:*})"`).
15704 if let Some(f) = crate::vm_helper::autoload_def_file(&name) {
15705 shf.filename = Some(f); // c:5625 getshfuncfile(shf)
15706 } else if let Some(prev) = tab.get(&name) {
15707 // Re-registration of an unchanged body relabels nothing.
15708 if prev.body.as_deref() == Some(body_source.as_str()) {
15709 shf.filename = prev.filename.clone();
15710 shf.node.flags |=
15711 prev.node.flags & crate::ported::zsh_h::PM_LOADDIR as i32;
15712 }
15713 }
15714 // c:Src/exec.c:5409 — `shf->lineno = lineno;`. Use
15715 // the same max(1, line_base) clamp as the synth_shf
15716 // in vm_helper::dispatch_function_call. Bug #396.
15717 shf.lineno = std::cmp::max(1, line_base);
15718 // c:Src/exec.c:5402 — `shfunc_set_sticky(shf);`
15719 // stamps the definition-time `sticky` emulation
15720 // snapshot onto the function so a later call can
15721 // re-enter that emulation (doshfunc c:5978). The
15722 // ported execfuncdef does this at exec.rs:7141, but
15723 // fusevm — not that walker — is what actually
15724 // registers a `name() { … }`, so `emulate sh -c
15725 // 'f() { … }'` produced a function with no sticky
15726 // emulation at all (B07emulate.ztst:6,7,8,12,13,14).
15727 crate::ported::exec::shfunc_set_sticky(&mut shf);
15728 // zshrs re-runs this registration when a function's
15729 // chunk is (re)compiled at call time — see the
15730 // filename note above. That second pass happens with
15731 // the AMBIENT sticky (normally none), which would
15732 // erase the definition-time stamp. An unchanged body
15733 // is not a redefinition, so keep what it had.
15734 if shf.sticky.is_none() {
15735 if let Some(prev) = tab.get(&name) {
15736 if prev.body.as_deref() == Some(body_source.as_str()) {
15737 shf.sticky = prev
15738 .sticky
15739 .as_deref()
15740 .map(|b| crate::ported::exec::sticky_emulation_dup(b, 0));
15741 }
15742 }
15743 }
15744 // docs/BUGS.md #1105 — C settles every lexer-time
15745 // decision when the definition runs (c:Src/exec.c:5389
15746 // `shf->funcdef = dupeprog(…)`) and prints that wordcode
15747 // back unchanged (c:Src/hashtable.c:954). zshrs stores
15748 // the raw source and re-lexes it to print, so the one
15749 // lexer-time option that survives into a deparse —
15750 // RCQUOTES, c:Src/lex.c:1328 — has to be recorded here
15751 // or a later `setopt rcquotes` rewrites the listing of a
15752 // function defined long before it. Skipped for the
15753 // re-registration of an unchanged body (the same test
15754 // the filename and sticky stamps above use): that second
15755 // pass runs at CALL time, whose option state is not the
15756 // definition's.
15757 if tab
15758 .get(&name)
15759 .is_none_or(|prev| prev.body.as_deref() != Some(body_source.as_str()))
15760 {
15761 crate::vm_helper::funcdef_note_rcquotes(
15762 &name,
15763 &body_source,
15764 crate::ported::zsh_h::isset(crate::ported::zsh_h::RCQUOTES),
15765 );
15766 }
15767 tab.add(shf);
15768 }
15769 // Lineage tap: this is where a `name() { … }` actually
15770 // lands in the VM path — `execfuncdef`'s shfunctab
15771 // install only runs for the interpreter path. The first
15772 // definition is the function's origin; a later one is a
15773 // `redefine` op on the same chain.
15774 if crate::provenance::active() {
15775 crate::provenance::on_func_define(
15776 &name,
15777 Some(body_source.as_str()),
15778 def_file_for_prov.as_deref(),
15779 std::cmp::max(1, line_base),
15780 );
15781 }
15782 exec.functions_compiled.insert(name, chunk);
15783 0
15784 }),
15785 Err(_) => 1,
15786 };
15787 Value::Status(status)
15788 }
15789
15790 // Wire the ShellHost so direct shell ops (Op::Glob, Op::TildeExpand,
15791 // Op::ExpandParam, Op::CmdSubst, Op::CallFunction, etc.) route through
15792 // ZshrsHost back into the executor.
15793 vm.set_shell_host(Box::new(ZshrsHost));
15794}
15795
15796impl ZshrsHost {
15797 /// True iff `c` can be a `(j:…:)` / `(s:…:)` delimiter — non-alphanumeric,
15798 /// non-underscore. Restricting to punctuation avoids `(jL)` consuming `L`
15799 /// as a delim instead of as the next flag.
15800 fn is_zsh_flag_delim(c: char) -> bool {
15801 !c.is_ascii_alphanumeric() && c != '_'
15802 }
15803}
15804
15805/// Shared `${name[idx]}` subscript dispatch for BUILTIN_ARRAY_INDEX
15806/// and the KSHARRAYS-unset arm of BUILTIN_ARRAY_INDEX_UNBRACED.
15807///
15808/// c:Src/subst.c subscript parsing — when paramsubst re-parses the
15809/// synthesized `${name[idx]}` body, characters like `'` `"` `\` `$`
15810/// etc. are LEXER-active inside the `[…]` and get reinterpreted
15811/// (quote-strip, paramsubst recursion, …). For PRE-EVALUATED key
15812/// strings (the dynamic-key fast path at compile_zsh.rs:3234 already
15813/// expanded `$k` via EXPAND_TEXT), the idx is a literal string that
15814/// must match the stored key byte-for-byte — no further
15815/// reinterpretation. Direct assoc lookup bypasses the lexer for this
15816/// case, avoiding the quote-strip bug where `h[a'b]` failed to
15817/// resolve because paramsubst's subscript lexer treated the `'` as a
15818/// quote. Bug #338. Only fires for simple assoc-name + non-flag idx
15819/// (no outer-flag sentinels, no `(…)` flag prefix on idx, no splat
15820/// operator). Other paths (slice, splat, flag-based search,
15821/// magic-assoc) still flow through paramsubst.
15822///
15823/// `ssub` is c:Src/subst.c:1761 `(pf_flags & PREFORK_SINGLE)` — see the
15824/// `BUILTIN_ARRAY_INDEX` argc contract. It only reaches the textual
15825/// `paramsubst` rebuild at the bottom: every direct-lookup shortcut above it
15826/// resolves a single assoc value, which c:4226's join leaves alone.
15827/// Spell an ALREADY-EXPANDED subscript so a flat `${name[…]}` rebuild reads it
15828/// back as the key/index it is.
15829///
15830/// c:Src/params.c:2047-2054 — `getindex` decides the whole-parameter splat on
15831/// the RAW bracket text, before `getarg` runs `parsestr`/`singsub` on it:
15832///
15833/// ```c
15834/// s = *pptr + 1;
15835/// if ((s[0] == '*' || s[0] == '@') && s + 1 == tbrack) {
15836/// ... v->start = 0; v->end = -1; s += 2;
15837/// } else {
15838/// start = getarg(&s, &inv, v, 0, &we, ...);
15839/// }
15840/// ```
15841///
15842/// So in C a subscript that merely EXPANDS to `@` or `*` can never be the
15843/// splat — the text at `s[0]` is still `$k`. zshrs expands the subscript in the
15844/// VM and then rebuilds the flat `${name[VALUE]}` string for `paramsubst`,
15845/// which puts that `@` exactly where c:2048 reads it as the splat: `k='@';
15846/// ${h[$k]}` enumerated the whole hash instead of reading the key `@`.
15847///
15848/// Every compiler route that reaches a rebuild rejects a SOURCE-literal
15849/// `[@]`/`[*]` first (`braced_subscript_ref`, `braced_subscript_dynamic_ref`
15850/// and `parse_zsh_flag_subscript` all bail on `key == "@" || key == "*"`), so
15851/// an `@`/`*` seen here is always an expansion result. Re-spell it with C's
15852/// exact-match subscript group (c:Src/params.c:1449-1450 `(e)`), which sidesteps
15853/// c:2048 — `s[0]` is `(` — and then keys on the literal text. `${h[(e)@]}` is
15854/// byte-identical to `k='@'; ${h[$k]}` on zsh 5.9 for every shape measured:
15855/// hash key (`AT`), array/scalar (`bad math expression`), `(k)` (`@`),
15856/// `${#…}` (`2`) and `${+…}` (`1`).
15857fn expanded_subscript_text(idx: &str) -> std::borrow::Cow<'_, str> {
15858 if idx == "@" || idx == "*" {
15859 std::borrow::Cow::Owned(format!("(e){}", idx)) // c:1449-1450
15860 } else {
15861 std::borrow::Cow::Borrowed(idx)
15862 }
15863}
15864
15865/// The value of one assoc element read by `array_index_lookup`. A MISSING
15866/// key reads as the empty string (c:Src/params.c:1606-1610 creates a
15867/// PM_UNSET node), and an unquoted empty word is then deleted by prefork
15868/// (c:Src/subst.c:183-186) — `print -l -- $h[missing] x` is ONE word. Returning
15869/// `Value::str("")` directly skipped that drop, so a missing key became an
15870/// empty argument; `nodes_to_value` is where the bridge applies it.
15871fn assoc_key_value(v: Option<String>) -> Value {
15872 match v {
15873 Some(s) if !s.is_empty() => Value::str(s),
15874 _ => nodes_to_value(vec![String::new()]),
15875 }
15876}
15877
15878/// `assoc_key_value` for a key read that answered from the plain hash
15879/// store. c:Src/params.c:1597-1606 hands back no node for a missing key, so
15880/// c:Src/subst.c:2804-2807 sets `vunset`, and with NO_UNSET the reference
15881/// is an error (c:3608-3613): `zerr("%s: parameter not set", idbeg)`. The
15882/// fast paths below answer before paramsubst runs, so they owe that check.
15883/// The alias views (getpmalias, c:Src/Modules/parameter.c) return an
15884/// unset-flagged node that zsh does not report, so `direct_assoc_key_get`'s
15885/// alias arms keep plain `assoc_key_value`.
15886fn hash_key_value(name: &str, idx: &str, v: Option<String>) -> Value {
15887 if v.is_none() && !crate::ported::zsh_h::isset(crate::ported::zsh_h::UNSET) {
15888 crate::ported::utils::zerr(&format!("{}[{}]: parameter not set", name, idx)); // c:3612
15889 crate::ported::utils::errflag
15890 .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, std::sync::atomic::Ordering::Relaxed);
15891 with_executor(|exec| exec.set_last_status(1));
15892 return Value::str(String::new());
15893 }
15894 assoc_key_value(v)
15895}
15896
15897/// One element of a plain indexed array by integer subscript, without the
15898/// textual `${name[idx]}` rebuild — which clones the WHOLE array on every
15899/// read, making a loop over an N-element array quadratic (8000 elements:
15900/// 2.7s against zsh's 0.017s; zpwr's zshRegenSearchableEnv.zsh indexes
15901/// `$lines[i]` over every autoload file and never finished).
15902///
15903/// `Some(elem)` only for the unambiguous case — a non-special `PM_ARRAY` in
15904/// the live paramtab, a nonzero integer subscript (a literal, or a bare
15905/// parameter holding one), KSH_ARRAYS off, and a non-empty element. Anything
15906/// else (flags, ranges, magic arrays, namerefs, empty elements whose word
15907/// elision depends on quoting) returns `None` and keeps the general path.
15908/// zshrs-original — no C counterpart (c:Src/params.c:getarg is O(1)).
15909fn array_elem_fast(name: &str, idx: &str) -> Option<String> {
15910 use crate::ported::zsh_h::{PM_ARRAY, PM_SPECIAL};
15911 if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
15912 return None;
15913 }
15914 let n: i64 = match idx.parse() {
15915 Ok(n) => n,
15916 Err(_) => {
15917 // A bare identifier holding an integer (`$a[i]`).
15918 let bare = idx
15919 .chars()
15920 .next()
15921 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
15922 && idx.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
15923 if !bare {
15924 return None;
15925 }
15926 crate::ported::params::getsparam(idx)?.trim().parse().ok()?
15927 }
15928 };
15929 if n == 0 || name.is_empty() || matches!(name, "argv" | "@" | "*") {
15930 return None;
15931 }
15932 if crate::ported::params::is_nameref(name) {
15933 return None;
15934 }
15935 let tab = crate::ported::params::paramtab().read().ok()?;
15936 let pm = tab.get(name)?;
15937 let visible = crate::ported::modules::param_private::getprivatenode(&**pm as *const _);
15938 if visible.is_null() || !std::ptr::eq(visible, &**pm as *const _) {
15939 return None;
15940 }
15941 let flags = pm.node.flags as u32;
15942 if crate::ported::zsh_h::PM_TYPE(flags) != PM_ARRAY || flags & PM_SPECIAL != 0 {
15943 return None;
15944 }
15945 let arr = pm.u_arr.as_ref()?;
15946 let at = if n > 0 {
15947 usize::try_from(n - 1).ok()?
15948 } else {
15949 arr.len().checked_sub(usize::try_from(-n).ok()?)?
15950 };
15951 let elem = arr.get(at)?;
15952 (!elem.is_empty()).then(|| elem.clone())
15953}
15954
15955fn array_index_lookup(name: &str, idx: &str, ssub: bool) -> Value {
15956 let idx_is_simple = !idx.starts_with('(') && idx != "@" && idx != "*" && !idx.contains(',');
15957 if idx_is_simple {
15958 if let Some(elem) = array_elem_fast(name, idx) {
15959 return Value::str(elem);
15960 }
15961 // assoc_key_hit: single-lock O(1) probe — exec.assoc() clones
15962 // the WHOLE map per lookup (O(n), quadratic in shell loops).
15963 // When `name` IS an assoc, exact-key semantics apply to EVERY
15964 // plain key: hit → value, miss → empty (C `${assoc[missing]}`).
15965 // Never fall through to the textual `${name[key]}` rebuild —
15966 // keys carrying `{`/`}`/`[`/`]` (zsh-autopair probes
15967 // `${AUTOPAIR_LBOUNDS[$pair]}` with pair='{') re-parse as
15968 // broken syntax there ("failed to compile regex: repetition
15969 // quantifier…" + a `}` appended per keystroke).
15970 if let Some((_, v)) = crate::vm_helper::assoc_key_hit(name, idx) {
15971 return hash_key_value(name, idx, v);
15972 }
15973 }
15974 // c:Src/params.c:1449-1450 getindex — a leading `(e)`/`(E)` flag
15975 // group makes the subscript LITERAL (group consumed, exact key).
15976 // The textual rebuild below re-parses a FLAT `${name[(e)KEY]}`
15977 // string, so a `]` / `}` that arrived via `$key` expansion
15978 // terminates the subscript / brace early — "bad substitution" or
15979 // spilled-junk values (zpwr expandstats iterates alias keys
15980 // containing brackets). C never re-parses: getarg scans the
15981 // TOKENIZED source where expanded data brackets are inert. Do the
15982 // exact-match lookup directly against the assoc (plain or magic
15983 // alias tables); search groups ((r)/(i)/(k)/…) and other targets
15984 // keep the textual path.
15985 if let Some(rest) = idx.strip_prefix('(') {
15986 if let Some(close) = rest.find(')') {
15987 let grp = &rest[..close];
15988 if !grp.is_empty() && grp.chars().all(|ch| ch == 'e' || ch == 'E') {
15989 let key = &rest[close + 1..];
15990 if let Some(hit) = direct_assoc_key_get(name, key) {
15991 return if is_alias_view(name) { assoc_key_value(hit) } else { hash_key_value(name, idx, hit) };
15992 }
15993 }
15994 }
15995 }
15996 // Plain assoc key that the flat rebuild would mangle (`]` closes
15997 // the subscript, `}` closes the brace): direct lookup. On a miss
15998 // return empty — the textual fallback cannot represent the key.
15999 if (idx.contains(']') || idx.contains('}')) && !idx.starts_with('(') {
16000 if let Some(hit) = direct_assoc_key_get(name, idx) {
16001 return if is_alias_view(name) { assoc_key_value(hit) } else { hash_key_value(name, idx, hit) };
16002 }
16003 }
16004 let body = format!("${{{}[{}]}}", name, expanded_subscript_text(idx));
16005 // The key here is already expanded; C validated only the raw text
16006 // (c:Src/params.c:2029-2045). See subst::SUBSCRIPT_PREEXPANDED.
16007 crate::ported::subst::SUBSCRIPT_PREEXPANDED.with(|c| c.set(true));
16008 // c:Src/subst.c:4226 — `if (isarr && ssub) { val = sepjoin(aval, NULL, 1);
16009 // isarr = 0; }`. `PREFORK_SINGLE` is the whole of `ssub` (c:1761), and the
16010 // ported `paramsubst` already carries that gate (`ssub_c4226` /
16011 // `ssub_join_c3903` in `src/ported/subst.rs`); this fast path just had no
16012 // way to say it.
16013 let v = paramsubst_to_value_pf(
16014 &body,
16015 if ssub {
16016 crate::ported::zsh_h::PREFORK_SINGLE
16017 } else {
16018 0
16019 },
16020 );
16021 // A body that never reached the subscript check must not leave the
16022 // carrier armed for the next, unrelated subscript.
16023 crate::ported::subst::SUBSCRIPT_PREEXPANDED.with(|c| c.set(false));
16024 v
16025}
16026
16027/// The `zsh/parameter` alias views `direct_assoc_key_get` serves itself.
16028fn is_alias_view(name: &str) -> bool {
16029 matches!(
16030 name,
16031 "aliases" | "galiases" | "saliases" | "dis_aliases" | "dis_galiases" | "dis_saliases"
16032 )
16033}
16034
16035/// Exact-key read against an assoc-like target WITHOUT the textual
16036/// `${name[key]}` reparse (see array_index_lookup — expanded `]`/`}`
16037/// in keys break the flat form). `Some(hit)` when `name` is a target
16038/// this helper understands (plain assoc, or the alias magic assocs of
16039/// zsh/parameter — Src/Modules/parameter.c getpmalias family);
16040/// `None` = not direct-capable, caller keeps the textual path.
16041fn direct_assoc_key_get(name: &str, key: &str) -> Option<Option<String>> {
16042 use crate::ported::zsh_h::{ALIAS_GLOBAL, DISABLED};
16043 // c:Src/Modules/parameter.c:1247+ getpmalias / getpmgalias /
16044 // getpmsalias — each view filters its table by flags.
16045 let alias_view = |global: bool, suffix: bool, disabled: bool| -> Option<String> {
16046 let tab = if suffix {
16047 crate::ported::hashtable::sufaliastab_lock()
16048 } else {
16049 crate::ported::hashtable::aliastab_lock()
16050 };
16051 tab.read().ok().and_then(|t| {
16052 t.iter().find_map(|(k, a)| {
16053 let f = a.node.flags as u32;
16054 if k == key
16055 && ((f & ALIAS_GLOBAL as u32 != 0) == global || suffix)
16056 && ((f & DISABLED as u32 != 0) == disabled)
16057 {
16058 Some(a.text.clone())
16059 } else {
16060 None
16061 }
16062 })
16063 })
16064 };
16065 match name {
16066 "aliases" => Some(alias_view(false, false, false)),
16067 "galiases" => Some(alias_view(true, false, false)),
16068 "saliases" => Some(alias_view(false, true, false)),
16069 "dis_aliases" => Some(alias_view(false, false, true)),
16070 "dis_galiases" => Some(alias_view(true, false, true)),
16071 "dis_saliases" => Some(alias_view(false, true, true)),
16072 _ => {
16073 // Single-lock O(1) probe (see assoc_key_hit) — the previous
16074 // double exec.assoc() cloned the whole map twice per lookup.
16075 crate::vm_helper::assoc_key_hit(name, key).map(|(_, v)| v)
16076 }
16077 }
16078}
16079
16080/// KSHARRAYS bare-`$name` expansion words for the unbraced
16081/// no-subscript form (BUILTIN_ARRAY_INDEX_UNBRACED's KSHARRAYS arm).
16082///
16083/// - `@` / `*` stay the full positional list (the c:Src/params.c:
16084/// 2293-2296 first-element collapse is gated on
16085/// `itype_end(t, IIDENT, 1) != t` — an identifier-shaped name —
16086/// which `@`/`*` are not). The literal `[idx]` then joins the LAST
16087/// word, matching zsh 5.9: `setopt ksharrays; set -- p q;
16088/// print -- $@[0]` → `zsh:1: no matches found: q[0]`.
16089/// - Identifier-named arrays collapse to the FIRST element
16090/// (c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`).
16091/// - Assocs collapse to the first value in scan order; the `options`
16092/// magic assoc's scan order is `OPTIONTAB` bucket order (first key
16093/// `posixargzero`), matching zsh 5.9: `emulate sh -L;
16094/// print $options[posixargzero]` → `off[posixargzero]`.
16095/// - Scalars / unset names expand to their value / empty (zsh 5.9:
16096/// `setopt ksharrays; print -- $unsetvar[0]` →
16097/// `zsh:1: no matches found: [0]`).
16098fn ksharrays_bare_words(name: &str) -> Vec<String> {
16099 if name == "@" || name == "*" {
16100 return with_executor(|exec| exec.pparams());
16101 }
16102 // Magic special-parameter lookups first — mirrors the
16103 // BUILTIN_GET_VAR precedence (partab before executor tables).
16104 if let Some(vals) = crate::vm_helper::partab_array_get(name) {
16105 return vec![vals.into_iter().next().unwrap_or_default()];
16106 }
16107 if let Some(keys) = crate::vm_helper::partab_scan_keys(name) {
16108 // c:Src/params.c:2342-2350 — under KSH emulation a bare special
16109 // hash is `${hash[0]}`, same arm as the plain assoc below.
16110 if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
16111 return vec![crate::vm_helper::partab_get(name, "0").unwrap_or_default()];
16112 }
16113 let v = keys
16114 .first()
16115 .and_then(|k| crate::vm_helper::partab_get(name, k))
16116 .unwrap_or_default();
16117 return vec![v];
16118 }
16119 let arr_or_assoc = with_executor(|exec| {
16120 if let Some(arr) = exec.array(name) {
16121 // c:Src/params.c:2293-2296 — first element only.
16122 return Some(arr.first().cloned().unwrap_or_default());
16123 }
16124 if let Some(map) = exec.assoc(name) {
16125 // c:Src/params.c:2351-2358 — under KSH EMULATION a bare
16126 // `$assoc` is `${assoc[0]}` (KEY-"0" lookup), EMPTY unless the
16127 // hash has a key "0": `emulate -L ksh; typeset -A h=(a 1 b 2);
16128 // print $h` is empty. Every other mode (`setopt ksharrays`,
16129 // `emulate sh`) falls through to the first bucket value below.
16130 if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
16131 return Some(map.get("0").cloned().unwrap_or_default());
16132 }
16133 // Mirrors BUILTIN_GET_VAR's bare-assoc ordering
16134 // (sorted keys, first value).
16135 let mut keys: Vec<&String> = map.keys().collect();
16136 keys.sort();
16137 return Some(
16138 keys.first()
16139 .and_then(|k| map.get(*k).cloned())
16140 .unwrap_or_default(),
16141 );
16142 }
16143 None
16144 });
16145 if let Some(v) = arr_or_assoc {
16146 return vec![v];
16147 }
16148 vec![with_executor(|exec| exec.get_variable(name))]
16149}
16150
16151/// Run `body` through `crate::ported::subst::paramsubst` and convert
16152/// the resulting node list into a fusevm `Value`. Centralises the
16153/// pattern duplicated across ~10 BUILTIN_* handlers:
16154/// - build a `${...}` body string from opcode operands
16155/// - paramsubst the body
16156/// - propagate errflag to `exec.last_status`
16157/// - delegate the LinkList → Value conversion to `nodes_to_value`
16158///
16159/// **Extension** — Rust-only helper. No direct C analog because C
16160/// zsh uses LinkList everywhere; the conversion happens at the
16161/// boundary back into the VM's stack.
16162fn paramsubst_to_value(body: &str) -> Value {
16163 paramsubst_to_value_pf(body, 0)
16164}
16165
16166/// `paramsubst_to_value` with an explicit `pf_flags` (`PREFORK_*`) set.
16167///
16168/// c:Src/subst.c:1627 — `paramsubst(l, n, str, qt, pf_flags, ret_flags)`.
16169/// The only caller that needs a non-zero set today is the `${(flags)NAME}`
16170/// fast path when the word is a scalar-assignment VALUE: C preforks that with
16171/// `PREFORK_SINGLE|PREFORK_ASSIGN` (c:Src/exec.c:2603 for `x=…`,
16172/// c:Src/exec.c:4239-4241 for the typeset-family `NAME=…` argument), and
16173/// `PREFORK_SINGLE` is the `ssub` that turns off c:3913's forced split.
16174fn paramsubst_to_value_pf(body: &str, pf_flags: i32) -> Value {
16175 // c:Src/subst.c:1625 paramsubst's `qt` flag is the C signal that
16176 // the current expansion is inside `"…"`. The fast-path bridges
16177 // (BUILTIN_PARAM_*, BUILTIN_BRIDGE_BRACE_ARRAY) used to hardcode
16178 // qt=false, which silently broke DQ-only semantics inside
16179 // `${arr:^other}` / `${arr:^^other}` (Src/subst.c:3456-3520).
16180 // The executor's `in_dq_context` counter is bumped by EXPAND_TEXT
16181 // mode 1 / mode 5 before the bridge fires, so reading it here
16182 // propagates the DQ flag without changing every bridge call site.
16183 let qt = with_executor(|exec| exec.in_dq_context > 0);
16184 // c:Src/subst.c:318-324 — this is the bridge's stand-in for stringsubst's
16185 // paramsubst call, and that call site is where SH_WORD_SPLIT enters:
16186 // if ((isset(SHWORDSPLIT) && !(pf_flags & PREFORK_NOSHWORDSPLIT)) ||
16187 // (pf_flags & PREFORK_SPLIT))
16188 // pf_flags |= PREFORK_SHWORDSPLIT;
16189 // node = paramsubst(list, node, &str, qt,
16190 // pf_flags & (PREFORK_SINGLE|PREFORK_SHWORDSPLIT|PREFORK_SUBEXP), …);
16191 // paramsubst's `spbreak` (c:1707) reads only that flag, never the option.
16192 // Passing the callers' bare flags left every compiled `${…}` fast path
16193 // unsplit: `setopt shwordsplit; s='a o b'; print -l ${s/o/x}` printed one
16194 // word where zsh prints `a` `x` `b` (same for `${s:-x}`, `${s#a}`,
16195 // `${s:1}`, `${(U)s}`, `${${s}/o/ }`). A scalar-assignment value still
16196 // passes PREFORK_SINGLE, which keeps `spbreak` off there.
16197 let pf_flags = {
16198 use crate::ported::zsh_h::{
16199 PREFORK_NOSHWORDSPLIT, PREFORK_SHWORDSPLIT, PREFORK_SINGLE, PREFORK_SPLIT,
16200 PREFORK_SUBEXP, SHWORDSPLIT,
16201 };
16202 let mut f = pf_flags;
16203 if (crate::ported::zsh_h::isset(SHWORDSPLIT) && f & PREFORK_NOSHWORDSPLIT == 0)
16204 || f & PREFORK_SPLIT != 0
16205 {
16206 f |= PREFORK_SHWORDSPLIT; // c:321
16207 }
16208 f & (PREFORK_SINGLE | PREFORK_SHWORDSPLIT | PREFORK_SUBEXP) // c:324-325
16209 };
16210 let mut ret_flags: i32 = 0;
16211 // c:Src/subst.c:183-186 — `body` is the `${…}` BODY, not the word: the
16212 // caller's literals are attached by its own concat opcodes afterwards.
16213 // `PARAMSUBST_AFFIXES_DEFERRED` (set by `BUILTIN_WORD_DEFER_EMPTIES`)
16214 // says the caller will also run the end-of-word empty-node removal, so
16215 // `paramsubst` must not run it early — see that declaration in
16216 // `src/ported/subst.rs`.
16217 //
16218 // The declaration belongs to the OUTERMOST expansion of the word only.
16219 // A bridge expansion re-entered from inside this one is its own
16220 // finished word with nobody left to attach anything to it, so it keeps
16221 // the removal — `IN_BRIDGE_PARAMSUBST` is that re-entry guard (the
16222 // ported `IN_PARAMSUBST_NEST` covers only the `stringsubst` route).
16223 // Both cells are restored afterwards so the word's remaining segments
16224 // still defer (`A${a%z}B${b%z}C`).
16225 let reentered = IN_BRIDGE_PARAMSUBST.with(|c| c.replace(true));
16226 let saved_defer = crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.get());
16227 if reentered {
16228 crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(0));
16229 }
16230 let (_full, _pos, nodes) =
16231 crate::ported::subst::paramsubst(body, 0, qt, pf_flags, &mut ret_flags);
16232 let null_return = take_paramsubst_null();
16233 crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(saved_defer));
16234 IN_BRIDGE_PARAMSUBST.with(|c| c.set(reentered));
16235 if null_return {
16236 // c:Src/subst.c:1878/326-327 — the word ends at this `$`. For a scalar
16237 // nothing of this expansion is kept. For an array, c:4383-4394 had
16238 // already inserted the elements evaluated before the failing one, so
16239 // those survive as words (`arr=(one '$(' two); ${(e)arr}` is `one`).
16240 // See BUILTIN_PREFORK_CUT_CHECK.
16241 let kept: Vec<String> = nodes
16242 .into_iter()
16243 .filter(|n| !n.is_empty())
16244 .map(|n| crate::ported::lex::untokenize(&n).to_string())
16245 .collect();
16246 return if kept.is_empty() {
16247 Value::str(String::new())
16248 } else {
16249 nodes_to_value(kept)
16250 };
16251 }
16252 // c:Src/exec.c:3523-3525 — an expansion error aborts the command with
16253 // `if (errflag) { if (!lastval) lastval = 1; …`: a non-zero status from
16254 // the previous command survives (`nosuchcmd; print ${.bad}` exits 127).
16255 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
16256 with_executor(|exec| {
16257 if exec.last_status() == 0 {
16258 exec.set_last_status(1);
16259 }
16260 });
16261 }
16262 // c:Src/lex.c untokenize — the final argv pass C runs on every
16263 // expanded word (glob.c:1862 / exec.c) DROPS the Nularg
16264 // empty-word sentinel (c:2089 `if (c != Nularg)`) that
16265 // remnulargs faithfully leaves in place (glob.c:3673 re-adds it
16266 // for all-empty results). These fast-path bridges are terminal —
16267 // their output lands directly in argv slots — so apply it here.
16268 // Without it, quoted splits with empty pieces ("${(s:|:)x}" on
16269 // "|a|b|") leak Nularg into argv.
16270 // c:Src/subst.c:178-181 — `prefork` runs `filesub` on the word AFTER
16271 // `stringsubst` and BEFORE anything untokenizes it, which is what makes a
16272 // `~` inside an expansion body expand: `filesubstr` (c:741) matches the
16273 // lexer's `Tilde` TOKEN, never an ASCII `~`. These bridges are terminal
16274 // and the untokenize below folds that token back to `~`, so the filesub
16275 // the word path runs afterwards had nothing left to act on and
16276 // `${XDG_CACHE_HOME:-~/.cache}` came back literal (powerlevel10k's dump
16277 // path, powerlevel10k.zsh-theme:62). Run C's order here: filesub first,
16278 // untokenize second.
16279 //
16280 // Gated exactly like the prefork site (subst.rs:396): not under
16281 // `SHFILEEXPANSION` (whose file expansion runs BEFORE substitution, so an
16282 // expansion result is never tilde-expanded), not inside `"…"` (`qt`), and
16283 // not while `SKIP_FILESUB` marks a `${var/pat/repl}` pattern context where
16284 // a literal `~` must survive.
16285 // The word this expansion belongs to runs its own end-of-word drop.
16286 // A lone node that is NOTHING BUT the c:36 `nulstring` marker is an
16287 // empty FIELD a split produced, not an empty word. C drops the marker
16288 // in its final untokenize pass (c:Src/lex.c:2089), but prefork's
16289 // node-removal decision at c:183 was made before that, with the marker
16290 // still in place, so the word survives as "". Folding it here instead
16291 // hands `nodes_to_value` a node it cannot tell from a genuine empty,
16292 // and its single-node drop deletes it — see the trace there. `IFS=:;
16293 // v=":"; set -- $v` under `--dash` reported `$#` as 0 where dash, bash
16294 // and ksh all report 1.
16295 // Only outside `"…"`. A quoted expansion keeps its empty string on its
16296 // own (`nodes_to_value`'s `in_dq_context` arm), so there is no drop to
16297 // protect it from — and the paths a quoted word takes do not all run
16298 // remnulargs afterwards, so keeping the marker there put a literal
16299 // Nularg in the output: `b=(1 2 3 4 5); c=(2 4 6);
16300 // print -r -- "[${(j: :)b:*c}]"` printed `[¡]` instead of `[]`.
16301 let lone_marker = !qt
16302 && nodes.len() == 1
16303 && nodes[0].chars().eq(std::iter::once(crate::ported::zsh_h::Nularg));
16304 let keep_nulargs = (saved_defer > 0 && !reentered) || lone_marker;
16305 let do_filesub = !qt
16306 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHFILEEXPANSION)
16307 && !crate::ported::subst::SKIP_FILESUB.with(|c| c.get());
16308 let nodes: Vec<String> = nodes
16309 .into_iter()
16310 .map(|n| {
16311 let n = if do_filesub {
16312 crate::ported::subst::filesub(
16313 &n,
16314 pf_flags
16315 & (crate::ported::zsh_h::PREFORK_TYPESET
16316 | crate::ported::zsh_h::PREFORK_ASSIGN),
16317 )
16318 } else {
16319 n
16320 };
16321 // c:36 `nulstring` survives for the word's end-of-word drop (see
16322 // BUILTIN_WORD_ELIDE_EMPTY / BUILTIN_ARRAY_DROP_EMPTY): untokenize
16323 // would fold it to "", which that drop cannot tell from a real
16324 // empty node.
16325 if keep_nulargs && n.contains(crate::ported::zsh_h::Nularg) {
16326 n.split(crate::ported::zsh_h::Nularg)
16327 .map(crate::ported::lex::untokenize)
16328 .collect::<Vec<_>>()
16329 .join(&crate::ported::zsh_h::Nularg.to_string())
16330 } else {
16331 crate::ported::lex::untokenize(&n)
16332 }
16333 })
16334 .collect();
16335 let value = nodes_to_value(nodes);
16336 // Provenance: this is the funnel every `${...}` bytecode fast path
16337 // reaches, so it is where a tracked parameter's chain is handed to
16338 // the value the expansion produced.
16339 if crate::provenance::active() {
16340 if let Some(name) = prov_subst_name(body) {
16341 crate::provenance::on_param_read(&name, &value);
16342 }
16343 }
16344 value
16345}
16346
16347/// !!! RUST-ONLY ADAPTER — the dual `$?` store !!!
16348///
16349/// `paramsubst_to_value_pf` for the compiled `${…}` fast-path builtins, whose
16350/// body can run a command substitution (`${y:-$(exit 4)}`, `${y#$(…)}`).
16351/// C has ONE `lastval`: getoutput's `lastval = cmdoutval` (c:Src/exec.c:4775)
16352/// is what a later `$?` in the same command reads, so
16353/// `print ${y:-$(exit 4)} $?` prints `4`. zshrs also keeps `vm.last_status`,
16354/// which `BUILTIN_GET_VAR` treats as authoritative, so the inner status has
16355/// to be handed back to it — the same two-way mirror `BUILTIN_CMD_SUBST_TEXT`
16356/// and `BUILTIN_EXPAND_TEXT` do around their own cmd-subst runs.
16357fn paramsubst_to_value_status(vm: &mut fusevm::VM, body: &str, pf_flags: i32) -> Value {
16358 // The substitution sees the live `$?` (`false; print ${y:-$(print $?)}`).
16359 let live_status = vm.last_status;
16360 with_executor(|exec| exec.set_last_status(live_status));
16361 let v = paramsubst_to_value_pf(body, pf_flags);
16362 vm.last_status = with_executor(|exec| exec.last_status()); // c:4775
16363 v
16364}
16365
16366/// Parameter name inside a `${...}` expansion body, for the provenance
16367/// tap in `paramsubst_to_value_pf`. Skips a leading `(flags)` group and
16368/// the `#`/`+`/`^`/`=`/`~` prefix sigils, then takes the identifier —
16369/// `${(k)assoc[key]}` yields `assoc`, `${#F}` yields `F`. Returns `None`
16370/// for the forms with no single named source (`$(...)`, `${(%)...}`).
16371fn prov_subst_name(body: &str) -> Option<String> {
16372 let mut rest = body.strip_prefix("${").or_else(|| body.strip_prefix('$'))?;
16373 rest = rest.trim_end_matches('}');
16374 // Skip one leading `(flags)` group.
16375 if let Some(after) = rest.strip_prefix('(') {
16376 rest = after.split_once(')').map(|(_, r)| r)?;
16377 }
16378 rest = rest.trim_start_matches(['#', '+', '^', '=', '~']);
16379 let name: String = rest
16380 .chars()
16381 .take_while(|c| c.is_alphanumeric() || *c == '_')
16382 .collect();
16383 if name.is_empty() {
16384 None
16385 } else {
16386 Some(name)
16387 }
16388}
16389
16390/// Wrap a `Vec<String>` (e.g. paramsubst nodes, multsub parts,
16391/// xpandbraces output) into a fusevm `Value`: 0 → empty Array, 1 →
16392/// Str, >1 → Array. Same unwrap idiom every handler that calls a
16393/// canonical Vec-returning fn does.
16394/// c:Src/subst.c:1663 — `int plan9 = isset(RCEXPANDPARAM);`
16395///
16396/// zsh calls the RC_EXPAND_PARAM word shape "plan9" after the rc(1)
16397/// shell it comes from. The option is read fresh on every expansion
16398/// (`setopt` mid-script changes the very next word), so the concat
16399/// builtins must consult it at RUNTIME, not bake it in at compile time.
16400fn plan9_active() -> bool {
16401 with_executor(|_exec| opt_state_get("rcexpandparam").unwrap_or(false))
16402}
16403
16404thread_local! {
16405 /// The `isarr` bit that `Value` cannot carry.
16406 ///
16407 /// c:Src/subst.c:4245 `if (isarr)` gates the whole array emit block,
16408 /// so plan9's word-removal rule (c:4362 `uremnode`) only ever applies
16409 /// to an ARRAY-valued expansion. An empty SCALAR has `isarr == 0`,
16410 /// takes the c:4437 scalar branch, and leaves the surrounding text
16411 /// intact — `setopt rcexpandparam; v=; print -rl -- x$v y` prints `x`
16412 /// and `y`, while the same line with an empty ARRAY prints only `y`.
16413 ///
16414 /// zshrs collapses BOTH shapes to `Value::Array(vec![])`: a real empty
16415 /// array, and an unquoted empty scalar (collapsed so a standalone `$v`
16416 /// contributes zero argv words, mirroring prefork's `uremnode` at
16417 /// c:Src/subst.c:184-187). The two are indistinguishable by the time
16418 /// they reach a concat builtin, so the expansion builtins record here
16419 /// whether the empty Array they just produced came from a scalar.
16420 ///
16421 /// Sticky until the next expansion overwrites it — a word folds its
16422 /// segments left-associatively (`concat(concat(x, $e), y)`), so the
16423 /// propagating second concat must still see the first one's bit.
16424 /// Consequence: only a builtin that RETURNS an empty `Value::Array` may
16425 /// write the cell. A builtin returning an empty SCALAR `Value` must leave
16426 /// it alone, or it resurrects a word an earlier empty array deleted
16427 /// (`a=(); x${a}"${P}"y` is zero words in zsh) — see the
16428 /// `word_has_quoted_span` arm of `BUILTIN_EXPAND_TEXT`.
16429 static EMPTY_EXPANSION_IS_SCALAR: std::cell::Cell<bool> =
16430 const { std::cell::Cell::new(false) };
16431}
16432
16433/// Record whether the empty expansion just produced was a scalar (`true`)
16434/// or a genuine array (`false`). See `EMPTY_EXPANSION_IS_SCALAR`.
16435fn note_empty_is_scalar(is_scalar: bool) {
16436 EMPTY_EXPANSION_IS_SCALAR.with(|c| c.set(is_scalar));
16437}
16438
16439/// True when the empty `Value::Array` about to be concatenated stands for
16440/// an empty SCALAR (c:4438-4467), not an empty array (c:4362).
16441fn empty_is_scalar() -> bool {
16442 EMPTY_EXPANSION_IS_SCALAR.with(|c| c.get())
16443}
16444
16445thread_local! {
16446 /// The other `isarr` bit `Value` cannot carry: whether an empty element
16447 /// of the array now on the stack is ELIDABLE.
16448 ///
16449 /// c:Src/subst.c:36 `char nulstring[] = {Nularg, '\0'};` — zsh does not
16450 /// represent a split-produced empty field as the empty string. Every
16451 /// place that manufactures one (the SH_WORD_SPLIT scalar split at
16452 /// c:3919 `sepsplit`, command substitution's split, the quoted-array
16453 /// retention at c:4354 `if (qt && !*y && isarr != 2) y =
16454 /// dupstring(nulstring)`) stores `nulstring`, whose node text is
16455 /// NON-empty — so prefork's node-removal test at c:183
16456 /// `if (*(char *)getdata(node))` KEEPS it, and `remnulargs` (c:170)
16457 /// turns it back into "" afterwards. Only an empty element that came
16458 /// from a genuine ARRAY read is stored as a true empty string, and only
16459 /// that one reaches c:186 `uremnode`.
16460 ///
16461 /// zshrs renders both shapes as `String::new()`, so the marker C tests
16462 /// on is gone by the time a word is assembled. This cell carries it
16463 /// instead: `get_var_impl` sets it on the unquoted array/assoc read
16464 /// paths (C's `isarr` != 2 array reference), and `BUILTIN_WORD_ELIDE_EMPTY`
16465 /// consumes it. Without the distinction the elide either dropped
16466 /// split fields zsh keeps (`setopt shwordsplit; IFS=:; s='a::b';
16467 /// print -rl -- ${s}` is `a` `` `b`; `IFS=:; print -rl -- $(printf
16468 /// 'a::b')` likewise) or kept array empties zsh drops.
16469 ///
16470 /// Sticky across a word's segment folds, cleared when an end-of-word
16471 /// drop builtin consumes it.
16472 static ARRAY_EMPTIES_ELIDABLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
16473}
16474
16475thread_local! {
16476 /// The opposite bit: this word's array came out of a QUOTED splat whose
16477 /// empty elements C stores as `nulstring`, so the end-of-word drop must
16478 /// KEEP them.
16479 ///
16480 /// c:Src/subst.c:4387, :4404, :4426 — every node the array emit block
16481 /// produces runs `if (qt && !*y && isarr != 2) y = dupstring(nulstring)`.
16482 /// `nulstring` (c:36 `{Nularg, '\0'}`) is NON-empty, so c:183's
16483 /// `if (*(char *)getdata(node))` keeps the node and c:186's `uremnode`
16484 /// never sees it; `remnulargs` (c:170) turns it back into `""`.
16485 ///
16486 /// The rule has a RUN-time cut-off that no compile-time gate can express:
16487 /// c:4261 `if ((!aval[0] || !aval[1]) && !plan9)` claims the empty and
16488 /// single-element cases FIRST and strips the surrounding `Dnull` markers
16489 /// (c:4272-4274 `*--aptr = '\0', fstr++`), so those nodes really are empty
16490 /// and c:186 deletes them. That is why `a=(); b=(); "${a[@]}""${b[@]}"` is
16491 /// ZERO words while `a=(x '' y); PRE"${a[@]}"POST` is three. Hence the bit
16492 /// is set by the READ (which knows the element count) and only when the
16493 /// compile site marked the splat quoted (`BUILTIN_ARRAY_ALL` argc 4, or
16494 /// `BUILTIN_GET_VAR_DQ` on the `@` positional).
16495 ///
16496 /// Word-scoped: cleared when a word opens (`BUILTIN_WORD_DEFER_EMPTIES`
16497 /// argc 0), cleared by any UNQUOTED array read
16498 /// (`note_array_empties_elidable`, its exact complement), and consumed by
16499 /// the end-of-word drop.
16500 ///
16501 /// Known residue: the bit is per-WORD, while C's `nulstring` is per-NODE,
16502 /// so a word mixing a quoted and an unquoted splat
16503 /// (`PRE"${a[@]}"${b[@]}POST`) keeps both sets of empties where C keeps
16504 /// only the quoted one.
16505 static QUOTED_SPLICE_KEEPS_EMPTIES: std::cell::Cell<bool> =
16506 const { std::cell::Cell::new(false) };
16507}
16508
16509/// Record that the value just read is a genuine ARRAY reference, so an empty
16510/// element of it is a true empty word. See `ARRAY_EMPTIES_ELIDABLE`.
16511fn note_array_empties_elidable() {
16512 ARRAY_EMPTIES_ELIDABLE.with(|c| c.set(true));
16513 // c:4387's `qt` half is false for this read, so nothing it produced is
16514 // `nulstring`. The two bits are complements; do not let a previous word's
16515 // quoted splat keep this one's empties.
16516 QUOTED_SPLICE_KEEPS_EMPTIES.with(|c| c.set(false));
16517}
16518
16519/// Read and clear the elidable-empties bit. See `ARRAY_EMPTIES_ELIDABLE`.
16520fn take_array_empties_elidable() -> bool {
16521 ARRAY_EMPTIES_ELIDABLE.with(|c| c.replace(false))
16522}
16523
16524/// c:Src/subst.c:4387 — record that a QUOTED splat of `n` elements was read.
16525/// c:4261's cut-off means the `nulstring` rewrite is only reachable for
16526/// `n >= 2`; below that the emit block strips the quotes and the node is a
16527/// true empty. See `QUOTED_SPLICE_KEEPS_EMPTIES`.
16528fn note_quoted_splice_elems(n: usize) {
16529 QUOTED_SPLICE_KEEPS_EMPTIES.with(|c| c.set(n >= 2));
16530}
16531
16532/// Read and clear the quoted-splat bit. See `QUOTED_SPLICE_KEEPS_EMPTIES`.
16533fn take_quoted_splice_keeps_empties() -> bool {
16534 QUOTED_SPLICE_KEEPS_EMPTIES.with(|c| c.replace(false))
16535}
16536
16537/// Declare that the word now being assembled attaches its own literals, so
16538/// `paramsubst` must leave c:183-186's empty-node removal to this word's
16539/// end-of-word drop. See `BUILTIN_WORD_DEFER_EMPTIES` and, in
16540/// `src/ported/subst.rs`, `PARAMSUBST_AFFIXES_DEFERRED`.
16541fn open_deferred_affix_word() {
16542 crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| c.set(c.get() + 1));
16543 // c:4387's `qt`/`nulstring` bit belongs to the word being assembled, so a
16544 // word that opens starts from a clean one — a preceding whole-word
16545 // `"${a[@]}"` emits no end-of-word drop and would otherwise leave it set.
16546 QUOTED_SPLICE_KEEPS_EMPTIES.with(|c| c.set(false));
16547}
16548
16549/// End the declaration `open_deferred_affix_word` made — the marker's
16550/// closing half (argc 1), emitted by `compile_zsh` immediately after this
16551/// word's drop. Pairing it with the OPEN rather than with the drop itself
16552/// matters: `BUILTIN_ARRAY_DROP_EMPTY` is also emitted for a bare splice
16553/// segment that opened nothing (`P${b[@]}${a%z}Q` emits one for `${b[@]}`),
16554/// and letting that close the enclosing word ended the declaration before
16555/// `${a%z}` was reached.
16556///
16557/// The `PARAMSUBST_EMPTIES_DEFERRED` report is cleared only when the
16558/// OUTERMOST word closes: the drop that consumes it is that word's.
16559fn close_deferred_affix_word() {
16560 let depth = crate::ported::subst::PARAMSUBST_AFFIXES_DEFERRED.with(|c| {
16561 c.set((c.get() - 1).max(0));
16562 c.get()
16563 });
16564 if depth == 0 {
16565 crate::ported::subst::PARAMSUBST_EMPTIES_DEFERRED.with(|c| c.set(false));
16566 }
16567}
16568
16569thread_local! {
16570 /// True while `paramsubst_to_value_pf` is inside `paramsubst`.
16571 ///
16572 /// The deferral `BUILTIN_WORD_DEFER_EMPTIES` declares is a property of
16573 /// the WORD the compiler is assembling, so it applies to that word's own
16574 /// segment expansions and to nothing they in turn expand. A bridge
16575 /// expansion re-entered from inside one (a nested body the compiler
16576 /// routed through its own opcodes, or anything the ported code drives
16577 /// back through this funnel) is a finished word already, so c:183-186's
16578 /// removal stays where it is for that one.
16579 static IN_BRIDGE_PARAMSUBST: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
16580}
16581
16582/// Re-assert `was_scalar` when `v` is an empty result.
16583///
16584/// For a pass-through stage — one that rewrites word TEXT but cannot turn a
16585/// scalar into an array or vice versa — the shape bit belongs to whichever
16586/// expansion produced the value, not to the stage. Such stages usually end in
16587/// `nodes_to_value`, which records "array" for an empty result, so without
16588/// this the producer's bit is lost before `concat_plan9` reads it.
16589/// Non-empty results carry their shape in the `Value` itself and are
16590/// returned untouched.
16591fn restore_empty_shape(v: Value, was_scalar: bool) -> Value {
16592 if matches!(&v, Value::Array(a) if a.is_empty()) {
16593 note_empty_is_scalar(was_scalar);
16594 }
16595 v
16596}
16597
16598/// `concat_splice` with the provenance tap applied to the result. Every
16599/// concat bytecode op routes through this (or `concat_plan9_prov`), so a
16600/// word built out of a tracked parameter keeps that parameter's lineage
16601/// even though the concatenated bytes are a fresh allocation.
16602fn concat_splice_prov(lhs: Value, rhs: Value) -> Value {
16603 if !crate::provenance::active() {
16604 return concat_splice(lhs, rhs);
16605 }
16606 let (l, r) = (lhs.clone(), rhs.clone());
16607 let out = concat_splice(lhs, rhs);
16608 crate::provenance::on_concat(&l, &r, &out);
16609 out
16610}
16611
16612/// `concat_plan9` with the provenance tap. See `concat_splice_prov`.
16613fn concat_plan9_prov(lhs: Value, rhs: Value) -> Value {
16614 if !crate::provenance::active() {
16615 return concat_plan9(lhs, rhs);
16616 }
16617 let (l, r) = (lhs.clone(), rhs.clone());
16618 let out = concat_plan9(lhs, rhs);
16619 crate::provenance::on_concat(&l, &r, &out);
16620 out
16621}
16622
16623/// c:Src/subst.c:4366-4437 — the NON-plan9 arm of paramsubst's array
16624/// emit block: "simply join the first and last values."
16625///
16626/// The word prefix (`ostr..aptr`) is concatenated onto element 0
16627/// (c:4386 `strcatsub(&y, ostr, aptr, x, xlen, NULL, …)`), the interior
16628/// elements are emitted bare (c:4393-4412), and the word suffix (`fstr`)
16629/// is concatenated onto the final element (c:4414-4429). Applied left-
16630/// associatively across a word's segments this reproduces zsh's
16631/// `pre${arr}post` → `prep` / `q` / `rpost`.
16632///
16633/// An EMPTY array never reaches this arm in C: c:4261
16634/// `if ((!aval[0] || !aval[1]) && !plan9)` collapses it to the scalar ""
16635/// first, so the surrounding text survives as one word (`x$e y` → `x`).
16636/// That is what the empty-Array arms below reproduce.
16637fn concat_splice(lhs: Value, rhs: Value) -> Value {
16638 match (lhs, rhs) {
16639 (Value::Array(la), Value::Array(ra)) => {
16640 if la.is_empty() {
16641 return Value::Array(ra);
16642 }
16643 if ra.is_empty() {
16644 return Value::Array(la);
16645 }
16646 // Last of la merges with first of ra; rest unchanged.
16647 let mut la = la.to_vec();
16648 let last_l = la.pop().unwrap();
16649 let mut ra_iter = ra.iter().cloned();
16650 let first_r = ra_iter.next().unwrap();
16651 let l_s = last_l.as_str_cow();
16652 let r_s = first_r.as_str_cow();
16653 let mut merged = String::with_capacity(l_s.len() + r_s.len());
16654 merged.push_str(&l_s);
16655 merged.push_str(&r_s);
16656 la.push(Value::str(merged));
16657 la.extend(ra_iter);
16658 Value::array(la)
16659 }
16660 (Value::Array(la), rhs_scalar) => {
16661 // c:4261 — empty array + empty surrounding text is zero
16662 // words, not one empty word. Bug #120 in docs/BUGS.md:
16663 // `b=("${a[@]:0:-1}")` gave len=1 instead of zsh's len=0.
16664 let rhs_s = rhs_scalar.as_str_cow();
16665 if la.is_empty() {
16666 if rhs_s.is_empty() {
16667 return Value::array(Vec::new());
16668 }
16669 return Value::str(rhs_s.to_string());
16670 }
16671 let mut la = la.to_vec();
16672 let last = la.pop().unwrap();
16673 let l_s = last.as_str_cow();
16674 let mut s = String::with_capacity(l_s.len() + rhs_s.len());
16675 s.push_str(&l_s);
16676 s.push_str(&rhs_s);
16677 la.push(Value::str(s));
16678 Value::array(la)
16679 }
16680 (lhs_scalar, Value::Array(ra)) => {
16681 let lhs_s = lhs_scalar.as_str_cow();
16682 if ra.is_empty() {
16683 // Symmetric c:4261 empty-array rule; see the arm above.
16684 if lhs_s.is_empty() {
16685 return Value::array(Vec::new());
16686 }
16687 return Value::str(lhs_s.to_string());
16688 }
16689 let mut ra = ra.to_vec();
16690 let first = ra.remove(0);
16691 let r_s = first.as_str_cow();
16692 let mut s = String::with_capacity(lhs_s.len() + r_s.len());
16693 s.push_str(&lhs_s);
16694 s.push_str(&r_s);
16695 let mut out = Vec::with_capacity(ra.len() + 1);
16696 out.push(Value::str(s));
16697 out.extend(ra);
16698 Value::array(out)
16699 }
16700 (lhs_s, rhs_s) => {
16701 let l = lhs_s.as_str_cow();
16702 let r = rhs_s.as_str_cow();
16703 let mut s = String::with_capacity(l.len() + r.len());
16704 s.push_str(&l);
16705 s.push_str(&r);
16706 Value::str(s)
16707 }
16708 }
16709}
16710
16711/// c:Src/subst.c:4316-4365 — the plan9 (RC_EXPAND_PARAM) arm of
16712/// paramsubst's array emit block.
16713///
16714/// Every element gets the FULL word prefix and suffix
16715/// (c:4341 `strcatsub(&y, ostr, aptr, x, xlen, y + 1, …)` inside the
16716/// per-element loop), giving the cross product with the surrounding
16717/// text: `pre${arr}post` → `preppost` / `preqpost` / `prerpost`.
16718///
16719/// An EMPTY array removes the WHOLE word: the c:4327
16720/// `while ((x = *aval++))` loop body never runs, so `plan9` is still
16721/// non-zero at c:4362 and the node is deleted —
16722/// `if (plan9) { uremnode(l, n); return n; }` (c:4362-4365). `e=();
16723/// setopt RC_EXPAND_PARAM; print -rl -- x$e y` prints only `y`. This is
16724/// the opposite of the non-plan9 rule at c:4261, which keeps `x`.
16725fn concat_plan9(lhs: Value, rhs: Value) -> Value {
16726 // c:4245 `if (isarr)` — an empty SCALAR never enters the array emit
16727 // block, so it contributes "" and the word survives (c:4437). Only a
16728 // real empty ARRAY reaches c:4362's `uremnode`. `Value` cannot tell
16729 // the two apart; EMPTY_EXPANSION_IS_SCALAR carries the missing bit.
16730 let scalar_empty = empty_is_scalar();
16731 match (lhs, rhs) {
16732 // c:4362-4365 — an empty array on either side deletes the word.
16733 // Propagated as an empty Array so a later concat in the same word
16734 // (`x${e[@]}y` folds twice) keeps the word deleted; pop_args
16735 // splats an empty Array into zero argv words.
16736 (Value::Array(la), rhs_v) if la.is_empty() => {
16737 if scalar_empty {
16738 // Empty scalar prefix: "" + rhs (c:4437 strcatsub).
16739 return match rhs_v {
16740 Value::Array(ra) if ra.is_empty() => Value::array(Vec::new()),
16741 other => other,
16742 };
16743 }
16744 Value::array(Vec::new())
16745 }
16746 (lhs_v, Value::Array(ra)) if ra.is_empty() => {
16747 if scalar_empty {
16748 // Empty scalar suffix: lhs + "" (c:4437 strcatsub).
16749 return lhs_v;
16750 }
16751 Value::array(Vec::new())
16752 }
16753 (Value::Array(la), Value::Array(ra)) => {
16754 let mut out = Vec::with_capacity(la.len() * ra.len());
16755 for a in la.iter() {
16756 let a_s = a.as_str_cow();
16757 for b in ra.iter() {
16758 let b_s = b.as_str_cow();
16759 let mut s = String::with_capacity(a_s.len() + b_s.len());
16760 s.push_str(&a_s);
16761 s.push_str(&b_s);
16762 out.push(Value::str(s));
16763 }
16764 }
16765 Value::array(out)
16766 }
16767 (Value::Array(la), rhs_scalar) => {
16768 let r = rhs_scalar.as_str_cow();
16769 let out: Vec<Value> = la
16770 .iter()
16771 .map(|a| {
16772 let a_s = a.as_str_cow();
16773 let mut s = String::with_capacity(a_s.len() + r.len());
16774 s.push_str(&a_s);
16775 s.push_str(&r);
16776 Value::str(s)
16777 })
16778 .collect();
16779 Value::array(out)
16780 }
16781 (lhs_scalar, Value::Array(ra)) => {
16782 let l = lhs_scalar.as_str_cow();
16783 let out: Vec<Value> = ra
16784 .iter()
16785 .map(|b| {
16786 let b_s = b.as_str_cow();
16787 let mut s = String::with_capacity(l.len() + b_s.len());
16788 s.push_str(&l);
16789 s.push_str(&b_s);
16790 Value::str(s)
16791 })
16792 .collect();
16793 Value::array(out)
16794 }
16795 (lhs_s, rhs_s) => {
16796 // Both scalar: nothing to distribute (c:4444 scalar branch).
16797 let l = lhs_s.as_str_cow();
16798 let r = rhs_s.as_str_cow();
16799 let mut s = String::with_capacity(l.len() + r.len());
16800 s.push_str(&l);
16801 s.push_str(&r);
16802 Value::str(s)
16803 }
16804 }
16805}
16806
16807/// Flatten one word-segment `Value` into its element strings: an Array splats
16808/// to its items, a scalar is a single element.
16809fn word_seg_elems(v: &Value) -> Vec<String> {
16810 match v {
16811 Value::Array(items) => items.iter().map(|i| i.as_str_cow().into_owned()).collect(),
16812 other => vec![other.as_str_cow().into_owned()],
16813 }
16814}
16815
16816/// Assemble a DQ word from its segments, mixing plan9 (`^`, cross-product) and
16817/// non-plan9 (splice) segments in one pass — see BUILTIN_WORD_ASSEMBLE_PLAN9.
16818///
16819/// c:Src/subst.c:4316-4437 — zsh threads a "growing edge" (`aptr`/`fstr`)
16820/// through the whole word: an element stays active until a splice freezes all
16821/// but the last. `active_lo` is the index where that active tail begins.
16822/// * plan9 segment → every active element crosses with EVERY new element;
16823/// all results stay active (c:4316-4350 cartesian). An empty plan9 array
16824/// deletes the word (c:4362 `uremnode`).
16825/// * splice segment → every active element takes the FIRST new element, the
16826/// remaining new elements append as fresh words; the last becomes the new
16827/// growing edge (c:4366-4437 first/last join). A single-element splice keeps
16828/// the whole active tail active (nothing frozen). An empty splice array
16829/// contributes nothing and leaves the word intact.
16830fn word_assemble_plan9(segments: &[Value], plan9_flags: &[bool]) -> Value {
16831 let mut words: Vec<String> = Vec::new();
16832 let mut active_lo: usize = 0;
16833 let mut started = false;
16834 for (i, seg) in segments.iter().enumerate() {
16835 let plan9 = plan9_flags.get(i).copied().unwrap_or(false);
16836 let elems = word_seg_elems(seg);
16837 if plan9 && elems.is_empty() {
16838 // c:4362-4365 — plan9 empty array deletes the whole word.
16839 return Value::array(Vec::new());
16840 }
16841 if !started {
16842 started = true;
16843 // c:Src/subst.c:4261 — `if ((!aval[0] || !aval[1]) && !plan9)`.
16844 // A NON-plan9 EMPTY expansion (empty array, or an empty scalar
16845 // that zshrs collapsed to the same empty `Value::Array`) is
16846 // folded into the word text as the empty string and the node
16847 // SURVIVES (c:4268-4274 `strcatsub` of prefix + "" + suffix).
16848 // Only the plan9 arm deletes the word, and that is the
16849 // `uremnode` case already returned above (c:4362-4365).
16850 //
16851 // Seeding `words` with that single empty element is what keeps a
16852 // growing edge alive for the segments that follow. Leaving
16853 // `words` empty instead made `words[active_lo..]` an empty slice
16854 // forever, so every later segment cross-multiplied against
16855 // nothing and the whole word vanished: `n=""; a=(x y z);
16856 // print -rl -- $n${^a}` printed nothing where zsh prints
16857 // `x`/`y`/`z`, and `$n$a${^a}` dropped the leading `x`. Only a
16858 // word whose FIRST segment was the empty one was affected —
16859 // `pre$n${^a}` already started from the literal and hit the
16860 // "contributes nothing" `continue` below.
16861 words = if elems.is_empty() {
16862 vec![String::new()]
16863 } else {
16864 elems
16865 };
16866 // plan9 → the whole first array is the growing edge; splice/scalar
16867 // → only its last element grows, earlier ones are finalized words.
16868 active_lo = if plan9 {
16869 0
16870 } else {
16871 words.len().saturating_sub(1)
16872 };
16873 continue;
16874 }
16875 if plan9 {
16876 let mut new_active = Vec::with_capacity(words[active_lo..].len() * elems.len());
16877 for a in &words[active_lo..] {
16878 for r in &elems {
16879 new_active.push(format!("{a}{r}"));
16880 }
16881 }
16882 let frozen_len = active_lo;
16883 words.truncate(frozen_len);
16884 words.extend(new_active);
16885 active_lo = frozen_len; // all cross-products stay active
16886 } else {
16887 if elems.is_empty() {
16888 // Non-plan9 empty array contributes nothing; word survives.
16889 continue;
16890 }
16891 let frozen_len = active_lo;
16892 let r0 = &elems[0];
16893 let head: Vec<String> = words[active_lo..]
16894 .iter()
16895 .map(|a| format!("{a}{r0}"))
16896 .collect();
16897 words.truncate(frozen_len);
16898 words.extend(head);
16899 words.extend(elems[1..].iter().cloned());
16900 active_lo = if elems.len() == 1 {
16901 frozen_len // single-element splice: head stays the growing edge
16902 } else {
16903 words.len() - 1 // multi: only the last appended word grows
16904 };
16905 }
16906 }
16907 match words.len() {
16908 0 => Value::array(Vec::new()),
16909 1 => Value::str(words.pop().unwrap()),
16910 _ => Value::array(words.into_iter().map(Value::str).collect()),
16911 }
16912}
16913
16914fn nodes_to_value(nodes: Vec<String>) -> Value {
16915 // c:Src/glob.c:3649 remnulargs — strip the Nularg (`\u{e1a1}`)
16916 // sentinel and other INULL bytes that paramsubst's splat block
16917 // emits for empty array elements (so prefork's empty-node-delete
16918 // pass doesn't drop them). Downstream consumers (cond `-z`/`-n`,
16919 // command args, etc.) must see the post-remnulargs strings. Bug
16920 // #185 in docs/BUGS.md: `[[ -z "${b[@]}" ]]` for b=("") returned
16921 // false because the leftover `\u{e1a1}` had StringLen=1.
16922 //
16923 // ORDER MATTERS, and the single-node drop below is the second half of
16924 // it. C tests a node's emptiness at c:183 BEFORE remnulargs runs at
16925 // c:170, and `nulstring` is one byte long, so a node holding only the
16926 // marker is NON-empty at that test. The drop has ALREADY happened by
16927 // the time the marker becomes "", and nothing removes the node twice.
16928 //
16929 // Traced for `IFS=:; v=":"; set -- $v` under `--dash`:
16930 // spacesplit(":") -> ["<nul>", ""] leading marker, trailing empty
16931 // paramsubst c:183 -> ["<nul>"] the real empty goes, marker stays
16932 // caller's fold -> [""] untokenize folds the marker
16933 // here, unguarded -> [] dropped a second time
16934 // `$#` was 0 where dash, bash and ksh all report 1. Two or more nodes
16935 // never reach the single-node drop, which is why `::` was already
16936 // right and only the lone separator was wrong.
16937 let node_was_marked: bool = nodes.len() == 1 && !nodes[0].is_empty();
16938 let stripped: Vec<String> = nodes
16939 .into_iter()
16940 .map(|mut s| {
16941 crate::ported::glob::remnulargs(&mut s);
16942 s
16943 })
16944 .collect();
16945 if stripped.is_empty() {
16946 // Zero nodes = an ARRAY-shaped expansion that produced no words
16947 // (empty array splat, empty slice). c:4245 `if (isarr)` holds, so
16948 // plan9 deletes the surrounding word (c:4362).
16949 note_empty_is_scalar(false);
16950 Value::array(Vec::new())
16951 } else if stripped.len() == 1 {
16952 let only = stripped.into_iter().next().unwrap();
16953 // c:Src/subst.c:183-186 — `else if (!(flags & PREFORK_SINGLE)
16954 // && !(*ret_flags & PREFORK_KEY_VALUE) && !keep)
16955 // uremnode(list, node);`
16956 // C zsh's prefork removes empty linknodes from the result
16957 // list when in non-SINGLE (argv-context) mode. The ported
16958 // prefork at subst.rs:388-396 honors the same delete-empty
16959 // pass, but some paramsubst paths land here with a single-
16960 // empty-string Vec instead of an empty Vec (paramsubst's
16961 // slice / substring / parameter-flag branches allocate a
16962 // result before checking emptiness). Mirror the prefork
16963 // drop at this layer: single-empty under !in_dq_context
16964 // collapses to Value::Array(empty), and pop_args (line 6243)
16965 // splats the empty Array → zero argv words. DQ context
16966 // (in_dq_context > 0) keeps the empty string so
16967 // `echo "${UNSET}"` still produces an empty arg per zsh's
16968 // quoting rules (c:Src/subst.c:1650-1656 isarr comment).
16969 if only.is_empty() && !node_was_marked {
16970 let in_dq = with_executor(|exec| exec.in_dq_context > 0);
16971 if !in_dq {
16972 // One empty node = a SCALAR-shaped empty result (c:4437),
16973 // not an empty array — see EMPTY_EXPANSION_IS_SCALAR.
16974 note_empty_is_scalar(true);
16975 return Value::array(Vec::new());
16976 }
16977 }
16978 Value::str(only)
16979 } else {
16980 Value::array(stripped.into_iter().map(Value::str).collect())
16981 }
16982}
16983
16984fn pop_args(vm: &mut fusevm::VM, argc: u8) -> Vec<String> {
16985 let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
16986 for _ in 0..argc {
16987 popped.push(vm.pop());
16988 }
16989 popped.reverse();
16990 let mut args: Vec<String> = Vec::with_capacity(popped.len());
16991 for v in popped {
16992 match v {
16993 Value::Array(items) => {
16994 for item in items.iter() {
16995 args.push(item.to_str());
16996 }
16997 }
16998 other => args.push(other.to_str()),
16999 }
17000 }
17001 // `expand_glob` set the glob-failed cell when a no-match glob
17002 // triggered nomatch (c:Src/glob.c:1877). Signal the failure via
17003 // last_status + the per-command glob_failed cell; the dispatcher
17004 // (`host_exec_external`) consumes + clears it and returns status 1
17005 // without running the command body.
17006 if with_executor(|exec| exec.current_command_glob_failed.get()) {
17007 with_executor(|exec| exec.set_last_status(1));
17008 }
17009 // c:Src/exec.c:2709 setunderscore / c:Src/params.c:252 underscore_gsu
17010 // — `$_` has exactly ONE store in zsh, the `zunderscore` global
17011 // (`Src/init.c:49`), read back through `underscoregetfn`. It is
17012 // never written into the parameter table: the `_` Param created by
17013 // `IPDEF2("_", underscore_gsu, PM_DONTIMPORT)` (c:Src/params.c:326)
17014 // carries `nullstrsetfn` as its setfn, so even `_=x` stores nothing.
17015 //
17016 // The deferred `pending_underscore` → `set_scalar("_")` promotion
17017 // that used to live here was a second, contradictory store: it wrote
17018 // the paramtab node, which (a) CLEARED the PM_UNSET that `unset _`
17019 // had just set — resurrecting the parameter, so `${+_}` flipped back
17020 // to 1 and `$_` kept reading a value where zsh reports empty — and
17021 // (b) shadowed the canonical zunderscore value. Every dispatch path
17022 // now calls `set_zunderscore` (the `setunderscore` equivalent) just
17023 // before running its command, which is where C sets it
17024 // (execcmd_exec, c:3545-3547), so the deferral is unnecessary as
17025 // well: argument expansion has already happened by then, exactly as
17026 // in C.
17027 args
17028}
17029
17030/// zsh dispatch order is alias → function → builtin → external. The
17031/// compiler emits direct CallBuiltin ops for known builtin names for
17032/// perf, which silently skips a user function that shadows the same
17033/// name (e.g. `echo() { ... }; echo hi` would run the C builtin
17034/// without this check). Returns Some(status) when the call is routed
17035/// to the user function; the builtin handler should fall through to
17036/// its native impl when None.
17037/// Fork+exec a system binary by name. Used by `reg_overridable!` as
17038/// the fall-through path when `[builtins].coreutils_shadows = off`
17039/// (the default) — runs the canonical `/bin/X` instead of zshrs's
17040/// in-process shadow so old scripts hit zero behavioral divergence.
17041///
17042/// Inherits stdin/stdout/stderr from the parent so pipelines work
17043/// transparently. Resolves the binary via PATH; mirrors what zsh's
17044/// own external-command dispatch would do. Returns the child's exit
17045/// status (or 127 if PATH lookup fails — the standard "command not
17046/// found" code).
17047/// RAII guard that queues signals for the lifetime of a synchronous
17048/// foreground `waitpid` (via `std::process::Command::status`/`wait`).
17049///
17050/// zshrs installs a process-wide SIGCHLD handler (`zhandler` →
17051/// `wait_for_processes` → `waitpid(-1, WNOHANG)`) that reaps EVERY
17052/// exited child to drive the job table. `std::process::Command` does
17053/// its own targeted `waitpid(pid)`; when the reaper fires on any
17054/// thread between the fork and that wait, it reaps the child first and
17055/// `Command::status()` fails with ECHILD ("No child processes (os
17056/// error 10)"). This surfaced as `zshrs: hostname: No child processes
17057/// (os error 10)` when a coreutils shadow (`coreutils_shadows = off`
17058/// default) fork-execs `/usr/bin/hostname` while a background prewarm
17059/// child exits at the same instant.
17060///
17061/// Holding this guard bumps `queueing_enabled` (a global SeqCst atomic
17062/// that `zhandler` honors on every thread), so a SIGCHLD arriving
17063/// during the wait is pushed onto the deferred queue instead of being
17064/// reaped — `Command::status()` reaps its own child and reads the real
17065/// status. On drop, `unqueue_signals()` drains the queue, so any
17066/// genuine background children that exited meanwhile still get reaped
17067/// and routed to the job table. This is the same queue_signals /
17068/// unqueue_signals fencing zsh uses around its own foreground waits
17069/// (Src/exec.c). Panic-safe via `Drop`.
17070/// Apply `entersubsh`'s trap reset to the CURRENT process state.
17071///
17072/// !!! WARNING: RUST-ONLY HELPER !!!
17073/// C does this inline inside `entersubsh` (c:Src/exec.c:1088-1092), which
17074/// every subshell — `( … )`, `$( … )` AND each forked pipeline stage —
17075/// funnels through. zshrs enters a subshell context in three separate
17076/// places (the in-process `subshell_begin`, the in-process
17077/// `run_command_substitution`, and the pipeline stage fork), so the reset
17078/// lives here to keep them from drifting apart.
17079///
17080/// ```c
17081/// if (!(flags & ESUB_KEEPTRAP))
17082/// for (sig = 0; sig <= SIGCOUNT; sig++)
17083/// if (!(sigtrapped[sig] & ZSIG_FUNC) &&
17084/// !(isset(POSIXTRAPS) && (sigtrapped[sig] & ZSIG_IGNORED)))
17085/// unsettrap(sig);
17086/// ```
17087///
17088/// `unsettrap` clears BOTH the body and the sigtrapped flags, so both
17089/// stores are reset here. Function-form traps (ZSIG_FUNC, kept in
17090/// shfunctab as TRAPxxx) survive by construction; under POSIX_TRAPS an
17091/// IGNORED trap survives too. The loop bound stops below the pseudo
17092/// signals (c:Src/signals.h:34-35), so ERR/ZERR and DEBUG survive while
17093/// SIGEXIT (sig 0) is cleared.
17094pub(crate) fn entersubsh_reset_traps() {
17095 let posixtraps = crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXTRAPS);
17096 if let Ok(mut tbl) = crate::ported::builtin::traps_table().lock() {
17097 tbl.retain(|name, body| {
17098 // Above SIGCOUNT — outside c:1088's loop entirely.
17099 if name == "ERR" || name == "ZERR" || name == "DEBUG" {
17100 return true;
17101 }
17102 // c:1090-1092 — otherwise keep ONLY (POSIXTRAPS && ignored).
17103 posixtraps && body.is_empty()
17104 });
17105 }
17106 if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
17107 let count = crate::ported::signals_h::SIGCOUNT as usize;
17108 for sig in 0..st.len().min(count + 1) {
17109 let state = st[sig];
17110 if state == 0 {
17111 continue;
17112 }
17113 if (state & crate::ported::zsh_h::ZSIG_FUNC) != 0 {
17114 continue; // c:1090
17115 }
17116 if posixtraps && (state & crate::ported::zsh_h::ZSIG_IGNORED) != 0 {
17117 continue; // c:1091
17118 }
17119 st[sig] = 0; // c:1092 unsettrap(sig)
17120 }
17121 }
17122}
17123
17124// ---------------------------------------------------------------------------
17125// In-process subshell: signal delivery belongs to the PARENT.
17126// ---------------------------------------------------------------------------
17127
17128/// Depth of nested in-process `( … )` subshells, for the signal
17129/// bookkeeping below.
17130///
17131/// !!! WARNING: RUST-ONLY HELPER !!!
17132/// C has no counter because it forks: `entersubsh` (`Src/exec.c:1123`)
17133/// runs in a fresh process whose signal state is a private copy. This
17134/// counter exists only so `subshell_signal_enter` / `_leave` can tell
17135/// the OUTERMOST boundary — the one where `$$` stops naming the shell
17136/// that is running the body — from a nested one.
17137static SUBSH_SIGNAL_DEPTH: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
17138
17139/// The top-level shell's `sigtrapped[]` while an in-process subshell
17140/// runs; `None` when no subshell is active.
17141///
17142/// !!! WARNING: RUST-ONLY HELPER !!!
17143/// This is C's `sigtrapped[]` (`Src/signals.c:39`) as the PARENT still
17144/// sees it — in C that array simply stays put in the parent process
17145/// while the forked child mutates its own copy. zshrs has one process
17146/// and one array, so the parent's view has to be kept beside it.
17147static SUBSH_PARENT_SIGTRAPPED: std::sync::Mutex<Option<Vec<i32>>> = std::sync::Mutex::new(None);
17148
17149/// Signals delivered to the process while an in-process subshell was
17150/// running, which the PARENT traps. Replayed at the outermost
17151/// `subshell_end`.
17152///
17153/// !!! WARNING: RUST-ONLY HELPER !!!
17154/// The C analogue is `trap_queue[]` (`Src/signals.c:92`): the parent
17155/// sets `trap_queueing_enabled` in `zwaitjob` (`Src/jobs.c:1688`
17156/// `queue_traps(wait_cmd)`) for as long as the child runs, so a trap
17157/// that arrives meanwhile is deferred and dispatched by
17158/// `unqueue_traps()` (`Src/jobs.c:1751` → `Src/signals.c:1041-1047`)
17159/// once the child is reaped. A separate queue is needed here because
17160/// zshrs's `trap_queue` gate (`handletrap`, c:974) tests the LIVE
17161/// `sigtrapped[]`, which the in-process subshell has already cleared.
17162static SUBSH_DEFERRED_SIGS: std::sync::Mutex<Vec<i32>> = std::sync::Mutex::new(Vec::new());
17163
17164/// Called from `zhandler` (`src/ported/signals.rs`, at C's c:410
17165/// queueing check) before any trap dispatch. Returns 1 when the
17166/// delivery has been recorded for the parent and the handler must
17167/// return without acting.
17168///
17169/// !!! WARNING: RUST-ONLY HELPER !!!
17170/// C needs nothing here. `( … )` forks (`Src/exec.c:2922`
17171/// `entersubsh(flags, &esret)`), so `kill -USR1 $$` inside the
17172/// subshell names the PARENT process: the child's handlers are never
17173/// entered, and the parent — which `entersubsh` never touched —
17174/// runs its own trap once `zwaitjob` drains the trap queue. zshrs runs
17175/// the body in the same process, so the one set of handlers has to
17176/// answer for both roles. Recording the delivery and replaying it
17177/// against the parent's restored table at `subshell_end` reproduces
17178/// both the disposition zsh picks and the point at which it runs.
17179///
17180/// SIGCHLD and SIGWINCH are never deferred, matching the two signals C
17181/// itself excludes from the `settrap` / `removetrap` disposition
17182/// switches (`Src/signals.c:714-718`, `Src/signals.c:810-814`): job
17183/// reaping and resize redisplay have to stay live inside the body.
17184///
17185/// When the parent has NO trap for the signal this returns 0 and the
17186/// handler proceeds normally. That is the one leg an in-process
17187/// subshell cannot reproduce: C's parent would take the DEFAULT action
17188/// (`( trap 'echo IN' USR1; kill -USR1 $$ )` kills zsh outright), which
17189/// here would have to kill the shell out from under a body that C
17190/// leaves running.
17191pub fn subshell_defer_signal(sig: libc::c_int) -> i32 {
17192 if sig == libc::SIGCHLD || sig == libc::SIGWINCH {
17193 return 0;
17194 }
17195 let idx = crate::ported::signals_h::SIGIDX(sig) as usize;
17196 // `try_lock`, not `lock`: this runs inside a signal handler, so a
17197 // delivery that lands while the SAME thread is mid-`subshell_signal_enter`
17198 // would self-deadlock on a blocking acquire. A miss degrades to normal
17199 // handling, which is what happened before this hook existed.
17200 let parent = match SUBSH_PARENT_SIGTRAPPED.try_lock() {
17201 Ok(g) => match g.as_ref() {
17202 Some(v) => v.get(idx).copied().unwrap_or(0),
17203 None => return 0,
17204 },
17205 Err(_) => return 0,
17206 };
17207 if parent == 0 {
17208 return 0;
17209 }
17210 if (parent & crate::ported::zsh_h::ZSIG_IGNORED) != 0 {
17211 // The parent ignores it; C's parent process would drop it on
17212 // the floor (`Src/signals.c:713-719` installed SIG_IGN).
17213 return 1;
17214 }
17215 match SUBSH_DEFERRED_SIGS.try_lock() {
17216 Ok(mut q) => q.push(sig),
17217 // Could not record it; fall through to normal handling rather
17218 // than swallow the delivery outright.
17219 Err(_) => return 0,
17220 }
17221 1
17222}
17223
17224/// Arm parent-side signal delivery for the duration of an in-process
17225/// `( … )` or `$( … )`. Must run BEFORE `entersubsh_reset_traps` clears
17226/// the table.
17227///
17228/// !!! WARNING: RUST-ONLY HELPER !!!
17229/// See `subshell_defer_signal`. Only the OUTERMOST subshell records the
17230/// snapshot: `$$` names the top-level shell at every nesting level, so
17231/// a nested `( ( … ) )` still routes deliveries to the same table.
17232pub(crate) fn subshell_signal_enter() {
17233 if SUBSH_SIGNAL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
17234 let snap = crate::ported::signals::sigtrapped
17235 .lock()
17236 .map(|g| g.clone())
17237 .unwrap_or_default();
17238 if let Ok(mut p) = SUBSH_PARENT_SIGTRAPPED.lock() {
17239 *p = Some(snap);
17240 }
17241 if let Ok(mut q) = SUBSH_DEFERRED_SIGS.lock() {
17242 q.clear();
17243 }
17244 }
17245}
17246
17247/// Disarm parent-side delivery and dispatch whatever arrived. Must run
17248/// AFTER `subshell_end` has put the parent's `sigtrapped[]` and
17249/// `traps_table` back.
17250///
17251/// !!! WARNING: RUST-ONLY HELPER !!!
17252/// The dispatch itself is C's `unqueue_traps()` tail
17253/// (`Src/signals.c:1043-1047`): `while (trap_queue_front !=
17254/// trap_queue_rear) handletrap(trap_queue[…])`, run at the point
17255/// `zwaitjob` reaches after the child is reaped (`Src/jobs.c:1751`).
17256pub(crate) fn subshell_signal_leave() {
17257 if SUBSH_SIGNAL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::SeqCst) != 1 {
17258 return;
17259 }
17260 if let Ok(mut p) = SUBSH_PARENT_SIGTRAPPED.lock() {
17261 *p = None;
17262 }
17263 let pending: Vec<i32> = match SUBSH_DEFERRED_SIGS.lock() {
17264 Ok(mut q) => std::mem::take(&mut *q),
17265 Err(_) => return,
17266 };
17267 for sig in pending {
17268 // c:Src/signals.c:1046 — `(void) handletrap(trap_queue[…]);`
17269 let _ = crate::ported::signals::handletrap(sig);
17270 }
17271}
17272
17273/// Put the process's signal DISPOSITIONS back the way the parent had
17274/// them, for every signal whose trap state the subshell changed.
17275///
17276/// !!! WARNING: RUST-ONLY HELPER !!!
17277/// C gets this from the fork: `trap 'echo IN' USR1` inside `( … )`
17278/// calls `settrap` → `install_handler` (`Src/signals.c:724-732`) in the
17279/// CHILD, so the parent's `SIG_DFL` survives untouched and a later
17280/// `kill -USR1 $$` still kills the shell. zshrs shares the process, so
17281/// the child's `sigaction` leaked out and swallowed the signal. The
17282/// branch order below is `removetrap`'s tail verbatim
17283/// (`Src/signals.c:801-815`) for the "parent had no trap" case, and
17284/// `settrap`'s two installs (`Src/signals.c:713-719` ignored,
17285/// `Src/signals.c:724-732` trapped) for the others.
17286pub(crate) fn subshell_restore_signal_dispositions(parent: &[i32], child: &[i32]) {
17287 let count = crate::ported::signals_h::SIGCOUNT;
17288 for sig in 1..=count {
17289 let i = sig as usize;
17290 let p = parent.get(i).copied().unwrap_or(0);
17291 let c = child.get(i).copied().unwrap_or(0);
17292 if p == c {
17293 continue;
17294 }
17295 // c:714-718 / c:810-814 — C leaves these two alone in both
17296 // the install and the reset switch.
17297 if sig == libc::SIGWINCH || sig == libc::SIGCHLD {
17298 continue;
17299 }
17300 if (p & crate::ported::zsh_h::ZSIG_IGNORED) != 0 {
17301 crate::ported::signals_h::signal_ignore(sig); // c:719
17302 } else if p != 0 {
17303 crate::ported::signals::install_handler(sig); // c:732
17304 } else if sig == libc::SIGINT && crate::ported::signals::is_interact() {
17305 crate::ported::signals::intr(); // c:804
17306 crate::ported::signals::noholdintr(); // c:805
17307 } else if sig == libc::SIGHUP {
17308 crate::ported::signals::install_handler(sig); // c:807
17309 } else if sig == libc::SIGPIPE
17310 && crate::ported::signals::is_interact()
17311 && crate::ported::exec::FORKLEVEL.load(std::sync::atomic::Ordering::Relaxed) == 0
17312 {
17313 crate::ported::signals::install_handler(sig); // c:809
17314 } else {
17315 crate::ported::signals_h::signal_default(sig); // c:815
17316 }
17317 }
17318}
17319
17320/// `waitpid(pid, &status, 0)` that retries on `EINTR`.
17321///
17322/// !!! WARNING: RUST-ONLY HELPER !!!
17323/// No C counterpart — C zsh reaches the same place by blocking signals
17324/// around its foreground waits (`queue_signals` / `unqueue_signals`
17325/// fencing in `Src/exec.c`), so its `waitpid` is never interrupted in
17326/// the first place.
17327///
17328/// zshrs installs a process-wide SIGCHLD handler (`zhandler` →
17329/// `wait_for_processes`). When it fires while the shell is blocked in
17330/// this wait, `waitpid` returns -1/`EINTR` WITHOUT touching `status`.
17331/// The pipeline reap loop ignored the return value and read the
17332/// still-zero `status`, so `WIFEXITED(0)` was true and every FORKED
17333/// stage reported exit 0: `false | true` published `$pipestatus` as
17334/// `0 0` instead of zsh's `1 0`, and `setopt pipefail` had no non-zero
17335/// entry left to promote (c:Src/jobs.c:434-435 `if (jpipestats[i])
17336/// pipefail = jpipestats[i];`, applied at c:451-454).
17337///
17338/// The handler can also win outright: if the child exits while the last
17339/// pipeline stage is still running IN THIS PROCESS, SIGCHLD is delivered
17340/// and `wait_for_processes` reaps it long before this loop is reached,
17341/// so `waitpid` here fails with `ECHILD` and the stage's status is gone.
17342/// That window is as wide as the last stage's whole run — `(exit 5) |
17343/// (sleep 0.5; exit 3)` published `$pipestatus` as `0 3` where zsh says
17344/// `5 3`. The reaper publishes every status it takes
17345/// (`extensions/reaped_status.rs`), which is what C gets for free by
17346/// having only one collector, so claim it back here.
17347///
17348/// Returns the raw wait status, or `None` if the child could not be
17349/// reaped and the reaper has no record of it either.
17350fn waitpid_eintr(pid: libc::pid_t) -> Option<i32> {
17351 loop {
17352 let mut status: i32 = 0;
17353 let rc = unsafe { libc::waitpid(pid, &mut status, 0) };
17354 if rc >= 0 {
17355 return Some(status);
17356 }
17357 let err = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
17358 if err != libc::EINTR {
17359 // The SIGCHLD reaper collected this child first; take the
17360 // status it recorded instead of reporting a clean exit.
17361 return crate::reaped_status::take(pid);
17362 }
17363 }
17364}
17365
17366/// [`waitpid_eintr`] that also returns the child's resource usage.
17367///
17368/// c:Src/signals.c:279 — the SIGCHLD reaper collects with `wait3(&status,
17369/// WAITFLAGS, &ru)` and stores it on the process, `pn->ti = ru` (c:352),
17370/// which is where `dumptime`'s per-process user/system times come from. A
17371/// child the reaper got to first is claimed back from `reaped_status` with
17372/// the times it recorded.
17373fn wait4_eintr(pid: libc::pid_t) -> Option<(i32, libc::rusage)> {
17374 loop {
17375 let mut status: i32 = 0;
17376 let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
17377 let rc = unsafe { libc::wait4(pid, &mut status, 0, &mut ru) };
17378 if rc >= 0 {
17379 return Some((status, ru));
17380 }
17381 let err = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
17382 if err != libc::EINTR {
17383 return crate::reaped_status::take_rusage(pid).map(|(st, utime, stime)| {
17384 let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
17385 ru.ru_utime = usec_timeval(utime);
17386 ru.ru_stime = usec_timeval(stime);
17387 (st, ru)
17388 });
17389 }
17390 }
17391}
17392
17393/// The processes of a `time`d multi-stage pipeline, collected for
17394/// `dumptime` (c:Src/jobs.c:1029-1037).
17395///
17396/// !!! WARNING: RUST-ONLY SHAPE — C KEEPS THESE ON THE JOB'S PROCS !!!
17397/// In C every forked stage is `addproc`ed onto `jobtab[thisjob]` with its
17398/// text (c:Src/exec.c:2907) and the timed job is dumped when it finishes.
17399/// zshrs's foreground pipeline has no job-table entry, so BUILTIN_TIME_SUBLIST
17400/// hands the per-stage texts to BUILTIN_RUN_PIPELINE through
17401/// [`TIMED_PIPELINE`] and reads the filled-in procs back.
17402pub(crate) struct TimedPipeline {
17403 /// One job text per stage, in pipeline order.
17404 texts: Vec<String>,
17405 /// The in-shell last stage is a process of the job (c:Src/exec.c:3690
17406 /// `!is_cursh` — an external command or a subshell).
17407 last_forks: bool,
17408 /// Filled by the pipeline driver.
17409 procs: Vec<crate::ported::zsh_h::process>,
17410}
17411
17412thread_local! {
17413 /// Set by BUILTIN_TIME_SUBLIST around a timed pipeline's body; TAKEN by
17414 /// the first BUILTIN_RUN_PIPELINE that runs, so a pipeline nested inside
17415 /// one of its stages is not mistaken for it.
17416 static TIMED_PIPELINE: std::cell::RefCell<Option<TimedPipeline>> =
17417 const { std::cell::RefCell::new(None) };
17418}
17419
17420/// c:Src/exec.c:3690 — `is_cursh = (is_builtin || is_shfunc || nullexec ||
17421/// type >= WC_CURSH)` for the compiler's hint (compile_zsh.rs
17422/// `command_cursh_hint`): 1 = current shell, 0 = forks, 2 = the command word
17423/// decides at run time.
17424fn time_body_is_cursh(hint: i64, name: &str) -> bool {
17425 match hint {
17426 1 => true,
17427 2 => {
17428 // c:3488-3491 — shfunctab is consulted BEFORE builtintab.
17429 let is_shfunc = crate::ported::hashtable::shfunctab_lock()
17430 .read()
17431 .map(|t| t.get(name).is_some())
17432 .unwrap_or(false);
17433 is_shfunc || crate::ported::builtin::createbuiltintable().contains_key(name)
17434 }
17435 _ => false,
17436 }
17437}
17438
17439/// `a - b` for rusage times — c:Src/jobs.c `dtime_tv`.
17440fn timeval_sub(a: libc::timeval, b: libc::timeval) -> libc::timeval {
17441 let mut sec = a.tv_sec - b.tv_sec;
17442 let mut usec = a.tv_usec as i64 - b.tv_usec as i64;
17443 if usec < 0 {
17444 sec -= 1;
17445 usec += 1_000_000;
17446 }
17447 libc::timeval {
17448 tv_sec: sec,
17449 tv_usec: usec as libc::suseconds_t,
17450 }
17451}
17452
17453/// Microseconds as a `timeval`.
17454fn usec_timeval(us: u64) -> libc::timeval {
17455 libc::timeval {
17456 tv_sec: (us / 1_000_000) as libc::time_t,
17457 tv_usec: (us % 1_000_000) as libc::suseconds_t,
17458 }
17459}
17460
17461/// A `process` entry for [`TimedPipeline`]: C's addproc fields (c:Src/jobs.c
17462/// 1473-1486) plus the wait's status and rusage (update_process).
17463fn timed_proc(
17464 pid: libc::pid_t,
17465 text: &str,
17466 status: i32,
17467 ru: &libc::rusage,
17468 bgtime: std::time::Instant,
17469) -> crate::ported::zsh_h::process {
17470 crate::ported::zsh_h::process {
17471 pid,
17472 text: text.to_string(),
17473 status,
17474 ti: crate::ported::zsh_h::timeinfo::from_rusage(ru),
17475 bgtime: Some(bgtime),
17476 endtime: Some(std::time::Instant::now()),
17477 }
17478}
17479
17480/// The level bookkeeping `entersubsh` does in a freshly forked child.
17481///
17482/// c:Src/exec.c:1221 — `forklevel = locallevel;`. `bin_break`'s `exit` arm
17483/// (c:Src/builtin.c:5870-5894) only defers the exit while `locallevel >
17484/// forklevel`; in a forked child the function it was called from is the
17485/// child's outermost level, so `exit N` ends the child with status N.
17486///
17487/// !!! WARNING: RUST-ONLY LINE — SUBSHELL_DEPTH HAS NO C COUNTERPART !!!
17488/// `SUBSHELL_DEPTH` counts the in-process `( … )` bodies this process is
17489/// inside, and `zexit` defers to EXIT_PENDING while it is non-zero because
17490/// exiting would take the parent shell with it. After a fork that reason is
17491/// gone: the child is its own process, as C's always is. Left at its
17492/// inherited value, `exit 2` in a pipeline stage or background job forked
17493/// from inside a function or `( … )` only unwound the child's VM, which then
17494/// exited 0 — `(setopt pipefail; false | exit 2 | true; print $?)` printed
17495/// 1 where zsh prints 2.
17496thread_local! {
17497 /// c:Src/exec.c:3063 — `last1 = forked = 1;`: execcmd_exec has already
17498 /// forked for the command it is about to run (a non-last pipeline stage,
17499 /// an `&` job, a coproc, the sh-emulation last stage).
17500 ///
17501 /// !!! WARNING: RUST-ONLY FLAG — C KEEPS `forked` AS AN execcmd_exec LOCAL !!!
17502 /// zshrs forks in the pipeline/bg/coproc drivers, one level above the
17503 /// stage chunk, so the fact crosses into the chunk through this flag. The
17504 /// chunk's leading BUILTIN_EXECCMD_FORKED_LEVEL consumes it.
17505 static EXECCMD_FORKED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
17506 /// The VM (by address) whose chunk head consumed EXECCMD_FORKED for a
17507 /// non-subshell command. BUILTIN_EXEC_FORKED_SIMPLE honours it only from
17508 /// that same VM, so a `$( … )` or function body run while the forked
17509 /// command's words expand — each on its own VM — never execs in its place.
17510 ///
17511 /// !!! WARNING: RUST-ONLY FLAG — C KEEPS `forked` AS AN execcmd_exec LOCAL !!!
17512 static FORKED_SIMPLE_VM: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
17513 /// The VM whose chunk head consumed EXECCMD_FORKED (subshell or not).
17514 /// BUILTIN_FORKED_REDIRS honours it only from that same VM.
17515 ///
17516 /// !!! WARNING: RUST-ONLY FLAG — C KEEPS `forked` AS AN execcmd_exec LOCAL !!!
17517 static FORKED_REDIR_VM: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
17518 /// Depth of the redirect scope holding the forked command's OWN
17519 /// redirections (0: none). c:Src/exec.c:2478 — addfd saves the original
17520 /// fd only `if (!forked && save[fd1] == -2)`, so those redirections park
17521 /// nothing: the child exits after the command and never restores.
17522 ///
17523 /// !!! WARNING: RUST-ONLY FLAG — C PASSES `forked` TO EVERY addfd CALL !!!
17524 static FORKED_REDIR_SCOPE: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
17525}
17526
17527/// c:Src/exec.c:2478 — `if (!forked && save[fd1] == -2)`: true when the
17528/// redirect scope at `depth` (the innermost open one) belongs to the command
17529/// this child was forked for, whose redirections save no original fd. A
17530/// saved dup there outlived the command in the child: `{ sleep 2 } >/dev/null
17531/// &` kept the caller's stdout open for two seconds, so `$( … & )` and a
17532/// pipe reader waited for it.
17533fn redir_scope_forked(depth: usize) -> bool {
17534 depth != 0 && FORKED_REDIR_SCOPE.with(|c| c.get()) == depth
17535}
17536
17537/// c:Src/exec.c:2440 — `fdtable[fdN] |= FDT_SAVED_MASK;` on the copy
17538/// movefd made FDT_INTERNAL (c:Src/utils.c:2006). The mark is how a forked
17539/// child's entersubsh finds the copies it will never restore and closes them
17540/// (c:1214-1217). Unmarked, a job forked inside `{ … } 2>/dev/null` kept the
17541/// caller's stderr open until it finished. The restore's zclose clears it.
17542pub(crate) fn mark_saved_fd(saved: i32) {
17543 if saved >= 0 {
17544 crate::ported::utils::fdtable_set(
17545 saved,
17546 crate::ported::zsh_h::FDT_INTERNAL | crate::ported::zsh_h::FDT_SAVED_MASK,
17547 );
17548 }
17549}
17550
17551/// Head of a stage / `&` / coproc chunk: account for execcmd_fork's
17552/// `entersubsh` (c:Src/exec.c:1200 `zsh_subshell++;`) when the driver forked.
17553///
17554/// C forks once per such command. For `( … )` that one fork IS the subshell:
17555/// with `forked` already set, the `if (!forked)` block (c:3715-3752) is
17556/// skipped and the body runs in this child without a second fork, so
17557/// `(print $ZSH_SUBSHELL) | cat` prints 1. zshrs's `( … )` always bumps in
17558/// subshell_begin, so for a subshell the fork's bump is left to it; any other
17559/// command takes it here. Without a fork (the in-shell last stage) nothing
17560/// happens. The bump is never undone: the child exits.
17561fn execcmd_forked_level(is_subsh: bool, vm_addr: usize) {
17562 if !EXECCMD_FORKED.with(|f| f.replace(false)) {
17563 return;
17564 }
17565 // The chunk's top command is the one forked for; see BUILTIN_FORKED_REDIRS.
17566 FORKED_REDIR_VM.with(|f| f.set(vm_addr));
17567 // c:Src/exec.c:1245-1246 — `if (!job_control_ok) opts[MONITOR] = 0;` in
17568 // entersubsh. job_control_ok (c:1133) needs ESUB_JOB_CONTROL, which
17569 // execcmd_fork passes only for a non-async `( … )` (c:2919-2920), and
17570 // POSIX_JOBS. Without this a pipeline stage kept MONITOR and ran
17571 // update_job's foreground tail for its own children:
17572 // `{ sh -c 'kill -INT $$'; echo in } | cat` lost `in`.
17573 let job_control_ok = is_subsh && crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXJOBS);
17574 if !job_control_ok && crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) {
17575 crate::ported::options::dosetopt(crate::ported::zsh_h::MONITOR, 0, 0);
17576 }
17577 // The chunk's own top command is the one this child was forked for; see
17578 // BUILTIN_EXEC_FORKED_SIMPLE. A `( … )` is too: its body runs as
17579 // `execlist(state, 0, 1)` (c:Src/exec.c:4379), so the body's last command
17580 // takes the fake exec in this child (compile_zsh exiting_tail_list).
17581 FORKED_SIMPLE_VM.with(|f| f.set(vm_addr));
17582 if !is_subsh {
17583 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
17584 if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
17585 pm.u_val += 1; // c:1200
17586 pm.u_str = Some(pm.u_val.to_string());
17587 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
17588 }
17589 }
17590 }
17591}
17592
17593fn forked_child_subsh_levels() {
17594 use std::sync::atomic::Ordering;
17595 crate::ported::exec::FORKLEVEL.store(
17596 crate::ported::params::locallevel.load(Ordering::Relaxed),
17597 Ordering::Relaxed,
17598 ); // c:1221
17599 // c:Src/exec.c:1263-1264 (55061) — `ancestor_loops += loops; loops = 0;`
17600 crate::ported::builtin::ANCESTOR_LOOPS.fetch_add(
17601 crate::ported::builtin::LOOPS.swap(0, Ordering::Relaxed),
17602 Ordering::Relaxed,
17603 );
17604 crate::ported::builtin::SUBSHELL_DEPTH.store(0, Ordering::Relaxed);
17605 // !!! RUST-ONLY: a forked child is no longer inside the in-process
17606 // `$( … )` it was forked from; its signal traps print to its own fd 1,
17607 // as in C, and the parent's saved fds are gone (mark_saved_fd).
17608 CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().clear());
17609}
17610
17611/// Holds SIGCHLD blocked across the span in which the VM forks children.
17612///
17613/// C: `execpline` opens that span with `child_block()` (c:Src/exec.c:1748),
17614/// before any stage is forked, and closes it with `child_unblock()` only
17615/// after the job is registered (c:1815, the async arm) or after the wait
17616/// (c:2017). Its comment says why: "We need to block SIGCHLD in case the
17617/// process we are spawning terminates before the job table is set up to
17618/// handle it" (c:2339-2343). `getoutputfile` does the same around its own
17619/// fork (c:4999/c:5030).
17620///
17621/// zshrs has a second, harder reason. The reaper allocates
17622/// (`wait_for_processes` builds a `Vec`), and libmalloc holds its fork
17623/// lock inside `fork()`. A SIGCHLD from an earlier stage delivered there
17624/// re-enters malloc on that lock and the process dies with SIGKILL
17625/// ("BUG IN CLIENT OF LIBPLATFORM: Trying to recursively lock an
17626/// os_unfair_lock", faulting stack `fork → _sigtramp → zhandler →
17627/// wait_for_processes → Vec::push → _xzm_fork_lock_wait`). With the
17628/// reaper armed and no block, `repeat 300 { true | false | true }; print
17629/// ok` was killed before printing on every run.
17630///
17631/// !!! WARNING: RUST-ONLY SHAPE — C CALLS child_block/child_unblock !!!
17632/// This guard restores the PRIOR state on drop instead of unconditionally
17633/// unblocking. C's execpline recursion never forks a pipeline from inside
17634/// another pipeline's still-blocked span in the same process, but the VM
17635/// does: the last stage of a pipeline runs in the parent, and a nested
17636/// pipeline or process substitution there would otherwise lift the outer
17637/// block before the outer wait. So SIGCHLD is unblocked on drop only if it
17638/// was unblocked on entry. A forked child never drops the guard (it leaves
17639/// through `exit`/`_exit`), so it inherits the blocked mask, as C's child
17640/// of `zfork` does until `execute` unblocks before `execve` (c:793).
17641#[cfg(unix)]
17642pub(crate) struct ChildBlockSpan {
17643 was_blocked: bool,
17644}
17645
17646#[cfg(unix)]
17647impl ChildBlockSpan {
17648 pub(crate) fn enter() -> Self {
17649 let mask = crate::ported::signals::signal_mask(libc::SIGCHLD);
17650 let old = crate::ported::signals::signal_block(&mask); // c:Src/signals.h:52
17651 let was_blocked = unsafe { libc::sigismember(&old, libc::SIGCHLD) } == 1;
17652 ChildBlockSpan { was_blocked }
17653 }
17654}
17655
17656#[cfg(unix)]
17657impl Drop for ChildBlockSpan {
17658 fn drop(&mut self) {
17659 if !self.was_blocked {
17660 crate::ported::signals_h::child_unblock(); // c:Src/signals.h:53
17661 }
17662 }
17663}
17664
17665pub(crate) struct ForegroundWaitGuard {
17666 /// c:Src/jobs.c:1638 — `int first = 1, q = queue_signal_level();`
17667 q: i32,
17668}
17669
17670impl ForegroundWaitGuard {
17671 #[inline]
17672 pub(crate) fn enter() -> Self {
17673 // c:Src/jobs.c:1638-1642 — `waitforpid`'s prologue verbatim:
17674 // int first = 1, q = queue_signal_level();
17675 // dont_queue_signals();
17676 // child_block(); /* unblocked in signal_suspend() */
17677 // `zwaitjob` opens the same window at c:1684-1689. Both then block
17678 // in `signal_suspend(SIGCHLD, wait_cmd)` (c:1658 / c:1710), and that
17679 // call's sigsuspend mask is EMPTY — c:Src/signals.c:216-232 does
17680 // `sigemptyset(&set)` and adds at most a conditional SIGINT. So a
17681 // foreground wait is a delivery point for every other signal,
17682 // SIGWINCH included: the shell's standing `winch_block()`
17683 // (c:Src/init.c:1458, re-armed by preprompt at c:Src/utils.c:1541)
17684 // is lifted for exactly as long as the shell waits. It is the one
17685 // window C reliably opens in the middle of a command, which is why
17686 // a resize during a completion reaches `$COLUMNS` while the
17687 // completion is still running.
17688 //
17689 // This guard used to be `queue_signals()` alone. That kept the
17690 // SIGCHLD reaper off the child by QUEUEING the delivery in
17691 // userspace rather than blocking it, and left SIGWINCH blocked for
17692 // the whole of a completion no matter how many children it forked
17693 // — a queued SIGWINCH is not dispatched until the queue level
17694 // reaches zero, which for a completion is after the widget has
17695 // returned. Measured with a completer that read `$COLUMNS`, ran
17696 // `sleep 1` and read it again, across a 24x80 -> 24x60 resize: zsh
17697 // reported 80 then 60, zshrs 80 then 80.
17698 //
17699 // The visible cost was the prompt row. `asklist`'s `trashzle`
17700 // (c:Src/Zle/compresult.c:1922 → c:Src/Zle/zle_main.c:2071-2095)
17701 // redraws the command line only when the handler has already raised
17702 // `resetneeded`/`winchanged` (c:Src/utils.c:1956-1958), so a
17703 // row-shrink during `git <TAB>` had zsh repainting `READY% git`
17704 // above the query and zshrs repainting nothing at all.
17705 //
17706 // `child_block` gives the same protection the queueing did and
17707 // gives it the way C does: the reaper cannot take the child out
17708 // from under `Child::wait` while SIGCHLD is blocked at the OS
17709 // level, and SIGWINCH is free to arrive.
17710 let q = crate::ported::signals_h::queue_signal_level(); // c:1638
17711 crate::ported::signals_h::dont_queue_signals(); // c:1641
17712 crate::ported::signals_h::child_block(); // c:1642
17713 // c:1643 — `queue_traps(wait_cmd);`. Signals are dispatched during
17714 // the wait but user TRAPS are held until the child is reaped; the
17715 // old `queue_signals` deferred both together, so this keeps the
17716 // trap half of that behaviour where C keeps it. `wait_cmd` is 0:
17717 // this is not the `wait` builtin.
17718 crate::ported::signals::queue_traps(0); // c:1643
17719 crate::ported::signals_h::winch_unblock(); // c:Src/signals.c:232
17720 ForegroundWaitGuard { q }
17721 }
17722}
17723
17724impl Drop for ForegroundWaitGuard {
17725 #[inline]
17726 fn drop(&mut self) {
17727 // c:Src/jobs.c:1667-1669 — `unqueue_traps(); child_unblock();
17728 // restore_queue_signals(q);`. The `winch_block` ahead of them puts
17729 // back the standing mask that c:Src/utils.c:1541 leaves in place
17730 // between prompts.
17731 crate::ported::signals_h::winch_block(); // c:Src/utils.c:1541
17732 crate::ported::signals::unqueue_traps(); // c:1667
17733 crate::ported::signals_h::child_unblock(); // c:1668
17734 crate::ported::signals_h::restore_queue_signals(self.q); // c:1669
17735 }
17736}
17737
17738/// `Command::status()` plus the reaper-stole-it recovery that the forked
17739/// pipeline stages already get from [`waitpid_eintr`].
17740///
17741/// [`ForegroundWaitGuard`] alone is not enough. Its `child_block` masks
17742/// SIGCHLD for the CALLING thread, and zshrs is multi-threaded: the signal
17743/// is delivered to any thread that has not blocked it, whose `zhandler`
17744/// runs `wait_for_processes` → `waitpid(-1, WNOHANG)` and takes the child.
17745/// `Command::status()` then fails with ECHILD, and because it owns the
17746/// child and never hands back the pid, the status the reaper published
17747/// (`extensions/reaped_status.rs`) cannot be claimed — the caller can only
17748/// report a failure for a command that ran fine.
17749///
17750/// That is what `echo a b c | wc -w` hit: the last stage runs in THIS
17751/// process, so it goes through here rather than through `waitpid_eintr`.
17752/// Measured in a Debian container before this helper existed, it printed
17753/// the correct `3` and then exited 127 with
17754/// `zshrs: wc: No child processes (os error 10)` on 136 of 200 runs.
17755///
17756/// Spawning explicitly keeps the pid, so the losing collector can claim
17757/// the status exactly as the pipeline's targeted wait does. The status is
17758/// rebuilt with `ExitStatus::from_raw`, so every caller keeps its own
17759/// mapping of exit codes and signals.
17760#[cfg(unix)]
17761pub(crate) fn foreground_status(
17762 command: &mut std::process::Command,
17763) -> std::io::Result<std::process::ExitStatus> {
17764 let _wait_guard = ForegroundWaitGuard::enter();
17765 let child = command.spawn()?;
17766 wait_pid_status(child.id() as libc::pid_t)
17767}
17768
17769/// Wait for the foreground child `pid`, claiming its status back from the
17770/// SIGCHLD reaper when the reaper collected it first (ECHILD). The caller
17771/// holds a `ForegroundWaitGuard` from before the spawn. Shared by
17772/// `foreground_status` and the `posix_spawn` path in
17773/// `vm_helper::execute_external_bg`, which has a pid but no `Child`.
17774pub(crate) fn wait_pid_status(pid: libc::pid_t) -> std::io::Result<std::process::ExitStatus> {
17775 use std::os::unix::process::ExitStatusExt as _;
17776 loop {
17777 let mut raw: libc::c_int = 0;
17778 let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
17779 if unsafe { libc::wait4(pid, &mut raw, 0, &mut ru) } == pid {
17780 let usec = |tv: libc::timeval| tv.tv_sec as u64 * 1_000_000 + tv.tv_usec as u64;
17781 fg_child_times_add(usec(ru.ru_utime), usec(ru.ru_stime));
17782 return Ok(std::process::ExitStatus::from_raw(raw));
17783 }
17784 let e = std::io::Error::last_os_error();
17785 match e.raw_os_error() {
17786 Some(libc::EINTR) => continue,
17787 Some(libc::ECHILD) => {
17788 return match take_reaped_status(pid) {
17789 Some((raw, utime, stime)) => {
17790 fg_child_times_add(utime, stime);
17791 Ok(std::process::ExitStatus::from_raw(raw))
17792 }
17793 None => Err(e),
17794 }
17795 }
17796 _ => return Err(e),
17797 }
17798 }
17799}
17800
17801thread_local! {
17802 /// User / system CPU (microseconds) of the foreground externals this
17803 /// thread has waited for since the collector was opened, or `None` when
17804 /// nothing collects. A timed pipeline's in-shell last stage opens it: C
17805 /// forks that stage and times it as one process (`pn->ti = ru`,
17806 /// c:Src/signals.c:352), zshrs runs it here and the externals it spawns
17807 /// are what that process would have been.
17808 ///
17809 /// !!! WARNING: RUST-ONLY COLLECTOR — C READS THE STAGE'S OWN wait3 RUSAGE !!!
17810 static FG_CHILD_TIMES: std::cell::Cell<Option<(u64, u64)>> = const { std::cell::Cell::new(None) };
17811}
17812
17813fn fg_child_times_add(utime: u64, stime: u64) {
17814 FG_CHILD_TIMES.with(|c| {
17815 if let Some((u, s)) = c.get() {
17816 c.set(Some((u + utime, s + stime)));
17817 }
17818 });
17819}
17820
17821/// Claim the reaper's record for `pid`, allowing for the fact that ECHILD
17822/// arrives BEFORE the record is published.
17823///
17824/// `wait_for_processes` takes the child with `waitpid` and only then stores
17825/// the status (`signals.rs`, `reaped_status::record`). Everything between
17826/// those two is a window in which the losing collector already sees ECHILD
17827/// and the ring is still empty: reading it once left 8 of 400 runs of
17828/// `echo a b c | wc -w` reporting `No child processes` even with the
17829/// recovery in place. The child is known dead by then, so waiting out the
17830/// window costs nothing that is not already lost, and this runs only on the
17831/// lost-race path.
17832///
17833/// The budget is bounded because the record may never come: a collector
17834/// that does not publish (the `waitpid(-1)` in `bin_wait`, say) leaves
17835/// nothing to claim, and the caller must be allowed to report the error
17836/// rather than spin.
17837#[cfg(unix)]
17838pub(crate) fn take_reaped_status(pid: i32) -> Option<(i32, u64, u64)> {
17839 const TRIES: u32 = 200; // 200 x 100us = 20ms
17840 for _ in 0..TRIES {
17841 if let Some(rec) = crate::reaped_status::take_rusage(pid) {
17842 return Some(rec);
17843 }
17844 let ts = libc::timespec { tv_sec: 0, tv_nsec: 100_000 };
17845 unsafe { libc::nanosleep(&ts, std::ptr::null_mut()) };
17846 }
17847 None
17848}
17849
17850/// !!! WARNING: RUST-ONLY HELPER !!!
17851///
17852/// C has no counterpart: `fork()` gives a forked `(...)` subshell its own
17853/// copy of the fd table, so the flock fds a subshell opens (`Src/utils.c:2111`
17854/// `addlockfd` marks them `FDT_FLOCK` / `FDT_FLOCK_EXEC`) vanish with the
17855/// child and its `fcntl(F_SETLK)` locks are released. zshrs runs `(...)`
17856/// in-process, so it has to enumerate those slots at subshell entry and
17857/// close the new ones at subshell exit. Walks the same `fdtable` /
17858/// `max_zsh_fd` pair as `zcloselockfd` (`Src/utils.c:2155-2164`).
17859fn current_flock_fds() -> Vec<i32> {
17860 use crate::ported::zsh_h::{FDT_FLOCK, FDT_FLOCK_EXEC};
17861 let max_fd = crate::ported::utils::MAX_ZSH_FD.load(std::sync::atomic::Ordering::Relaxed);
17862 if max_fd < 0 {
17863 return Vec::new();
17864 }
17865 (0..=max_fd)
17866 .filter(|fd| {
17867 let slot = crate::ported::utils::fdtable_get(*fd);
17868 slot == FDT_FLOCK || slot == FDT_FLOCK_EXEC
17869 })
17870 .collect()
17871}
17872
17873/// `exec`'s external command: C's `execute(args, cflags, use_defpath)`
17874/// (c:Src/exec.c:4369, body c:729-881) through the same search and zexecve
17875/// as every other external (`vm_helper::execute_spawn`). `in_place` execs
17876/// over this process and only returns on failure; otherwise (an in-process
17877/// subshell, whose C counterpart was a forked child) the command is spawned
17878/// and waited for. Returns the command's status, or C's exit status for a
17879/// failed exec. `args` is the command and its arguments; `exec_argv0` the
17880/// `exec -a` name; `login` the `-`/`exec -l` flag.
17881fn exec_execute(
17882 args: &[String],
17883 exec_argv0: Option<String>,
17884 login: bool,
17885 use_defpath: bool,
17886 in_place: bool,
17887) -> i32 {
17888 let cmd = &args[0];
17889 crate::lowfd::cloexec_internal_fds(); // c:4352
17890 // c:3318-3328 — `exec -a NAME` travels as ARGV0 in the environment;
17891 // execute() takes argv[0] from it and unsets it (c:758-768), else
17892 // prefixes `-` for `exec -l` / `-` (c:772-776).
17893 let argv0_env = exec_argv0.or_else(|| std::env::var("ARGV0").ok());
17894 let mut argv: Vec<String> = Vec::with_capacity(args.len());
17895 argv.push(match &argv0_env {
17896 Some(z) => z.clone(),
17897 None if login => format!("-{}", cmd),
17898 None => cmd.clone(),
17899 });
17900 argv.extend(args[1..].iter().cloned());
17901 // c:810-819 — `command -p`: the default-path hit alone.
17902 let defpath = if use_defpath && !cmd.contains('/') {
17903 crate::ported::exec::search_defpath(cmd, libc::PATH_MAX as usize)
17904 } else {
17905 None
17906 };
17907 let _wait_guard = (!in_place).then(ForegroundWaitGuard::enter);
17908 let eno = if use_defpath && !cmd.contains('/') && defpath.is_none() {
17909 0 // c:815-819 — not on the default path
17910 } else {
17911 match crate::vm_helper::execute_spawn(
17912 cmd,
17913 &argv,
17914 defpath.as_deref(),
17915 None,
17916 argv0_env.is_some(),
17917 in_place,
17918 ) {
17919 Ok(pid) => {
17920 return match wait_pid_status(pid) {
17921 Ok(status) => crate::exec_jobs::wait_status_val(status),
17922 Err(_) => 127,
17923 }
17924 }
17925 Err(eno) => eno,
17926 }
17927 };
17928 drop(_wait_guard);
17929 // c:871-881
17930 if eno != 0 {
17931 crate::ported::utils::zerr(&format!(
17932 "{}: {}",
17933 crate::ported::utils::zsh_errno_msg(eno),
17934 cmd
17935 ));
17936 return if eno == libc::EACCES || eno == libc::ENOEXEC { 126 } else { 127 };
17937 }
17938 // c:873-874 — `if (commandnotfound(arg0, args) == 0) _realexit();`
17939 if let Some(rc) =
17940 with_executor(|exec| exec.dispatch_function_call("command_not_found_handler", args))
17941 {
17942 return rc;
17943 }
17944 crate::ported::utils::zerr(&format!(
17945 "command not found: {}",
17946 crate::ported::utils::nicedupstring(cmd)
17947 ));
17948 127
17949}
17950
17951/// Whether `exec` is running inside a subshell that zshrs keeps in this
17952/// process — `( … )` (the snapshot stack) or a command substitution
17953/// (`SubshStateGuard`, the in-process `entersubsh`). C forked both, so its
17954/// exec replaced only that child; replacing THIS process would end the
17955/// parent shell too (`x=$(exec echo hi)` ended the script).
17956fn exec_in_process_subshell() -> bool {
17957 with_executor(|exec| !exec.subshell_snapshots.is_empty())
17958 || crate::ported::exec::SUBSH_STATE_DEPTH.load(std::sync::atomic::Ordering::SeqCst) > 0
17959}
17960
17961fn try_user_fn_override(name: &str, args: &[String]) -> Option<i32> {
17962 let has_fn = with_executor(|exec| {
17963 exec.functions_compiled.contains_key(name) || exec.function_exists(name)
17964 });
17965 if !has_fn {
17966 return None;
17967 }
17968 Some(with_executor(|exec| {
17969 exec.dispatch_function_call(name, args).unwrap_or(127)
17970 }))
17971}
17972
17973/// Builtin ID for `${name}` reads — routes through canonical
17974/// `getsparam` (Src/params.c:3076) via paramtab + env walk so nested
17975/// VMs (function calls) see the same storage.
17976pub const BUILTIN_GET_VAR: u16 = 283;
17977
17978/// Like `BUILTIN_GET_VAR` but forces double-quoted (DQ) semantics on
17979/// the read regardless of the runtime `in_dq_context`. The compiler
17980/// emits this for a QUOTED simple-var read (`"$name"`) — those compile
17981/// to a direct GET_VAR with no EXPAND_TEXT wrapper, so `in_dq_context`
17982/// is 0 and the plain GET_VAR would wrongly word-elide an array's empty
17983/// elements (`a=(1 "" 3); "$a"` must keep the empty → `1 3`, not
17984/// `1 3`). With force_dq the array joins via sepjoin keeping empties and
17985/// a scalar is returned verbatim (no empty-drop, no SH_WORD_SPLIT).
17986pub const BUILTIN_GET_VAR_DQ: u16 = 639;
17987
17988/// Builtin ID for `name=value` assignments — pops [name, value] and
17989/// routes through canonical `setsparam` (Src/params.c:3350).
17990pub const BUILTIN_SET_VAR: u16 = 284;
17991
17992/// Builtin ID that sets the thread-local [`SET_VAR_GLOB_ELIGIBLE`] flag true.
17993/// Emitted by the compiler immediately before a `BUILTIN_SET_VAR` whose scalar
17994/// RHS carried an UNQUOTED glob token, so the runtime knows the RHS is a literal
17995/// glob pattern eligible for GLOB_ASSIGN. Takes no stack args, pushes nothing.
17996pub const BUILTIN_MARK_GLOB_ELIGIBLE: u16 = 640;
17997
17998/// Builtin ID for pipeline execution. Pops N sub-chunk indices from the stack;
17999/// each index points into `vm.chunk.sub_chunks` (compiled stage bodies). Forks
18000/// N children, wires stdin/stdout between them via pipes, runs each stage's
18001/// bytecode on a fresh VM in its child, parent waits for all and pushes the
18002/// last stage's exit status. This is bytecode-native pipeline execution —
18003/// no tree-walker delegation.
18004pub const BUILTIN_RUN_PIPELINE: u16 = 285;
18005
18006/// Builtin ID for `Array → String` joining. Pops one value: if it's an Array,
18007/// joins its string-coerced elements with a single space; otherwise passes
18008/// through. Used after `Op::Glob` to convert the pattern's matched paths into
18009/// the single argv-token form the bytecode word model expects (no per-word
18010/// splitting yet — that's a future phase).
18011pub const BUILTIN_ARRAY_JOIN: u16 = 286;
18012
18013/// Builtin ID for `cmd &` background execution. IDs 287/288/289 are reserved
18014/// for the planned array work in Phase G1 (SET_ARRAY/SET_ASSOC/ARRAY_INDEX),
18015/// so this lands at 290. Pops the sub-chunk index then the job text; forks;
18016/// child detaches (`setsid`), runs the sub-chunk on a fresh VM, exits with
18017/// last_status; parent registers the job in the canonical JOBTAB
18018/// (initjob/addproc/spawnjob per c:Src/exec.c:1700-1758) so `jobs` / `wait
18019/// %N` / `kill %N` / `disown` and the zsh/parameter assocs all see it, then
18020/// returns Status(0) immediately.
18021pub const BUILTIN_RUN_BG: u16 = 290;
18022
18023/// Indexed-array assignment: `arr=(a b c)`. Compile_simple emits N element
18024/// pushes followed by name push, then `CallBuiltin(BUILTIN_SET_ARRAY, N+1)`.
18025/// The handler pops args (last popped = name in our pushing order) and stores
18026/// `Vec<String>` into `executor.arrays`. Tree-walker callers see the same
18027/// storage. Any prior scalar binding in `executor.variables` for `name` is
18028/// removed so `${name}` (scalar context) consistently reflects the array's
18029/// first element via `get_variable`.
18030pub const BUILTIN_SET_ARRAY: u16 = 287;
18031
18032/// Single-key set on an associative array: `foo[key]=val`. Stack (top-down):
18033/// [name, key, value]. Stores `value` into `executor.assoc_arrays[name][key]`,
18034/// creating the outer entry if missing. compile_simple detects `var[...]=...`
18035/// in assignments and emits this builtin.
18036pub const BUILTIN_SET_ASSOC: u16 = 288;
18037
18038/// `${arr[idx]}` — single-element array index. Pops two args:
18039/// stack: [name, idx_str]
18040/// Returns the indexed element as Value::str. Indexing semantics: zsh is
18041/// 1-based by default; bash is 0-based. We follow zsh.
18042/// Special idx values: `@` and `*` return the whole array as Value::Array
18043/// (which fuses correctly via the Op::Exec splice for argv splice).
18044pub const BUILTIN_ARRAY_INDEX: u16 = 289;
18045
18046/// `${#arr[@]}` and `${#arr}` (when arr is an array name) — array length.
18047/// Pops one arg: name. Returns Value::str of len.
18048
18049/// `${arr[@]}` — splice all elements as a Value::Array. Pops one arg: name.
18050/// The Array gets flattened by Op::Exec/ExecBg/CallFunction into argv.
18051pub const BUILTIN_ARRAY_ALL: u16 = 292;
18052
18053/// Flatten one level of Value::Array nesting. Pops N values; for each, if it's
18054/// a Value::Array, its elements are appended directly; otherwise the value is
18055/// appended as-is. Pushes a single Value::Array of the flattened result. Used
18056/// by the for-loop word-list compile path: when a word like `${arr[@]}`
18057/// produces a nested Array, this lets `for i in ${arr[@]}` iterate over the
18058/// inner elements rather than the outer single-element array.
18059pub const BUILTIN_ARRAY_FLATTEN: u16 = 293;
18060
18061/// `coproc [name] { body }` — bidirectional pipe to async child. Pops a name
18062/// (optional, "" for default) and a sub-chunk index. Creates two pipes, forks,
18063/// child redirects its fd 0/1 to the inner ends and runs the body, parent
18064/// stores [write_fd, read_fd] into the named array (default `COPROC`). Caller
18065/// closes the fds and `wait`s when done. Job-table integration deferred to
18066/// Phase G6 alongside the bg `&` work.
18067pub const BUILTIN_RUN_COPROC: u16 = 294;
18068
18069/// `arr+=(d e f)` — append N elements to an existing indexed array. Compile
18070/// emits N element pushes + name push, then `CallBuiltin(295, N+1)`. Handler
18071/// drains args (last popped = name), extends `executor.arrays[name]` (creates
18072/// the entry if missing). Mirrors zsh's `+=` semantics for indexed arrays.
18073pub const BUILTIN_APPEND_ARRAY: u16 = 295;
18074
18075/// `name[@]=(...)` / `name[*]=(...)` whole-array SET. Identical to
18076/// BUILTIN_SET_ARRAY for an indexed array / scalar (whole replace), but
18077/// rejects an associative target with "attempt to set slice of
18078/// associative array" (c:Src/params.c:3324-3327).
18079pub const BUILTIN_SET_ARRAY_AT: u16 = 633;
18080
18081/// `name[@]+=(...)` / `name[*]+=(...)` whole-array APPEND. Indexed
18082/// append (push), assoc target → same slice-of-assoc error as 633.
18083pub const BUILTIN_APPEND_ARRAY_AT: u16 = 634;
18084
18085/// `select var in words; do body; done` — interactive numbered-menu loop.
18086/// Compile emits N word pushes + var-name push + sub-chunk index push, then
18087/// `CallBuiltin(296, N+2)`. Handler prints `1) word1\n2) word2\n...` to
18088/// stderr, prints `$PROMPT3` (default `?# `) to stderr, reads a line from
18089/// stdin. On EOF returns 0. On a valid 1-based number, sets `var` to the
18090/// chosen word, runs the sub-chunk, then redisplays the menu and loops. On
18091/// invalid input redraws the menu without running the body. `break` from
18092/// inside the body exits the loop (handled by the body's own bytecode).
18093pub const BUILTIN_RUN_SELECT: u16 = 296;
18094
18095/// `m[k]+=value` — append onto an existing assoc-array value (string concat).
18096/// If the key doesn't exist, behaves like SET_ASSOC. Stack: [name, key, value].
18097
18098/// `break` from inside a body that runs on a sub-VM (select, future
18099/// loop-via-builtin constructs). Writes the canonical
18100/// `crate::ported::builtin::BREAKS` atomic (port of `Src/loop.c:46
18101/// breaks`). Outer-loop builtins drain BREAKS/CONTFLAG after each
18102/// body run, matching the loop.c:529-534 drain pattern.
18103pub const BUILTIN_SET_BREAK: u16 = 299;
18104
18105/// `continue` from inside a sub-VM body. Sets CONTFLAG=1 + bumps
18106/// BREAKS, matching `bin_break`'s WC_CONTINUE arm at Src/builtin.c
18107/// c:5836 `contflag = 1; FALLTHROUGH; breaks++;`.
18108pub const BUILTIN_SET_CONTINUE: u16 = 300;
18109
18110/// Brace expansion: `{a,b,c}` → 3 values, `{1..5}` → 5 values, `{01..05}` →
18111/// zero-padded numerics, `{a..e}` → letter range. Pops one string, returns
18112/// Value::Array of expansions (empty array → original string preserved).
18113pub const BUILTIN_BRACE_EXPAND: u16 = 301;
18114
18115/// Glob qualifier filter: `*(qualifier)` filters glob results by predicate.
18116/// Pops [pattern, qualifier_string]. Returns Value::Array of matching paths.
18117
18118/// Re-export the regex_match host method as a builtin so `[[ s =~ pat ]]`
18119/// works even when fusevm's Op::RegexMatch isn't routed (compat fallback).
18120
18121/// Word-split a string on IFS (default: whitespace). Pops one string,
18122/// returns Value::Array of fields. Used in array-literal context where
18123/// `arr=($(cmd))` should expand cmd's stdout into multiple elements.
18124pub const BUILTIN_WORD_SPLIT: u16 = 304;
18125
18126/// `${=name}` / SH_WORD_SPLIT forced IFS split — c:Src/subst.c:3920-3928
18127/// `aval = sepsplit(val, spsep, 0, 1);`.
18128///
18129/// Unlike BUILTIN_WORD_SPLIT (which routes through `multsub`'s
18130/// PREFORK_SPLIT walker — the c:553-620 loop that COLLAPSES runs of
18131/// separators and never emits an empty field), this is the *other* zsh
18132/// splitter: `sepsplit` → `Src/utils.c:3711 spacesplit(s, allownull=0)`,
18133/// which distinguishes the two IFS classes and preserves empty fields.
18134///
18135/// Stack: \[value\]. argc selects the empty-field rule:
18136/// * argc == 0 — the expansion is a bare unquoted word (`${=v}`): the
18137/// leading/trailing `""` fields spacesplit emits for skipped
18138/// IFS-WHITESPACE are empty argv words and prefork deletes them
18139/// (c:Src/subst.c:184-187 `uremnode`).
18140/// * argc == 1 — the expansion is quoted (`"${=v}"`) or has adjacent
18141/// word segments (`x${=v}y`): those fields survive, because in C the
18142/// word's `Dnull` quote markers / literal prefix+suffix attach to the
18143/// first and last elements (c:4386 / c:4429 strcatsub) and the node is
18144/// no longer empty. `v=$' a:b '` → `""`, `a:b`, `""` quoted; `x`,
18145/// `a:b`, `y` with surrounding text.
18146///
18147/// Empty fields that come from an IFS-NON-whitespace separator are the
18148/// `nulstring` (`Nularg`, c:Src/subst.c:36) and survive in BOTH cases —
18149/// `IFS=x; v=xaxbx` splits to `""`, `a`, `b`, `""` quoted or not.
18150pub const BUILTIN_FORCE_SPLIT: u16 = 643;
18151
18152/// Register a pre-compiled fusevm chunk as a function. Stack: [name,
18153/// base64-bincode-of-Chunk]. Used by compile_zsh's compile_funcdef to
18154/// register functions parsed via parse_init+parse without going through the
18155/// ShellCommand JSON serialization path.
18156pub const BUILTIN_REGISTER_COMPILED_FN: u16 = 305;
18157/// `BUILTIN_VAR_EXISTS` constant.
18158pub const BUILTIN_VAR_EXISTS: u16 = 306;
18159/// Native param-modifier builtins. Each takes a fixed argv shape and
18160/// returns the modified value as Value::Str.
18161///
18162/// `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
18163/// — pop [name, op_byte, rhs]. op_byte: 0=`:-`, 1=`:=`, 2=`:?`, 3=`:+`.
18164pub const BUILTIN_PARAM_DEFAULT_FAMILY: u16 = 307;
18165/// `${var:offset[:length]}` — pop [name, offset, length] (length=-1 means
18166/// "rest of value"; negative offset counts from end).
18167pub const BUILTIN_PARAM_SUBSTRING: u16 = 308;
18168/// `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}` — pop
18169/// [name, pattern, op_byte]. op_byte: 0=`#`, 1=`##`, 2=`%`, 3=`%%`.
18170pub const BUILTIN_PARAM_STRIP: u16 = 309;
18171/// `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
18172/// `${var/%pat/repl}` — pop [name, pattern, replacement, op_byte].
18173/// op_byte: 0=first, 1=all, 2=anchor-prefix, 3=anchor-suffix.
18174pub const BUILTIN_PARAM_REPLACE: u16 = 310;
18175/// `${#name}` — character length of a scalar value, or element count
18176/// of an indexed/assoc array. Pops \[name\], returns count as Value::Str.
18177pub const BUILTIN_PARAM_LENGTH: u16 = 311;
18178/// `$((expr))` arithmetic substitution. Pops \[expr_string\], evaluates
18179/// via the executor's MathEval (integer-aware), returns result as
18180/// Value::Str. Bypasses ArithCompiler's float-only Op::Div path so
18181/// `$((10/3))` returns "3" not "3.333...".
18182pub const BUILTIN_ARITH_EVAL: u16 = 312;
18183/// `(( ... ))` math command post-eval status hook. Pops nothing,
18184/// pushes Value::Status. If errflag is set (math error in the
18185/// preceding BUILTIN_ARITH_EVAL call), clears it and emits status=2
18186/// matching c:Src/math.c arith-failure semantics. Otherwise emits
18187/// the current vm.last_status. Used by compile_arith's `(( ... ))`
18188/// path so the math command swallows errors without halting the
18189/// script — `$((... ))` substitutions skip this hook so their
18190/// errflag propagates up to the containing command.
18191pub const BUILTIN_ARITH_CMD_FINISH: u16 = 527;
18192/// `$(cmd)` command substitution. Pops \[cmd_string\], runs through
18193/// `run_command_substitution` which compiles via parse_init+parse + ZshCompiler
18194/// and captures stdout via an in-process pipe. Returns trimmed output
18195/// as Value::Str. Avoids the sub-chunk word-emit quoting bug in the
18196/// raw Op::CmdSubst path.
18197pub const BUILTIN_CMD_SUBST_TEXT: u16 = 313;
18198/// Text-based word expansion. Pops \[preserved_text\]: the word with
18199/// quotes preserved (Dnull→`"`, Snull→`'`, Bnull→`\`), runs
18200/// `expand_string` (variable + cmd-sub + arith) then `xpandbraces`
18201/// then `expand_glob`. Returns Value::str (single match) or
18202/// Value::Array (multi-match brace/glob).
18203pub const BUILTIN_EXPAND_TEXT: u16 = 314;
18204
18205/// `[[ a -ef b ]]` — same-inode test. Stack: [a, b]. Pushes Bool true iff
18206/// both paths resolve to the same `(dev, inode)` pair (zsh + bash semantics).
18207pub const BUILTIN_SAME_FILE: u16 = 315;
18208
18209/// `[[ a -nt b ]]` — file `a` newer than file `b` (mtime strict).
18210/// Stack: [path_a, path_b]. Pushes Bool. zsh-compatible "missing"
18211/// rules: if both exist, compare mtime; if only `a` exists → true;
18212/// otherwise false.
18213pub const BUILTIN_FILE_NEWER: u16 = 324;
18214
18215/// `[[ a -ot b ]]` — mirror of `-nt`. If both exist, compare mtime;
18216/// if only `b` exists → true; otherwise false.
18217pub const BUILTIN_FILE_OLDER: u16 = 325;
18218
18219/// `[[ -k path ]]` — sticky bit (S_ISVTX) set on path.
18220pub const BUILTIN_HAS_STICKY: u16 = 326;
18221/// `[[ -u path ]]` — setuid bit (S_ISUID).
18222pub const BUILTIN_HAS_SETUID: u16 = 327;
18223/// `[[ -g path ]]` — setgid bit (S_ISGID).
18224pub const BUILTIN_HAS_SETGID: u16 = 328;
18225/// `[[ -O path ]]` — owned by effective UID.
18226pub const BUILTIN_OWNED_BY_USER: u16 = 329;
18227/// `[[ -G path ]]` — owned by effective GID.
18228pub const BUILTIN_OWNED_BY_GROUP: u16 = 330;
18229/// `[[ -N path ]]` — file modified since last accessed (atime <= mtime).
18230pub const BUILTIN_FILE_MODIFIED_SINCE_ACCESS: u16 = 341;
18231
18232/// `name+=val` (no parens) — runtime-dispatched append.
18233/// If name is an indexed array → push val as element.
18234/// If name is an assoc array → error (zsh requires `(k v)` form).
18235/// Else → scalar concat (existing SET_VAR behavior).
18236pub const BUILTIN_APPEND_SCALAR_OR_PUSH: u16 = 331;
18237
18238/// `[[ -c path ]]` — character device.
18239pub const BUILTIN_IS_CHARDEV: u16 = 332;
18240/// `[[ -b path ]]` — block device.
18241pub const BUILTIN_IS_BLOCKDEV: u16 = 333;
18242/// `[[ -p path ]]` — FIFO / named pipe.
18243pub const BUILTIN_IS_FIFO: u16 = 334;
18244/// `[[ -S path ]]` — socket.
18245pub const BUILTIN_IS_SOCKET: u16 = 335;
18246/// `BUILTIN_ERREXIT_CHECK` constant.
18247pub const BUILTIN_ERREXIT_CHECK: u16 = 336;
18248/// Fatal-error-only abort check, for use INSIDE an `&&` / `||` chain.
18249///
18250/// A chain suppresses the errexit check (a non-zero status is "consumed"
18251/// by the connector — `false && x` must not fire ERREXIT or the ZERR
18252/// trap). But an errflag — a *fatal* error such as a `[[ ]]` bad pattern
18253/// — is not a status the connector can consume: zsh abandons the whole
18254/// list. Without this, zshrs ran the `||` right-hand side after the
18255/// error (`[[ x = [a- ]] || touch f` created `f`; zsh does not) and the
18256/// aborted builtin then overwrote the cond's status 2 with 1.
18257///
18258/// Same errflag arm as `BUILTIN_ERREXIT_CHECK`, with the errexit/ZERR
18259/// half omitted.
18260pub const BUILTIN_FATAL_ABORT_CHECK: u16 = 641;
18261/// Post-`always`-arm checks for the canonical RETFLAG / BREAKS /
18262/// CONTFLAG atomics that mark try-block escapes. Each returns
18263/// Value::Int(1) when the corresponding atomic is set (and consumes
18264/// it so the next escape doesn't re-fire) and Value::Int(0) otherwise.
18265/// Paired with JumpIfFalse + Jump to outer return_patches /
18266/// break_patches / continue_patches by compile_zsh's `Try` arm.
18267pub const BUILTIN_RETFLAG_CHECK: u16 = 600;
18268/// `BUILTIN_BREAKS_CHECK` constant.
18269pub const BUILTIN_BREAKS_CHECK: u16 = 601;
18270/// `BUILTIN_CONTFLAG_CHECK` constant.
18271pub const BUILTIN_CONTFLAG_CHECK: u16 = 602;
18272/// `loops++` on entry to a compiled for/while/until/repeat
18273/// (c:Src/loop.c:114/427/523).
18274pub const BUILTIN_LOOP_ENTER: u16 = 656;
18275/// `loops--` on exit from a compiled for/while/until/repeat
18276/// (c:Src/loop.c:188/491/546).
18277pub const BUILTIN_LOOP_EXIT: u16 = 657;
18278/// Post-body `if (breaks) { breaks--; … }` drain (c:Src/loop.c:529-534).
18279/// Int(1) = terminate this loop, Int(0) = next iteration.
18280pub const BUILTIN_LOOP_BREAK_DRAIN: u16 = 658;
18281/// Non-consuming `breaks != 0` probe for execlist's per-statement gate
18282/// (c:Src/exec.c:1370).
18283pub const BUILTIN_BREAKS_PENDING: u16 = 659;
18284/// `shtokenize` the top-of-stack string in place — c:Src/subst.c:4419-4420
18285/// `if (globsubst) shtokenize(y)`, the step that makes a `${~spec}` /
18286/// `$~spec` value's metachars PATTERN-ACTIVE.
18287///
18288/// zshrs expands a `[[ ]]` operand at the VM level and hands `cond_str`
18289/// (c:Src/cond.c:525) a finished string, so the token state C carries in
18290/// the word itself has to be re-applied at the point of use. Without it a
18291/// module condition compiles the value as a literal: `[[ -prefix $~pat ]]`
18292/// (Completion/Base/Utility/_numbers sh:65) is the one shipped completer
18293/// that depends on it.
18294pub const BUILTIN_COND_SHTOKENIZE: u16 = 660;
18295/// Fire the DEBUG trap (SIGDEBUG) before each statement.
18296/// c:Src/exec.c:1357-1500 DEBUGBEFORECMD — when a "DEBUG" entry is
18297/// installed via `trap '...' DEBUG`, run the body just before the
18298/// next command. Cheap when no DEBUG trap is set (one hashmap lookup
18299/// returns None and we early-out).
18300pub const BUILTIN_DEBUG_TRAP: u16 = 603;
18301/// `set -n` / `set -o noexec` — parse but don't execute. Returns
18302/// Value::Int(1) when the noexec option is set so the caller's
18303/// JumpIfTrue skips the statement body. c:Src/exec.c:1390 main loop
18304/// check.
18305pub const BUILTIN_NOEXEC_CHECK: u16 = 604;
18306/// c:Src/exec.c:4051 — `else if (isset(EXECOPT) && !errflag)`: a simple
18307/// command's words are expanded (prefork c:3304, globlist c:3757) whether
18308/// or not NO_EXEC is set; only the execution is skipped. No args. Returns
18309/// Int(1) under NO_EXEC, after setting the status to 1 when the expansion
18310/// raised errflag (c:3760-3763); the caller's JumpIfTrue then drops the
18311/// expanded words without running the command. Int(0) otherwise.
18312pub const BUILTIN_NOEXEC_CMD_GATE: u16 = 748;
18313/// Block-level redirect-failure gate. Reads exec.redirect_failed
18314/// (set by host.redirect when a redirect open fails); returns
18315/// Value::Int(1) AND clears the flag if set, else 0. Emit-side at
18316/// compile_zsh.rs::compile_command's Redirected arm pairs with a
18317/// JumpIfTrue → WithRedirectsEnd to abandon the body. Without this,
18318/// a multi-statement block after a failed redir kept running every
18319/// statement after the first (the first builtin consumed the flag,
18320/// subsequent statements ran unimpeded).
18321pub const BUILTIN_REDIRECT_FAILED_CHECK: u16 = 605;
18322/// Drop-in replacement for fusevm's Op::Exec for the dynamic-first-
18323/// word path (`$cmd`, `$(cmd)`, `~/bin/foo`). Returns
18324/// Value::Status(vm.last_status) when post-expansion argv is empty
18325/// (preserves the inner cmd-subst's exit), Value::Status(126) with
18326/// "permission denied" when `argv[0]` is empty, otherwise routes
18327/// through executor.host_exec_external like Op::Exec did.
18328pub const BUILTIN_EXEC_DYNAMIC: u16 = 606;
18329/// Reset `use_cmdoutval` to 0 at the START of a dynamic command (before
18330/// its words expand), so a command substitution from a PREVIOUS command
18331/// can't leak into this command's null-command status decision
18332/// (c:Src/exec.c:3009 `use_cmdoutval = !args`). See BUILTIN_EXEC_DYNAMIC.
18333pub const BUILTIN_USE_CMDOUTVAL_RESET: u16 = 637;
18334/// Reset `cmdoutval` to 0 at the START of a simple command that carries
18335/// assignments, before any of its words or assignment values expand
18336/// (c:Src/exec.c:2796 execcmd_analyse, c:1368 execsimple WC_ASSIGN).
18337pub const BUILTIN_CMDOUTVAL_RESET: u16 = 731;
18338/// Tilde-expand a match pattern's leading `~`, the way `singsub` does
18339/// before the pattern reaches `patcompile`.
18340///
18341/// c:Src/cond.c:299-307 — `right = dupstring(ecrawstr(…)); singsub(&right);
18342/// … patcompile(right, …)`. `singsub` is `prefork(PREFORK_SINGLE)`
18343/// (c:Src/subst.c:520), and prefork runs `filesub` on every word, so an
18344/// unquoted `~` in a `[[ … == ~/* ]]` pattern — or one that arrives via
18345/// `${~var}` — is a home directory, not a literal character. `case`
18346/// patterns take the same route (c:Src/loop.c:610 `singsub(&pat)`).
18347/// zshrs untokenizes the pattern at compile time and re-tokenizes it in
18348/// the matcher, so the expansion has to happen here.
18349///
18350/// Only a LEADING `~` is considered, which is all `filesubstr`
18351/// (c:Src/subst.c:741) ever expands: a `~` elsewhere is EXTENDED_GLOB's
18352/// "except" operator and must survive untouched, and a quoted one has
18353/// already been backslash-escaped by `escape_quoted_glob_metas` so it
18354/// fails the leading-char test.
18355///
18356/// powerlevel10k's directory segment is the visible consumer: its
18357/// `_POWERLEVEL9K_DIR_CLASSES` walk matches `$PWD` against `~` and `~/*`
18358/// via `[[ $_p9k__cwd == ${~a} ]]` (internal/p10k.zsh:2029). Without the
18359/// expansion both classes missed, every path under `$HOME` fell through
18360/// to the `*` DEFAULT class, and the prompt showed the generic folder
18361/// icon in place of the home / home-subfolder one.
18362fn pattern_filesub(pattern: &str) -> String {
18363 let first = pattern.chars().next();
18364 if first != Some('~') && first != Some(crate::ported::zsh_h::Tilde) {
18365 return pattern.to_string();
18366 }
18367 // filesubstr keys on the Tilde TOKEN, so shtokenize first (a raw `~`
18368 // becomes Tilde; an already-tokenized one passes through), then
18369 // untokenize the surviving glob metas back for the matcher.
18370 let mut tok = pattern.to_string();
18371 crate::ported::glob::shtokenize(&mut tok);
18372 crate::ported::lex::untokenize(&crate::ported::subst::filesub(&tok, 0))
18373}
18374
18375/// `[[ lhs == pat ]]` / `!=` glob compare — cond-specific so the
18376/// bad-pattern diagnostic follows Src/cond.c:308-316: zwarnnam
18377/// "bad pattern: %s" WITHOUT errflag (the script continues) and the
18378/// cond statement exits 2. Stack: [lhs, pat] → Bool. On compile
18379/// failure pushes Bool(false) and arms COND_BAD_PATTERN so
18380/// BUILTIN_COND_STATUS_FROM_BOOL reports 2.
18381pub const BUILTIN_COND_STRMATCH: u16 = 624;
18382/// Pops the cond result Bool → Int status per Src/cond.c: true→0,
18383/// false→1, but 2 when COND_BAD_PATTERN was armed during this cond
18384/// (covers `!=` where LogNot flips the Bool before status time).
18385pub const BUILTIN_COND_STATUS_FROM_BOOL: u16 = 625;
18386/// `[[ ]]` unknown condition. Pops \[op_name\], emits `zerr("unknown
18387/// condition: %s")` and sets ERRFLAG_ERROR so the next BUILTIN_ERREXIT_CHECK
18388/// (trigger 4) aborts the input — matching zsh's COND_MODI "unknown condition"
18389/// path (Src/cond.c:150-188) for a `-X` op with no matching loadable module.
18390/// Returns Bool(false). Replaces a compile-time `eprintln!` hack that printed
18391/// the message but never set errflag (so the line didn't abort).
18392pub const BUILTIN_COND_UNKNOWN: u16 = 632;
18393/// Bare-`exec` redirect epilogue. Consumes `exec.redirect_failed` and
18394/// applies the C `done:` tail of execcmd_exec:
18395/// - c:Src/exec.c:252-259 execerr — `redir_err = lastval = 1` (the
18396/// failed redirect makes the exec statement exit 1, NOT fatal by
18397/// itself);
18398/// - c:Src/exec.c:4367-4386 — `if (isset(POSIXBUILTINS) && (cflags
18399/// & (BINF_PSPECIAL|BINF_EXEC)) ...) { if (redir_err || errflag)
18400/// { if (!isset(INTERACTIVE)) exit(1); } }` — POSIX_BUILTINS makes
18401/// a failed exec redirect fatal in a non-interactive shell.
18402/// Returns Value::Status(0|1) for the trailing SetStatus.
18403pub const BUILTIN_EXEC_REDIR_DONE: u16 = 626;
18404/// Assignment-prefix epilogue for bare `exec` redirects
18405/// (`x=$(cmd) exec >file`). c:Src/exec.c:3969-3976 — nullexec==1
18406/// runs addvars THEN, without POSIX_BUILTINS, restores the params
18407/// (`save_params` / `restore_params`): the RHS side effects fire but
18408/// the values don't persist. With POSIX_BUILTINS the assignments
18409/// stick. Pops the BEGIN_INLINE_ENV frame either way.
18410pub const BUILTIN_EXEC_INLINE_ENV_DONE: u16 = 627;
18411
18412/// `< file` / `> file` with no command word (NULLCMD path).
18413/// Resolves NULLCMD (default "cat") / READNULLCMD (default "more")
18414/// at runtime per Src/exec.c:3386-3419, then dispatches that word
18415/// exactly as execcmd's fall-through does: shell function (c:3485)
18416/// → builtin (c:3489) → external. Argc is 1: the int (0 or 1) on the
18417/// stack indicates whether this is a single REDIR_READ redirect
18418/// (selects READNULLCMD when set + non-empty).
18419pub const BUILTIN_NULLCMD_EXEC: u16 = 607;
18420/// c:Src/exec.c:3395-3399 — the redirection-only command's refusal,
18421/// taken before its redirections open: no NULLCMD, or CSH_NULLCMD set,
18422/// is `zerr("redirection with no command")` with `lastval = 1` and
18423/// ERRFLAG_ERROR. argc=0; Bool(true) = go on to NULLCMD_EXEC.
18424pub const BUILTIN_NULLCMD_CHECK: u16 = 760;
18425/// Emitted right before BUILTIN_EXEC_DYNAMIC when the simple command has
18426/// redirections. No args. The VM has already opened them, so the dispatch
18427/// passes `execcmd_exec` an empty `redir` list; this tells its AUTOCD test
18428/// (c:Src/exec.c:3596 `(!redir || empty(redir))`) that there were some.
18429pub const BUILTIN_EXEC_DYNAMIC_REDIRS: u16 = 761;
18430/// Emitted right before a command word compiled to a builtin opcode: the
18431/// command runs in the current shell, so execcmd_exec's is_cursh arm
18432/// (c:Src/exec.c:3674-3682) marks the pipeline's job STAT_CURSH, plus
18433/// STAT_NOPRINT while it has no processes. No args; pushes Int(0).
18434pub const BUILTIN_MARK_CURSH: u16 = 762;
18435/// Opens the window in which the prefix assignments of `X=… cmd` expand.
18436/// argc=1: the command word ("" when it is an expansion). Closed by
18437/// BUILTIN_SEAL_INLINE_ENV. See PREFIX_ASSIGN_CMD.
18438pub const BUILTIN_PREFIX_ASSIGNS_START: u16 = 763;
18439/// `.` (dot) — alias of source/bin_dot but dispatches with the
18440/// literal name "." so the diagnostic prefix matches zsh's
18441/// (`zsh:.:1: …` vs source's `zsh:source:1: …`).
18442/// c:Src/builtin.c:9308 — `BUILTIN(".", BINF_PSPECIAL, bin_dot, …)`.
18443pub const BUILTIN_DOT: u16 = 608;
18444/// `logout` — fusevm maps this to BUILTIN_EXIT alongside `exit`/`bye`,
18445/// which drops the name and dispatches with BIN_EXIT funcid. zsh's
18446/// `logout` outside a login shell must emit "not login shell" + exit 1,
18447/// which only fires when bin_break sees BIN_LOGOUT funcid. Dedicated
18448/// opcode dispatches via BUILTINS table by literal name "logout".
18449pub const BUILTIN_LOGOUT: u16 = 610;
18450/// `BUILTIN_PARAM_SUBSTRING_EXPR` constant.
18451pub const BUILTIN_PARAM_SUBSTRING_EXPR: u16 = 337;
18452/// `BUILTIN_XTRACE_LINE` constant.
18453pub const BUILTIN_XTRACE_LINE: u16 = 338;
18454/// `BUILTIN_XTRACE_ARRAY_LINE` — xtrace an `arr=(...)` assignment from the
18455/// whole assembled `Value::Array` (see compile_zsh array-literal codegen).
18456pub const BUILTIN_XTRACE_ARRAY_LINE: u16 = 649;
18457/// `BUILTIN_MAKE_ARRAY_COUNTED` — like `Op::MakeArray(u16)` but the element
18458/// count is a runtime `Int` on the stack, so it is not capped at 65535. Used
18459/// by the array-literal codegen only when the literal has > u16::MAX elements
18460/// (e.g. a .zcompdump's ~103k-element `_comps=(...)`).
18461pub const BUILTIN_MAKE_ARRAY_COUNTED: u16 = 650;
18462/// `BUILTIN_ARGV_RFLATTEN` — pop one `Op::MakeArray`-packed argv bundle and
18463/// push it back as ONE recursively-flattened `Value::Array` of scalars. Emitted
18464/// by the simple-command codegen ONLY on the >255-arg overflow path: the
18465/// `Call`/`CallFunction`/`CallBuiltin` opcodes carry argc as a u8, so a command
18466/// with more than 255 args is packed into a single Array (dispatched with
18467/// argc=1) instead. But those call ops flatten their argv only ONE level, which
18468/// would stringify a nested Array (a brace/glob/`$arr` word contributes a
18469/// `Value::Array`). Pre-flattening here — same descent as
18470/// [`flatten_array_value`], the array-assignment path — makes the bundle flat
18471/// so the call op's single-level splat restores every positional arg. Bit
18472/// compsys: a completer's `_arguments <specs…>` with a large brace-form option
18473/// set (curl ships 59 `{-x,--long}` specs) dropped the long forms.
18474pub const BUILTIN_ARGV_RFLATTEN: u16 = 653;
18475/// `BUILTIN_ARRAY_JOIN_STAR` constant.
18476pub const BUILTIN_ARRAY_JOIN_STAR: u16 = 339;
18477/// `BUILTIN_SET_RAW_OPT` constant.
18478pub const BUILTIN_SET_RAW_OPT: u16 = 340;
18479
18480/// `time { compound; ... }` — wall-clock-time the sub-chunk and print
18481/// elapsed seconds. Stack: [sub_chunk_idx as Int]. Runs the sub-chunk
18482/// on the current VM (so positional/local state is shared) and prints
18483/// the timing summary to stderr in zsh's format. Pushes Status.
18484pub const BUILTIN_TIME_SUBLIST: u16 = 316;
18485
18486/// `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocation.
18487/// Stack: [path, varid, op_byte]. Opens `path` per `op_byte`, gets the
18488/// new fd (≥10 in zsh; we use libc::open with O_CLOEXEC bit cleared so
18489/// the inherited fd survives Command::new spawns), stores the fd number
18490/// as a string in `$varid`. Pushes Status (0 success, 1 error).
18491pub const BUILTIN_OPEN_NAMED_FD: u16 = 317;
18492
18493/// Word-segment concat that does cartesian-product distribution over
18494/// arrays. Stack: [lhs, rhs]. Used for RC_EXPAND_PARAM `${arr}` and
18495/// explicit-distribute forms (`${^arr}`, `${(@)…}`).
18496///
18497/// - both scalar: `Value::str(a + b)` (fast path, identical to Op::Concat)
18498/// - lhs Array, rhs scalar: `Value::Array([a + rhs for a in lhs])`
18499/// - lhs scalar, rhs Array: `Value::Array([lhs + b for b in rhs])`
18500/// - both Array: cartesian product `[a + b for a in lhs for b in rhs]`
18501pub const BUILTIN_CONCAT_DISTRIBUTE: u16 = 318;
18502
18503/// Forced-distribute concat — like `BUILTIN_CONCAT_DISTRIBUTE` but
18504/// always distributes cartesian regardless of the `rcexpandparam`
18505/// option. Emitted by the segments fast-path when an
18506/// `is_distribute_expansion` segment is present (`${^arr}`,
18507/// `${(@)arr}`, `${(s.…)arr}` etc.) per zsh: the source-level
18508/// distribution flag overrides the option default.
18509/// Direct port of Src/subst.c:1875 `case Hat: nojoin = 1` and the
18510/// `rcexpandparam` test bypass for the explicit-distribute flags.
18511pub const BUILTIN_CONCAT_DISTRIBUTE_FORCED: u16 = 522;
18512
18513/// Capture current `last_status` into the `TRY_BLOCK_ERROR` variable.
18514/// Emitted between the try block and the always block of `{ … } always
18515/// { … }` so the finally arm can read $TRY_BLOCK_ERROR.
18516pub const BUILTIN_SET_TRY_BLOCK_ERROR: u16 = 320;
18517/// `BUILTIN_RESTORE_TRY_BLOCK_STATUS` constant.
18518pub const BUILTIN_RESTORE_TRY_BLOCK_STATUS: u16 = 432;
18519/// `BUILTIN_BEGIN_INLINE_ENV` constant.
18520pub const BUILTIN_BEGIN_INLINE_ENV: u16 = 433;
18521/// `BUILTIN_END_INLINE_ENV` constant.
18522pub const BUILTIN_END_INLINE_ENV: u16 = 434;
18523/// Closes the current inline-env frame's save list. Emitted right
18524/// after the prefix assignments of `X=foo cmd` have committed and
18525/// before `cmd` dispatches, so assignments performed BY the command
18526/// are not recorded into (and therefore not reverted with) the frame.
18527/// c:Src/exec.c:4410 `save_params` snapshots only the parsed
18528/// WC_ASSIGN chain; the list is closed before the builtin/shell
18529/// function runs. Without the seal, `X=y . file` reverted every
18530/// global the sourced file assigned — which emptied git's
18531/// `git-completion.bash` option tables (`__git_log_common_options`
18532/// et al.) that `_git` sources via `GIT_SOURCING_ZSH_COMPLETION=y . …`.
18533pub const BUILTIN_SEAL_INLINE_ENV: u16 = 654;
18534
18535/// End-of-sublist `waitonejob` for a sublist that ran wholly in the
18536/// current shell. Emitted by `compile_zsh::compile_sublist` after each
18537/// element of the `&&`/`||` chain whose parse-time `cmplx` flag is set
18538/// (see `compile_zsh::sublist_elem_is_cmplx`) and whose top level is
18539/// NOT a multi-stage pipeline.
18540///
18541/// c:Src/exec.c:1489-1492 — `execlist` routes each sublist element on
18542/// the parse-time flag: `if (WC_SUBLIST_FLAGS(code) & WC_SUBLIST_SIMPLE)
18543/// execsimple(state); else execpline(state, code, ltype, ...);`. Only
18544/// the `execpline` arm builds a job, and `execpline` ends by calling
18545/// `waitonejob` on it.
18546///
18547/// c:Src/jobs.c:1748-1757 — `waitonejob(Job jn)`:
18548/// ```c
18549/// if (jn->procs || jn->auxprocs) zwaitjob(jn - jobtab, 0);
18550/// else { deletejob(jn, 0); pipestats[0] = lastval; numpipestats = 1; }
18551/// ```
18552/// A sublist that forked (a real multi-stage pipeline) takes the
18553/// `zwaitjob` arm, whose `storepipestats` (c:Src/jobs.c:420) publishes
18554/// the per-stage array — in zshrs that is `BUILTIN_RUN_PIPELINE`'s own
18555/// `set_array("pipestatus", ...)`. Every other cmplx sublist runs with
18556/// an empty proc list and takes the `else` arm, which is what this
18557/// builtin performs. Resolving which arm applies is a compile-time
18558/// decision in C (the parse-time flag) and is a compile-time decision
18559/// here too, so no marker is emitted for the pipeline case at all.
18560///
18561/// This is what makes a compound command publish `$pipestatus`:
18562/// `if ...; fi`, `for ...; done`, `case ... esac`, `while ...; done`,
18563/// `{ ... }`, `( ... )` and a bare command all reach `execpline` when
18564/// their body is cmplx, so zsh leaves `numpipestats == 1`. It also
18565/// makes the OUTER sublist win over an inner pipeline's array —
18566/// `if true; then true|false; fi` is `n=1 p=(1)`, not `(0 1)` — because
18567/// the outer procs-less job overwrites what the inner job stored.
18568///
18569/// Fires BEFORE the `!` negation (`emit_negate_status`): C applies
18570/// `WC_SUBLIST_NOT` inside `execpline` after the wait, so
18571/// `! [[ -z x ]]` records the PRE-negation status — `p=(1)` with `$?`
18572/// of 0.
18573pub const BUILTIN_SUBLIST_FINISH: u16 = 655;
18574
18575/// `[[ -o option ]]` — shell-option-set test. Stack: \[option_name\].
18576/// Normalizes the name (strip underscores, lowercase) and reads
18577/// `exec.options`. Pushes Bool.
18578pub const BUILTIN_OPTION_SET: u16 = 321;
18579/// Tri-state `[[ -o NAME ]]` — same lookup as BUILTIN_OPTION_SET
18580/// but returns a Value::Int (0=set, 1=unset, 3=invalid-name). The
18581/// 3-state code matches zsh's `[[ -o invalid ]]` exit (Src/cond.c
18582/// :502 `optison()`). Used by compile_cond's `-o` arm to skip the
18583/// generic bool→status conversion and preserve the invalid-name
18584/// signal in `$?`.
18585pub const BUILTIN_OPTION_CHECK_TRISTATE: u16 = 609;
18586
18587/// `${var:#pattern}` — array filter: remove elements matching `pattern`.
18588/// Stack: [name, pattern]. For scalar `var`, returns empty if it matches
18589/// the pattern, else the value. For array `var`, returns Array of
18590/// non-matching elements.
18591pub const BUILTIN_PARAM_FILTER: u16 = 322;
18592
18593/// `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()` —
18594/// subscripted-array assign with array-literal RHS. Stack:
18595/// [...elements, name, key]. Empty elements + single-int key `a[i]=()`
18596/// removes that element. Comma-key `a[i,j]=(...)` splices.
18597pub const BUILTIN_SET_SUBSCRIPT_RANGE: u16 = 323;
18598
18599/// `[[ -X file ]]` for unknown unary test op `-X`. Stack: \[op_name\].
18600/// Emits zsh's `unknown condition: -X` diagnostic to stderr and
18601/// pushes Bool(false). Without this, unknown conditions silently
18602/// returned false matching neither zsh's error format nor the
18603/// expected status code (zsh returns 2 for parse error).
18604
18605/// `[[ -t fd ]]` — fd-is-a-tty check. Stack: \[fd_string\].
18606/// Routes through libc::isatty. Pushes Bool.
18607///
18608/// ID 644 (unique, next free above the previous max of 643). This was
18609/// 325, which COLLIDED with BUILTIN_FILE_OLDER (also 325). The VM's
18610/// builtin table is last-registration-wins, and FILE_OLDER registered
18611/// after IS_TTY, so every `[[ -t fd ]]` silently dispatched to the
18612/// file-`-ot` handler: it compared the mtime of a file NAMED by the fd
18613/// string ("0", "1", …) — which never exists — so `[[ -t 0 ]]` was
18614/// always false. That broke interactive detection (`[[ -t 0 && -t 1 ]]`)
18615/// and any config gated on it. c:Src/cond.c:390 `return !isatty(...)`.
18616pub const BUILTIN_IS_TTY: u16 = 644;
18617/// Runtime rejection of a process substitution used inside a `[[ … ]]`
18618/// cond operand. c:Src/exec.c:4918/5040/5069 — `getoutputfile`/`getproc`
18619/// error `"process substitution %s cannot be used here"` when `thisjob ==
18620/// -1`, which is the case during cond evaluation. zshrs's THISJOB never
18621/// distinguishes that context at runtime, so the compiler emits this
18622/// builtin (gated on `in_cond_operand`) instead of the ProcessSubIn/Out
18623/// opcode. Pops the substitution text, zerrs, sets errflag (aborting the
18624/// statement → empty stdout, exit 1, matching zsh), returns empty.
18625pub const BUILTIN_PROCSUB_COND_ERROR: u16 = 645;
18626/// `${^arr}` cross-product concat — RC_EXPAND_PARAM forced ON by the `^` flag.
18627///
18628/// Distinct from BUILTIN_CONCAT_DISTRIBUTE_FORCED, which the other distribute
18629/// shapes (`${(@)a}`, `${(f)v}`, `${a[@]}`) share: those keep the word when the
18630/// array is EMPTY (`x${(@)a}y` → `xy`), but plan9 DELETES it
18631/// (c:Src/subst.c:4362-4365 `if (plan9) { uremnode(l, n); return n; }`), so
18632/// `x${^a}y` with `a=()` produces no word at all. One builtin cannot serve both
18633/// — the plan9-ness is known only at compile time, from the `^` flag itself.
18634/// Routes to `concat_plan9`, which already ports both c:4362's removal and the
18635/// c:4316-4350 cartesian emit, and is what the OPTION path
18636/// (`setopt rcexpandparam`) has always used.
18637pub const BUILTIN_CONCAT_PLAN9: u16 = 646;
18638/// `${^^arr}` concat — RC_EXPAND_PARAM forced OFF by the doubled flag
18639/// (c:Src/subst.c:2553-2555 `plan9 = 0`).
18640///
18641/// The mirror of BUILTIN_CONCAT_PLAN9. Needed because every other concat
18642/// builtin consults `plan9_active()` (the runtime OPTION) and so cross-products
18643/// anyway under `setopt rcexpandparam`, while `^^` must override the option:
18644/// setopt rcexpandparam; a=(a b c); print -rl -- ${^^a}.x
18645/// # zsh: `a`, `b`, `c.x` — spliced, NOT `a.x b.x c.x`
18646/// The override is computed in paramsubst but the distribution call is made
18647/// here, so — like the `^` flag — the only place that knows is the compiler.
18648/// Routes straight to `concat_splice`, C's non-plan9 join-first-and-last path
18649/// (c:4366-4437).
18650pub const BUILTIN_CONCAT_SPLICE_NOPLAN9: u16 = 647;
18651/// Atomic word assembler for a DQ word that MIXES a plan9 (`^`) segment with a
18652/// non-plan9 (splice/scalar) segment — e.g. `"${(@)^a}${(@)b}"`.
18653///
18654/// The per-pair concat fold (CONCAT_PLAN9 / CONCAT_SPLICE picked ONCE for the
18655/// whole word) cannot express a word where segment A distributes but segment B
18656/// splices: a single operator does full-cross OR first/last-splice, never both,
18657/// and it loses track of which trailing elements are still the "growing edge".
18658/// zsh (Src/subst.c:4316-4437) instead threads a growing edge through the whole
18659/// word — an element is "active" until a splice freezes all but the last.
18660///
18661/// This builtin ports that edge-tracking directly. Stack (bottom→top):
18662/// descriptor, seg0, seg1, …, seg(n-1) with argc = n + 1
18663/// where `descriptor` is an n-char string, one char per segment: `'1'` = plan9
18664/// (`^`), `'0'` = splice/scalar/literal. Each segment value is an Array (splat)
18665/// or scalar (1 element). Result is the assembled Array (or scalar / deleted).
18666pub const BUILTIN_WORD_ASSEMBLE_PLAN9: u16 = 652;
18667/// `break N`/`continue N` runtime-count validator (see registration).
18668pub const BUILTIN_BREAK_COUNT_VALIDATE: u16 = 648;
18669/// `[[ -r/-w/-x file ]]` via access(2) (doaccess) — see handler.
18670pub const BUILTIN_COND_ACCESS: u16 = 638;
18671
18672/// Evaluate a `[[ ]]` module/completion condition (`-prefix`/`-suffix`/
18673/// `-after`/`-between`). Stack (top-first): argc operand words, then the
18674/// operator word. Dispatches to `complete::eval_mod_cond`. Result pushed as
18675/// Bool (true = condition matched). Used by the `ZshCond::ModCond` compile arm.
18676pub const BUILTIN_COND_MOD: u16 = 651;
18677/// c:Src/cond.c:122-193 COND_MODI — an infix module condition `[[ a -op b ]]`
18678/// (Src/parse.c:2697-2701). Stack: left word, right word, operator word
18679/// (top). Looks the operator up as an infix condition, autoloading its
18680/// module (`getconddef(1, name + 1, 1)`, c:150); failing that, as a prefix
18681/// condition named by the left word (c:173-177). Bool result; an unknown
18682/// condition arms the status-2 carrier like BUILTIN_COND_MOD.
18683pub const BUILTIN_COND_MODI: u16 = 749;
18684/// c:Src/exec.c:3548-3579 — the `rm *` confirmation. Stack: the expanded
18685/// argv words of an `rm` (left in place), then a mask (top) of the words
18686/// whose source ends in an unquoted `*`. Sets ERRFLAG_ERROR when the user
18687/// declines (checkrmall returns 0), which aborts the command.
18688pub const BUILTIN_RM_STAR_CHECK: u16 = 750;
18689
18690/// `provenance` — report the lineage of a tracked parameter: where its
18691/// bytes entered the shell (command substitution, glob, heredoc, an
18692/// earlier assignment) and every bytecode-level op that touched them
18693/// since. Handler: `ShellExecutor::builtin_provenance`; engine:
18694/// `src/extensions/provenance.rs`. ID 661 is the first free slot above
18695/// the 653-660 block.
18696pub const BUILTIN_PROVENANCE: u16 = 661;
18697
18698/// PRINT_EXIT_VALUE report for one finished simple command. Direct port
18699/// of c:Src/exec.c:4308-4316 (`execcmd_exec`'s tail):
18700/// ```c
18701/// if (isset(PRINTEXITVALUE) && isset(SHINSTDIN) && lastval && !subsh)
18702/// fprintf(stderr, "zsh: exit %lld\n", lastval);
18703/// ```
18704/// The ported `execcmd_exec` carries the same code (exec.rs), but fusevm —
18705/// not that walker — is what actually runs a command, so the report never
18706/// fired. `compile_simple` emits this call right after the dispatch's
18707/// `Op::SetStatus` (both the builtin and the function/external arm), which
18708/// is exactly where the C line sits. Pushes Status(0), which the emit side
18709/// pops; `vm.last_status` is left untouched.
18710pub const BUILTIN_PRINT_EXIT_VALUE: u16 = 662;
18711
18712/// Update `$LINENO` to track the source line of the next statement.
18713/// Stack: \[n\] (the line number from `ZshPipe.lineno`). Direct port
18714/// of zsh's `lineno` global tracking (Src/input.c:330) — the
18715/// compiler emits one of these per top-level pipe so `$LINENO`
18716/// reflects the source position at runtime. ID 342 picked because
18717/// the previous `326` collided with `BUILTIN_HAS_STICKY` (the 325
18718/// collision between IS_TTY and FILE_OLDER has since been fixed by
18719/// moving IS_TTY to 644).
18720pub const BUILTIN_SET_LINENO: u16 = 342;
18721
18722/// Pop a scalar from the VM stack, run expand_glob on it, push the
18723/// result as Value::Array. Used by the segment-concat compile path
18724/// when var refs concatenate with glob meta literals (`$D/*`,
18725/// `${prefix}*`, etc.) — those skip the bridge's pathname-expansion
18726/// pass and would otherwise leak the glob meta to argv as a literal.
18727pub const BUILTIN_GLOB_EXPAND: u16 = 343;
18728
18729/// MULTIOS-gated glob expansion for redirect-target words
18730/// (c:Src/glob.c:2161-2167 xpandredir: "Globbing is only done for
18731/// multios."). Same stack shape as BUILTIN_GLOB_EXPAND; additionally
18732/// passes the word through literally when `unsetopt multios`.
18733// 624 is BUILTIN_COND_STRMATCH — the VM's builtin table is
18734// last-registration-wins, so a duplicate id silently shadows the
18735// earlier handler.
18736pub const BUILTIN_REDIR_GLOB_EXPAND: u16 = 628;
18737
18738/// Reset the default-word glob-pending carrier at the START of a word
18739/// whose source contains a glob metachar (so the flag never leaks from a
18740/// prior word/statement). Paired with BUILTIN_DEFAULT_WORD_GLOB.
18741pub const BUILTIN_DEFAULT_WORD_GLOB_RESET: u16 = 635;
18742
18743/// Filename-generate the ASSEMBLED word ONLY when the default/alternate
18744/// paramsubst arm took a SOURCE word carrying glob metachars
18745/// (subst::DEFAULT_WORD_GLOB_PENDING). A parameter VALUE never sets the
18746/// flag, so `x='*file'; ${x:-d}` stays literal while `${x:-*file}` /
18747/// `${x:-a*}bar` glob. Reads+clears the flag. c:Src/subst.c → globlist.
18748pub const BUILTIN_DEFAULT_WORD_GLOB: u16 = 636;
18749/// `BUILTIN_SET_LOOP_VAR` constant — for-loop variable binding via
18750/// `setloopvar` (Src/params.c:6362): a PM_NAMEREF loop var REBINDS
18751/// to each word (SETREFNAME + setscope) instead of assigning
18752/// through the chain. Returns Bool(false) when zerr fired
18753/// (read-only reference / invalid self reference) so the loop
18754/// driver aborts, mirroring C execfor's errflag check.
18755pub const BUILTIN_SET_LOOP_VAR: u16 = 629;
18756
18757/// Emitted once per typeset-family command, just before its first
18758/// `NAME=…` argument: from there on every word is one of C's postassigns
18759/// (c:Src/parse.c:1986-1989, c:2008-2050), globbed inside execcmd_exec's
18760/// builtin branch (c:Src/exec.c:4167-4285) where a NOMATCH leaves `lastval`
18761/// alone. Sets aside a NOMATCH the earlier command words raised
18762/// (c:3755-3763, `lastval = 1`).
18763///
18764/// Arg: the number of postassign words (dispatch leaves them out of `$_`).
18765pub const BUILTIN_TYPESET_POSTASSIGNS_BEGIN: u16 = 684;
18766/// Emitted after the last word of a command that emitted
18767/// BUILTIN_TYPESET_POSTASSIGNS_BEGIN: moves a postassign NOMATCH off the
18768/// per-command cell (so argument collection does not store status 1) and
18769/// puts back the command-word NOMATCH BEGIN set aside.
18770pub const BUILTIN_TYPESET_POSTASSIGNS_END: u16 = 685;
18771/// Emitted right before the dispatch of a command whose command word was the
18772/// enabled, unquoted TYPESET reserved word (C's WC_TYPESET, c:Src/parse.c:1931-1932).
18773/// c:Src/exec.c:3088-3096 — `if (type == WC_TYPESET && (hn =
18774/// builtintab->getnode2(builtintab, cmdarg)))`: "If reserved word for typeset
18775/// command found (and so enabled), use regardless of whether builtin is
18776/// enabled as we share the implementation." `getnode2` ignores DISABLED, so
18777/// `disable typeset` does not stop the reserved-word form. No args; sets the
18778/// carrier dispatch_builtin consumes before its disabled-builtin test.
18779pub const BUILTIN_TYPESET_RESWD: u16 = 686;
18780/// The `[` spelling of the test builtin (c:Src/builtin.c:7231 `bin_test`,
18781/// funcid BIN_BRACKET). fusevm's name table maps `[` and `test` to one
18782/// BUILTIN_TEST slot, which cannot tell them apart when the closing `]` is
18783/// missing; compile_zsh emits this id when the command word is `[`, so
18784/// bin_test sees BIN_BRACKET and reports "']' expected".
18785pub const BUILTIN_TEST_BRACKET: u16 = 690;
18786/// Emitted right before the dispatch of a command that carried the `-`
18787/// precommand modifier (c:Src/builtin.c:42 `BIN_PREFIX("-", BINF_DASH)`,
18788/// accumulated into `cflags` at c:Src/exec.c:3246). No args; sets the
18789/// EXEC_DASH carrier. c:Src/exec.c:772-776 — an external command then gets
18790/// argv[0] `-name`; a function or builtin runs unchanged.
18791pub const BUILTIN_EXEC_DASH: u16 = 687;
18792/// Emitted right before the external dispatch of the SIMPLE command at the
18793/// top of a chunk that a driver may run in a child forked for it (`cmd &`, a
18794/// pipeline stage, a coproc — compile_zsh.rs `forked_simple_exec`). No args.
18795/// When that chunk's BUILTIN_EXECCMD_FORKED_LEVEL did find the fork, it sets
18796/// the EXEC_CARRIER_FORKED bit: c:Src/exec.c:3063 `last1 = forked = 1` and
18797/// c:4369 `execute(args, cflags, use_defpath)` — the child becomes the command.
18798pub const BUILTIN_EXEC_FORKED_SIMPLE: u16 = 741;
18799/// Emitted right before the external/function dispatch of a simple command,
18800/// with the command's job text (c:Src/exec.c:3536-3538 `getjobtext`). Sets
18801/// the JOB_TEXT carrier the foreground wait reads when it reports a job that
18802/// died of a signal (exec_jobs::foreground_job_report).
18803pub const BUILTIN_JOB_TEXT: u16 = 744;
18804/// c:Src/exec.c:3755-3757 `globlist(args, 0)` over the WHOLE argument list,
18805/// after prefork has expanded every word (c:3357-3359). Stack: the N
18806/// expanded word values in source order, then two Int bitmasks: bit i of the
18807/// first marks word i as an assembled value to glob like BUILTIN_GLOB_EXPAND,
18808/// bit i of the second marks it as a BUILTIN_EXPAND_TEXT mode-10 result that
18809/// still carries its glob tokens; argc = N + 2 (N <= 63). Globs the
18810/// eligible words in order and stops at the first error (c:Src/subst.c:494
18811/// `for (…; !errflag && node; …)`), leaving later words as they are. Pushes
18812/// one value per word (an Array where a glob expanded).
18813pub const BUILTIN_GLOBLIST: u16 = 688;
18814/// c:Src/subst.c:142-147 — `if (!(node = stringsubst(…))) return;`: prefork
18815/// stops at a NULL paramsubst (a failed `(e)` re-lex), and c:118-121 stops it
18816/// before the next word once errflag is set. No args; pushes
18817/// Bool(PREFORK_CUT || errflag & ERRFLAG_ERROR) without clearing either. The compiler checks it after each
18818/// expansion segment of a word (true: keep the prefix assembled so far, plus
18819/// a literal `"` for an opening DQ, and skip the word's remaining segments,
18820/// c:1878) and after each word (true: push the later words as their
18821/// unexpanded source text).
18822pub const BUILTIN_PREFORK_CUT_CHECK: u16 = 689;
18823
18824/// EXTEND step of typeset paren-init packing. Pops `argc` values:
18825/// [base, e1, …, eN] — base is either the opener (`name=(` /
18826/// `name+=(`) or a previous EXTEND result. Pushes base with
18827/// `\u{1f}` + element appended per element. Array values SPLICE
18828/// their items as separate elements (`typeset b=( x $arr )` splat);
18829/// an empty Array contributes nothing (unquoted-empty elision).
18830/// CallBuiltin's argc is u8, so the compiler emits one EXTEND per
18831/// ≤200-element chunk — p10k's 408-element `__p9k_colors=( … )`
18832/// overflowed a single-shot pack (argc wrapped mod 256 and the
18833/// stack spilled into the arg list: "not an identifier: 173…").
18834/// BUILTIN_TYPESET_PAREN_CLOSE appends the final `\u{1f})`,
18835/// yielding the exact REJOIN_SEP-delimited one-arg form
18836/// bin_typeset's single-arg splitter consumes (builtin.rs ~4891,
18837/// empties preserved, leading/trailing sentinel-empties trimmed
18838/// once). One arg in → one arg out: bin_typeset's multi-arg rejoin
18839/// (paren-depth scan, unsafe on EXPANDED paren-literal elements
18840/// like p10k's `')' ''`) never runs.
18841pub const BUILTIN_TYPESET_PAREN_PACK: u16 = 630;
18842
18843/// CLOSE step — pops the EXTEND chain's result, pushes it with
18844/// `\u{1f})` appended. See BUILTIN_TYPESET_PAREN_PACK.
18845pub const BUILTIN_TYPESET_PAREN_CLOSE: u16 = 631;
18846
18847/// Shared body of BUILTIN_GLOB_EXPAND / BUILTIN_REDIR_GLOB_EXPAND.
18848/// c:Src/glob.c:1872 — `zglob` runs per-word in the argv pipeline.
18849/// When the upstream EXPAND_TEXT returned an array (e.g. `${a:e}`
18850/// splat → ["txt","md"]), glob each element separately, not a
18851/// sepjoin'd scalar. `skip_glob` short-circuits to a literal
18852/// pass-through (noglob, or a redirect target under
18853/// `unsetopt multios`).
18854fn glob_expand_word_value(raw: Value, skip_glob: bool) -> Value {
18855 let patterns: Vec<String> = match raw {
18856 Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
18857 other => vec![other.to_str()],
18858 };
18859 // c:Src/glob.c:1230 — zglob returns before globbing when
18860 // `unset(EXECOPT)`, so NO_EXEC never reports a NOMATCH.
18861 if skip_glob || !crate::ported::zsh_h::isset(crate::ported::zsh_h::EXECOPT) {
18862 return if patterns.is_empty() {
18863 Value::array(Vec::new())
18864 } else if patterns.len() == 1 {
18865 Value::str(patterns.into_iter().next().unwrap())
18866 } else {
18867 Value::array(patterns.into_iter().map(Value::str).collect())
18868 };
18869 }
18870 let mut out: Vec<String> = Vec::with_capacity(patterns.len());
18871 for pattern in &patterns {
18872 // c:Src/subst.c — filename generation runs `filesub` (tilde/`=`
18873 // expansion) BEFORE globbing. A `~`/`=` reaching this word-glob op
18874 // comes from `${~spec}` / GLOB_SUBST marking a substituted VALUE:
18875 // literal and quoted `~` words are filesub'd (or skip glob) upstream
18876 // and never arrive here. filesubstr matches the Tilde TOKEN, so
18877 // shtokenize first (raw `~`->Tilde; already-Tilde `${~a[@]}` results
18878 // pass through), run filesub, then untokenize surviving glob metas
18879 // for expand_glob. Gated on `~`/`=` (raw or token) so ordinary
18880 // substituted words skip the roundtrip. Fixes `${~x}` x="~/foo".
18881 // c:178 — prefork's second-pass filesub only runs when
18882 // `unset(SHFILEEXPANSION)` (see the GLOB_SUBST arm above).
18883 let filesubbed = if (pattern.contains('~')
18884 || pattern.contains('=')
18885 || pattern.contains(crate::ported::zsh_h::Tilde)
18886 || pattern.contains(crate::ported::zsh_h::Equals))
18887 && !crate::ported::zsh_h::isset(crate::ported::zsh_h::SHFILEEXPANSION)
18888 {
18889 let mut tok = pattern.clone();
18890 crate::ported::glob::shtokenize(&mut tok);
18891 crate::ported::lex::untokenize(&crate::ported::subst::filesub(&tok, 0))
18892 } else {
18893 pattern.clone()
18894 };
18895 let matches = with_executor(|exec| exec.expand_glob(&filesubbed));
18896 if matches.is_empty() {
18897 // c:1872 nullglob — drop this word, don't emit a hole
18898 continue;
18899 }
18900 for m in matches {
18901 out.push(m);
18902 }
18903 }
18904 if out.is_empty() {
18905 return Value::array(Vec::new());
18906 }
18907 if patterns.len() == 1 && out.len() == 1 && out[0] == patterns[0] {
18908 // No real matches; expand_glob returned the literal. Pass
18909 // back as scalar so downstream ops don't re-flatten.
18910 return Value::str(out.into_iter().next().unwrap());
18911 }
18912 Value::array(out.into_iter().map(Value::str).collect())
18913}
18914
18915/// Push a `CmdState` token onto the command-context stack. Direct
18916/// port of zsh's `cmdpush(int cmdtok)` (Src/prompt.c:1623). The
18917/// stack is consulted by `%_` in PS4/prompt expansion to produce
18918/// the cumulative control-flow-context labels (`if`, `then`,
18919/// `cmdand`, `cmdor`, `cmdsubst`, …) that `zsh -x` xtrace shows
18920/// in the trace prefix. Compile_zsh emits push/pop pairs around
18921/// each compound command (if/while/[[…]]/((…))/$(…) etc.).
18922/// Token is a `CmdState as u8`.
18923pub const BUILTIN_CMD_PUSH: u16 = 344;
18924
18925/// Pop the top of the command-context stack. Direct port of zsh's
18926/// `cmdpop(void)` (Src/prompt.c:1631).
18927pub const BUILTIN_CMD_POP: u16 = 345;
18928
18929/// Emit an xtrace line built from the top `argc` values on the VM
18930/// stack, peeked WITHOUT consuming. Used to trace simple commands
18931/// AFTER expansion, so `echo for $i` shows as `echo for a` / `echo
18932/// for b`. Direct port of Src/exec.c:2055-2066.
18933pub const BUILTIN_XTRACE_ARGS: u16 = 346;
18934
18935/// Trace one assignment: emits `name=<quoted-value> ` (no newline)
18936/// to xtrerr if XTRACE is on. Coalesces with subsequent
18937/// XTRACE_ASSIGN / XTRACE_ARGS calls onto the SAME line via the
18938/// `XTRACE_DONE_PS4` flag so `a=1 b=2 echo $a $b` produces:
18939/// `<PS4>a=1 b=2 echo 1 2\n`
18940/// matching C zsh's `execcmd_exec` body (Src/exec.c:2517-2582):
18941/// xtr = isset(XTRACE);
18942/// if (xtr) { printprompt4(); doneps4 = 1; }
18943/// while (assign) {
18944/// if (xtr) fprintf(xtrerr, "%s=", name);
18945/// ... eval value ...
18946/// if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
18947/// }
18948///
18949/// Stack contract on entry: [..., name, value]. Both peeked, NOT
18950/// consumed (the matching SET_VAR call pops them after). argc = 2.
18951pub const BUILTIN_XTRACE_ASSIGN: u16 = 525;
18952
18953/// Emit a trailing `\n` + flush iff XTRACE is on AND PS4 was
18954/// emitted by an earlier XTRACE_ASSIGN this line. Used at the end
18955/// of compile_simple's assignment-only path so the trace line gets
18956/// terminated. Mirrors C's exec.c:3397-3399 (the assign-only return
18957/// path through execcmd_exec which does `fputc('\n', xtrerr);
18958/// fflush(xtrerr)`).
18959///
18960/// Stack: untouched. argc = 0.
18961pub const BUILTIN_XTRACE_NEWLINE: u16 = 526;
18962
18963/// Push the live `xtrace` opt-state as `Value::Int(1)` (on) or
18964/// `Value::Int(0)` (off). Used by `compile_cond` to gate the
18965/// trace-string-building block on xtrace state at runtime — without
18966/// this the trace path's `compile_word_str` on each operand re-
18967/// evaluates side-effectful expressions (`$((i++))`) once for the
18968/// trace string and once for the actual condition, doubling the
18969/// effective increment. Bug #159 in docs/BUGS.md.
18970///
18971/// Stack: pushes Int(0|1). argc = 0.
18972pub const BUILTIN_XTRACE_IS_ON: u16 = 611;
18973
18974/// Reset the `DONETRAP` flag at the start of each top-level statement
18975/// (sublist boundary). Mirrors C `Src/exec.c:1455` — `donetrap = 0`.
18976/// Stack: untouched. argc = 0. Bug #303 in docs/BUGS.md.
18977pub const BUILTIN_DONETRAP_RESET: u16 = 612;
18978
18979/// c:Src/exec.c:1700-1706 — the `exiting` epilogue of execlist, run at the end
18980/// of a forked compound command (see compile_zsh `emit_exiting_exit_trap`):
18981/// `if (exiting && sigtrapped[SIGEXIT]) { errflag = 0; dotrap(SIGEXIT);
18982/// sigtrapped[SIGEXIT] = 0; errflag = eflag; }`. Stack: pushes Int(0). argc = 0.
18983pub const BUILTIN_EXITING_EXIT_TRAP: u16 = 729;
18984
18985/// Head of a pipeline-stage / `&` / coproc chunk: `execcmd_forked_level`.
18986/// Stack: pops Int(is_subsh), pushes Int(0). argc = 1.
18987pub const BUILTIN_EXECCMD_FORKED_LEVEL: u16 = 740;
18988/// Emitted right before the WithRedirectsBegin of the command at the top of
18989/// a chunk a driver may run in a child forked for it, when that command has
18990/// redirections. No args. When the chunk's BUILTIN_EXECCMD_FORKED_LEVEL found
18991/// the fork, the scope it opens saves no fds (c:Src/exec.c:2478
18992/// `if (!forked && …)`); see redir_scope_forked.
18993pub const BUILTIN_FORKED_REDIRS: u16 = 745;
18994/// c:Src/exec.c:1748 — execpline's `child_block();` ahead of a synchronous
18995/// pipeline, and its job frame (c:1656-1666 `pj = thisjob; thisjob = newjob =
18996/// initjob()`). Stack: \[depth\], the pipeline's nesting depth in its chunk.
18997/// Paired with BUILTIN_EXECPLINE_CHILD_UNBLOCK.
18998pub const BUILTIN_EXECPLINE_CHILD_BLOCK: u16 = 746;
18999/// c:Src/exec.c:2017 — execpline's `child_unblock();` once the pipeline
19000/// has run (and been waited for), after the job frame closes (the procs-less
19001/// job is deleted, `thisjob = pj`). Stack: \[depth\]. Unconditional, as in C: a
19002/// pipeline nested inside another one's command (a function body, `eval`)
19003/// leaves SIGCHLD unblocked for the rest of the outer command.
19004pub const BUILTIN_EXECPLINE_CHILD_UNBLOCK: u16 = 747;
19005
19006/// Take a copy of stderr for this simple command's xtrace output before
19007/// its redirections apply (c:Src/exec.c:3765-3773). Released when the
19008/// redirect scope it precedes ends (c:4445-4452).
19009///
19010/// Stack: untouched. argc = 0.
19011pub const BUILTIN_XTRERR_COPY: u16 = 730;
19012
19013/// c:Src/exec.c:1417 (`int oldnoerrexit = noerrexit;`) + c:1536-1538
19014/// (`if (isandor || isnot) noerrexit |= NOERREXIT_EXIT|NOERREXIT_RETURN;`).
19015/// Saves the current `noerrexit` on a per-thread stack and ORs in the two
19016/// suppression bits for the duration of one `&&`/`||` chain operand (or a
19017/// `!`-negated command). Stack: untouched. argc = 0.
19018pub const BUILTIN_NOERREXIT_SUPPRESS: u16 = 665;
19019
19020/// c:Src/exec.c:1621 / c:1626 — `noerrexit = oldnoerrexit;`. Pops the
19021/// matching save pushed by [`BUILTIN_NOERREXIT_SUPPRESS`].
19022/// Stack: untouched. argc = 0.
19023pub const BUILTIN_NOERREXIT_RESTORE: u16 = 666;
19024
19025/// c:Src/loop.c:144 + :201 (execfor), :480 (execwhile), :536 (execrepeat) —
19026/// `lastval = 1;` on the `errflag` exit from a loop body. Emitted only on the
19027/// fatal-abort path of a compiled for/while/until/repeat; `execselect` has no
19028/// such assignment in C and never emits it.
19029/// Stack: pushes Int(0). argc = 0.
19030pub const BUILTIN_LOOP_ERRFLAG_STATUS: u16 = 667;
19031
19032/// `BUILTIN_LOOP_ERRFLAG_BREAK` — the loop's own post-body errflag guard,
19033/// `c:Src/loop.c:479-482` (execwhile) / `:198-203` (execfor) / `:535-538`
19034/// (execrepeat) / `:329-330` (execselect). Returns 1 when the loop must
19035/// BREAK. argc = 0.
19036///
19037/// This is NOT the same test as the per-statement prologue. C's list loop
19038/// (`c:Src/exec.c:1443`, `while (… && !breaks && !retflag && !errflag)`)
19039/// ENDS the list on errflag; the prologue gate only SKIPS one statement.
19040/// Outside a loop those are indistinguishable, which is why the gap survived:
19041/// inside one, every statement is skipped, the loop's own exit test still
19042/// runs, and a residual `$? == 0` means it NEVER terminates.
19043pub const BUILTIN_LOOP_ERRFLAG_BREAK: u16 = 671;
19044
19045// ───────────────────────────────────────────────────────────────────────
19046// Compiled-arithmetic runtime ops.
19047//
19048// `ArithCompiler` lowers `(( … ))` and `$(( … ))` straight to fusevm ops.
19049// Six operators have no faithful fusevm op, so they lower to these
19050// instead — one indirect call per OCCURRENCE of the operator, against
19051// the whole-expression re-lex `BUILTIN_ARITH_EVAL` pays on EVERY
19052// evaluation.
19053// ───────────────────────────────────────────────────────────────────────
19054
19055/// `a / b` — c:Src/math.c:1237-1257. Integer division when both operands
19056/// are integers (`Op::Div` is unconditionally float), `notzero`'s
19057/// "division by zero" on an integer zero divisor, and the c:1252
19058/// `b == -1` special case that avoids the `INT_MIN / -1` trap. argc = 2.
19059pub const BUILTIN_ARITH_DIV: u16 = 672;
19060
19061/// `a % b` — c:Src/math.c:1258-1272. Same zero-divisor diagnostic as
19062/// DIV (`Op::Mod` silently yields 0), `fmod` on floats, and c:1269's
19063/// `b == -1 → 0`. argc = 2.
19064pub const BUILTIN_ARITH_MOD: u16 = 673;
19065
19066/// `a ** b` — c:Src/math.c:1335-1358. Integer power by repeated
19067/// multiplication when both operands are integers (`Op::Pow` is
19068/// `powf`, which returns a float and loses precision above 2^53),
19069/// float promotion on a negative exponent (c:1337-1342), and the
19070/// c:1352 "imaginary power" error. argc = 2.
19071pub const BUILTIN_ARITH_POW: u16 = 674;
19072
19073/// `a && b` in its NON-short-circuiting form — the `&&=` compound
19074/// assignment (c:Src/math.c:40 DANDEQ), where both sides are already on
19075/// the stack. Result is integer 1/0, c:1200's `BOOL` type. argc = 2.
19076pub const BUILTIN_ARITH_LOGAND: u16 = 675;
19077
19078/// `a || b` for `||=` — c:Src/math.c:41 DOREQ. argc = 2.
19079pub const BUILTIN_ARITH_LOGOR: u16 = 676;
19080
19081/// `a ^^ b` — c:Src/math.c:26 DXOR, logical xor. No C equivalent and no
19082/// fusevm op; both operands always evaluate. argc = 2.
19083pub const BUILTIN_ARITH_LOGXOR: u16 = 677;
19084
19085/// Write one arithmetic result back to a parameter — c:Src/math.c:972
19086/// `setmathvar`, reached from c:1364-1372 when the operator was
19087/// `OP_E2`/`OP_E2IO`. Pops \[name, value\].
19088///
19089/// Distinct from `BUILTIN_SET_VAR`, which stores a shell scalar: only
19090/// `setmathvar` applies the PM_INTEGER / FORCEFLOAT coercions and the
19091/// `lastbase` display base that make `typeset -i x; (( x = 3.7 ))`
19092/// store 3 rather than "3.7". argc = 2.
19093pub const BUILTIN_SET_MATH_VAR: u16 = 678;
19094
19095/// Finish a compiled `(( … ))` math command. Pops the numeric result,
19096/// records it as `lastmathval` (c:Src/math.c:1500, which
19097/// `callmathfunc`'s MFF_USERFUNC branch returns at c:1115), and sets
19098/// the command status from it: c:Src/exec.c:5267 `val == 0`, i.e. 0
19099/// when the expression is non-zero and 1 when it is zero — or 2, with
19100/// `errflag` cleared, when the expression raised a math error.
19101///
19102/// Replaces the `LoadConst("0") / StrEq / …six status ops… /
19103/// BUILTIN_ARITH_CMD_FINISH` tail the string-evaluator path emits, and
19104/// keeps the numeric result numeric instead of round-tripping it
19105/// through its printed form. argc = 1.
19106pub const BUILTIN_ARITH_CMD_FINISH_VAL: u16 = 679;
19107
19108/// `lastbase = <n>` for the COMPILED arithmetic path. Pops \[base\], sets the
19109/// math layer's input base, returns it.
19110///
19111/// c:Src/math.c:367 `mathevall` opens every evaluation with `lastbase = -1;`
19112/// and `lexconstant` (c:462) sets the base of each literal it reads. The
19113/// interpreted evaluator is that code; the compiled path never enters it, so
19114/// nothing reset the base between evaluations and `assignsparam`'s
19115/// `pm.base == 0 ? lastbase` inheritance (c:Src/params.c:2801) handed the next
19116/// freshly created integer whatever base the LAST evaluation had left behind:
19117///
19118/// ```text
19119/// typeset -i iv="2#101" # sets lastbase = 2, for `iv`
19120/// (( c += 3 )) # compiled: no literal, no reset
19121/// print -r -- "$c" # zsh: 3 zshrs: 2#11
19122/// ```
19123///
19124/// Emitted once at the head of every compiled expression with `-1`, and again
19125/// by [`crate::arith_compiler`] with the base of a `0x`/`0b`/`0o` literal it
19126/// lexes — which is what `lexconstant` does, at the point the literal is read.
19127/// argc = 1.
19128pub const BUILTIN_ARITH_LASTBASE: u16 = 683;
19129
19130/// The whole quiet-case statement prologue in one call.
19131///
19132/// c:Src/exec.c:1451-1502 runs four things before every sublist: the
19133/// `lineno` write (c:1451), `donetrap = 0` (c:1455), the
19134/// DEBUG_BEFORE_CMD block (c:1476-1502) and the `noexec` gate (c:1390).
19135/// In C those are inline field writes and flag tests inside `execlist`;
19136/// zshrs emitted them as four separate `CallBuiltin`s plus the constant
19137/// load for `$ZSH_DEBUG_CMD`'s text, on EVERY statement, whether or not
19138/// a DEBUG trap existed to read any of it.
19139///
19140/// This op does the two unconditional writes and then reports whether
19141/// the other two blocks can be skipped entirely: `Bool(true)` when no
19142/// DEBUG trap is installed in EITHER registry (`sigtrapped[SIGDEBUG]`
19143/// or `traps_table["DEBUG"]` — the same pair `BUILTIN_DEBUG_TRAP`
19144/// consults), `noexec` is unset, and `errflag` is clear. Those are
19145/// exactly the conditions under which `BUILTIN_DEBUG_TRAP` returns
19146/// `Int(0)` and `BUILTIN_NOEXEC_CHECK` returns `Int(0)`, so a `true`
19147/// answer means the skipped ops were provably no-ops. On `false` the
19148/// compiler's slow path runs both blocks unchanged.
19149///
19150/// argc = 1: the line number, or -1 for a construct that records none
19151/// (c:Src/parse.c:2112's braceless short-function body), which C
19152/// signals with `code == 0`.
19153pub const BUILTIN_STMT_PROLOGUE_FAST: u16 = 680;
19154
19155/// `BUILTIN_WORD_DEFER_EMPTIES` constant — opens a word whose surrounding
19156/// literals are attached by the compiler's own concat opcodes, not by
19157/// `paramsubst`.
19158///
19159/// c:Src/subst.c:183-186 removes an assembled list node whose text is
19160/// empty, and `stringsubst` has already glued the word's prefix and
19161/// suffix onto the array elements by then (c:4386 / c:4414, or every
19162/// element under plan9 at c:4341). `src/ported/subst.rs`'s port of that
19163/// removal is correctly ordered when the caller hands it the whole word,
19164/// but the compiled fast paths hand it only the `${…}` body. This marker
19165/// tells it so (`PARAMSUBST_AFFIXES_DEFERRED`); the matching end-of-word
19166/// `BUILTIN_WORD_ELIDE_EMPTY` / `BUILTIN_ARRAY_DROP_EMPTY` then performs
19167/// the removal on the finished word, which is C's order.
19168///
19169/// argc 0 opens the word, argc 1 closes it; the pushed status is popped by
19170/// the emitter in both halves. Emitted only where a drop will run.
19171pub const BUILTIN_WORD_DEFER_EMPTIES: u16 = 681;
19172
19173/// c:`Src/subst.c:3030-3033` + c:3912-3930 for the BARE-name `${=NAME}` /
19174/// `${==NAME}` / `$=NAME` spelling — everything up to, but not including,
19175/// c:3932's `sepsplit`, which the compile site emits as `BUILTIN_FORCE_SPLIT`.
19176///
19177/// argc is two bits:
19178/// bit 0 the `spbreak`/`nojoin` pair the spelling sets — set for `${=NAME}`
19179/// (c:2566-2570 `spbreak = 2; if (nojoin < 2) nojoin = !(ifs && *ifs);`)
19180/// and clear for `${==NAME}` (c:2561-2565 `spbreak = 0; if (nojoin < 2)
19181/// nojoin = 0;`).
19182/// bit 1 `qt` — the word is double-quoted, which c:3033 needs. The runtime
19183/// `in_dq_context` counter cannot answer it: a whole-word `"${=a}"`
19184/// compiles to this call with no `BUILTIN_EXPAND_TEXT` wrapper around
19185/// it, so the counter is 0 there. Same dual check the bare `$NAME`
19186/// fast path makes (`compile_zsh.rs`, `word_is_single_dq_span`).
19187///
19188/// The `ssub` callers do NOT come here: c:4226 joins and stops for them, and
19189/// the compile site keeps them on `BUILTIN_GET_VAR_DQ`.
19190pub const BUILTIN_BARE_SPBREAK: u16 = 682;
19191
19192thread_local! {
19193 /// c:Src/exec.c:1417 — C keeps `oldnoerrexit` as an execlist-local
19194 /// automatic, so the save/restore pairs nest with the C call stack.
19195 /// zshrs's compiler emits the two halves as separate ops, so the saved
19196 /// values need an explicit stack. Thread-local because `noerrexit`
19197 /// itself is per-shell state and worker threads run their own lists.
19198 static NOERREXIT_SAVES: std::cell::RefCell<Vec<i32>> =
19199 const { std::cell::RefCell::new(Vec::new()) };
19200}
19201
19202/// `[[ -z X ]]` / `[[ -n X ]]` operand-empty test that honours zsh's
19203/// array-splice semantics. C zsh evaluates `[[ -z X ]]` per
19204/// `Src/cond.c:347` (case 'z'): `s` is the SCALAR operand passed
19205/// through `cond_str`'s singsub. For `"${arr[@]}"` zsh expands per
19206/// `Src/subst.c:multsub` which yields each element as its own word
19207/// list node; cond.c then sees the joined-or-single-element form.
19208///
19209/// The compile-side `-z` shortcut at `compile_zsh.rs:5371` used
19210/// `Op::StringLen` which calls `Value::len` — for `Value::Array`
19211/// that returns ARRAY LENGTH, not string length. `b=("")` produced
19212/// `Value::Array([""])` → `len = 1` → `-z` returned false.
19213///
19214/// This builtin pops one `Value` and pushes `1` (empty) or `0`
19215/// (non-empty) per the cond context:
19216/// - `Value::Str(s)` → s.is_empty()
19217/// - `Value::Array([])` → true (zero words → vacuous-empty)
19218/// - `Value::Array([s])` → s.is_empty() (single-word case)
19219/// - `Value::Array([_; n>=2])` → false (multiple non-empty
19220/// words; zsh would raise "unknown condition" but the
19221/// observable test result is non-empty/false)
19222///
19223/// Companion to BUILTIN_COND_STR_NONEMPTY (#185 in docs/BUGS.md).
19224pub const BUILTIN_COND_STR_EMPTY: u16 = 613;
19225
19226/// `[[ -n X ]]` operand-non-empty test (logical complement of
19227/// BUILTIN_COND_STR_EMPTY).
19228pub const BUILTIN_COND_STR_NONEMPTY: u16 = 614;
19229
19230/// `N<<<"str"` / `N<<HERE` — here-string or here-document redirect to
19231/// an explicit fd. Pops `[content, fd, from_heredoc]` from the stack;
19232/// creates a temp file, writes the body, reopens read-only, dup2's to
19233/// `fd`, unlinks the temp path so it disappears on close. Mirrors C
19234/// `Src/exec.c:4655 getherestr` + `addfd(forked, save, mfds, fn->fd1,
19235/// fil, 0, NULL)` at c:3766-3780. Bug #205 in docs/BUGS.md.
19236///
19237/// `from_heredoc` is C's `REDIRF_FROM_HEREDOC` (set at
19238/// c:Src/parse.c:2970-2971): 0 appends the trailing newline of
19239/// c:4671-4672, 1 passes the body through byte-for-byte.
19240///
19241/// The fd itself is parked in the enclosing redirect scope via
19242/// `save_fd_for_scope` unless a bare `exec` is being applied
19243/// (c:3978-3986 nullexec==1), so `cmd N<<E` is undone at the end of
19244/// the command while `exec N<<E` persists.
19245///
19246/// Stack: pushes `Value::Status(0)` on success, `Status(1)` on
19247/// failure. argc = 3.
19248pub const BUILTIN_EXEC_HERESTR_FD: u16 = 615;
19249
19250/// MULTIOS write/append fan-out for `cmd > a > b` / `cmd > a >> b`
19251/// style redirects (Bug #36 in docs/BUGS.md). zsh's MULTIOS option
19252/// (Src/exec.c:2418 `mfds[fd1]` check + addfd splice) creates a
19253/// pipe at fd1, spawns an internal "tee" process that copies
19254/// stdin → every collected target file. Without this, only the
19255/// LAST redirect target survives because each dup2 overwrites the
19256/// previous binding.
19257///
19258/// Stack layout (pushed by compile_zsh's compile_redirs coalescing
19259/// pass): `[target_1, op_byte_1, target_2, op_byte_2, …, target_N,
19260/// op_byte_N, fd]`. Pops 2N+1 elements; `argc = 2*N + 1`. A target
19261/// may be a Value::Array of glob matches (spliced into one member
19262/// per match, c:Src/glob.c:2195-2203); an op may be DUP_WRITE for a
19263/// numeric `>&N` member (c:Src/exec.c:3895-3917).
19264///
19265/// Runtime (MULTIOS set):
19266/// 1. Seed the member list with `dup(1)` when this command's
19267/// stdout is the pipeline output (c:Src/exec.c:3722-3724).
19268/// 2. Open/dup all targets per their op_byte in redirect order
19269/// (WRITE truncate + noclobber gate / APPEND / DUP_WRITE live
19270/// dup); the first member replaces the fd (c:2448-2450).
19271/// 3. Save `dup(fd)` onto the active redirect_scope_stack so
19272/// `host_redirect_scope_end` restores the original fd.
19273/// 4. Create a pipe; spawn a thread that reads from the pipe
19274/// read-end and writes every chunk to every opened target.
19275/// 5. dup2 the pipe write-end onto `fd` so the command's writes
19276/// go through the splitter.
19277/// 6. Track `(pipe_write_fd, JoinHandle)` so scope-end can close
19278/// the pipe (draining the thread) and join before restoring.
19279///
19280/// MULTIOS unset (c:2418 `unset(MULTIOS)` replace arm): each entry
19281/// is applied as a plain sequential replace via host_apply_redirect
19282/// — every file still opened/truncated, last one wins.
19283pub const BUILTIN_MULTIOS_REDIRECT: u16 = 617;
19284
19285/// MULTIOS input-side concatenation for `cmd < a < b` shapes
19286/// (Bug #36 input arm). C zsh's `Src/exec.c:2418` mfds dispatch
19287/// also covers the read direction — when multiple `<` redirects
19288/// target the same fd, mfds[fd] grows and addfd splices a
19289/// concatenating cat into the pipe.
19290///
19291/// Stack layout (mirrors the write side): `[source_1, op_1,
19292/// source_2, op_2, …, source_N, op_N, fd]`. Pops 2N + 1 elements
19293/// (argc = 2N + 1). op is READ for file sources, DUP_READ for
19294/// numeric `<&N` members; a source may be a Value::Array of glob
19295/// matches (spliced, c:Src/glob.c:2195-2203).
19296///
19297/// Runtime (MULTIOS set):
19298/// 1. Open/dup every source in redirect order; first member
19299/// replaces the fd (c:Src/exec.c:2448-2450).
19300/// 2. Save `dup(fd)` onto the redirect_scope_stack.
19301/// 3. Create a pipe; spawn a thread that reads each source in
19302/// order and writes every chunk to the pipe write-end. Close
19303/// write-end when done so the consumer sees EOF.
19304/// 4. dup2 the pipe read-end onto `fd`.
19305/// 5. Track the JoinHandle so scope-end joins (no fd-close needed
19306/// here — the producer thread closes its own pipe write-end
19307/// on exit).
19308///
19309/// MULTIOS unset: sequential replace via host_apply_redirect — last
19310/// source wins (c:2418).
19311pub const BUILTIN_MULTIOS_READ: u16 = 618;
19312
19313/// BUILTIN_MULTIOS_READ member op for a here-string body: the runtime writes
19314/// the body plus a trailing newline to a temp file (c:Src/exec.c:4671-4672,
19315/// `if (!(fn->flags & REDIRF_FROM_HEREDOC)) t[len++] = '\n';`). fusevm's
19316/// `redirect_op` values stop at 8.
19317pub const MULTIOS_OP_HERESTR_BODY: u8 = 9;
19318/// BUILTIN_MULTIOS_READ member op for a here-document body: written verbatim
19319/// (REDIRF_FROM_HEREDOC, c:Src/parse.c:2970-2971).
19320pub const MULTIOS_OP_HEREDOC_BODY: u8 = 10;
19321
19322/// c:Src/exec.c:4673-4679 getherestr — `gettempfile`, `write_loop`, `close`,
19323/// reopen read-only, `unlink`: a read descriptor on the body, as
19324/// BUILTIN_EXEC_HERESTR_FD builds for a single here-string.
19325fn multios_body_fd(body: &str, append_newline: bool) -> std::io::Result<i32> {
19326 let text = if append_newline { format!("{}\n", body) } else { body.to_string() };
19327 let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
19328 let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
19329 if write_fd < 0 {
19330 return Err(std::io::Error::last_os_error());
19331 }
19332 let bytes = text.as_bytes();
19333 let mut off = 0;
19334 while off < bytes.len() {
19335 let n = unsafe {
19336 libc::write(write_fd, bytes[off..].as_ptr() as *const libc::c_void, bytes.len() - off)
19337 };
19338 if n <= 0 {
19339 let err = std::io::Error::last_os_error();
19340 unsafe {
19341 libc::close(write_fd);
19342 libc::unlink(tmpl.as_ptr() as *const libc::c_char);
19343 }
19344 return Err(err);
19345 }
19346 off += n as usize;
19347 }
19348 unsafe { libc::close(write_fd) }; // c:4676
19349 let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
19350 let err = std::io::Error::last_os_error();
19351 unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) }; // c:4678
19352 if read_fd < 0 {
19353 Err(err)
19354 } else {
19355 Ok(read_fd)
19356 }
19357}
19358
19359/// c:Src/exec.c:3775-3777 + c:2447-2480 addfd — when `fd` is 0 and the
19360/// top redirect scope is the pipeline stage command's own list, take the
19361/// pipeline input as the input multio's first member: returns a private
19362/// dup of the pipe (above the script's fd range). One-shot per scope. With
19363/// MULTIOS unset C's addfd replaces the pipe instead (c:2418), so the seed is
19364/// dropped.
19365fn take_pipe_input_seed(exec: &ShellExecutor, fd: i32) -> Option<i32> {
19366 if fd != 0
19367 || PIPE_INPUT_SCOPE.with(|c| c.get()) != Some(exec.redirect_scope_stack.len())
19368 {
19369 return None;
19370 }
19371 PIPE_INPUT_SCOPE.with(|c| c.set(None));
19372 if !opt_state_get("multios").unwrap_or(true) {
19373 return None;
19374 }
19375 let dup = unsafe { libc::dup(0) };
19376 (dup >= 0).then(|| crate::ported::utils::movefd(dup)) // c:2458 `fdN = movefd(fd1)`
19377}
19378
19379/// The concatenating half of an input multio (c:Src/exec.c:2447-2480): a
19380/// pipe onto `fd` fed by a thread that reads every member in order. The
19381/// members are closed by the thread; the join is tracked on the top multios
19382/// scope. Returns false when the pipe cannot be created (members closed).
19383fn multios_read_concat(exec: &mut ShellExecutor, fd: i32, source_fds: Vec<i32>) -> bool {
19384 let (read_end, write_end) = match os_pipe::pipe() {
19385 Ok(p) => p,
19386 Err(_) => {
19387 for f in &source_fds {
19388 unsafe {
19389 libc::close(*f);
19390 }
19391 }
19392 return false;
19393 }
19394 };
19395 // dup the pipe read-end onto fd before spawning the
19396 // producer; close the original read_end so the consumer
19397 // (reading via fd) is the sole reference until scope-end.
19398 let read_dup = unsafe { libc::dup(AsRawFd::as_raw_fd(&read_end)) };
19399 drop(read_end);
19400 if read_dup < 0 {
19401 for f in &source_fds {
19402 unsafe {
19403 libc::close(*f);
19404 }
19405 }
19406 return false;
19407 }
19408 unsafe {
19409 libc::dup2(read_dup, fd);
19410 libc::close(read_dup);
19411 }
19412 // macOS raises a write's SIGPIPE on the process, not the writing
19413 // thread, so the thread mask below does not cover it there; mark the
19414 // write end itself instead.
19415 #[cfg(target_os = "macos")]
19416 unsafe {
19417 // <sys/fcntl.h>: `#define F_SETNOSIGPIPE 73` (absent from the libc crate).
19418 const F_SETNOSIGPIPE: libc::c_int = 73;
19419 libc::fcntl(AsRawFd::as_raw_fd(&write_end), F_SETNOSIGPIPE, 1);
19420 }
19421 // Spawn the producer.
19422 let handle = std::thread::spawn(move || {
19423 // !!! WARNING: RUST-ONLY !!! C's multio concatenator is a forked
19424 // child (c:Src/exec.c:2472 `zfork`), so a reader that stops early
19425 // (`read x`) SIGPIPEs only that child. This producer is a thread of
19426 // the shell: block SIGPIPE here so the write fails with EPIPE
19427 // instead of killing the shell. A write-raised SIGPIPE is directed at
19428 // the writing thread, so the mask covers it.
19429 unsafe {
19430 let mut set: libc::sigset_t = std::mem::zeroed();
19431 libc::sigemptyset(&mut set);
19432 libc::sigaddset(&mut set, libc::SIGPIPE);
19433 libc::pthread_sigmask(libc::SIG_BLOCK, &set, std::ptr::null_mut());
19434 }
19435 let mut w = write_end;
19436 let mut buf = [0u8; 8192];
19437 for sfd in source_fds {
19438 loop {
19439 let n = unsafe { libc::read(sfd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
19440 if n <= 0 {
19441 break;
19442 }
19443 let n = n as usize;
19444 if std::io::Write::write_all(&mut w, &buf[..n]).is_err() {
19445 break;
19446 }
19447 }
19448 // zclose, not close: movefd marked the member FDT_INTERNAL
19449 // (c:Src/utils.c:2007-2010), and zclose clears that entry.
19450 let _ = crate::ported::utils::zclose(sfd);
19451 }
19452 // Closing w (the write_end) at scope drop signals EOF
19453 // to the consumer.
19454 });
19455 // Track using a closed-write sentinel — the producer
19456 // owns write_end so we just need to join. Use -1 fd
19457 // marker meaning "no fd to close".
19458 if let Some(top) = exec.multios_scope_stack.last_mut() {
19459 top.push((-1, handle));
19460 } else {
19461 let _ = handle.join();
19462 }
19463 true
19464}
19465
19466/// One member of an input multio applied as a plain replacement (the
19467/// NO_MULTIOS and single-member paths): a here-document / here-string body
19468/// goes through `multios_body_fd` and addfd's save + dup2 (c:2421-2443);
19469/// every other op keeps `host_apply_redirect`.
19470fn multios_apply_member(exec: &mut ShellExecutor, fd: i32, op_byte: u8, source: &str) {
19471 if op_byte != MULTIOS_OP_HERESTR_BODY && op_byte != MULTIOS_OP_HEREDOC_BODY {
19472 exec.host_apply_redirect(fd as u8, op_byte, source);
19473 return;
19474 }
19475 exec.save_fd_for_scope(fd);
19476 match multios_body_fd(source, op_byte == MULTIOS_OP_HERESTR_BODY) {
19477 Ok(read_fd) => {
19478 if read_fd != fd {
19479 unsafe {
19480 libc::dup2(read_fd, fd);
19481 libc::close(read_fd);
19482 }
19483 }
19484 }
19485 Err(e) => {
19486 // c:Src/exec.c:3828 — `zwarn("can't create temp file for here document: %e", errno);`
19487 crate::ported::utils::zwarn(&format!(
19488 "can't create temp file for here document: {}",
19489 crate::ported::utils::zsh_errno_msg(e.raw_os_error().unwrap_or(0))
19490 ));
19491 exec.redirect_failed = true;
19492 }
19493 }
19494}
19495
19496/// Toggle `ShellExecutor::exec_redirs_permanent`. Emitted by
19497/// compile_zsh's bare-`exec`-with-redirects arm tightly around each
19498/// `Op::Redirect`: `LoadInt(1); CallBuiltin; …Redirect…; LoadInt(0);
19499/// CallBuiltin`. While set, `host_apply_redirect` skips pushing the
19500/// saved fd into the enclosing redirect scope, making the fd change
19501/// permanent.
19502///
19503/// c:Src/exec.c:3978-3986 — nullexec==1 (`exec` carrying only
19504/// redirections): "If nullexec is 1 we specifically *don't* restore
19505/// the original fd's before returning" — the per-execcmd `save[]`
19506/// dups are closed unrestored. An ENCLOSING group's own saves are a
19507/// different execcmd's `save[]` and still restore (verified:
19508/// `{ exec 2>/dev/null; } 2>&1; ls /nope` prints the ls error in zsh).
19509pub const BUILTIN_EXEC_PERM_REDIRS: u16 = 619;
19510
19511/// Set `ShellExecutor::pipe_output_pending`. Emitted by compile_pipe
19512/// at the head of a NON-LAST pipeline-stage sub-chunk when that
19513/// stage's top-level command carries redirects (`Simple` with redirs
19514/// or `Redirected` compound). The forked stage child runs the chunk
19515/// with stdout already dup2'd onto the pipe; the first
19516/// `host_redirect_scope_begin` (the stage command's own redirect
19517/// list) consumes the flag into `pipe_output_scope`, enabling the
19518/// MULTIOS stream-split for fd-1 write redirects in that list.
19519///
19520/// c:Src/exec.c:3722-3724 — `addfd(forked, save, mfds, 1, output, 1,
19521/// NULL)` registers the pipe in mfds[1] in the SAME execcmd that
19522/// walks the stage command's redirect list; mfds is per-execcmd, so
19523/// nested body commands (`{ echo a > f; } | cat`) never see it.
19524pub const BUILTIN_PIPE_OUTPUT_MARK: u16 = 620;
19525
19526/// Install the pipeline stage's parked fds onto 0/1.
19527///
19528/// c:Src/exec.c:3720-3724 — `addfd(forked, save, mfds, 0, input, 0,
19529/// NULL)` / `addfd(..., 1, output, 1, NULL)`. Runs after prefork
19530/// (c:3304) and globlist (c:3702) have expanded the stage's argument
19531/// words, which is why a `$(...)` in those words reads the shell's
19532/// original stdin rather than the pipe. Emitted by
19533/// `compile_zsh.rs::emit_stage_fds_install`; the fds themselves are
19534/// parked by `BUILTIN_RUN_PIPELINE`.
19535pub const BUILTIN_PIPE_FDS_INSTALL: u16 = 642;
19536
19537/// Magic-equals prefork for a single arg word of a
19538/// `BINF_MAGICEQUALS` builtin head (`alias`). Direct port of
19539/// c:Src/exec.c:3298-3304 — `esprefork = PREFORK_TYPESET;
19540/// prefork(args, esprefork, NULL)` runs on the argv BEFORE the addfd
19541/// redirect loop at c:3720, so an expansion zerr (`alias bad===` →
19542/// equalsubstr "= not found" at Src/subst.c:726) prints to the
19543/// command's UN-redirected stderr. argc=1: pops the just-pushed
19544/// word value, runs shtokenize → prefork(PREFORK_TYPESET) →
19545/// untokenize on it (each element for Array splices), pushes the
19546/// result back. Emitted by compile_simple per arg word when the
19547/// dispatch head is `alias`; BUILTIN_ALIAS itself no longer
19548/// preforks (it would double-fire the diagnostic).
19549pub const BUILTIN_MAGIC_EQUALS_PREFORK: u16 = 621;
19550
19551/// Bare (unbraced) `$name[idx]` subscript — same dispatch as
19552/// `BUILTIN_ARRAY_INDEX` while KSHARRAYS is unset, but under
19553/// KSHARRAYS the unbraced form does NOT subscript (c:Src/subst.c:
19554/// 2800-2802 + 2867): `$name` expands bare and `[idx]` stays literal
19555/// trailing text that undergoes filename generation. Operands:
19556/// [name, idx, suffix, quoted].
19557pub const BUILTIN_ARRAY_INDEX_UNBRACED: u16 = 622;
19558
19559/// Assignment-only simple-command exit status. Direct port of
19560/// `lv = (errflag ? errflag : cmdoutval)` (c:Src/exec.c:1322,
19561/// execsimple's WC_ASSIGN arm) / `if (errflag) lastval = 1; else
19562/// lastval = cmdoutval;` (c:Src/exec.c:3393-3396, execcmd_exec's
19563/// no-command-word varspc path; redir variant at c:3977). Pops
19564/// [had_cmd_subst]; cmdoutval is the live vm.last_status when a
19565/// `$()` ran in any RHS of the chain, 0 otherwise. Writes the
19566/// canonical LASTVAL (C's single `lastval` global) so the
19567/// non-interactive errflag abort exits with this value per
19568/// Src/init.c:234. Caller pairs with SetStatus.
19569pub const BUILTIN_ASSIGN_ONLY_STATUS: u16 = 623;
19570
19571/// c:Src/exec.c addvars — `if (!pm) { lastval = 1; if (!cmdoutval)
19572/// cmdoutval = 1; }`. Set by BUILTIN_SET_VAR on assignsparam
19573/// failure, consumed by BUILTIN_ASSIGN_ONLY_STATUS so the
19574/// assignment-only command reports status 1. Process-global like
19575/// C's `cmdoutval` (function bodies may run on a different thread
19576/// than the opcode that reads the status back).
19577pub static ASSIGN_FAILED_FLAG: std::sync::atomic::AtomicBool =
19578 std::sync::atomic::AtomicBool::new(false);
19579
19580/// Precommand walk ahead of the redirections, for a `builtin`/`command`/
19581/// `exec` head with redirections (c:Src/exec.c:3104-3402 run before c:3720).
19582/// Operands: [prefix, words…]; returns the words minus the prefix as one
19583/// array. Pairs with BUILTIN_PRECMD_CHECK_OK.
19584pub const BUILTIN_PRECMD_CHECK: u16 = 742;
19585
19586/// Whether the last BUILTIN_PRECMD_CHECK let the command run. argc=0;
19587/// pushes Bool. Resets the flag to true.
19588pub const BUILTIN_PRECMD_CHECK_OK: u16 = 743;
19589
19590thread_local! {
19591 /// BUILTIN_PRECMD_CHECK's verdict, read once by BUILTIN_PRECMD_CHECK_OK
19592 /// (the two ops run back to back on the same VM thread).
19593 static PRECMD_CHECK_PASSED: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
19594}
19595
19596/// The error exits of C's precommand walk (c:Src/exec.c:3104-3402) for
19597/// `full` = [prefix, words…]; `false` when the walk ended the command.
19598///
19599/// * A shell function named like the prefix shadows it (c:3483-3487): the
19600/// redirections belong to the function call.
19601/// * c:3250-3290 — `exec`'s option errors: the walk `zerr`s and sets
19602/// errflag itself.
19603/// * c:3395-3402 — the walk consumed every word and redirections remain:
19604/// `zerr("redirection with no command")`.
19605///
19606/// `builtin NAME` naming no builtin (c:3490) stays with BUILTIN_BUILTIN: its
19607/// notion of "a builtin" also covers the zshrs extension and native-command
19608/// registries, which only that handler consults.
19609fn precmd_walk_ok(full: &[String]) -> bool {
19610 use std::sync::atomic::Ordering::Relaxed;
19611 let Some(prefix) = full.first() else {
19612 return true;
19613 };
19614 let has_fn = with_executor(|exec| {
19615 exec.functions_compiled.contains_key(prefix.as_str()) || exec.function_exists(prefix)
19616 });
19617 if has_fn {
19618 return true;
19619 }
19620 let before = crate::ported::utils::errflag.load(Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR;
19621 let walk = crate::ported::exec::execcmd_compile_head(full, crate::ported::zsh_h::WC_SIMPLE);
19622 if before == 0
19623 && (crate::ported::utils::errflag.load(Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR) != 0
19624 {
19625 return false; // c:3258/c:3276/c:3285 `zerr(...); … goto done`
19626 }
19627 if walk.is_empty_command && !walk.has_command_vv {
19628 crate::ported::utils::zerr("redirection with no command"); // c:3397
19629 return false;
19630 }
19631 // c:3488-3499 — a word reached through `builtin` is looked up in
19632 // builtintab here, before c:3786 applies the redirections, so
19633 // `builtin nosuch 2>/dev/null` still reports on the original stderr and
19634 // opens nothing.
19635 if walk.precmd_skip > 0
19636 && crate::lex::untokenize(&walk.preargs[walk.precmd_skip - 1]) == "builtin"
19637 {
19638 let cmdarg = crate::lex::untokenize(&walk.preargs[walk.precmd_skip]);
19639 if !builtin_prefix_finds(&cmdarg) {
19640 // c:3491 — `zwarn("no such builtin: %s", cmdarg);`
19641 crate::ported::utils::zwarn(&format!("no such builtin: {}", cmdarg));
19642 return false; // c:3492 `lastval = 1;` … `return;`
19643 }
19644 }
19645 true
19646}
19647
19648/// `redirection with no command` parse-time error for bare
19649/// `builtin 2>&1` / `command < file` / `exec >&-` precmd-keyword
19650/// shapes with a redirect but no following command. Direct port
19651/// of `Src/exec.c:3342 zerr("redirection with no command")`.
19652/// argc=0; pushes Value::Status(1).
19653pub const BUILTIN_REDIR_NO_CMD: u16 = 616;
19654
19655/// GLOB_SUBST guard for `[[ x == $pat ]]` pattern RHS coming from
19656/// parameter / command substitution. C-zsh's `[[ == ]]` semantics
19657/// (Src/options.c GLOB_SUBST default OFF + Src/cond.c:552
19658/// `cond_match` + Src/pattern.c patcompile tokenization-based
19659/// meta detection) treat chars from substitution as LITERAL
19660/// unless GLOB_SUBST is on. The Rust patcompile accepts both
19661/// tokenized and raw-ASCII meta chars, losing the distinction,
19662/// so `pat="h*"; [[ hello == $pat ]]` matched in zshrs but not
19663/// in zsh. Bug #116 in docs/BUGS.md.
19664///
19665/// Compile-time signal: emitted by `compile_cond_expr` ONLY when
19666/// the RHS contains `$` or backtick. Runtime checks the live
19667/// option state. If GLOB_SUBST is OFF, the popped string has
19668/// its glob metachars escaped with `\` so the downstream StrMatch
19669/// → patcompile treats them as literals. If GLOB_SUBST is ON,
19670/// the value passes through unchanged so `setopt glob_subst`
19671/// restores zsh's pattern-on-expansion behavior.
19672///
19673/// Stack: pops one string, pushes the (possibly escaped) result.
19674/// argc = 1.
19675pub const BUILTIN_GLOB_SUBST_GUARD: u16 = 528;
19676
19677/// `${~spec}` / `$~spec` pattern-data guard — the `strcatsub`
19678/// `if (glbsub) shtokenize(dest)` step (c:Src/subst.c:822/830) applied
19679/// to a value that is about to become a `[[ … == pat ]]` RHS or a
19680/// `case` arm pattern.
19681///
19682/// `BUILTIN_GLOB_SUBST_GUARD` covers the option-driven leg (GLOB_SUBST
19683/// off → escape the value's metas). It cannot cover the FLAG leg,
19684/// because `${~spec}` forces the substitution's metachars ACTIVE and
19685/// the compiler therefore emits no guard at all — the value reached
19686/// `patcompile` as raw bytes, which made a DATA backslash
19687/// indistinguishable from a SOURCE-level quote. c:Src/glob.c:3651
19688/// leaves a backslash before a non-`ztokens` char in the string as
19689/// ordinary data (`p='a\ b'; [[ 'a b' == ${~p} ]]` must NOT match,
19690/// `[[ 'a\ b' == ${~p} ]]` must), so the value has to be respelled in
19691/// the normalizer's literal-backslash form before it is compiled.
19692/// docs/BUGS.md #1090, globsubst leg.
19693///
19694/// Stack: pops one string, pushes the respelled result. argc = 1.
19695pub const BUILTIN_PAT_DATA_BACKSLASH: u16 = 668;
19696/// Stage an ALREADY-EXPANDED here-document body as pending stdin,
19697/// verbatim — no trailing newline appended.
19698///
19699/// c:Src/exec.c:4671-4672 — `getherestr` appends the newline only when
19700/// the here-string did NOT come from a here-document:
19701/// if (!(fn->flags & REDIRF_FROM_HEREDOC))
19702/// t[len++] = '\n';
19703/// The quoted form already had a non-appending sink (`Op::HereDoc`),
19704/// but the UNQUOTED form has to run `BUILTIN_EXPAND_TEXT` first, and
19705/// the only stack-consuming sink available afterwards was
19706/// `Op::HereString` — which appends unconditionally, because `<<<`
19707/// genuinely must. The lowering compensated with
19708/// `trim_end_matches('\n')`, and strip-all-then-append-one is lossy in
19709/// both directions: it ADDED a newline to a body that ended without one
19710/// (`cat <<EOF` + `hello` with no final newline printed `hello\n` where
19711/// zsh prints `hello`), and COLLAPSED N trailing newlines to one
19712/// (`hello\n\n\n` printed as `hello\n`). argc = 1.
19713pub const BUILTIN_HEREDOC_BODY_SINK: u16 = 669;
19714
19715/// `BUILTIN_WORD_ELIDE_EMPTY` — prefork's empty-word removal
19716/// (c:Src/subst.c:180-187) for a word that is EXACTLY one unquoted
19717/// `$NAME`. Emitted only by that compile fast path, because C's test is
19718/// post-assembly and only the word SHAPE makes it decidable early.
19719/// argc = 1.
19720pub const BUILTIN_WORD_ELIDE_EMPTY: u16 = 670;
19721
19722
19723/// Coerce a string parameter value to a math number (Int or Float)
19724/// for arithmetic-context reads, mirroring C-zsh's `getmathparam`
19725/// (Src/math.c:337). When the variable holds a string like "hello"
19726/// that isn't numeric, C falls back to recursively evaluating the
19727/// raw string as an arith expression; if that fails too, returns 0.
19728///
19729/// Used by the ArithCompiler pre-load path so `(( y = x ))` with
19730/// `x="hello"` reads `x` as integer 0, then assigns y as integer 0
19731/// — matching zsh's behaviour. The previous Rust port used
19732/// BUILTIN_GET_VAR which returned the raw string "hello"; the
19733/// ArithCompiler stored it verbatim in y's slot, and the post-sync
19734/// BUILTIN_SET_VAR wrote y="hello" as scalar instead of y=0 as
19735/// integer. Bug #118 in docs/BUGS.md.
19736///
19737/// Stack: pops `name` (string), pushes coerced numeric Value.
19738/// argc = 1.
19739pub const BUILTIN_GET_MATH_VAR: u16 = 529;
19740
19741/// GLOB_SUBST runtime gate for words containing parameter / command
19742/// substitution. C-zsh's `prefork` (Src/subst.c) runs `shtokenize`
19743/// on the substituted value when `GLOB_SUBST` is set, making the
19744/// substituted chars eligible for filename generation. With the
19745/// option off, substituted chars stay literal.
19746///
19747/// The Rust port's compile_zsh emits `compile_word_str` for words
19748/// like `/tmp/X/$pat`, which returns the post-expansion string but
19749/// never runs glob expansion (no path here triggers
19750/// BUILTIN_GLOB_EXPAND). Bug #119 in docs/BUGS.md: with `setopt
19751/// glob_subst`, `for f in /tmp/X/$pat` (pat="*.txt") never matched
19752/// `*.txt` files.
19753///
19754/// This opcode wraps the substitution result and dispatches at
19755/// runtime: when GLOB_SUBST is OFF, return unchanged; when ON,
19756/// pass the value through `expand_glob` so glob metas become
19757/// active. Emitted by `compile_for_words` (and similar sites)
19758/// after WORD_SPLIT for words with unquoted expansion.
19759///
19760/// Stack: pops a Value (Str or Array of Str), pushes the glob-
19761/// expanded result (still Str or Array depending on input shape).
19762/// argc = 1.
19763pub const BUILTIN_GLOB_SUBST_EXPAND: u16 = 530;
19764/// `BUILTIN_ASSOC_HAS_KEY` constant — `${(k)assoc[name]}` key-existence
19765/// query. Returns the key text on hit, empty string on miss. Bug #145.
19766pub const BUILTIN_ASSOC_HAS_KEY: u16 = 531;
19767/// `BUILTIN_ARRAY_DROP_EMPTY` constant — filter empty elements from
19768/// an Array on the stack. Used by `for x in $@` / `for x in $*`
19769/// unquoted forms. Bug #166.
19770pub const BUILTIN_ARRAY_DROP_EMPTY: u16 = 532;
19771/// `BUILTIN_QUOTED_STAR_ONE_WORD` — normalize the result of a QUOTED
19772/// `"$*"` / `"${*}"` expansion to EXACTLY ONE word.
19773///
19774/// c:Src/subst.c:3032 — the quoted (`qt`) branch of paramsubst ends in
19775/// `val = sepjoin(aval, sep, 1)`, a plain string join. Joining the
19776/// EMPTY positional list yields `""`, so `"$*"` with no positionals is
19777/// one empty word, exactly like `"$empty"` — which is why
19778/// `set --; printf '%d|%s|%d\n' $# "$*" 7` prints `0||7` in zsh, bash,
19779/// dash and ksh alike.
19780///
19781/// zshrs routes `"$*"` through `BUILTIN_EXPAND_TEXT` mode 1, whose
19782/// `multsub` returns a ZERO-node list for the empty case (correct for
19783/// `"$@"`, which really does vanish) and the bridge turns that into
19784/// `Value::Array(vec![])` — so the word was elided and printf saw one
19785/// argument fewer. This op restores the join's single-word shape at the
19786/// one call site that knows the splat was `*` and not `@`.
19787///
19788/// Stack: pops the expansion result, pushes `Value::str("")` when it
19789/// was an empty Array, and the value unchanged otherwise. argc = 1.
19790pub const BUILTIN_QUOTED_STAR_ONE_WORD: u16 = 663;
19791/// `BUILTIN_KSH_FUNSUB` — zsh NOFORK command substitution
19792/// (`Src/subst.c:1904-2100`), which also covers the ksh93 funsub
19793/// `${ list; }` and mksh valsub `${| list; }`: a command substitution that
19794/// runs in the CURRENT shell environment rather than a subshell.
19795///
19796/// Three forms, selected by the character right after `${`
19797/// (c:Src/subst.c:1924/1930):
19798/// * blank → `${ cmd }`, value is the body's STDOUT. c:2026-2029 scopes
19799/// it under `.zsh.cmdsubst`; c:2035-2044 captures via a temp-file
19800/// redirect so the body still runs in the current shell.
19801/// * `|` → `${| cmd }`, value is `$REPLY`, which is LOCAL to the body
19802/// (c:2018-2024 `createparam("REPLY", PM_LOCAL|PM_UNSET|PM_HIDE)`).
19803/// * `{VAR}` → `${{VAR} cmd }`, value is `$VAR`, NOT localised, and an
19804/// array stays an array (c:2082-2083 re-enters the parameter path).
19805///
19806/// ksh(1), Command Substitution: "${ command;} … the command is executed
19807/// in the current shell environment", and the value is the standard output
19808/// with trailing newlines removed (zsh strips ONE newline unquoted and
19809/// none quoted — c:1908 `trim = (!EMULATION(EMULATE_ZSH)) ? 2 : !qt`).
19810/// mksh(1)'s valsub matches zsh's `${| … }` exactly.
19811///
19812/// Stack (bottom→top): body, rplyvar, kind, qt — kind 0 = stdout capture,
19813/// 1 = REPLY form, 2 = named-variable form; qt = 1 when the word was
19814/// double-quoted. argc = 4. Pushes the resulting string, or an Array when
19815/// the named variable holds one / when the unquoted result is word-split.
19816pub const BUILTIN_KSH_FUNSUB: u16 = 664;
19817/// `BUILTIN_QUOTEDZPUTS` constant — run top-of-stack value through
19818/// `crate::ported::utils::quotedzputs` and push the quoted result.
19819/// Used by the cond xtrace path so non-printable bytes (e.g.
19820/// `$'\C-[OP'` expanded ESC+OP) get re-wrapped in `$'…'` form for
19821/// the trace prefix line, matching zsh's `Src/exec.c` cond trace
19822/// which calls `quotedzputs(operand, xtrerr)` on each side. Bug
19823/// surfaced when `[[ -n $'\C-[OP' ]]` traced as `[[ -n OP ]]`
19824/// (raw bytes leaked through the terminal) vs zsh's
19825/// `[[ -n $'\C-[OP' ]]` source-form preservation.
19826pub const BUILTIN_QUOTEDZPUTS: u16 = 533;
19827/// `BUILTIN_QUOTE_TOKENIZED_OUTPUT` — port of
19828/// `crate::ported::exec::quote_tokenized_output` (Src/exec.c:2114)
19829/// applied to top-of-stack scalar. Used by cond xtrace for the RHS
19830/// of pattern-context comparisons (`=` / `==` / `!=`) where C zsh
19831/// emits the SOURCE form: untokenize lexer tokens (Star → `*`,
19832/// Inpar → `(`, …) and backslash-escape special chars, but
19833/// preserve literal ASCII unchanged. Distinct from quotedzputs
19834/// which wraps the whole string in `'…'` / `$'…'` based on
19835/// non-printability — that's wrong for `[[ x = a* ]]` which must
19836/// render as `[[ x = a* ]]`, not `'a*'`.
19837pub const BUILTIN_QUOTE_TOKENIZED_OUTPUT: u16 = 534;
19838
19839/// Bridge into subst_port::substitute_brace_array for nested forms
19840/// that need to PRESERVE array shape across the expand_string
19841/// boundary. Stack: `[content_string]`. Returns Value::Array of the
19842/// per-element words. Used by the compile path for
19843/// `${(@)<nested>...##pat}` shapes — the standard substitute_brace
19844/// returns String which collapses array→scalar; this builtin
19845/// preserves the multi-word output via paramsubst's third return
19846/// (`nodes` vec, the C source's `aval` thread).
19847pub const BUILTIN_BRIDGE_BRACE_ARRAY: u16 = 347;
19848
19849/// Word-segment concat with FIRST/LAST sticking. Stack: [lhs, rhs].
19850/// Used for default unquoted splice forms (`${arr[@]}`, `$@`, `$*`)
19851/// where prefix sticks to first element only and suffix to last only.
19852///
19853/// Distribution table:
19854/// - both scalar: `Value::str(a + b)` (fast path)
19855/// - lhs scalar, rhs Array(b₀..bₙ): `Value::Array([lhs+b₀, b₁, …, bₙ])`
19856/// - lhs Array(a₀..aₙ), rhs scalar: `Value::Array([a₀, …, aₙ₋₁, aₙ+rhs])`
19857/// - both Array: `Value::Array([a₀, …, aₙ₋₁, aₙ+b₀, b₁, …, bₙ])`
19858/// (last of lhs merges with first of rhs; the rest stay separate)
19859///
19860/// This is the default zsh semantics for `print -l X${arr[@]}Y` →
19861/// "Xa", "b", "cY" — three distinct args, surrounding text only on ends.
19862pub const BUILTIN_CONCAT_SPLICE: u16 = 319;
19863
19864/// `${(flags)name}` — zsh parameter expansion flags. Stack: [name, flags].
19865/// Flags applied left-to-right. Supported subset (high-value, used by zpwr):
19866///
19867/// `L` — lowercase the value (scalar; or each element if array)
19868/// `U` — uppercase
19869/// `j:sep:` — join array with `sep` (delim is the char after `j`)
19870/// `s:sep:` — split scalar on `sep` (returns Value::Array)
19871/// `f` — split on newlines (shorthand for `s.\n.`)
19872/// `o` — sort array ascending
19873/// `O` — sort array descending
19874/// `P` — indirect: read name's value as another var name, return that's value
19875/// `@` — keep as array (returns Value::Array — useful before `j` etc.)
19876/// `k` — keys of assoc array
19877/// `v` — values of assoc array
19878/// `#` — word count (array length as scalar)
19879///
19880/// Flags can stack: `(jL)` joins then lowercases; `(s.,.U)` splits on `,`
19881/// then uppercases each element. The long-tail flags (`q`, `qq`, `qqq` for
19882/// quoting, `A` for assoc, `%` for prompt expansion, `e`/`g` for re-eval,
19883/// `n`/`p` for numeric, `t` for type, etc.) are deferred — they hit the
19884/// runtime fallback via the catch-all expansion path.
19885pub const BUILTIN_PARAM_FLAG: u16 = 297;
19886
19887/// `ShellHost` implementation that delegates to the current `ShellExecutor`
19888/// via the `with_executor` thread-local.
19889///
19890/// Construct fresh on each VM run (it carries no state itself). The VM
19891/// dispatches host method calls during `vm.run()`, and `with_executor`
19892/// resolves to the executor pointer set by `ExecutorContext::enter`.
19893/// fusevm-host implementation tying bytecode ops to the
19894/// shell executor.
19895/// zshrs-original — no C counterpart. C zsh has no bytecode VM
19896/// to host; everything runs through `execlist()`/`execpline()`
19897/// directly (Src/exec.c lines 1349/1668).
19898pub struct ZshrsHost;
19899
19900/// Short label for a sub-chunk, used as a provenance origin. A `Chunk`
19901/// keeps no original source text (only ops, constants and a source
19902/// *file* name), so the readable handle is reconstructed from the
19903/// leading string constants — for `$(date +%s)` that is `date +%s`.
19904fn prov_chunk_label(sub: &fusevm::Chunk) -> String {
19905 sub.constants
19906 .iter()
19907 .filter_map(|c| match c {
19908 Value::Str(s) if !s.is_empty() => Some(s.as_str()),
19909 _ => None,
19910 })
19911 .take(3)
19912 .collect::<Vec<_>>()
19913 .join(" ")
19914}
19915
19916impl fusevm::ShellHost for ZshrsHost {
19917 fn glob(&mut self, pattern: &str, _recursive: bool) -> Vec<String> {
19918 let matches = with_executor(|exec| exec.expand_glob(pattern));
19919 if crate::provenance::active() {
19920 crate::provenance::on_glob(pattern, &matches);
19921 }
19922 matches
19923 }
19924
19925 fn tilde_expand(&mut self, s: &str) -> String {
19926 with_executor(|exec| s.to_string())
19927 }
19928
19929 fn brace_expand(&mut self, s: &str) -> Vec<String> {
19930 // Direct call to the canonical brace expander
19931 // (Src/glob.c::xpandbraces port at glob.rs:1678). Was
19932 // routing through singsub which uses PREFORK_SINGLE — that
19933 // flag explicitly suppresses brace expansion in subst.c:166,
19934 // so `print X{1,2,3}Y` returned the literal string.
19935 //
19936 // brace_ccl: respect the BRACE_CCL option which the bracket-
19937 // class form `{a-z}` requires. Pull from executor options.
19938 let brace_ccl = with_executor(|exec| opt_state_get("braceccl").unwrap_or(false));
19939 crate::ported::glob::xpandbraces(s, brace_ccl)
19940 }
19941
19942 fn str_match(&mut self, s: &str, pattern: &str) -> bool {
19943 let pattern: &str = &pattern_filesub(pattern);
19944 // bash `shopt -s nocasematch` — bash(1): "bash matches patterns in a
19945 // case-insensitive fashion when performing matching while executing
19946 // CASE or [[ conditional commands". This host method is the `case`
19947 // arm dispatch, the half BUILTIN_COND_STRMATCH (which already does
19948 // this, ~4.4k lines up) does not cover, so `shopt -s nocasematch;
19949 // case ABC in abc)` wrongly took the `*)` arm. Same treatment: fold
19950 // BOTH sides for the match decision — glob metacharacters are not
19951 // letters, so `*` / `?` / `[…]` structure survives `to_lowercase`.
19952 // No-op unless the bash shopt is active; --zsh unaffected.
19953 let (folded_s, folded_pat);
19954 let (s, pattern): (&str, &str) = if crate::dash_mode::nocasematch() {
19955 folded_s = s.to_lowercase();
19956 folded_pat = pattern.to_lowercase();
19957 (&folded_s, &folded_pat)
19958 } else {
19959 (s, pattern)
19960 };
19961 // Shell glob match — `*`, `?`, `[...]`, alternation. After the
19962 // cond path moved to BUILTIN_COND_STRMATCH, the consumer here
19963 // is the `case` arm dispatch, whose bad-pattern semantics are
19964 // Src/loop.c:663-667: `if (!(pprog = patcompile(pat, ...)))
19965 // zerr("bad pattern: %s", pat);` — errflag set, the arm
19966 // doesn't match, and the script aborts at the next command
19967 // boundary (matching `zsh -fc 'case x in [a-) ...'` printing
19968 // the diagnostic with exit 0 = untouched lastval).
19969 // Same drop-in split as the cond path: a bare POSIX-family drop-in
19970 // reads a source-level `(` by its own rules, not by zsh's parse-time
19971 // tokenization — see `dropin_source_pattern_parens_literal`.
19972 let pat_eff = if crate::pattern_data_escape::dropin_source_pattern_parens_literal() {
19973 crate::pattern_data_escape::escape_shglob_parens(
19974 pattern,
19975 crate::pattern_data_escape::dropin_keeps_ksh_groups(),
19976 )
19977 } else {
19978 pattern.to_string()
19979 };
19980 let mut pat_tok = pat_eff.clone();
19981 crate::ported::glob::tokenize(&mut pat_tok);
19982 if crate::ported::pattern::patcompile(
19983 &pat_tok,
19984 crate::ported::zsh_h::PAT_STATIC as i32,
19985 None,
19986 )
19987 .is_none()
19988 {
19989 crate::ported::utils::zerr(&format!("bad pattern: {}", pattern)); // c:667
19990 return false;
19991 }
19992 glob_match_static(s, &pat_eff)
19993 }
19994
19995 fn expand_param(&mut self, name: &str, _modifier: u8, _args: &[Value]) -> Value {
19996 // Sole funnel: route through `getsparam` matching C zsh's
19997 // `getsparam(name)` → `getvalue` → `getstrvalue` →
19998 // `Param.gsu->getfn` dispatch (Src/params.c:3076 / 2335).
19999 //
20000 // The lookup chain (GSU dispatch + variables + env + array-
20001 // join) lives in `params::getsparam`; subst.rs and this
20002 // bridge both call into it so the logic is in exactly one
20003 // place — mirroring C's "every read goes through getsparam"
20004 // architecture. fuseVM bytecode triggers this bridge when
20005 // the VM hits a PARAM opcode, equivalent to C's wordcode VM
20006 // resolving a parameter read during `exec.c` execution.
20007 //
20008 // Modifier handling: the `_modifier` / `_args` parameters
20009 // are populated by the bytecode compiler but applied by
20010 // separate VM opcodes (LENGTH/STRIP/SUBST/etc.) downstream
20011 // of this fetch — matching C's split between getsparam
20012 // (value fetch) and paramsubst's modifier-walk loop. This
20013 // bridge is the value-fetch step only.
20014 let val_str = crate::ported::params::getsparam(name).unwrap_or_default();
20015 let value = Value::str(val_str);
20016 // Provenance: a read of a tracked parameter hands the
20017 // parameter's chain to the produced value, by `Arc` identity
20018 // for the rest of this chunk and by content for the host
20019 // boundaries downstream that only see `String`.
20020 if crate::provenance::active() {
20021 crate::provenance::on_param_read(name, &value);
20022 }
20023 value
20024 }
20025
20026 fn process_sub_in(&mut self, sub: &fusevm::Chunk) -> String {
20027 // c:Src/exec.c:4999 (`getoutputfile`: `child_block();`) and, for
20028 // `<(cmd)`, the enclosing execpline's c:1748 block that C's
20029 // `getproc` forks inside. The VM opens no execpline span around a
20030 // simple command, so take it here. See ChildBlockSpan.
20031 #[cfg(unix)]
20032 let _child_block = ChildBlockSpan::enter();
20033 // c:Src/exec.c:4906 getoutputfile — `=(cmd)` (marked "equalsubst" by the
20034 // compiler) is the TEMP-FILE flavor: create a real regular file, fork a
20035 // writer whose stdout is the file, WAIT for it (so the file is complete
20036 // and seekable before the consumer runs), and return the file path. It
20037 // is unlinked at job end. This differs from `<(cmd)` below, which is a
20038 // /dev/fd pipe that is never waited on.
20039 if sub.source == "equalsubst" {
20040 let nam = crate::ported::utils::gettempname(None, true)
20041 .unwrap_or_else(|| format!("/tmp/zshrs_eqsub_{}", std::process::id()));
20042 let cpath = match std::ffi::CString::new(nam.as_str()) {
20043 Ok(c) => c,
20044 Err(_) => return String::from("/dev/null"),
20045 };
20046 // c:4945 — O_WRONLY|O_CREAT|O_EXCL|O_NOCTTY, 0600.
20047 let fd = unsafe {
20048 libc::open(
20049 cpath.as_ptr(),
20050 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOCTTY,
20051 0o600,
20052 )
20053 };
20054 if fd < 0 {
20055 return String::from("/dev/null");
20056 }
20057 let sub_for_child = sub.clone();
20058 match unsafe { libc::fork() } {
20059 -1 => {
20060 unsafe { libc::close(fd) };
20061 let _ = fs::remove_file(&nam);
20062 return String::from("/dev/null");
20063 }
20064 0 => {
20065 forked_child_subsh_levels(); // c:Src/exec.c:1221
20066 // c:Src/exec.c:5037 entersubsh → c:1200 `zsh_subshell++`.
20067 // The child _exits, so the guard is never dropped.
20068 std::mem::forget(CmdSubstSubshellBump::enter());
20069 // c:4985 — child: stdout → the temp file, run the body, exit.
20070 // Clear the inherited pending-file list so this child never
20071 // unlinks the PARENT's =() temp files when its own commands
20072 // dispatch (fork copies the list; unlink hits the shared fs).
20073 // c:Src/exec.c:5044 — `execode(prog, 0, 1, "equalsubst")`.
20074 std::mem::forget(crate::ported::exec::EvalContextFrame::push("equalsubst"));
20075 PSUB_PENDING_FILES.with(|v| v.borrow_mut().clear());
20076 unsafe {
20077 libc::dup2(fd, libc::STDOUT_FILENO);
20078 libc::close(fd);
20079 }
20080 let mut vm = fusevm::VM::new(sub_for_child);
20081 register_builtins(&mut vm);
20082 vm.set_shell_host(Box::new(ZshrsHost));
20083 let _ = vm.run();
20084 let _ = std::io::stdout().flush();
20085 unsafe { libc::_exit(0) };
20086 }
20087 child_pid => {
20088 // c:4976-4980 — parent: close the write fd and WAIT so the
20089 // file is fully written before the consumer opens it.
20090 unsafe {
20091 libc::close(fd);
20092 let mut status: libc::c_int = 0;
20093 libc::waitpid(child_pid, &mut status, 0);
20094 }
20095 psub_pending_file_add(&nam); // c:4960
20096 return nam;
20097 }
20098 }
20099 }
20100 // c:Src/exec.c::getproc — `<(cmd)` uses pipe + fork + the
20101 // `/dev/fd/N` filesystem entry (where N is the read end of
20102 // the pipe held open in the parent). Consumer opens
20103 // `/dev/fd/N`, reads the cmd's stdout through the pipe.
20104 // Both macOS and Linux expose `/dev/fd` for held-open file
20105 // descriptors. Previous Rust port captured stdout into
20106 // `/tmp/zshrs_psub_*` tempfiles synchronously — works for
20107 // `diff <(a) <(b)` style readers that scan once but diverges
20108 // from zsh's observable path string and breaks any consumer
20109 // that introspects the path or expects a non-seekable pipe.
20110 let mut fds: [libc::c_int; 2] = [-1, -1];
20111 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
20112 // Pipe creation failed — fall back to tempfile so we at
20113 // least return SOMETHING.
20114 let fifo_path = format!(
20115 "/tmp/zshrs_psub_fallback_{}_{}",
20116 std::process::id(),
20117 with_executor(|e| {
20118 let n = e.process_sub_counter;
20119 e.process_sub_counter += 1;
20120 n
20121 })
20122 );
20123 let _ = fs::remove_file(&fifo_path);
20124 return fifo_path;
20125 }
20126 let (read_end, write_end) = (fds[0], fds[1]);
20127 let sub_for_child = sub.clone();
20128 match unsafe { libc::fork() } {
20129 -1 => {
20130 unsafe {
20131 libc::close(read_end);
20132 libc::close(write_end);
20133 }
20134 return String::from("/dev/null");
20135 }
20136 0 => {
20137 forked_child_subsh_levels(); // c:Src/exec.c:1221
20138 // c:Src/exec.c:5095 entersubsh → c:1200 `zsh_subshell++`.
20139 std::mem::forget(CmdSubstSubshellBump::enter());
20140 // Child: close read end, dup write end to stdout,
20141 // run the sub-chunk, exit. The exit closes the
20142 // write end automatically, so the parent's reader
20143 // gets EOF when the cmd finishes.
20144 PSUB_PENDING_FILES.with(|v| v.borrow_mut().clear());
20145 unsafe {
20146 libc::close(read_end);
20147 libc::dup2(write_end, libc::STDOUT_FILENO);
20148 libc::close(write_end);
20149 }
20150 // c:Src/exec.c:5101/5150 — `execode(prog, 0, 1, out ?
20151 // "outsubst" : "insubst");`. execode (c:1245-1266) APPENDS its
20152 // context for the duration of the body, so `<(cmd)` — whose child WRITES (out=1) — runs as `…:outsubst`.
20153 // zshrs's ported getproc carries these citations but is NOT the
20154 // live path (established in #1062) — the VM forks here instead,
20155 // so the push belongs in this child. No pop needed: this runs
20156 // INSIDE the forked child and dies with it. Bug #1069 (procsub
20157 // legs).
20158 if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
20159 ctx.push("outsubst".to_string());
20160 let joined = ctx.join(":");
20161 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
20162 if let Some(pm) = tab.get_mut("zsh_eval_context") {
20163 pm.u_arr = Some(ctx.clone());
20164 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20165 }
20166 if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
20167 pm.u_str = Some(joined);
20168 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20169 }
20170 }
20171 }
20172 crate::fusevm_disasm::maybe_print_stdout("process_subst_in", &sub_for_child);
20173 let mut vm = fusevm::VM::new(sub_for_child);
20174 register_builtins(&mut vm);
20175 vm.set_shell_host(Box::new(ZshrsHost));
20176 let _ = vm.run();
20177 let _ = std::io::stdout().flush();
20178 unsafe { libc::_exit(0) };
20179 }
20180 child_pid => {
20181 // c:Src/exec.c:5092 `procsubstpid = pid;` — record the
20182 // forked child's PID so `${sysparams[procsubstpid]}`
20183 // returns it (was reading the never-updated atomic, so it
20184 // always came back 0). p10k's gitstatus daemon reads
20185 // `sysparams[procsubstpid]` right after `sysopen <(cmd)`
20186 // to track its worker PID; with 0 the daemon's self-check
20187 // failed and gitstatus fell back to re-downloading
20188 // gitstatusd — surfacing as "no prebuilt gitstatusd".
20189 crate::ported::exec::procsubstpid
20190 .store(child_pid, std::sync::atomic::Ordering::Relaxed);
20191 // Parent: close write end, keep read end open under
20192 // the same fd value so `/dev/fd/N` resolves to the
20193 // pipe's read side. NOTE: FD_CLOEXEC must STAY clear
20194 // — consumers like `cat <(cmd)` and `diff <(a) <(b)`
20195 // discover the fd via exec inheritance, so closing
20196 // on exec defeats the whole point. C zsh's getproc
20197 // (Src/exec.c:5045+) leaves the fd open across exec.
20198 unsafe {
20199 libc::close(write_end);
20200 }
20201 // Park read_end for close-after-consuming-command,
20202 // exactly like process_sub_out does for its write_end
20203 // (c:Src/exec.c addfilelist(NULL, fd) → deletefilelist).
20204 // WITHOUT this the parent's read_end stayed open for
20205 // the whole shell lifetime: p10k's async worker /
20206 // realtime clock do `exec {fd}< <(cmd)` on every prompt,
20207 // so each keystroke/redraw leaked a pipe fd until the
20208 // ~256-fd limit was hit and the shell locked up
20209 // (107 leaked pipes + 107 unreaped children observed).
20210 let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
20211 PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, read_end)));
20212 // Reap the forked child so it doesn't linger as a
20213 // zombie. `<(cmd)` children are fire-and-forget (their
20214 // output flows through the pipe); C reaps them via the
20215 // job machinery. A non-blocking reap here is scheduled;
20216 // do a best-effort WNOHANG now and the rest drain on
20217 // subsequent proc-subs / prompt cycles.
20218 crate::fusevm_bridge::note_psub_child(child_pid);
20219 }
20220 }
20221 let path = format!("{}/{}", crate::ported::config_h::PATH_DEV_FD, read_end); // c:Src/exec.c:5144
20222 if crate::provenance::active() {
20223 crate::provenance::on_process_subst(&prov_chunk_label(sub), &path);
20224 }
20225 path
20226 }
20227
20228 fn process_sub_out(&mut self, sub: &fusevm::Chunk) -> String {
20229 // c:Src/exec.c:1748 — `getproc` forks inside the enclosing
20230 // execpline's `child_block()` span; the VM has none around a simple
20231 // command, so take it here. See ChildBlockSpan.
20232 #[cfg(unix)]
20233 let _child_block = ChildBlockSpan::enter();
20234 // c:Src/exec.c:5025 getproc, PATH_DEV_FD branch — `>(cmd)`
20235 // (out == 0): `mpipe(pipes)`, fork; the CHILD `redup(pipes[0],
20236 // 0)` (pipe read end onto stdin) and `closem` drops the write
20237 // end; the PARENT closes pipes[0] and hands the consumer
20238 // `/dev/fd/<pipes[1]>` (the write end). The previous Rust port
20239 // used mkfifo + a child that BLOCKED in open(FIFO, O_RDONLY)
20240 // before running cmd — with no writer the child never started,
20241 // never exited, and kept its inherited stdout (e.g. a `$()`
20242 // capture pipe) open forever: `a=$(print -r -- >(true))` hung.
20243 // With the pipe shape the child runs immediately and exits,
20244 // releasing inherited fds exactly like zsh (verified: zsh
20245 // blocks ~2s on `a=$(print -r -- >(sleep 2))`, then EOFs).
20246 let mut fds: [libc::c_int; 2] = [-1, -1];
20247 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
20248 // Pipe creation failed — fall back to a plain temp file so
20249 // the consumer at least has a writable path.
20250 let fallback = format!(
20251 "/tmp/zshrs_psub_out_{}_{}",
20252 std::process::id(),
20253 with_executor(|e| {
20254 let n = e.process_sub_counter;
20255 e.process_sub_counter += 1;
20256 n
20257 })
20258 );
20259 let _ = fs::write(&fallback, "");
20260 return fallback;
20261 }
20262 let (read_end, write_end) = (fds[0], fds[1]);
20263 let sub_for_child = sub.clone();
20264 match unsafe { libc::fork() } {
20265 -1 => {
20266 unsafe {
20267 libc::close(read_end);
20268 libc::close(write_end);
20269 }
20270 String::from("/dev/null")
20271 }
20272 0 => {
20273 forked_child_subsh_levels(); // c:Src/exec.c:1221
20274 // c:Src/exec.c:5150 entersubsh → c:1200 `zsh_subshell++`.
20275 std::mem::forget(CmdSubstSubshellBump::enter());
20276 // Child: close the write end (c: closem after redup),
20277 // dup the read end onto stdin (c: redup(pipes[0], 0)),
20278 // run the sub-chunk, exit. Other std fds stay
20279 // inherited — zsh's child keeps the surrounding
20280 // command's stdout/stderr.
20281 unsafe {
20282 libc::close(write_end);
20283 libc::dup2(read_end, libc::STDIN_FILENO);
20284 libc::close(read_end);
20285 }
20286 // c:Src/exec.c:5101/5150 — `execode(prog, 0, 1, out ?
20287 // "outsubst" : "insubst");`. execode (c:1245-1266) APPENDS its
20288 // context for the duration of the body, so `>(cmd)` — whose child READS (out=0) — runs as `…:insubst`.
20289 // zshrs's ported getproc carries these citations but is NOT the
20290 // live path (established in #1062) — the VM forks here instead,
20291 // so the push belongs in this child. No pop needed: this runs
20292 // INSIDE the forked child and dies with it. Bug #1069 (procsub
20293 // legs).
20294 if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
20295 ctx.push("insubst".to_string());
20296 let joined = ctx.join(":");
20297 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
20298 if let Some(pm) = tab.get_mut("zsh_eval_context") {
20299 pm.u_arr = Some(ctx.clone());
20300 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20301 }
20302 if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
20303 pm.u_str = Some(joined);
20304 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20305 }
20306 }
20307 }
20308 crate::fusevm_disasm::maybe_print_stdout("process_subst_out:child", &sub_for_child);
20309 let mut vm = fusevm::VM::new(sub_for_child);
20310 register_builtins(&mut vm);
20311 vm.set_shell_host(Box::new(ZshrsHost));
20312 let _ = vm.run();
20313 unsafe { libc::_exit(0) };
20314 }
20315 child_pid => {
20316 // c:Src/exec.c:5143 `procsubstpid = pid;` — same fix as
20317 // the `<(cmd)` in-path above: record the forked child's
20318 // PID for `${sysparams[procsubstpid]}` (was always 0).
20319 crate::ported::exec::procsubstpid
20320 .store(child_pid, std::sync::atomic::Ordering::Relaxed);
20321 // Parent: close the read end, keep the write end open
20322 // under its fd value so `/dev/fd/N` resolves to the
20323 // pipe's write side. FD_CLOEXEC must STAY clear —
20324 // consumers (`tee >(cmd)`) discover the fd via exec
20325 // inheritance, matching process_sub_in above and C's
20326 // fdtable[fd] = FDT_PROC_SUBST bookkeeping. Park the
20327 // fd for close-after-consuming-command (c: addfilelist
20328 // (NULL, fd) → deletefilelist) so the child's reader
20329 // EOFs — without this `tee >(wc -c) </dev/null` left
20330 // wc blocked until shell exit.
20331 unsafe {
20332 libc::close(read_end);
20333 }
20334 let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
20335 PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, write_end)));
20336 let path = format!("{}/{}", crate::ported::config_h::PATH_DEV_FD, write_end); // c:Src/exec.c:5144
20337 if crate::provenance::active() {
20338 crate::provenance::on_process_subst(&prov_chunk_label(sub), &path);
20339 }
20340 path
20341 }
20342 }
20343 }
20344
20345 fn subshell_begin(&mut self) {
20346 with_executor(|exec| {
20347 // Special parameters whose value lives in a process GLOBAL behind a
20348 // GSU (`Src/params.c`'s `ifs`, `wordchars`, `home`, `histsiz`, …)
20349 // rather than in the param table. Mirrors the getfn dispatch list at
20350 // params.rs:12548. C isolates these for free by forking `(...)`;
20351 // zshrs's in-process subshell has to snapshot them by hand, or a
20352 // subshell-local `IFS=,` rewrites the parent's word-splitting.
20353 const SUBSHELL_SPECIAL_GLOBALS: &[&str] = &[
20354 "IFS",
20355 "HOME",
20356 "TERM",
20357 "USERNAME",
20358 "WORDCHARS",
20359 "TERMINFO",
20360 "TERMINFO_DIRS",
20361 "KEYBOARD_HACK",
20362 "histchars",
20363 "HISTSIZE",
20364 "SAVEHIST",
20365 ];
20366 // An UNSET special yields None and is skipped: the paramtab snapshot
20367 // restores its PM_UNSET flag, and the getfn dispatch refuses to read
20368 // the (stale) global while PM_UNSET is set (params.rs:12552).
20369 let special_globals_snap: Vec<(String, String)> = SUBSHELL_SPECIAL_GLOBALS
20370 .iter()
20371 .filter_map(|n| crate::ported::params::getsparam(n).map(|v| ((*n).to_string(), v)))
20372 .collect();
20373 // Snapshot paramtab + hashed-storage too (step 1 of the
20374 // store unification mirrors writes there; restoring only
20375 // the HashMaps leaks subshell-scoped writes to the parent
20376 // via paramtab readers like `paramsubst → vars_get`).
20377 let paramtab_snap = crate::ported::params::paramtab()
20378 .read()
20379 .ok()
20380 .map(|t| t.clone())
20381 // c:Src/params.c:854 — a fresh table is 151 buckets, not
20382 // the 17-bucket `Default`.
20383 .unwrap_or_else(|| crate::ported::hashtable::hashtable_nodes::newhashtable(151));
20384 let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
20385 .lock()
20386 .ok()
20387 .map(|m| m.clone())
20388 .unwrap_or_default();
20389 let loop_flags_snap = {
20390 use std::sync::atomic::Ordering::SeqCst;
20391 (
20392 crate::ported::builtin::LOOPS.load(SeqCst),
20393 crate::ported::builtin::BREAKS.load(SeqCst),
20394 crate::ported::builtin::CONTFLAG.load(SeqCst),
20395 crate::ported::builtin::ANCESTOR_LOOPS.load(SeqCst),
20396 )
20397 };
20398 // c:Src/exec.c:1263-1264 (55061) — entersubsh's `ancestor_loops
20399 // += loops; loops = 0;`: a `break` in `( … )` has no loop of its
20400 // own to leave.
20401 {
20402 use std::sync::atomic::Ordering::SeqCst;
20403 crate::ported::builtin::ANCESTOR_LOOPS
20404 .fetch_add(crate::ported::builtin::LOOPS.swap(0, SeqCst), SeqCst);
20405 }
20406 exec.subshell_snapshots.push(SubshellSnapshot {
20407 // c:Src/utils.c:2111 `addlockfd` — the fds carrying
20408 // `zsystem flock` locks. Recorded so subshell_end can close
20409 // the ones the subshell itself opened (C's fork does it for
20410 // free). See SubshellSnapshot::flock_fds.
20411 flock_fds: current_flock_fds(),
20412 loop_flags: loop_flags_snap,
20413 cmdoutval: crate::ported::exec::cmdoutval.load(std::sync::atomic::Ordering::Relaxed),
20414 stopmsg: crate::ported::builtin::STOPMSG.load(std::sync::atomic::Ordering::Relaxed),
20415 paramtab: paramtab_snap,
20416 paramtab_hashed_storage: paramtab_hashed_snap,
20417 special_globals: special_globals_snap,
20418 positional_params: exec.pparams(),
20419 // Snapshot canonical `traps_table` — bin_trap writes
20420 // there (`Src/builtin.c`).
20421 traps: crate::ported::builtin::traps_table()
20422 .lock()
20423 .map(|t| t.clone())
20424 .unwrap_or_default(),
20425 // Snapshot option store so `(set -e)` /
20426 // `(setopt extendedglob)` don't leak to parent.
20427 opts: crate::ported::options::opt_state_snapshot(),
20428 // c:Src/exec.c:2880 — fork() copies the alias tables to
20429 // the subshell. `(alias x=y)` inside the subshell dies
20430 // with the child; the parent doesn't see x. Bug #209.
20431 tables: crate::ported::exec::SubshForkCopy::save(),
20432 // c:Src/exec.c::entersubsh — same fork-copy
20433 // semantics for shfunctab. `(f() { ... })` defined
20434 // inside the subshell dies with the child; parent's
20435 // `type f` reports "not found". Bug #208 in
20436 // docs/BUGS.md.
20437 shfuncs: crate::ported::hashtable::shfunctab_lock()
20438 .read()
20439 .ok()
20440 .map(|t| t.snapshot())
20441 .unwrap_or_default(),
20442 functions_compiled: exec.functions_compiled.clone(),
20443 function_source: exec.function_source.clone(),
20444 // c:Src/exec.c::entersubsh — fork-copy semantics for
20445 // THINGYTAB (ZLE widget registry). A subshell `zle -N`
20446 // / `zle -D` mutation dies with the child in C zsh;
20447 // mirror via in-process snapshot. Bug #453.
20448 thingytab: crate::ported::zle::zle_thingy::thingytab()
20449 .lock()
20450 .ok()
20451 .map(|t| t.clone())
20452 .unwrap_or_default(),
20453 // c:Src/exec.c::entersubsh — same fork-copy for the
20454 // KEYMAPNAMTAB (named keymap registry). `bindkey -N km`
20455 // / `bindkey -D km` inside a subshell dies with the
20456 // child. Bug #454.
20457 keymapnamtab: crate::ported::zle::zle_keymap::keymapnamtab()
20458 .lock()
20459 .ok()
20460 .map(|t| t.clone())
20461 .unwrap_or_default(),
20462 // c:Src/exec.c::entersubsh fork semantics — `$!`
20463 // (clone::lastpid) set by a `&` INSIDE the subshell
20464 // dies with the child: `( : & ); echo $!` -> 0.
20465 lastpid: crate::ported::modules::clone::lastpid
20466 .load(std::sync::atomic::Ordering::Relaxed),
20467 // c:Src/exec.c::entersubsh fork semantics — the
20468 // subshell gets a COPY of the job table; its disown/
20469 // wait/`&` mutations die with it. Bug #462.
20470 jobtab: crate::ported::jobs::JOBTAB
20471 .get_or_init(|| std::sync::Mutex::new(Vec::new()))
20472 .lock()
20473 .map(|t| t.clone())
20474 .unwrap_or_default(),
20475 curjob: *crate::ported::jobs::CURJOB
20476 .get_or_init(|| std::sync::Mutex::new(-1))
20477 .lock()
20478 .unwrap(),
20479 prevjob: *crate::ported::jobs::PREVJOB
20480 .get_or_init(|| std::sync::Mutex::new(-1))
20481 .lock()
20482 .unwrap(),
20483 maxjob: *crate::ported::jobs::MAXJOB
20484 .get_or_init(|| std::sync::Mutex::new(0))
20485 .lock()
20486 .unwrap(),
20487 thisjob: *crate::ported::jobs::THISJOB
20488 .get_or_init(|| std::sync::Mutex::new(-1))
20489 .lock()
20490 .unwrap(),
20491 oldjobtab: crate::ported::jobs::OLDJOBTAB
20492 .get_or_init(|| std::sync::Mutex::new(Vec::new()))
20493 .lock()
20494 .map(|t| t.clone())
20495 .unwrap_or_default(),
20496 oldmaxjob: *crate::ported::jobs::OLDMAXJOB
20497 .get_or_init(|| std::sync::Mutex::new(0))
20498 .lock()
20499 .unwrap(),
20500 // c:Src/exec.c:2880 — fork copies the fd table; the
20501 // child's `exec >file` / `exec N<&-` die with it.
20502 fd_frame: crate::ported::exec::SubshFdFrame::enter(),
20503 redir_depth: exec.redirect_scope_stack.len(),
20504 slots_mark: crate::exec_jobs::execpline_slots_mark(),
20505 // c:Src/signals.c:39 `sigtrapped` — saved so End restores the
20506 // parent's per-signal trap flags (see the field docs).
20507 sigtrapped: crate::ported::signals::sigtrapped
20508 .lock()
20509 .map(|g| g.clone())
20510 .unwrap_or_default(),
20511 // c:Src/exec.c:160 `int subsh;` — saved so End can put the
20512 // parent's value back (subshells nest).
20513 subsh: crate::ported::exec::subsh.load(std::sync::atomic::Ordering::Relaxed),
20514 // The parent's libc locale — see SubshellSnapshot::locale.
20515 locale: crate::vm_helper::subsh_locale_save(),
20516 });
20517 // c:Src/exec.c:1192-1193 — `if (!(flags & ESUB_FAKE)) subsh = 1;`
20518 // A `( … )` is a real subshell, so the body runs with subsh set.
20519 // The forked child carries it in C; the in-process body needs it
20520 // set explicitly or per-command checks that read it — notably
20521 // PRINT_EXIT_VALUE (c:4309 `&& !subsh`) — behave as if the
20522 // command ran in the parent.
20523 crate::ported::exec::subsh.store(1, std::sync::atomic::Ordering::Relaxed);
20524 // C forks for `(...)` — count the fork-equivalent so
20525 // `time (builtin)` reports like zsh (see FORK_EVENTS).
20526 crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
20527 // c:Src/exec.c:1088-1092 — entersubsh resets traps in the child:
20528 // if (!(flags & ESUB_KEEPTRAP))
20529 // for (sig = 0; sig <= SIGCOUNT; sig++)
20530 // if (!(sigtrapped[sig] & ZSIG_FUNC) &&
20531 // !(isset(POSIXTRAPS) && (sigtrapped[sig] & ZSIG_IGNORED)))
20532 // unsettrap(sig);
20533 //
20534 // A subshell does NOT inherit string-form traps. Two exemptions:
20535 // FUNCTION-form traps (`TRAPUSR1() { … }`, ZSIG_FUNC) survive, and
20536 // under POSIX_TRAPS an IGNORED trap (`trap '' SIG`) survives.
20537 //
20538 // The ZSIG_FUNC exemption is structural here rather than a flag
20539 // test: zshrs keeps string-form bodies in traps_table and
20540 // function-form ones in shfunctab as TRAPxxx, so filtering only
20541 // traps_table leaves the function form untouched by construction.
20542 // ZSIG_IGNORED is `trap '' SIG`, which stores an empty body.
20543 //
20544 // The LOOP BOUND is also part of the spec, not an implementation
20545 // detail: `sig <= SIGCOUNT` never reaches the PSEUDO-signals,
20546 // which zsh numbers above the real ones —
20547 // #define SIGZERR (SIGCOUNT+1)
20548 // #define SIGDEBUG (SIGCOUNT+2) (c:Src/signals.h:34-35)
20549 // so ERR/ZERR and DEBUG traps SURVIVE a subshell, while SIGEXIT
20550 // (sig 0) is inside the loop and is cleared. Verified against the
20551 // oracle: `trap 'print e' ERR; (trap)` lists the ERR trap;
20552 // `trap 'print u' USR1; (trap)` lists nothing.
20553 //
20554 // Without this a subshell kept the parent's traps: `(trap)` listed
20555 // them where zsh lists nothing, and — the part that isn't
20556 // cosmetic — an inherited trap FIRED inside the child, so
20557 // `trap 'print p' USR1; (kill -USR1 $$; print after)` printed
20558 // p before after instead of after…p (the signal is meant to reach
20559 // the parent, whose trap runs there).
20560 //
20561 // The snapshot pushed above restores the parent's table at
20562 // subshell_end, which is what makes clearing safe for zshrs's
20563 // in-process subshell.
20564 {
20565 // Record the PARENT's `sigtrapped[]` BEFORE the reset below
20566 // wipes it. C gets it from the fork: the child mutates a
20567 // private copy (`Src/signals.c:39`) and the parent's stays
20568 // put. (`limits[]` travels in SubshForkCopy.)
20569 subshell_signal_enter();
20570 entersubsh_reset_traps();
20571 }
20572 // c:Src/exec.c:2862 — subshell fork flags carry ESUB_PGRP,
20573 // so entersubsh runs `clearjobtab(monitor)` (c:1219): the
20574 // child gets an EMPTY job table plus the procless control
20575 // job grabbed at Src/jobs.c:1828 (`thisjob = initjob()`).
20576 // That's why zsh's `(jobs)` prints nothing and `(kill %1)`
20577 // hits the empty control job instead of the parent's job 1.
20578 // The snapshot pushed above restores the parent's table at
20579 // subshell_end. Bug #462.
20580 let monitor = crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
20581 crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
20582 // c:Src/exec.c:1133 + c:1245-1246 — `job_control_ok = monitor &&
20583 // (flags & ESUB_JOB_CONTROL) && isset(POSIXJOBS)`, and a
20584 // non-async `( … )` carries ESUB_JOB_CONTROL (c:2919-2920);
20585 // `if (!job_control_ok) opts[MONITOR] = 0;`. With MONITOR off
20586 // `jobs` lists the parent's oldjobtab and fg/bg refuse with
20587 // "no job control in this shell". The option snapshot above
20588 // restores it at subshell_end.
20589 if !(monitor != 0 && crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXJOBS)) {
20590 crate::ported::options::dosetopt(crate::ported::zsh_h::MONITOR, 0, 0);
20591 }
20592 // clearjobtab left THISJOB on the control job (Src/jobs.c:
20593 // 1828). In C the very next pipeline's execpline reassigns
20594 // thisjob (Src/exec.c:1700 `thisjob = newjob = initjob()`),
20595 // so by the time any builtin runs, thisjob never aliases
20596 // the control job. zshrs has no per-pipeline job slot —
20597 // model the between-pipelines state (-1) so getjob's
20598 // `jobnum != thisjob` (c:jobs.c:2107) doesn't reject %1 and
20599 // setcurjob doesn't demote an inherited curjob that
20600 // collides with the control slot.
20601 *crate::ported::jobs::THISJOB
20602 .get_or_init(|| std::sync::Mutex::new(-1))
20603 .lock()
20604 .unwrap() = -1;
20605 // Subshell starts with EXIT trap cleared so the parent's
20606 // EXIT handler doesn't fire when the subshell ends. zsh:
20607 // each subshell has its own trap context. Other signals
20608 // are inherited (well, parent's are still in place — but
20609 // a trap set INSIDE the subshell shouldn't leak out).
20610 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
20611 t.remove("EXIT");
20612 }
20613 let level = exec
20614 .scalar("ZSH_SUBSHELL")
20615 .and_then(|s| s.parse::<i32>().ok())
20616 .unwrap_or(0);
20617 // c:Src/exec.c — ZSH_SUBSHELL carries PM_READONLY (declared
20618 // in params.rs special_params); setsparam would be rejected
20619 // by assignstrvalue's PM_READONLY guard. Write u_val
20620 // directly — same bypass pattern as BUILTIN_SET_LINENO at
20621 // line 2784. C zsh's PM_SPECIAL GSU vtable handles this
20622 // implicitly via the setfn callback.
20623 let new_level = (level + 1) as i64;
20624 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
20625 if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
20626 pm.u_val = new_level;
20627 pm.u_str = Some(new_level.to_string());
20628 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
20629 }
20630 }
20631 });
20632 // Bump SUBSHELL_DEPTH so zexit defers process::exit (see
20633 // SUBSHELL_DEPTH declaration in src/ported/builtin.rs for
20634 // rationale).
20635 crate::ported::builtin::SUBSHELL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
20636 // c:Src/exec.c::entersubsh — C zsh's subshell is a forked
20637 // child process: signals sent to the parent (via `kill $$`
20638 // inside the subshell, where `$$` is the parent's pid)
20639 // never reach the child's signal handlers. zshrs's
20640 // in-process subshell shares the process pid with the
20641 // parent, so without queueing the subshell's trap handler
20642 // fires for signals that zsh would deliver only to the
20643 // parent. Queue signals across the subshell body so the
20644 // parent's restored trap table sees them after
20645 // subshell_end's unqueue drain. Bug #450.
20646 crate::ported::signals_h::queue_signals();
20647 }
20648
20649 fn subshell_end(&mut self) -> Option<i32> {
20650 // Fire subshell's EXIT trap BEFORE restoring parent state so
20651 // the trap body sees the subshell's vars and exit status. zsh
20652 // forks for `(...)` so the trap runs in the child process,
20653 // before exit. We mirror by running it here, just before the
20654 // pop+restore. REMOVE the trap before firing so the inner
20655 // execute_script doesn't fire it again at its own end.
20656 let exit_trap_body = crate::ported::builtin::traps_table()
20657 .lock()
20658 .ok()
20659 .and_then(|mut t| t.remove("EXIT"));
20660 if let Some(body) = exit_trap_body {
20661 // Execute the trap body. Errors during trap execution
20662 // don't bubble — zsh ignores trap-body errors.
20663 with_executor(|exec| {
20664 let _ = exec.execute_string_without_exit_hooks(&body);
20665 });
20666 }
20667 with_executor(|exec| {
20668 // c:Src/exec.c::entersubsh — the subshell is a forked child, so a
20669 // NOMATCH failure raised inside it (c:Src/glob.c:1877) lives and
20670 // dies with that process. The per-command glob-failed cell is the
20671 // in-process stand-in for that failure, and a body like `( y* )`
20672 // ends before any command boundary consumes it: the parent's NEXT
20673 // command then read it and returned 1 without running
20674 // (`( y* ); print a` lost `print a`, `( y* ) || print x` lost
20675 // the or-branch).
20676 exec.current_command_glob_failed.set(false);
20677 if let Some(snap) = exec.subshell_snapshots.pop() {
20678 // c:Src/exec.c:1123 entersubsh — the forked child owns its fds, so
20679 // redirections a body error left open (`WithRedirectsEnd` skipped)
20680 // go with it: `(print ${a[]} 2>&1); print -u2 err` keeps fd 2.
20681 exec.unwind_redirect_scopes_to(snap.redir_depth);
20682 crate::exec_jobs::execpline_slots_forget_to(snap.slots_mark);
20683 // c:Src/exec.c::entersubsh fork semantics — `loops` /
20684 // `breaks` / `contflag` are process globals the child
20685 // owns a private copy of, so `(break)` inside a loop
20686 // cannot end the PARENT's loop. See
20687 // SubshellSnapshot::loop_flags.
20688 {
20689 use std::sync::atomic::Ordering::SeqCst;
20690 let (loops, breaks, contflag, ancestor_loops) = snap.loop_flags;
20691 crate::ported::builtin::ANCESTOR_LOOPS.store(ancestor_loops, SeqCst);
20692 crate::ported::builtin::LOOPS.store(loops, SeqCst);
20693 crate::ported::builtin::BREAKS.store(breaks, SeqCst);
20694 crate::ported::builtin::CONTFLAG.store(contflag, SeqCst);
20695 }
20696 // c:Src/exec.c:225 `cmdoutval` — the child's copy dies with
20697 // it. See SubshellSnapshot::cmdoutval.
20698 crate::ported::exec::cmdoutval.store(snap.cmdoutval, std::sync::atomic::Ordering::Relaxed);
20699 // See SubshellSnapshot::stopmsg.
20700 crate::ported::builtin::STOPMSG.store(snap.stopmsg, std::sync::atomic::Ordering::Relaxed);
20701 // c:Src/utils.c:2155-2164 `zcloselockfd` — release the
20702 // `zsystem flock` locks the subshell itself took. Under C
20703 // the forked child's fds close on exit; here we close the
20704 // fds that appeared while the subshell was running.
20705 // See SubshellSnapshot::flock_fds.
20706 for fd in current_flock_fds() {
20707 if !snap.flock_fds.contains(&fd) {
20708 crate::ported::utils::zcloselockfd(fd);
20709 }
20710 }
20711 // c:Src/exec.c:160 / :1192-1193 — the child's `subsh = 1`
20712 // dies with the fork in C; restore the parent's value here.
20713 crate::ported::exec::subsh.store(snap.subsh, std::sync::atomic::Ordering::Relaxed);
20714 // c:Src/signals.c:39 — same fork-copy reasoning for the
20715 // per-signal trap flags cleared at subshell entry.
20716 //
20717 // The `sigaction` DISPOSITIONS the body installed leak
20718 // out too, and the flag restore alone does not undo
20719 // them: `( trap 'echo IN' USR1 )` runs `settrap` →
20720 // `install_handler` (c:Src/signals.c:730) on the shared
20721 // process, so afterwards a `kill -USR1 $$` in the parent
20722 // hit zshrs's handler, found no trap, and was swallowed
20723 // where zsh — whose parent still had SIG_DFL, the child
20724 // having installed the handler on its own copy — dies.
20725 // Roll the dispositions back for exactly the signals
20726 // whose flags differ.
20727 let child_sigtrapped = match crate::ported::signals::sigtrapped.lock() {
20728 Ok(mut st) => {
20729 let prev = st.clone();
20730 *st = snap.sigtrapped.clone();
20731 prev
20732 }
20733 Err(_) => Vec::new(),
20734 };
20735 subshell_restore_signal_dispositions(&snap.sigtrapped, &child_sigtrapped);
20736 // c:Src/exec.c:2880 — alias, hash-table, `environ`, cwd,
20737 // umask, rlimit, zstyle, module, sched … writes in a
20738 // subshell die with the forked child: the parent never sees
20739 // `(alias x=y)` (Bug #209 in docs/BUGS.md), `(export y=sub)`
20740 // or `(zmodload zsh/X)` (Bug #210). See SubshForkCopy.
20741 // BEFORE the paramtab restore below: rolling a module back
20742 // looks its parameters up in the LIVE paramtab.
20743 snap.tables.restore();
20744 // Restore paramtab + hashed storage so subshell-scoped
20745 // writes via setsparam/setaparam/sethparam don't leak
20746 // to the parent via paramtab readers.
20747 if let Some(tab) = crate::ported::params::paramtab()
20748 .write()
20749 .ok()
20750 .as_deref_mut()
20751 {
20752 *tab = snap.paramtab;
20753 }
20754 // Restore the global-backed specials (see
20755 // SubshellSnapshot::special_globals). MUST run after the
20756 // paramtab restore above: setsparam writes through the GSU setfn
20757 // to BOTH the process global and the param node, so the paramtab
20758 // overwrite would otherwise clobber the node half of it.
20759 // Restore parent's option store so `(set -e)` /
20760 // `(setopt extendedglob)` don't leak. zsh forks
20761 // subshells so child option changes die with the
20762 // child; we run in-process and must restore. This runs
20763 // BEFORE the special-global replay below: that replay is
20764 // assignsparam, which honours options the body set.
20765 crate::ported::options::opt_state_restore(snap.opts);
20766 for (name, val) in &snap.special_globals {
20767 crate::ported::params::setsparam(name, val);
20768 }
20769 // The body's `setlocale` (c:Src/params.c:4811 setlang,
20770 // c:4877 lcsetfn) changed only the forked child in C.
20771 crate::vm_helper::subsh_locale_restore(snap.locale.as_deref());
20772 // c:Src/exec.c::entersubsh fork semantics — restore
20773 // the parent's `$!`; a background job inside `(...)`
20774 // dies with the child in C zsh.
20775 crate::ported::modules::clone::lastpid
20776 .store(snap.lastpid, std::sync::atomic::Ordering::Relaxed);
20777 // c:Src/exec.c::entersubsh fork semantics — restore the
20778 // parent's job table + curjob/prevjob/maxjob/thisjob.
20779 // The subshell mutated only its own copy. Bug #462.
20780 if let Ok(mut t) = crate::ported::jobs::JOBTAB
20781 .get_or_init(|| std::sync::Mutex::new(Vec::new()))
20782 .lock()
20783 {
20784 *t = snap.jobtab;
20785 }
20786 *crate::ported::jobs::CURJOB
20787 .get_or_init(|| std::sync::Mutex::new(-1))
20788 .lock()
20789 .unwrap() = snap.curjob;
20790 *crate::ported::jobs::PREVJOB
20791 .get_or_init(|| std::sync::Mutex::new(-1))
20792 .lock()
20793 .unwrap() = snap.prevjob;
20794 *crate::ported::jobs::MAXJOB
20795 .get_or_init(|| std::sync::Mutex::new(0))
20796 .lock()
20797 .unwrap() = snap.maxjob;
20798 *crate::ported::jobs::THISJOB
20799 .get_or_init(|| std::sync::Mutex::new(-1))
20800 .lock()
20801 .unwrap() = snap.thisjob;
20802 // c:Src/jobs.c:101/104 — the subshell's oldjobtab died with
20803 // the child in C; put the parent's back.
20804 *crate::ported::jobs::OLDJOBTAB
20805 .get_or_init(|| std::sync::Mutex::new(Vec::new()))
20806 .lock()
20807 .unwrap() = snap.oldjobtab;
20808 *crate::ported::jobs::OLDMAXJOB
20809 .get_or_init(|| std::sync::Mutex::new(0))
20810 .lock()
20811 .unwrap() = snap.oldmaxjob;
20812 if let Some(m) = crate::ported::params::paramtab_hashed_storage()
20813 .lock()
20814 .ok()
20815 .as_deref_mut()
20816 {
20817 *m = snap.paramtab_hashed_storage;
20818 }
20819 exec.set_pparams(snap.positional_params);
20820 // Restore parent's traps (the subshell's own traps die
20821 // with it). zsh: `(trap "X" USR1)` doesn't leak the
20822 // USR1 trap out of the subshell. Write back to the
20823 // canonical `traps_table` (bin_trap writes there).
20824 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
20825 *t = snap.traps;
20826 }
20827 // (The parent's option store was put back above, before
20828 // the special-global replay.)
20829 // c:Src/exec.c::entersubsh — same fork-copy
20830 // semantics for shfunctab. Restore parent's function
20831 // table from snapshot so `(f() { ... })` definitions
20832 // inside the subshell don't leak to the parent.
20833 // Bug #208 in docs/BUGS.md.
20834 if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
20835 tab.restore(snap.shfuncs);
20836 }
20837 // Restore the runtime dispatch tables (compiled chunks
20838 // + source). Without these, a subshell-defined
20839 // override leaves its bytecode in place even after
20840 // shfunctab is restored — `g` after the subshell would
20841 // still run the override.
20842 exec.functions_compiled = snap.functions_compiled;
20843 exec.function_source = snap.function_source;
20844 // c:Src/exec.c::entersubsh — restore parent's THINGYTAB
20845 // so a subshell's `zle -N w f` / `zle -D w` doesn't
20846 // affect the parent's widget registry. Bug #453.
20847 if let Ok(mut t) = crate::ported::zle::zle_thingy::thingytab().lock() {
20848 *t = snap.thingytab;
20849 }
20850 // Same for KEYMAPNAMTAB. Bug #454.
20851 if let Ok(mut t) = crate::ported::zle::zle_keymap::keymapnamtab().lock() {
20852 *t = snap.keymapnamtab;
20853 }
20854 // c:Src/exec.c:2880 — a bare `exec >file` / `exec N>&-`
20855 // inside `(...)` died with the C child; put the parent's
20856 // descriptors back (see SubshFdFrame).
20857 drop(snap.fd_frame);
20858 }
20859 });
20860 // Decrement SUBSHELL_DEPTH. If a deferred subshell exit
20861 // landed inside (EXIT_PENDING set with depth > 0), promote
20862 // the deferred status into the subshell's exit status now
20863 // that we're at the boundary, then clear so the parent
20864 // continues. Matches C zsh's "subshell-exit-via-fork"
20865 // boundary where the child's process::exit(N) becomes
20866 // $WAITSTATUS / $? in the parent.
20867 crate::ported::builtin::SUBSHELL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
20868 // c:Src/exec.c — drain the signal queue against the now-
20869 // restored parent trap table. Pairs with the
20870 // queue_signals() call at the end of subshell_begin.
20871 // Any `kill $$` from inside the subshell is processed
20872 // here against OUTER's trap, matching C zsh's
20873 // signal-delivery-to-parent semantics. Bug #450.
20874 crate::ported::signals_h::unqueue_signals();
20875 // Replay the signals that arrived while the body ran and that
20876 // the PARENT traps, now that the parent's `sigtrapped[]` and
20877 // `traps_table` are back. See `subshell_defer_signal`.
20878 subshell_signal_leave();
20879 // c:Src/exec.c — a `( … )` subshell is a FORK in C: an errflag
20880 // abort inside the child ends the child with its lastval as
20881 // the exit status, and the flag dies with the child process.
20882 // The parent's $? picks up the status and the parent's lists
20883 // keep running. zsh 5.9: `(readonly r=1; r=2); echo "after
20884 // $?"` prints `after 1`. zshrs runs the subshell in-process,
20885 // so mirror the fork isolation by clearing ERRFLAG_ERROR at
20886 // the subshell boundary — exec.last_status() already carries
20887 // the child's lastval (synced by ERREXIT_CHECK trigger 4).
20888 //
20889 // ERRFLAG_HARD must die at this boundary too: `${u:?msg}` sets
20890 // errflag |= ERRFLAG_HARD (c:Src/subst.c:3344) and then, in a
20891 // C forked subshell, `_exit(1)` (c:3353) — the parent never
20892 // sees ANY errflag bit. A leaked HARD bit here made every
20893 // subsequent zerr() take the silent arm (c:Src/utils.c:175-177
20894 // `if (errflag || noerrs) { errflag |= ERRFLAG_ERROR; return; }`),
20895 // so the next eval/source's parse silently "failed" and the
20896 // D04 harness shell wedged after chunk 10's
20897 // `(print ${unset1:?exiting1})`.
20898 //
20899 // !!! DASH-FAMILY GATE — see dash_mode::fatal_error_status !!!
20900 // dash's `sh_error()` unwinds via `exraise(EXERROR)`, which sets
20901 // `exitstatus = 2` before `exitshell()`; the `( … )` boundary IS
20902 // one of the two places that unwind lands, so the subshell reports
20903 // 2 rather than zsh's `lastval == ERRFLAG_ERROR == 1`. Read the
20904 // flag BEFORE the clear below; the status is published at the
20905 // deferred-`exit` arm uses (run_chunk otherwise restores
20906 // `vm.last_status` over any write made here).
20907 let dash_fatal_status = {
20908 let ef = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
20909 let fatal = crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD;
20910 if ef & fatal != 0 {
20911 crate::extensions::dash_mode::fatal_error_status()
20912 } else {
20913 None
20914 }
20915 };
20916 crate::ported::utils::errflag.fetch_and(
20917 !(crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
20918 std::sync::atomic::Ordering::Relaxed,
20919 );
20920 // c:Src/builtin.c:5834 / Src/exec.c:1443 — `retflag` dies at the
20921 // fork boundary for the same reason `errflag` above does. In C a
20922 // `return` inside `( … )` sets retflag in the CHILD; the child's
20923 // execlist unwinds, the child _exit()s, and the PARENT's retflag
20924 // was never touched. zshrs runs subshells in-process, so the flag
20925 // survived and returned from the enclosing FUNCTION:
20926 // f() { ( return 1 ); print IN }; f
20927 // printed nothing where zsh prints `IN`. Storing 0 is exactly a
20928 // restore-to-entry: a non-zero retflag unwinds its list
20929 // immediately (c:1443's `!retflag` gate), so the parent can never
20930 // be sitting at a subshell with the flag already set. Twin of the
20931 // save/restore `$( … )` already does in vm_helper.rs (the
20932 // `saved_retflag` pair around the cmd-subst sub-VM), and of the
20933 // loops/breaks/contflag restore in SubshellSnapshot::loop_flags.
20934 crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
20935 let exit_pending =
20936 crate::ported::builtin::EXIT_PENDING.load(std::sync::atomic::Ordering::Relaxed);
20937 if exit_pending != 0 {
20938 // c:Src/builtin.c — `exit N` masks N to 8 bits because
20939 // POSIX _exit takes the low byte as status. `(exit 256)`
20940 // and `(exit 0)` are indistinguishable to the parent;
20941 // `(exit 257)` exits with 1. Without the mask zshrs's
20942 // in-process subshell propagated the full i32 (256) into
20943 // the parent's $?, diverging from zsh.
20944 let raw = crate::ported::builtin::EXIT_VAL.load(std::sync::atomic::Ordering::Relaxed);
20945 // dash's `exraise(EXERROR)` assigns `exitstatus = 2` at the
20946 // RAISE, so a fatal error wins over whatever deferred-exit
20947 // value the unwind happened to carry. Only an errflag-driven
20948 // unwind reaches this — a real `(exit 5)` never sets the flag,
20949 // so `(exit 5)` still reports 5.
20950 let val = dash_fatal_status.unwrap_or(raw & 0xFF);
20951 with_executor(|exec| exec.set_last_status(val));
20952 crate::ported::builtin::EXIT_PENDING.store(0, std::sync::atomic::Ordering::Relaxed);
20953 crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
20954 crate::ported::builtin::BREAKS.store(0, std::sync::atomic::Ordering::Relaxed);
20955 // Return the deferred-exit status so the VM updates its
20956 // own `last_status`. Otherwise run_chunk's post-script
20957 // `set_last_status(vm.last_status)` would clobber LASTVAL
20958 // back to the stale pre-subshell value.
20959 return Some(val);
20960 }
20961 // Same publication path for a fatal error that did NOT arm a
20962 // deferred exit (e.g. the failed-assignment unwind).
20963 if let Some(st) = dash_fatal_status {
20964 with_executor(|exec| exec.set_last_status(st));
20965 return Some(st);
20966 }
20967 None
20968 }
20969
20970 fn redirect(&mut self, fd: u8, op: u8, target: &str) {
20971 // Apply a redirection at the OS level for the next command/builtin.
20972 // The host tracks saved fds in a per-executor stack so a future
20973 // `with_redirects_end` can restore. For now, this is a thin wrapper
20974 // that performs the dup2; pairing with explicit save/restore is
20975 // delivered by `with_redirects_begin/end`.
20976 with_executor(|exec| exec.host_apply_redirect(fd, op, target));
20977 }
20978
20979 fn with_redirects_begin(&mut self, count: u8) {
20980 with_executor(|exec| exec.host_redirect_scope_begin(count));
20981 }
20982
20983 fn regex_match(&mut self, s: &str, regex: &str) -> bool {
20984 // c:Src/Modules/regex.c:54 `zcond_regex_match` — POSIX ERE
20985 // matching + populate `$MATCH` / `$MBEGIN` / `$MEND` /
20986 // `$match[]` / `$mbegin[]` / `$mend[]` (or `$BASH_REMATCH`
20987 // under BASHREMATCH). Direct delegation to the canonical
20988 // port at src/ported/modules/regex.rs:58.
20989 //
20990 // The bridge passthru path delivers TOKEN-form bytes here
20991 // (Inbrack \u{e191}, Outbrack \u{e192}, Star \u{e187}, Quest
20992 // \u{e186}, etc.) since the lexer tokenizes regex meta chars
20993 // inside `[[ ]]`. The host regex engine expects ASCII, so
20994 // untokenize the pattern (and subject, for safety) once at
20995 // this boundary. zsh C reaches its POSIX-ERE engine through
20996 // the same untokenize path inside zcond_regex_match.
20997 let s_clean = crate::lex::untokenize(s);
20998 let regex_clean = crate::lex::untokenize(regex);
20999 // c:Src/cond.c:113-119 — WHICH engine `=~` uses is an option:
21000 //
21001 // char *modname = isset(REMATCHPCRE) ? "zsh/pcre" : "zsh/regex";
21002 //
21003 // and the two speak different languages (POSIX ERE vs PCRE), so the
21004 // option decides whether `\d` is a digit class or a literal `d`, and
21005 // whether `(?<name>…)` compiles at all. This dispatch was missing:
21006 // `=~` always used the regex module, so `setopt rematchpcre` silently
21007 // did nothing.
21008 if crate::ported::zsh_h::isset(crate::ported::zsh_h::REMATCHPCRE) {
21009 // c:115 — "zsh/pcre" → the `-pcre-match` cond.
21010 crate::ported::modules::pcre::cond_pcre_match(
21011 &[s_clean, regex_clean],
21012 crate::ported::modules::pcre::CPCRE_PLAIN,
21013 ) != 0
21014 } else {
21015 // c:115 — "zsh/regex" → the `-regex-match` cond.
21016 crate::ported::modules::regex::zcond_regex_match(
21017 &[s_clean.as_str(), regex_clean.as_str()],
21018 crate::ported::modules::regex::ZREGEX_EXTENDED,
21019 ) != 0
21020 }
21021 }
21022
21023 fn with_redirects_end(&mut self) {
21024 with_executor(|exec| exec.host_redirect_scope_end());
21025 // c:Src/exec.c:5172 — if any redirect in this scope failed
21026 // (noclobber-blocked, ENOENT for read, etc.), the command's
21027 // exit status is forced to 1 regardless of what the (still-
21028 // executed) command's own exit was. C zsh prevents the
21029 // command from running at all when a redirect fails; the
21030 // Rust port still runs it (sinking output to /dev/null in
21031 // the noclobber arm at host_apply_redirect:5481) and then
21032 // overrides $? here. Same observable effect for the common
21033 // pattern `echo x > existing-file` under noclobber.
21034 let failed = with_executor(|exec| {
21035 let f = exec.redirect_failed;
21036 exec.redirect_failed = false;
21037 f
21038 });
21039 if failed {
21040 with_executor(|exec| exec.set_last_status(1));
21041 }
21042 }
21043
21044 fn heredoc(&mut self, content: &str) {
21045 // C `Src/exec.c:4641` — `parsestr(&buf)` runs parameter +
21046 // command substitution on the heredoc body. The lexer's
21047 // quoted-delimiter detection (`<<'EOF'`) routes through the
21048 // `Op::HereDoc` path in `compile_zsh.rs` which short-circuits
21049 // before reaching here; unquoted forms route through the
21050 // BUILTIN_EXPAND_TEXT mode-4 emit path that calls singsub.
21051 // This handler covers the verbatim/quoted case.
21052 if crate::provenance::active() {
21053 crate::provenance::on_heredoc("heredoc", content);
21054 }
21055 with_executor(|exec| exec.host_set_pending_stdin(content.to_string()));
21056 }
21057
21058 fn herestring(&mut self, content: &str) {
21059 // Shell semantics: herestring appends a newline. `<<<` body
21060 // substitution (`Src/exec.c:4655 getherestr` calls
21061 // `quotesubst` + `untokenize`) lands here verbatim; the
21062 // upstream compiler routes through `Op::HereString` after
21063 // BUILTIN_EXPAND_TEXT for the substitution pass, so callers
21064 // of `host.herestring` see the already-expanded form.
21065 let mut s = content.to_string();
21066 s.push('\n');
21067 if crate::provenance::active() {
21068 crate::provenance::on_heredoc("herestring", &s);
21069 }
21070 with_executor(|exec| exec.host_set_pending_stdin(s));
21071 }
21072
21073 fn exec(&mut self, args: Vec<String>) -> i32 {
21074 // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close
21075 // any `>(cmd)` write ends owned by this command once it
21076 // finishes (drops on every return path below).
21077 let _psub_fds = PsubFdGuard;
21078 // c:Src/subst.c paramsubst — when `${var:?msg}` or `${var?msg}`
21079 // triggered the "parameter null or not set" error, errflag
21080 // is raised and zsh aborts the simple command without
21081 // attempting exec. The expansion may have produced empty
21082 // argv[0] which falls into the c:?/permission-denied path
21083 // below, masking the real diagnostic with a spurious
21084 // "permission denied:" line and rc=126 instead of rc=1.
21085 // Honour errflag here so the script ends with the
21086 // paramsubst error as the sole diagnostic. Bug #86.
21087 //
21088 // c:Src/exec.c — C's execlist loop clears ERRFLAG_ERROR
21089 // between sublists when the error came from a NOMATCH-style
21090 // command failure (glob no-match, etc.) so subsequent
21091 // sublists run. zshrs's vm dispatch handles this at the
21092 // post-command-boundary HERE: if THIS command has its
21093 // `current_command_glob_failed` cell set (meaning the glob
21094 // NOMATCH happened during this command's argv prep), surface
21095 // status 1 and clear BOTH the cell AND ERRFLAG_ERROR so the
21096 // NEXT exec call sees a clean state. The errflag from
21097 // genuine script-fatal errors (parse, redirect, paramsubst
21098 // `${:?msg}`) does NOT come paired with glob_failed, so
21099 // those still short-circuit + propagate.
21100 if let Some(status) = external_glob_failure_status() {
21101 return status;
21102 }
21103 if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
21104 & crate::ported::zsh_h::ERRFLAG_ERROR)
21105 != 0
21106 {
21107 // c:Src/exec.c:3636-3637 — `if (errflag) { lastval = 1; … return; }`
21108 // after prefork. The abort that follows exits with LASTVAL, so
21109 // store it: `/bin/echo $((1/0))` exited with the previous status.
21110 crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
21111 return 1;
21112 }
21113 // c:Src/exec.c — two distinct empty-command cases:
21114 //
21115 // 1. args=[""] — an explicit empty-string command word
21116 // (`""`, `"\$unset"`, `\$'\$x'`). zsh attempts exec(2)
21117 // on the empty path → EACCES → "permission denied", \$?
21118 // = 126.
21119 //
21120 // 2. args=[] — the WORD LIST is empty (unquoted \$(\$cmd)
21121 // that produced empty, or an unquoted unset \$var that
21122 // elided). zsh: no exec is attempted; \$? becomes the
21123 // last cmd-subst's exit status (the inner sub-VM
21124 // already set last_status), and the line completes
21125 // silently. Critically NOT 126.
21126 if args.is_empty() {
21127 // c:Src/exec.c — empty word list passes through to a
21128 // no-op; preserve whatever the inner cmd-subst's exit
21129 // is. Return last_status so the caller's SetStatus
21130 // round-trips correctly.
21131 return with_executor(|exec| exec.last_status());
21132 }
21133 if args[0].is_empty() {
21134 let script_name =
21135 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
21136 let lineno: u64 = with_executor(|exec| {
21137 exec.scalar("LINENO")
21138 .and_then(|s| s.parse::<u64>().ok())
21139 .unwrap_or(1)
21140 });
21141 eprintln!("{}:{}: permission denied: ", script_name, lineno);
21142 return 126;
21143 }
21144 // c:Src/exec.c — when any redirect in the current scope
21145 // failed (e.g. noclobber blocked a `>` overwrite), zsh
21146 // refuses to execute the command and exits with status 1.
21147 // The Rust port still applied the command (writing to the
21148 // /dev/null sink installed by host_apply_redirect's
21149 // noclobber arm), but the success status overwrote the
21150 // intended `1`. Short-circuit here so the exec returns 1
21151 // without running the body.
21152 let redir_failed = with_executor(|exec| {
21153 let f = exec.redirect_failed;
21154 exec.redirect_failed = false;
21155 f
21156 });
21157 if redir_failed {
21158 return 1;
21159 }
21160 // c:Src/exec.c:3545-3547 — `setunderscore(lastnode(args))` for the
21161 // command about to run. Write the canonical `zunderscore` global,
21162 // NOT the paramtab node: `_`'s setfn is `nullstrsetfn`
21163 // (c:Src/params.c:252-253), so a table write has no counterpart in
21164 // C and clobbers the PM_UNSET bit that `unset _` relies on.
21165 if let Some(last) = args.last() {
21166 crate::ported::params::set_zunderscore(std::slice::from_ref(last)); // c:3546
21167 }
21168 // Provenance: record which argv slot each tracked value landed
21169 // in, before the command consumes it.
21170 if crate::provenance::active() {
21171 crate::provenance::on_exec("exec", &args);
21172 }
21173 // Route external command spawning through `executor.execute_external`
21174 // so intercepts (AOP before/after/around), command_hash lookups,
21175 // pre/postexec hooks, and zsh-specific fork-then-exec all apply.
21176 // Without this override, fusevm's default `host.exec` calls
21177 // `Command::new` directly, bypassing zshrs's dispatch logic.
21178 let status = with_executor(|exec| exec.host_exec_external(&args));
21179 // c:Src/jobs.c:1748 waitonejob (no-procs else-branch). zshrs's
21180 // exec model routes external commands through host_exec_external
21181 // (which already waitpid'd in-line); the canonical waitonejob
21182 // expects a Job to derive lastval, but here we already know
21183 // it. Synthesize a procs-less job so waitonejob's no-procs
21184 // branch fires the `pipestats[0]=lastval; numpipestats=1;`
21185 // update via the canonical port.
21186 crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
21187 let mut synth = crate::ported::zsh_h::job::default();
21188 crate::ported::jobs::waitonejob(&mut synth);
21189 status
21190 }
21191
21192 fn cmd_subst(&mut self, sub: &fusevm::Chunk) -> String {
21193 // Run the sub-chunk on a nested VM with the same host wired up,
21194 // capturing stdout. The current executor remains active via the
21195 // thread-local — the nested VM uses CallBuiltin to dispatch shell
21196 // ops back through `with_executor`.
21197 // c:Src/exec.c:4800 getoutput — `mpipe(pipes)`: both ends are moved
21198 // out of the script's fd range (c:Src/utils.c:2210 mpipe → movefd),
21199 // so a closed stdout cannot hand the pipe fd 1 itself.
21200 let (read_end, write_end) = match os_pipe::pipe() {
21201 Ok(p) => p,
21202 Err(_) => return String::new(),
21203 };
21204 let read_end = unsafe {
21205 <os_pipe::PipeReader as std::os::fd::FromRawFd>::from_raw_fd(crate::lowfd::movefd_past_script(
21206 std::os::fd::IntoRawFd::into_raw_fd(read_end),
21207 ))
21208 };
21209 // A closed fd 1 is saved as -1 and restored by closing it again
21210 // (c:Src/utils.c:2047-2048 redup: `if (x < 0) zclose(y)`). Bailing out
21211 // here skipped the body entirely: `{ x=$(print b >&2) } >&-` lost `b`.
21212 // The saves exist only because the body runs in process (C's forked
21213 // child saves nothing): keep them past the script's `{var}` range.
21214 let saved_stdout = unsafe {
21215 libc::fcntl(libc::STDOUT_FILENO, libc::F_DUPFD_CLOEXEC, crate::lowfd::EXTENSION_FD_FLOOR)
21216 };
21217 let saved_stderr = unsafe {
21218 libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD_CLOEXEC, crate::lowfd::EXTENSION_FD_FLOOR)
21219 };
21220 // A job the body forks must not keep them: they belong to the
21221 // parent C never forked the body from (see mark_saved_fd).
21222 mark_saved_fd(saved_stdout);
21223 mark_saved_fd(saved_stderr);
21224 let write_fd = AsRawFd::as_raw_fd(&write_end);
21225 unsafe {
21226 libc::dup2(write_fd, libc::STDOUT_FILENO); // c:4840 redup(pipes[1], 1)
21227 }
21228 drop(write_end);
21229
21230 // c:Bug #56 — publish the saved outer fds so a trap firing
21231 // during the nested VM run can route its body output to the
21232 // PARENT's stdout instead of the cmdsub's pipe-bound fd 1.
21233 // zsh's forked cmdsub gets this for free (trap runs in the
21234 // parent process whose fd 1 is untouched). zshrs's
21235 // in-process cmdsub needs this thread-local stack so the
21236 // trap dispatcher can find the right destination fd.
21237 CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().push((saved_stdout, saved_stderr)));
21238
21239 // Nested scope for `>(cmd)` fd ownership — commands inside
21240 // the cmdsub must not drain the enclosing command's pending
21241 // psub fds (see PSUB_SCOPE_DEPTH).
21242 let _psub_scope = PsubScope::enter();
21243
21244 // c:Src/exec.c:1161 — forked cmdsub child runs entersubsh()
21245 // which does `zsh_subshell++`; in-process equivalent.
21246 let _subshell_bump = CmdSubstSubshellBump::enter();
21247
21248 crate::fusevm_disasm::maybe_print_stdout("host.cmd_subst", sub);
21249 let mut vm = fusevm::VM::new(sub.clone());
21250 register_builtins(&mut vm);
21251 vm.set_shell_host(Box::new(ZshrsHost));
21252 let _ = vm.run();
21253 let cmd_status = vm.last_status;
21254
21255 CMDSUBST_OUTER_FDS.with(|s| {
21256 s.borrow_mut().pop();
21257 });
21258
21259 unsafe {
21260 libc::dup2(saved_stdout, libc::STDOUT_FILENO);
21261 }
21262 crate::ported::utils::zclose(saved_stdout);
21263 if saved_stderr >= 0 {
21264 crate::ported::utils::zclose(saved_stderr);
21265 }
21266
21267 // Inner cmd's status not propagated for the same reason as
21268 // run_command_substitution — see GAPS.md.
21269 let _ = cmd_status;
21270
21271 let mut buf = String::new();
21272 let mut reader = read_end;
21273 let _ = reader.read_to_string(&mut buf);
21274 // Strip trailing newlines (POSIX command substitution semantics)
21275 while buf.ends_with('\n') {
21276 buf.pop();
21277 }
21278 // Provenance: a command substitution is a lineage ORIGIN — the
21279 // bytes did not exist in the shell before this ran.
21280 if crate::provenance::active() {
21281 crate::provenance::on_cmd_subst(&prov_chunk_label(sub), &buf);
21282 }
21283 buf
21284 }
21285
21286 fn call_function(&mut self, name: &str, args: Vec<String>) -> Option<i32> {
21287 // c:Src/exec.c — when the command word is empty (e.g. `""`
21288 // or `"$nonexistent"`), zsh attempts the exec(2) which
21289 // returns EACCES ("permission denied") and exits 126. The
21290 // Rust port silently treated empty as a no-op (status 0).
21291 // Match zsh by emitting the diagnostic and returning 126.
21292 if name.is_empty() {
21293 let script_name =
21294 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
21295 let lineno: u64 = with_executor(|exec| {
21296 exec.scalar("LINENO")
21297 .and_then(|s| s.parse::<u64>().ok())
21298 .unwrap_or(1)
21299 });
21300 eprintln!("{}:{}: permission denied: ", script_name, lineno);
21301 with_executor(|exec| exec.set_last_status(126));
21302 return Some(126);
21303 }
21304 // c:Src/exec.c — redirect failure in this scope means the
21305 // command should NOT run. The Host::exec path already has
21306 // this gate (at fn exec above); call_function takes external
21307 // commands like `cat <&3` through a different code path, so
21308 // gate here too. Without this, bad-fd redirects produced
21309 // the diagnostic but the external command still ran, so $?
21310 // came out from the command's natural exit instead of the
21311 // forced 1.
21312 let redir_failed = with_executor(|exec| {
21313 let f = exec.redirect_failed;
21314 exec.redirect_failed = false;
21315 f
21316 });
21317 // c:Src/exec.c:4147-4154 — a failed prefix assignment skips the
21318 // command with `lastval = 1` in the same way. For an external the
21319 // assignments run in the forked child (c:4343-4350 `addvars(…); if
21320 // (errflag) _exit(1);`), so the shell again only sees status 1:
21321 // x=${bad?err} /bin/echo ran; print rc=$? zsh: rc=1
21322 // while a shell function runs them in the shell, whose errflag ends
21323 // the list.
21324 let prefix_failed = PREFIX_ASSIGN_FAILED.with(|c| c.replace(false));
21325 if redir_failed || prefix_failed {
21326 // c:Src/exec.c:3719 forks an external command (execcmd_fork)
21327 // BEFORE the redirection loop at c:3785, so a redirection that
21328 // `zerr`s (`<&""` → "file number expected", c:Src/glob.c:2192)
21329 // raises errflag in the CHILD, which `_exit(1)`s; the parent only
21330 // sees status 1 and runs the next command:
21331 // /bin/cat <&""; print after $? zsh: after 1
21332 // zshrs applies redirections in the shell before choosing what
21333 // runs, so the errflag landed in the shell and ended the script.
21334 // For a name that resolves to neither a function nor a zsh
21335 // builtin, take the error back out of the shell's errflag. A
21336 // zshrs extension builtin counts as external here: zsh has no
21337 // such builtin, so the same name reaches execcmd_fork there
21338 // (an in-process stand-in for `env`, `cat`, … must fail like the
21339 // command it stands in for: `env <&""; print after` continues).
21340 let is_external = !with_executor(|exec| exec.function_exists(name))
21341 && !crate::ported::builtin::createbuiltintable().contains_key(name);
21342 if is_external {
21343 // A `${name?msg}` in the prefix assignments also sets
21344 // ERRFLAG_HARD; the forked child took both bits with it.
21345 let bits = if prefix_failed {
21346 crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD
21347 } else {
21348 crate::ported::zsh_h::ERRFLAG_ERROR
21349 };
21350 crate::ported::utils::errflag.fetch_and(!bits, std::sync::atomic::Ordering::Relaxed);
21351 // A NOMATCH / NULL_GLOB failure in the redirect target's
21352 // filename generation belongs to the same child: take its
21353 // carrier too, or the command-boundary gate still ends the list.
21354 let _ = external_glob_failure_status();
21355 }
21356 with_executor(|exec| exec.set_last_status(1));
21357 return Some(1);
21358 }
21359 // c:Src/exec.c:3755-3763 — a shell function is not forked, so a
21360 // failed argument glob is `lastval = 1; goto err;` in the shell: the
21361 // body never runs, and errflag (left set) ends the list with status
21362 // 1 (`f() { :; }; f *nomatch` exits 1).
21363 if with_executor(|exec| exec.function_exists(name))
21364 && with_executor(|exec| exec.current_command_glob_failed.replace(false))
21365 {
21366 consume_tilde_globsubst_carrier();
21367 with_executor(|exec| exec.set_last_status(1));
21368 crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
21369 return Some(1);
21370 }
21371 // Provenance: same argv record as `exec`, but ONLY when the name
21372 // really resolves to a shell function — an external command
21373 // reaches `exec` further down and would otherwise be recorded
21374 // twice for the same call site.
21375 if crate::provenance::active() && with_executor(|exec| exec.function_exists(name)) {
21376 let mut argv = Vec::with_capacity(args.len() + 1);
21377 argv.push(name.to_string());
21378 argv.extend(args.iter().cloned());
21379 crate::provenance::on_exec("call", &argv);
21380 }
21381 // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
21382 // functions, NOT builtins. zshrs ships fast native impls, but they
21383 // must behave like the zsh functions — command-not-found until
21384 // `autoload -Uz <name>` creates a function entry. When autoloaded we
21385 // run the native impl here (short-circuiting the fpath source, which
21386 // can hang zshrs's parser on zsh-specific syntax); when NOT autoloaded
21387 // we fall through (return None → resolution ends in command-not-found),
21388 // matching `zsh -f; zmv` → "command not found: zmv".
21389 // A DEFINED zmv/zcp/zln/zcalc body (user-written, or a stub already
21390 // loaded by `autoload +X`) is an ordinary shell function and runs as
21391 // one through the function dispatch below. The native port stands in
21392 // only for the fpath load of a still-PM_UNDEFINED stub
21393 // (c:Src/exec.c:5691-5695 execautofn → loadautofn, which clears
21394 // PM_UNDEFINED at c:5790/c:5815).
21395 let native_autoload =
21396 matches!(name, "zmv" | "zcp" | "zln" | "zcalc") && autoload_stub_pending(name);
21397 if matches!(name, "zmv" | "zcp" | "zln" | "zcalc")
21398 && !with_executor(|exec| exec.function_exists(name))
21399 {
21400 return None;
21401 }
21402 // c:Src/exec.c:3485-3489 — `shfunctab->getnode` is consulted before
21403 // `builtintab`: a shell function shadows every builtin of the same
21404 // name. The zshrs-only builtins dispatched by name below (znative,
21405 // ztest_*, the daemon z* family) are builtins too, so they sit behind
21406 // the same function check as the builtintab arm further down.
21407 let has_user_fn = user_fn_shadows_builtin(name);
21408 match name {
21409 "zmv" if native_autoload => {
21410 return Some(crate::extensions::ext_builtins::zmv(&args, "mv"));
21411 }
21412 "zcp" if native_autoload => {
21413 return Some(crate::extensions::ext_builtins::zmv(&args, "cp"));
21414 }
21415 "zln" if native_autoload => {
21416 return Some(crate::extensions::ext_builtins::zmv(&args, "ln"));
21417 }
21418 "zcalc" if native_autoload => {
21419 return Some(crate::extensions::ext_builtins::zcalc(&args));
21420 }
21421 // znative — the plugin package manager (src/extensions/pkg/). Installs
21422 // + loads zsh script and native (Rust cdylib) plugins from a global
21423 // content-addressed store. `znative add owner/repo`, `znative load`, ...
21424 "znative" if !has_user_fn => {
21425 return Some(crate::extensions::pkg::builtin::znative(&args));
21426 }
21427 // ztest framework (src/extensions/ztest.rs — port of
21428 // ../strykelang's unit-test framework). All zassert_*/
21429 // ztest_* names route through the single try_dispatch
21430 // helper so adding/removing assertions only touches
21431 // ztest.rs.
21432 n if !has_user_fn && crate::extensions::ztest::try_dispatch_known(n) => {
21433 let status = with_executor(|exec| {
21434 crate::extensions::ztest::try_dispatch(exec, n, &args).unwrap_or(1)
21435 });
21436 return Some(status);
21437 }
21438 // Daemon-managed z* builtins — thin IPC wrappers. Short-circuit BEFORE
21439 // the function-lookup path so a missing daemon doesn't fall through to
21440 // "command not found". The name list is owned by the daemon crate
21441 // (zshrs_daemon::builtins::ZSHRS_BUILTIN_NAMES); routing through
21442 // try_dispatch keeps this site zero-touch as new z* builtins land.
21443 n if !has_user_fn && crate::daemon::builtins::is_zshrs_builtin(n) => {
21444 return run_daemon_builtin(n, &args);
21445 }
21446 _ => {}
21447 }
21448
21449 // c:Src/exec.c:3050-3068 — module-provided builtins (registered
21450 // via each module's `bintab` and folded into the canonical
21451 // `builtintab` by `createbuiltintable`) must dispatch BEFORE
21452 // PATH lookup. fusevm's `shell_builtins::builtin_id` doesn't
21453 // know about per-module entries like `log`
21454 // (Src/Modules/watch.c:693) — they reach call_function as
21455 // CallFunction ops. Consult the merged builtintab here so
21456 // `log` runs the canonical `bin_log` instead of falling
21457 // through to `/usr/bin/log` on macOS. Bug #72 in docs/BUGS.md.
21458 //
21459 // User-defined functions still take precedence over builtins
21460 // (zsh's `alias → function → builtin → external` resolution
21461 // order, c:Src/exec.c:3038-3068). Check `functions_compiled`
21462 // first so a user `log() { ... }` shadows the module bin_log.
21463 // c:Src/exec.c — shfunctab->getnode (the DISABLED-filtering
21464 // accessor) returns NULL for entries flipped to DISABLED via
21465 // `disable -f NAME`. functions_compiled holds the body
21466 // independently of the DISABLED flag, so check shfunctab first
21467 // and mask the lookup when the entry is disabled (see
21468 // `user_fn_shadows_builtin`; `has_user_fn` computed above). Bug #221
21469 // in docs/BUGS.md.
21470 if !has_user_fn {
21471 // c:Src/exec.c:3056 — `builtintab->getnode(builtintab,
21472 // cmdarg)` returns NULL for DISABLED entries, falling
21473 // execcmd through to PATH lookup. Mirror by gating the
21474 // bn_in_tab match on the BUILTINS_DISABLED set. Bug #106
21475 // in docs/BUGS.md.
21476 let disabled = crate::ported::builtin::BUILTINS_DISABLED
21477 .lock()
21478 .map(|s| s.contains(name))
21479 .unwrap_or(false);
21480 let bn_in_tab =
21481 !disabled && crate::ported::builtin::createbuiltintable().contains_key(name);
21482 if bn_in_tab {
21483 // c:Src/exec.c:4287 — `lastval = execbuiltin(args, assigns,
21484 // (Builtin) hn);`. The store happens BEFORE any errflag
21485 // handling, so a builtin that BOTH raises errflag (zerr) and
21486 // returns non-zero still publishes its status. zshrs relied on
21487 // the VM's trailing `SetStatus` op to publish it, and that op
21488 // is skipped once the builtin set ERRFLAG_ERROR — so
21489 // `() { private SECONDS }` (makeprivate's zerrnam + return 1)
21490 // reported 0 where zsh reports 1 (V10private.ztst:22).
21491 let __ret = dispatch_builtin_raw(name, args);
21492 // c:4289-4293 — `if (!(errflag & ERRFLAG_INT)) lastval = ret;`
21493 // (see `dispatch_builtin`).
21494 let __st = if (crate::ported::utils::errflag
21495 .load(std::sync::atomic::Ordering::Relaxed)
21496 & crate::ported::zsh_h::ERRFLAG_INT)
21497 != 0
21498 {
21499 crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed)
21500 } else {
21501 __ret
21502 };
21503 crate::ported::builtin::LASTVAL.store(__st, std::sync::atomic::Ordering::Relaxed); // c:4287
21504 with_executor(|exec| exec.set_last_status(__st)); // c:4287
21505 return Some(__st);
21506 }
21507 // zshrs-original opcode builtins (async, doctor, peach, …) are not
21508 // in builtintab, so a run-time-resolved name (`$var`) never reaches
21509 // them. Dispatch by name here — after ported builtins, before
21510 // external — matching the shell's function -> builtin -> external
21511 // order (`has_user_fn` was checked above, so functions still win).
21512 if let Some(status) = try_run_registered_builtin(name, &args) {
21513 return Some(status);
21514 }
21515 }
21516
21517 // c:Src/lex.c:1928 — an alias is replaced by its text while the line is
21518 // LEXED; nothing consults the alias table once the command runs. The
21519 // dispatch-time rewrite that used to sit here (gated on INTERACTIVE)
21520 // expanded the already-expanded command name a second time:
21521 // `alias ls='ls -G'; ls x` ran `ls -G -G x`, and `\ls` picked it up.
21522
21523 // $_ pre-body bump and pending-underscore tracking are
21524 // ZshrsHost-only concerns (prompt rendering). Apply BEFORE
21525 // delegating to dispatch_function_call so the body sees the
21526 // bumped value.
21527 //
21528 // c:Src/exec.c:3491 — `setunderscore((args && nonempty(args))
21529 // ? ((char *) getdata(lastnode(args))) : "")`. C sets $_ to
21530 // the LAST node of the WHOLE args list (which includes argv[0]
21531 // == the function name). So for a no-arg `f`, $_ becomes "f"
21532 // inside the function body. The Rust port at the CallFunction
21533 // op-handler receives `args` WITHOUT the command name
21534 // (compile_zsh.rs:1571 only pushes simple.words[1..]). The
21535 // last() fallback `|| fn_name.clone()` already covers the
21536 // no-arg case, but `exec.set_scalar("_", ...)` writes paramtab
21537 // — the canonical `$_` read goes through `underscoregetfn`
21538 // (params.rs:7836) which reads the `zunderscore` Mutex.
21539 // setsparam("_") doesn't update that mutex, so the body's
21540 // `${_}` returned empty. Bug #279 in docs/BUGS.md. Mirror the
21541 // C `setunderscore` by writing via `set_zunderscore` directly.
21542 let fn_name = name.to_string();
21543 // c:Src/exec.c:5487-5488 — an ANONYMOUS function (execfuncdef, no names)
21544 // sets `$_` from its own arguments only, `""` when it has none; the
21545 // name pushed in front (c:5493) is not one of them.
21546 let anon_fn = fn_name.starts_with("_zshrs_anon_");
21547 let no_arg_underscore = if anon_fn { String::new() } else { fn_name.clone() };
21548 {
21549 let dollar_underscore = args.last().cloned().unwrap_or_else(|| no_arg_underscore.clone());
21550 // c:3546 — zunderscore is the only store; the paramtab write
21551 // that used to accompany this cleared PM_UNSET (see pop_args).
21552 crate::ported::params::set_zunderscore(std::slice::from_ref(&dollar_underscore));
21553 }
21554
21555 // Delegate the actual function dispatch to the canonical
21556 // `dispatch_function_call` (which itself wraps the canonical
21557 // `doshfunc` port from `Src/exec.c:5823`). Single doshfunc
21558 // call-site keeps scope-mgmt invariants in one place.
21559 // c:Src/exec.c:772-776 — a function runs unchanged under `-`; only a
21560 // name that is not a function falls through to the external spawn,
21561 // which still needs the carrier.
21562 let exec_dash = take_exec_carrier();
21563 let job_text = JOB_TEXT.with(|t| t.borrow_mut().take());
21564 PREFORK_CUT.with(|c| c.set(false)); // the words are complete
21565 // c:Src/exec.c:3674-3680 — a shell function runs in the current shell.
21566 if with_executor(|exec| exec.function_exists(&fn_name)) {
21567 crate::exec_jobs::mark_thisjob_cursh(false);
21568 }
21569 let status = with_executor(|exec| exec.dispatch_function_call(&fn_name, &args));
21570 if status.is_none() {
21571 EXEC_DASH.with(|c| c.set(exec_dash));
21572 JOB_TEXT.with(|t| *t.borrow_mut() = job_text);
21573 } else {
21574 // c:Src/exec.c:5643-5644 execshfunc — `if (!list_pipe)
21575 // deletefilelist(last_file_list, 0);` once doshfunc returns: the
21576 // `=(cmd)` temp files and `>(cmd)` fds made for the call's
21577 // arguments go with it, also when the body ran `exit` (that only
21578 // sets exit_pending, the function still returns here first).
21579 close_pending_psub_fds();
21580 }
21581
21582 // Anonymous functions (`() { … } args`, compiled by
21583 // parse_anon_funcdef as `_zshrs_anon_N` / `_zshrs_anon_kw_N`)
21584 // execute exactly ONCE and must not persist. zsh runs the body and
21585 // frees the function, so `${functions}` / `typeset -f` never show
21586 // it. Remove every trace right after the single invocation —
21587 // AFTER `status` is captured, so the body's exit code is preserved
21588 // ($? — calling `unfunction` here would reset it to 0 instead).
21589 // Without this, real plugins that use `() { … }` (fzf-tab, zinit,
21590 // p10k, …) leaked dozens of `_zshrs_anon_N` into `$functions`,
21591 // diverging from zsh's function table on every such config.
21592 if fn_name.starts_with("_zshrs_anon_") {
21593 // `${functions}` / `typeset -f` enumerate the canonical
21594 // `shfunctab` (via scanpmfunctions); the bytecode call path
21595 // also keeps the body in the executor's compiled-fn maps. Clear
21596 // BOTH so no trace of the one-shot anon survives.
21597 crate::ported::hashtable::removeshfuncnode(&fn_name);
21598 with_executor(|exec| {
21599 exec.functions_compiled.remove(&fn_name);
21600 exec.function_source.remove(&fn_name);
21601 exec.function_line_base.remove(&fn_name);
21602 exec.function_def_file.remove(&fn_name);
21603 });
21604 }
21605
21606 // c:Src/exec.c:6207-6265 — doshfunc saves `ou = zunderscore`
21607 // around the body and runs `setunderscore(ou)` (c:6257) on the way
21608 // out, so a function call leaves `$_` at the CALL's last argument
21609 // rather than at whatever the body's last command set. The value
21610 // saved there is the one execcmd_exec installed just before the
21611 // call (c:3546), i.e. exactly `args.last()`.
21612 {
21613 let last_call_arg = args.last().cloned().unwrap_or(no_arg_underscore);
21614 crate::ported::params::set_zunderscore(std::slice::from_ref(&last_call_arg));
21615 // c:6257
21616 }
21617
21618 status
21619 }
21620}
21621
21622// ───────────────────────────────────────────────────────────────────────────
21623/// Render a failed-redirect open error the way C's `zerrmsg` `%e` format
21624/// code does (Src/utils.c): `strerror(errno)` with the first character
21625/// lowercased, except `EIO` (kept capitalized) and `EINTR` (→ "interrupt").
21626/// C's redirect open failures call `zwarn("%e: %s", errno, fname)`
21627/// (Src/exec.c:3741); zshrs's `zwarning` takes a pre-built string, so the
21628/// `%e` part is built here. Replaces the prior hardcoded `ErrorKind` match
21629/// that fell back to a generic "redirect failed" for `EROFS`/`EACCES`/etc.
21630/// Port of `IS_WRITE_FILE` (zsh.h:403, `REDIR_WRITE <= X <= REDIR_READWRITE`)
21631/// over the fusevm redirect op bytes compile_zsh lowers those types to:
21632/// `>`/`>|` (WRITE/CLOBBER), `>>`/`>>|` (APPEND), `>&`/`&>` and their append
21633/// forms (WRITE_BOTH/APPEND_BOTH, compile_zsh.rs REDIR_ERRWRITE..), `<>`.
21634fn redir_is_write_file(op_byte: u8) -> bool {
21635 matches!(
21636 op_byte,
21637 r::WRITE | r::APPEND | r::CLOBBER | r::READ_WRITE | r::WRITE_BOTH | r::APPEND_BOTH
21638 )
21639}
21640
21641fn redir_errno_msg(err: &std::io::Error) -> String {
21642 let errno = match err.raw_os_error() {
21643 Some(n) if n != 0 => n,
21644 _ => return "redirect failed".to_string(),
21645 };
21646 if errno == libc::EINTR {
21647 return "interrupt".to_string(); // c:zerrmsg %e — EINTR special-case
21648 }
21649 let cptr = unsafe { libc::strerror(errno) };
21650 if cptr.is_null() {
21651 return "redirect failed".to_string();
21652 }
21653 let msg = unsafe { std::ffi::CStr::from_ptr(cptr) }.to_string_lossy();
21654 if errno == libc::EIO {
21655 return msg.into_owned(); // c:zerrmsg %e — EIO keeps capitalization
21656 }
21657 // c:zerrmsg %e — `fputc(tulower(errmsg[0])); fputs(errmsg + 1)`.
21658 let mut chars = msg.chars();
21659 match chars.next() {
21660 Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
21661 None => "redirect failed".to_string(),
21662 }
21663}
21664
21665// Host-routed shell ops: ShellExecutor methods invoked by ZshrsHost from the
21666// fusevm VM. Not a port of Src/exec.c (see file-level docs above) — they're
21667// the bridge between fusevm opcodes and ShellExecutor state.
21668// ───────────────────────────────────────────────────────────────────────────
21669impl ShellExecutor {
21670 // ─── Host-routed shell ops (called by ZshrsHost from fusevm) ────────────
21671
21672 /// Apply a single redirection. The current scope's saved-fd vec gets a
21673 /// dup of the original fd so it can be restored by `host_redirect_scope_end`.
21674 /// `op_byte` matches `fusevm::op::redirect_op::*`.
21675 /// Park the CURRENT contents of `fd` in the enclosing redirect
21676 /// scope so `host_redirect_scope_end` can restore them.
21677 ///
21678 /// c:Src/exec.c:2421-2443 — `addfd`'s "starting a new multio" arm:
21679 ///
21680 /// ```c
21681 /// if (!forked && save[fd1] == -2) {
21682 /// if (fd1 == fd2) save[fd1] = -1;
21683 /// else {
21684 /// int fdN = movefd(fd1);
21685 /// /* fd1 may already be closed here, so
21686 /// * ignore bad file descriptor error */
21687 /// if (fdN < 0) { if (errno != EBADF) { … } }
21688 /// …
21689 /// save[fd1] = fdN;
21690 /// }
21691 /// }
21692 /// ```
21693 ///
21694 /// A `save[]` slot of -1 therefore means "fd1 was CLOSED before
21695 /// this redirection", and `fixfds` (c:4522-4532) feeds it to
21696 /// `redup(-1, i)`, whose first arm is `zclose(y)` (c:Src/utils.c:
21697 /// 2047-2048). So zsh CLOSES the fd again at teardown rather than
21698 /// leaving the redirection behind — `print x 3<file; cat <&3`
21699 /// reports "3: bad file descriptor" in zsh.
21700 ///
21701 /// c:Src/exec.c:3978-3986 — a bare `exec` (nullexec==1)
21702 /// "specifically *doesn't* restore the original fd's", so nothing
21703 /// is parked for it.
21704 pub fn save_fd_for_scope(&mut self, fd: i32) {
21705 if self.exec_redirs_permanent {
21706 // Permanent — except inside an in-process subshell, whose
21707 // fd table C forks away (see SubshFdFrame).
21708 crate::ported::exec::SubshFdFrame::touch(fd);
21709 return;
21710 }
21711 // c:2478 — `if (!forked && save[fd1] == -2)`.
21712 if redir_scope_forked(self.redirect_scope_stack.len()) {
21713 return;
21714 }
21715 // c:2425 `movefd(fd1)` — zshrs keeps the original fd open and
21716 // dups it aside instead (the caller's dup2 overwrites it), so
21717 // F_DUPFD stands in for movefd's dup-then-close.
21718 let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
21719 // c:2422-2423 / c:2430-2436 — a closed fd1 parks -1, not a dup.
21720 let slot = if saved >= 0 { saved } else { -1 };
21721 if let Some(top) = self.redirect_scope_stack.last_mut() {
21722 top.push((fd, slot));
21723 mark_saved_fd(slot);
21724 } else if saved >= 0 {
21725 // No scope — leave saved fd open and let the next scope
21726 // reclaim it. (Caller without a scope leaks the dup; this
21727 // matches `WithRedirects` parser construction always wrapping.)
21728 unsafe { libc::close(saved) };
21729 }
21730 }
21731
21732 /// Apply a file-open result to a redirect fd; on error, emit
21733 /// zsh-format diagnostic, set redirect_failed, sink fd to /dev/null.
21734 /// Shared between WRITE/APPEND/READ/CLOBBER arms in
21735 /// host_apply_redirect to keep the error-handling identical.
21736 fn redir_open_or_fail(
21737 fd: i32,
21738 result: std::io::Result<fs::File>,
21739 target: &str,
21740 redirect_failed: &mut bool,
21741 ) -> bool {
21742 match result {
21743 Ok(file) => {
21744 let new_fd = file.into_raw_fd();
21745 unsafe {
21746 // When the target fd was already closed (e.g. `exec 0<&-;
21747 // cmd < file`), open() returns the lowest free fd, which is
21748 // `fd` itself. Then `dup2(fd, fd)` is a no-op: closing new_fd
21749 // would CLOSE the fd we just opened, AND — since Rust's
21750 // File::open sets O_CLOEXEC and a no-op dup2 does NOT clear
21751 // it — an exec'd child would lose the descriptor. So in the
21752 // reuse case, keep the fd and clear its close-on-exec flag;
21753 // otherwise dup2 (which clears cloexec on the copy) + close.
21754 if new_fd != fd {
21755 libc::dup2(new_fd, fd);
21756 libc::close(new_fd);
21757 } else {
21758 libc::fcntl(fd, libc::F_SETFD, 0);
21759 }
21760 }
21761 true
21762 }
21763 Err(e) => {
21764 // c:Src/exec.c:3741 — zwarn("%e: %s", errno, fname) with the
21765 // real lineno prefix; redir_errno_msg builds the `%e` errno
21766 // message for all errnos (not just the few hardcoded before).
21767 let msg = redir_errno_msg(&e);
21768 crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
21769 *redirect_failed = true;
21770 // The /dev/null sink keeps a failed scoped redirect
21771 // from leaking the aborted command's output to the
21772 // wrong fd until scope-end restores it. For a bare
21773 // `exec` redirect (permanent, no scope restore) C
21774 // leaves the fd UNTOUCHED — execerr() aborts the
21775 // statement and the original fd 1 keeps flowing
21776 // (A04redirect: `exec >./nonexistent/x` then `echo
21777 // output` still prints). c:Src/exec.c:3735-3742.
21778 let permanent = with_executor(|exec| exec.exec_redirs_permanent);
21779 if !permanent {
21780 if let Ok(devnull) = fs::OpenOptions::new()
21781 .read(true)
21782 .write(true)
21783 .open("/dev/null")
21784 {
21785 let new_fd = devnull.into_raw_fd();
21786 unsafe {
21787 if new_fd != fd {
21788 libc::dup2(new_fd, fd);
21789 libc::close(new_fd);
21790 } else {
21791 libc::fcntl(fd, libc::F_SETFD, 0);
21792 }
21793 }
21794 }
21795 }
21796 false
21797 }
21798 }
21799 }
21800 /// REDIR_ERRWRITE / REDIR_ERRAPP (`>&word`, `>>&word`, `&>`, `&>>`):
21801 /// one file onto BOTH fd 1 and fd 2. c:Src/exec.c:3985-3999 — the open
21802 /// failing is `zwarn("%e: %s", errno, fn->name); execerr();`, i.e. the
21803 /// diagnostic, status 1 and the command abandoned; an empty name fails
21804 /// with ENOENT (`>&''` → `no such file or directory: `).
21805 fn redir_errwrite(&mut self, result: std::io::Result<fs::File>, target: &str) {
21806 match result {
21807 Ok(file) => {
21808 let new_fd = file.into_raw_fd();
21809 unsafe {
21810 libc::dup2(new_fd, 1);
21811 libc::dup2(new_fd, 2);
21812 libc::close(new_fd);
21813 }
21814 }
21815 Err(e) => {
21816 crate::ported::utils::zwarn(&format!("{}: {}", redir_errno_msg(&e), target));
21817 self.set_last_status(1);
21818 self.redirect_failed = true;
21819 }
21820 }
21821 }
21822 /// `host_apply_redirect` — see implementation.
21823 pub fn host_apply_redirect(&mut self, fd: u8, op_byte: u8, target: &str) {
21824 if redir_target_expansion_failed(self) {
21825 return;
21826 }
21827 // c:Src/exec.c:3775-3777 — in a pipeline stage whose input is the
21828 // pipe, mfds[0] already holds it, so `<file` / `<&N` becomes the
21829 // multio's second member (c:2447-2480) instead of replacing it.
21830 if fd == 0
21831 && matches!(op_byte, r::READ | r::DUP_READ)
21832 && target.trim_start_matches('&') != "-"
21833 {
21834 if let Some(seed) = take_pipe_input_seed(self, 0) {
21835 self.host_apply_redirect(0, op_byte, target);
21836 let member = if self.redirect_failed {
21837 -1
21838 } else {
21839 unsafe { libc::dup(0) }
21840 };
21841 if member < 0 {
21842 let _ = crate::ported::utils::zclose(seed);
21843 return;
21844 }
21845 let member = crate::ported::utils::movefd(member); // c:2465
21846 multios_read_concat(self, 0, vec![seed, member]);
21847 return;
21848 }
21849 }
21850 // `&>` / `&>>` always target both fd 1 and fd 2 regardless of the
21851 // fd byte the parser supplied (the lexer's tokfd clamp makes the
21852 // raw value unreliable for these forms).
21853 let fd: i32 = if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
21854 1
21855 } else {
21856 fd as i32
21857 };
21858 // c:Src/exec.c — for DUP_READ / DUP_WRITE forms (<&N / >&N),
21859 // validate the source fd is open BEFORE the save-and-dup
21860 // dance below. The save's `dup(fd)` reclaims the lowest free
21861 // fd, which on closed-fd reuse would let dup2(src=N, …)
21862 // succeed against the freshly-claimed slot — masking the
21863 // user's "bad file descriptor" error. Check src_fd first.
21864 if matches!(op_byte, r::DUP_READ | r::DUP_WRITE) {
21865 let n_check = target.trim_start_matches('&');
21866 if n_check != "-" {
21867 if let Ok(src_fd) = n_check.parse::<i32>() {
21868 // c:Src/exec.c:3884-3897 — a descriptor above 9 that
21869 // the shell knows about is NOT the script's to
21870 // duplicate:
21871 //
21872 // else if (fn->fd2 > 9 &&
21873 // (fn->fd2 <= max_zsh_fd &&
21874 // ((fdtable[fn->fd2] != FDT_UNUSED &&
21875 // fdtable[fn->fd2] != FDT_EXTERNAL) ||
21876 // fn->fd2 == coprocin ||
21877 // fn->fd2 == coprocout))) {
21878 // fil = -1;
21879 // errno = EBADF;
21880 //
21881 // `FDT_EXTERNAL` is exempt because that is a
21882 // descriptor the script itself asked for (`{v}>file`,
21883 // c:2409) and 0/1/2 (c:Src/init.c:1900). Anything
21884 // past `max_zsh_fd` is left alone on purpose —
21885 // c:3886-3891: "the shell doesn't know about it. Just
21886 // assume the user knows what they're doing."
21887 //
21888 // Only the open-ness of the descriptor was checked
21889 // here, so `>&11` happily duplicated the shell's own
21890 // history database and `>&10` its log. The `exec
21891 // N>&-` half of this pair was already ported
21892 // (fusevm_bridge.rs:7047, c:3830-3835); this half was
21893 // not, and the ported copy in `ported/exec.rs:11466`
21894 // is not on the VM's redirection path.
21895 let shell_owned = src_fd > 9 && {
21896 let max_fd =
21897 crate::ported::utils::MAX_ZSH_FD.load(std::sync::atomic::Ordering::Relaxed);
21898 let cin = crate::ported::modules::clone::coprocin
21899 .load(std::sync::atomic::Ordering::Relaxed);
21900 let cout = crate::ported::modules::clone::coprocout
21901 .load(std::sync::atomic::Ordering::Relaxed);
21902 src_fd <= max_fd && {
21903 let kind = crate::ported::utils::fdtable_get(src_fd)
21904 & crate::ported::zsh_h::FDT_TYPE_MASK;
21905 (kind != crate::ported::zsh_h::FDT_UNUSED
21906 && kind != crate::ported::zsh_h::FDT_EXTERNAL)
21907 || src_fd == cin
21908 || src_fd == cout
21909 }
21910 };
21911 if unsafe { libc::fcntl(src_fd, libc::F_GETFD) } == -1 || shell_owned {
21912 // c:Src/exec.c — zwarn with real lineno prefix.
21913 crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src_fd));
21914 self.set_last_status(1);
21915 self.redirect_failed = true;
21916 return;
21917 }
21918 }
21919 }
21920 }
21921 // c:Src/exec.c:3978-3986 — bare `exec` redirects (nullexec==1)
21922 // skip the save entirely: "we specifically *don't* restore the
21923 // original fd's". C's save[] is per-execcmd, so exec's redirs
21924 // never enter an enclosing group's save list either; pushing
21925 // into `redirect_scope_stack.last_mut()` here (the enclosing
21926 // group's scope) made `{ exec 1>&-; … } 2>/dev/null` restore
21927 // stdout at group end — diverging from zsh, which keeps fd 1
21928 // closed for the rest of the script. `save_fd_for_scope` makes
21929 // that call (it parks nothing for a bare `exec` outside an
21930 // in-process subshell).
21931 self.save_fd_for_scope(fd);
21932 // For `&>` / `&>>` also save fd 2 so the scope restores it after
21933 // the body. Otherwise stderr stays redirected past the command.
21934 if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
21935 self.save_fd_for_scope(2);
21936 }
21937 // c:Src/exec.c:3722-3724 + 2447-2480 — MULTIOS split when this
21938 // command's stdout IS the pipeline output. C registers the pipe
21939 // in mfds[1] (`addfd(forked, save, mfds, 1, output, 1, NULL)`)
21940 // BEFORE walking the explicit redirect list, so a write-side
21941 // redirect of fd 1 finds mfds[1] occupied and, with MULTIOS
21942 // set, "split[s] the stream": fd 1 becomes the write end of an
21943 // internal pipe whose reader tees every chunk to BOTH the
21944 // pipeline pipe and the new target. That is why
21945 // `{ echo a; echo b >&2; } 3>&1 1>&2 2>&3 3>&- | cat` sends
21946 // `a` to the pipe (via the tee) AND to stderr — plain dup2
21947 // replacement loses the pipe stream. The scope-depth gate
21948 // mirrors mfds being per-execcmd: only the redirect list
21949 // attached to the stage's own command joins the pipe; nested
21950 // commands inside the body (`{ echo a > f; } | cat`) get a
21951 // fresh "mfds" and replace as usual.
21952 if fd == 1
21953 && self
21954 .pipe_output_scope
21955 .is_some_and(|d| d + 1 == self.redirect_scope_stack.len())
21956 && crate::ported::options::opt_state_get("multios").unwrap_or(true)
21957 {
21958 // Resolve the new write target exactly as the plain arms
21959 // below would, but as a raw fd for the tee.
21960 let new_target_fd: i32 = match op_byte {
21961 r::DUP_WRITE => {
21962 // Numeric `>&N` only; `-` (close) and `p` (coproc)
21963 // fall through to the plain arms.
21964 target
21965 .trim_start_matches('&')
21966 .parse::<i32>()
21967 .map(|src| unsafe { libc::fcntl(src, libc::F_DUPFD_CLOEXEC, 10) })
21968 .unwrap_or(-1)
21969 }
21970 r::WRITE | r::CLOBBER => fs::File::create(target)
21971 .map(|f| f.into_raw_fd())
21972 .unwrap_or(-1),
21973 r::APPEND => fs::OpenOptions::new()
21974 .create(true)
21975 .append(true)
21976 .open(target)
21977 .map(|f| f.into_raw_fd())
21978 .unwrap_or(-1),
21979 _ => -1,
21980 };
21981 if new_target_fd >= 0 {
21982 let pipe_dup = unsafe { libc::fcntl(1, libc::F_DUPFD_CLOEXEC, 10) };
21983 match (pipe_dup >= 0).then(os_pipe::pipe) {
21984 Some(Ok((read_end, write_end))) => {
21985 // c:Src/exec.c:5222 / Src/utils.c:1990-2012 —
21986 // `mpipe()` runs both pipe ends through
21987 // `movefd()`, which lifts any fd below 10 out
21988 // of the user-visible `>&N` range. Same abort
21989 // hazard as the BUILTIN_MULTIOS_REDIRECT arm.
21990 let read_end = unsafe {
21991 <os_pipe::PipeReader as std::os::unix::io::FromRawFd>::from_raw_fd(
21992 crate::extensions::fds::movefd(read_end.into_raw_fd()),
21993 )
21994 };
21995 // Splitter: same read-loop shape as
21996 // BUILTIN_MULTIOS_REDIRECT, with one ordering
21997 // refinement. C's tee is a forked process
21998 // (closemn → teeproc) whose wakeup latency lets
21999 // the stage's DIRECT pipe writes land first —
22000 // observed zsh output for `{ echo a; echo b >&2; }
22001 // 3>&1 1>&2 2>&3 3>&- | cat` is `b` then `a`,
22002 // 15/15 runs. A Rust thread wakes faster than
22003 // the debug-build VM dispatches the next echo,
22004 // inverting the order. Emulate the C timing
22005 // observably: stream to the NEW target (file /
22006 // stderr dup) immediately, but defer the
22007 // pipe-bound copy until EOF (or a 64KB cap so a
22008 // long-running stream still flows instead of
22009 // growing memory unboundedly).
22010 let write_now = |tfd: i32, data: &[u8]| {
22011 let mut off = 0;
22012 while off < data.len() {
22013 let w = unsafe {
22014 libc::write(
22015 tfd,
22016 data[off..].as_ptr() as *const libc::c_void,
22017 data.len() - off,
22018 )
22019 };
22020 if w <= 0 {
22021 break;
22022 }
22023 off += w as usize;
22024 }
22025 };
22026 let handle = std::thread::spawn(move || {
22027 let mut rd = read_end;
22028 let mut buf = [0u8; 8192];
22029 let mut pipe_pending: Vec<u8> = Vec::new();
22030 loop {
22031 match std::io::Read::read(&mut rd, &mut buf) {
22032 Ok(0) | Err(_) => break,
22033 Ok(n) => {
22034 write_now(new_target_fd, &buf[..n]);
22035 pipe_pending.extend_from_slice(&buf[..n]);
22036 if pipe_pending.len() >= 65536 {
22037 write_now(pipe_dup, &pipe_pending);
22038 pipe_pending.clear();
22039 }
22040 }
22041 }
22042 }
22043 write_now(pipe_dup, &pipe_pending);
22044 unsafe {
22045 libc::close(pipe_dup);
22046 libc::close(new_target_fd);
22047 }
22048 });
22049 let write_raw = AsRawFd::as_raw_fd(&write_end);
22050 unsafe { libc::dup2(write_raw, 1) };
22051 drop(write_end);
22052 // A forked command's scope parked no copy of fd 1
22053 // (save_fd_for_scope), so nothing would release this
22054 // write end at scope end and the join below would
22055 // wait forever; a -1 slot closes it there, as the
22056 // child's exit closes it in C.
22057 if redir_scope_forked(self.redirect_scope_stack.len()) {
22058 if let Some(top) = self.redirect_scope_stack.last_mut() {
22059 top.push((1, -1));
22060 }
22061 }
22062 // Scope-end closes this dup (the last writer once
22063 // the saved fd 1 is restored) → EOF → join.
22064 let close_on_end = unsafe { libc::fcntl(1, libc::F_DUPFD_CLOEXEC, 10) };
22065 if let Some(top) = self.multios_scope_stack.last_mut() {
22066 top.push((close_on_end, handle));
22067 } else {
22068 unsafe { libc::close(close_on_end) };
22069 let _ = handle.join();
22070 }
22071 return;
22072 }
22073 _ => unsafe {
22074 // pipe()/dup failure — fall through to plain replace.
22075 if pipe_dup >= 0 {
22076 libc::close(pipe_dup);
22077 }
22078 libc::close(new_target_fd);
22079 },
22080 }
22081 }
22082 }
22083 match op_byte {
22084 r::WRITE => {
22085 // Honor `setopt noclobber`: refuse to overwrite an
22086 // existing regular file unless `>!` / `>|` (CLOBBER).
22087 // zsh internally stores the inverted-name `clobber`
22088 // (default ON); `setopt noclobber` writes
22089 // `clobber=false`. Honor both keys.
22090 //
22091 // c:Src/exec.c:2241-2245 clobber_open recover path:
22092 // after O_EXCL fails, reopen and `if (!S_ISREG(...))
22093 // return fd;` — non-regular targets (char/block-
22094 // special, FIFO, socket) bypass the noclobber check.
22095 // Bug #30 in docs/BUGS.md: this bridge-side check did
22096 // a bare `Path::exists()` and treated `/dev/null` as
22097 // a protected file, breaking `setopt no_clobber; echo
22098 // hi > /dev/null` and every `2> /dev/null` idiom.
22099 // Add a regular-file stat gate that matches the C
22100 // semantic. The canonical clobber_open at
22101 // src/ported/exec.rs:2123 already handles this; the
22102 // bridge duplicates a stripped-down version here and
22103 // must mirror the same check.
22104 let noclobber = opt_state_get("noclobber").unwrap_or(false)
22105 || !opt_state_get("clobber").unwrap_or(true);
22106 let target_meta = std::fs::metadata(target).ok();
22107 let target_is_regular_file = target_meta
22108 .as_ref()
22109 .map(|m| m.file_type().is_file())
22110 .unwrap_or(false);
22111 // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY permits
22112 // re-using an EMPTY regular file under noclobber: `setopt
22113 // noclobber clobberempty; : >f; echo hi >f` overwrites f.
22114 // The inline bridge check ignored this and errored.
22115 let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
22116 && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
22117 if noclobber && target_is_regular_file && !clobber_empty_ok {
22118 eprintln!(
22119 "{}:{}: file exists: {}",
22120 shname(),
22121 crate::ported::lex::lineno(),
22122 target
22123 );
22124 self.set_last_status(1);
22125 // c:Src/exec.c — set redirect_failed so the scope-end
22126 // hook (`with_redirects_end` in this file) forces
22127 // $? to 1 regardless of the still-running command's
22128 // own exit. Without this the next command (e.g.
22129 // `echo x` writing to /dev/null below) succeeds
22130 // and overwrites the redirect-failure status,
22131 // making noclobber unobservable from $?.
22132 self.redirect_failed = true;
22133 // Sink the upcoming command's stdout to /dev/null
22134 // so we don't leak its output to the terminal.
22135 // zsh skips the command entirely; we approximate by
22136 // discarding the output (the redirect target was
22137 // the user's chosen sink, but with noclobber the
22138 // file is protected — discarding matches the
22139 // user's intent better than printing to terminal).
22140 if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
22141 let new_fd = file.into_raw_fd();
22142 unsafe {
22143 libc::dup2(new_fd, fd);
22144 libc::close(new_fd);
22145 }
22146 }
22147 return;
22148 }
22149 if !Self::redir_open_or_fail(
22150 fd,
22151 fs::File::create(target),
22152 target,
22153 &mut self.redirect_failed,
22154 ) {
22155 self.set_last_status(1);
22156 }
22157 }
22158 r::CLOBBER => {
22159 if !Self::redir_open_or_fail(
22160 fd,
22161 fs::File::create(target),
22162 target,
22163 &mut self.redirect_failed,
22164 ) {
22165 self.set_last_status(1);
22166 }
22167 }
22168 r::APPEND => {
22169 // c:Src/exec.c:3924-3927 — `>>` honors NO_CLOBBER+!APPENDCREATE
22170 // by opening O_APPEND|O_WRONLY WITHOUT O_CREAT, so missing
22171 // files yield ENOENT. zsh source:
22172 // if (!isset(CLOBBER) && !isset(APPENDCREATE) &&
22173 // !IS_CLOBBER_REDIR(fn->type))
22174 // mode = O_WRONLY|O_APPEND|O_NOCTTY;
22175 // else mode = O_WRONLY|O_APPEND|O_CREAT|O_NOCTTY;
22176 // (IS_CLOBBER_REDIR — `>>!`/`>>|` — is currently flattened
22177 // to plain APPEND at compile time in
22178 // src/extensions/compile_zsh.rs:1654-1655, so the bang/pipe
22179 // forms can't be distinguished here yet.)
22180 let noclobber = opt_state_get("noclobber").unwrap_or(false)
22181 || !opt_state_get("clobber").unwrap_or(true);
22182 let append_create = opt_state_get("appendcreate").unwrap_or(false)
22183 || opt_state_get("append_create").unwrap_or(false);
22184 let open_result = if noclobber && !append_create {
22185 fs::OpenOptions::new().append(true).open(target) // no create
22186 } else {
22187 fs::OpenOptions::new()
22188 .create(true)
22189 .append(true)
22190 .open(target)
22191 };
22192 if !Self::redir_open_or_fail(fd, open_result, target, &mut self.redirect_failed) {
22193 self.set_last_status(1);
22194 }
22195 }
22196 r::READ => {
22197 if !Self::redir_open_or_fail(
22198 fd,
22199 fs::File::open(target),
22200 target,
22201 &mut self.redirect_failed,
22202 ) {
22203 self.set_last_status(1);
22204 }
22205 }
22206 r::READ_WRITE => {
22207 if let Ok(file) = fs::OpenOptions::new()
22208 .create(true)
22209 .truncate(false) // <> opens existing-or-new without truncating
22210 .read(true)
22211 .write(true)
22212 .open(target)
22213 {
22214 let new_fd = file.into_raw_fd();
22215 unsafe {
22216 // See redir_open_or_fail: when the opened fd IS the
22217 // destination (target fd was closed), keep it and clear
22218 // O_CLOEXEC; else dup2 + close.
22219 if new_fd != fd {
22220 libc::dup2(new_fd, fd);
22221 libc::close(new_fd);
22222 } else {
22223 libc::fcntl(fd, libc::F_SETFD, 0);
22224 }
22225 }
22226 }
22227 }
22228 r::DUP_READ | r::DUP_WRITE => {
22229 // Target is a numeric fd reference like `&3`. The parser
22230 // strips the `&` prefix before we get here in some paths,
22231 // others retain it — accept both. Also support `-` for
22232 // close-fd (`<&-` / `>&-`) per POSIX. The src_fd
22233 // validity check ran above before the save-and-dup.
22234 let n = target.trim_start_matches('&');
22235 if n == "-" {
22236 unsafe { libc::close(fd) };
22237 } else if n == "p" {
22238 // c:Src/exec.c — `<&p` / `>&p` route through the
22239 // coprocin / coprocout globals. zsh's `coproc CMD`
22240 // launch publishes those fds; the canonical
22241 // bin_print / bin_read `-p` arms already consume
22242 // them. The DUP redirect form is the third
22243 // consumer: it must dup the coproc fd onto the
22244 // target slot so the next command's stdin/stdout
22245 // is wired to the running coprocess. Bug #388.
22246 let coproc_fd = if op_byte == r::DUP_READ {
22247 crate::ported::modules::clone::coprocin
22248 .load(std::sync::atomic::Ordering::Relaxed)
22249 } else {
22250 crate::ported::modules::clone::coprocout
22251 .load(std::sync::atomic::Ordering::Relaxed)
22252 };
22253 if coproc_fd < 0 {
22254 eprintln!("{}:1: no coprocess", shname());
22255 self.set_last_status(1);
22256 self.redirect_failed = true;
22257 } else {
22258 unsafe {
22259 libc::dup2(coproc_fd, fd);
22260 }
22261 }
22262 } else if let Ok(src_fd) = n.parse::<i32>() {
22263 unsafe { libc::dup2(src_fd, fd) };
22264 } else if op_byte == r::DUP_WRITE {
22265 // c:Src/glob.c:2184-2187 xpandredir — a MERGEOUT
22266 // word that expands to a non-number becomes
22267 // REDIR_ERRWRITE: `cmd >& word` opens `word` and
22268 // routes BOTH fd 1 and fd 2 there. Reached only
22269 // for dynamic words (`>&$var`); static filenames
22270 // were converted at compile time.
22271 self.redir_errwrite(fs::File::create(target), target);
22272 } else {
22273 // c:Src/glob.c:2185 — MERGEIN non-number:
22274 // `zerr("file number expected")`.
22275 crate::ported::utils::zerr("file number expected");
22276 self.set_last_status(1);
22277 self.redirect_failed = true;
22278 }
22279 }
22280 r::WRITE_BOTH => {
22281 self.redir_errwrite(fs::File::create(target), target);
22282 }
22283 r::APPEND_BOTH => {
22284 self.redir_errwrite(
22285 fs::OpenOptions::new().create(true).append(true).open(target),
22286 target,
22287 );
22288 }
22289 _ => {}
22290 }
22291 }
22292
22293 /// Push a fresh redirect scope. `_count` is informational — the actual
22294 /// saved fds are appended by host_apply_redirect into the top scope.
22295 pub fn host_redirect_scope_begin(&mut self, _count: u8) {
22296 // c:Src/exec.c:3722-3724 — the pipeline child set
22297 // `pipe_output_pending` right after dup2'ing its stdout onto
22298 // the pipe; the FIRST redirect scope opened in that child is
22299 // the stage command's own redirect list (same execcmd as the
22300 // pipe's addfd into mfds[1]). Capture the depth so only THAT
22301 // list's fd-1 write redirects MULTIOS-join the pipe.
22302 if self.pipe_output_pending {
22303 self.pipe_output_pending = false;
22304 self.pipe_output_scope = Some(self.redirect_scope_stack.len());
22305 }
22306 let clean = (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
22307 & crate::ported::zsh_h::ERRFLAG_ERROR)
22308 == 0;
22309 let depth = self.redirect_scope_stack.len();
22310 REDIR_SCOPE_ERRFLAG_CLEAN.with(|v| {
22311 let mut v = v.borrow_mut();
22312 v.truncate(depth);
22313 v.push(clean);
22314 });
22315 self.redirect_scope_stack.push(Vec::new());
22316 self.multios_scope_stack.push(Vec::new());
22317 let serial = SUBLIST_SERIAL.with(|c| c.get());
22318 REDIR_SCOPE_OPENED.with(|c| c.set((serial, self.redirect_scope_stack.len())));
22319 // c:Src/exec.c:3775-3777 — only the stage command's own redirect
22320 // list (same sublist as the pipe install) sees the seeded mfds[0].
22321 if PIPE_INPUT_INSTALLED.with(|c| c.take()) == Some(serial) {
22322 PIPE_INPUT_SCOPE.with(|c| c.set(Some(self.redirect_scope_stack.len())));
22323 }
22324 }
22325
22326 /// Restore every redirect scope opened above `depth`.
22327 ///
22328 /// c:Src/exec.c:4364 — `fixfds(save)` runs on EVERY exit path out of
22329 /// `execcmd_exec`, the one an early `return` takes out of a compound
22330 /// command carrying redirections included (`while … done < f`,
22331 /// `{ …; return } < f`, `if …; then return; fi < f`). zshrs compiles
22332 /// `return` to a Jump past the body's `WithRedirectsEnd`, so that
22333 /// scope stayed on the stack and its saved fds never came back — the
22334 /// caller inherited the callee's redirected fd.
22335 ///
22336 /// gitstatus hit this: `gitstatus.plugin.zsh`'s daemon sources
22337 /// `gitstatus/install`, whose `_gitstatus_install_main` returns out of
22338 /// `while … done <"$gitstatus_dir"/install.info`. fd 0 stayed on
22339 /// install.info instead of reverting to the request FIFO, so
22340 /// `gitstatusd` read EOF on startup and exited ("EOF. Exiting."),
22341 /// `gitstatus_start` failed, `VCS_STATUS_REMOTE_URL` came back empty
22342 /// and powerlevel10k rendered the generic git icon in place of the
22343 /// per-forge one.
22344 pub fn unwind_redirect_scopes_to(&mut self, depth: usize) {
22345 while self.redirect_scope_stack.len() > depth {
22346 self.host_redirect_scope_end();
22347 }
22348 }
22349
22350 /// Pop the top redirect scope, restoring saved fds.
22351 pub fn host_redirect_scope_end(&mut self) {
22352 // c:Src/exec.c — restore saved fds FIRST so the multios
22353 // pipe-write end is released from `fd`, then close our
22354 // tracked close_on_end (the last surviving writer dup), then
22355 // join the splitter thread. If we closed close_on_end before
22356 // restoring saved, `fd` would still hold a pipe writer and
22357 // the thread would block forever waiting for EOF.
22358 if redir_scope_forked(self.redirect_scope_stack.len()) {
22359 FORKED_REDIR_SCOPE.with(|c| c.set(0));
22360 }
22361 if let Some(saved) = self.redirect_scope_stack.pop() {
22362 for (fd, saved_fd) in saved.into_iter().rev() {
22363 // c:Src/exec.c:4530 `redup(save[i], i)` →
22364 // c:Src/utils.c:2047-2048 `if (x < 0) zclose(y);`.
22365 // A -1 slot means the fd was CLOSED before the
22366 // redirection (c:2422-2423, c:2430-2436), so the
22367 // restore is a close, not a dup2 — otherwise the
22368 // redirection outlives the command it was written on
22369 // (`print x 3<file; cat <&3` printed the file instead
22370 // of "3: bad file descriptor").
22371 if saved_fd < 0 {
22372 unsafe { libc::close(fd) };
22373 continue;
22374 }
22375 unsafe {
22376 libc::dup2(saved_fd, fd);
22377 }
22378 // c:Src/utils.c:2049 — redup's `zclose(x)`, which also
22379 // clears the fdtable mark mark_saved_fd set.
22380 crate::ported::utils::zclose(saved_fd);
22381 }
22382 }
22383 if let Some(scope) = self.multios_scope_stack.pop() {
22384 // Close ALL tracked writer dups BEFORE joining any
22385 // thread. When one splitter holds a dup of another's
22386 // pipe write-end (two multios in one scope where a later
22387 // one duped fd 1 while an earlier splitter owned it),
22388 // joining in push order deadlocks: splitter A's EOF
22389 // waits on splitter B's writer dup, which only closes
22390 // after B's thread exits — blocked behind A's join.
22391 let mut handles = Vec::with_capacity(scope.len());
22392 for (write_fd, handle) in scope {
22393 if write_fd >= 0 {
22394 unsafe {
22395 libc::close(write_fd);
22396 }
22397 }
22398 handles.push(handle);
22399 }
22400 for handle in handles {
22401 let _ = handle.join();
22402 }
22403 }
22404 // The scope that captured the pipeline-output marker is gone;
22405 // deeper-nested future scopes must not re-match its depth.
22406 if self.pipe_output_scope == Some(self.redirect_scope_stack.len()) {
22407 self.pipe_output_scope = None;
22408 }
22409 if PIPE_INPUT_SCOPE.with(|c| c.get()).is_some_and(|d| d > self.redirect_scope_stack.len()) {
22410 PIPE_INPUT_SCOPE.with(|c| c.set(None));
22411 }
22412 // c:Src/exec.c:4445-4452 — the command is done: drop its xtrace copy.
22413 let depth = self.redirect_scope_stack.len();
22414 while let Some(fd) = XTRERR_COPIES.with(|v| {
22415 let mut v = v.borrow_mut();
22416 if v.last().is_some_and(|&(d, _)| d > depth) { v.pop().map(|(_, fd)| fd) } else { None }
22417 }) {
22418 xtrerr_flush(); // c:4448 fclose(newxtrerr)
22419 crate::ported::utils::xtrerr.store(libc::STDERR_FILENO, std::sync::atomic::Ordering::Relaxed); // c:4449
22420 let _ = crate::ported::utils::zclose(fd); // c:4451
22421 }
22422 }
22423
22424 /// Set up `content` as stdin (fd 0) for the next command.
22425 /// Used by `Op::HereDoc(idx)` and `Op::HereString`.
22426 ///
22427 /// c:Src/exec.c:4655 getherestr — C writes the body to a TEMP
22428 /// FILE (gettempfile → write_loop → close → reopen O_RDONLY →
22429 /// unlink), NOT a pipe. The previous pipe+writer-thread shape
22430 /// SIGPIPE'd the whole shell when the consumer never read the
22431 /// body (`: <<< ${(F)x/y}` — D04parameter chunk 211, flaky
22432 /// rc=141): the redirect-scope teardown closed the read end
22433 /// while the detached thread was still in write_all, and the
22434 /// shell's SIGPIPE disposition is SIG_DFL. A temp file has no
22435 /// reader/writer coupling — matching C exactly, including
22436 /// lseek-ability of fd 0, which pipes don't give.
22437 pub fn host_set_pending_stdin(&mut self, content: String) {
22438 // c:Src/exec.c:3775-3777 — a here-document or here-string on a
22439 // pipeline stage fed by a pipe joins the pipe as mfds[0]'s second
22440 // member (c:2447-2480): `cat o1 | cat <<<hs` reads o1 then hs.
22441 if let Some(seed) = take_pipe_input_seed(self, 0) {
22442 self.host_set_pending_stdin(content);
22443 let member = unsafe { libc::dup(0) };
22444 if member < 0 {
22445 let _ = crate::ported::utils::zclose(seed);
22446 return;
22447 }
22448 let member = crate::ported::utils::movefd(member); // c:2465
22449 multios_read_concat(self, 0, vec![seed, member]);
22450 return;
22451 }
22452 // c:4673 — `gettempfile(NULL, 1, &s)`.
22453 let mut tmp = std::env::temp_dir();
22454 tmp.push(format!(
22455 "zshrs-herestr-{}-{:x}",
22456 std::process::id(),
22457 std::time::SystemTime::now()
22458 .duration_since(std::time::UNIX_EPOCH)
22459 .map(|d| d.as_nanos())
22460 .unwrap_or(0)
22461 ));
22462 // c:Src/exec.c:4719 — `unmetafy(t, &len);` runs BEFORE
22463 // `write_loop(fd, t, len)`: what lands in the temp file is the RAW
22464 // byte stream, never zshrs's metafied `Meta` + `byte ^ 32` pairs.
22465 // Writing `content.as_bytes()` leaked the metafication onto fd 0,
22466 // so `read -d $'\xa0' <<<$'first\xa0second'` saw `c2 83 c2 80`
22467 // where every other writer (`print`, a pipe) emits the single `a0`.
22468 let raw = crate::ported::utils::unmetafy_str(&content); // c:4719
22469 // c:4675 — `write_loop(fd, t, len); close(fd);`
22470 if std::fs::write(&tmp, &raw).is_err() {
22471 return; // c:4674 — tempfile failure → no redirect
22472 }
22473 // c:Src/utils.c gettempfile → mkstemp creates the temp file mode
22474 // 0600 IGNORING the umask, so the O_RDONLY reopen below always
22475 // succeeds. `std::fs::write` honors the umask, so under `umask
22476 // 0777` the file landed mode 0000 and the reopen failed with
22477 // EACCES — `cat <<<x` then read empty stdin. Force 0600 to match
22478 // mkstemp's umask-independent permissions.
22479 let _ = std::fs::set_permissions(
22480 &tmp,
22481 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
22482 );
22483 // c:4677 — `fd = open(s, O_RDONLY | O_NOCTTY);`
22484 let file = match std::fs::File::open(&tmp) {
22485 Ok(f) => f,
22486 Err(_) => {
22487 let _ = std::fs::remove_file(&tmp);
22488 return;
22489 }
22490 };
22491 // c:4678 — `unlink(s);` — fd stays valid, name disappears.
22492 let _ = std::fs::remove_file(&tmp);
22493 // c:Src/exec.c:2478 — `if (!forked && save[fd1] == -2)`.
22494 let saved = if redir_scope_forked(self.redirect_scope_stack.len()) {
22495 -1
22496 } else {
22497 unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD_CLOEXEC, 10) }
22498 };
22499 if saved >= 0 {
22500 if let Some(top) = self.redirect_scope_stack.last_mut() {
22501 top.push((libc::STDIN_FILENO, saved));
22502 mark_saved_fd(saved);
22503 } else {
22504 unsafe { libc::close(saved) };
22505 }
22506 }
22507 // c:Src/utils.c:redup — `if (x != y) { dup2(x, y); zclose(x); }`.
22508 // When fd 0 was already CLOSED before this heredoc runs,
22509 // `File::open` returns the lowest free descriptor, which is 0
22510 // itself — so `read_fd == STDIN_FILENO`. C's redup skips both the
22511 // dup2 (a no-op for equal fds) AND the close in that case, leaving
22512 // the just-opened temp file installed at fd 0. Unconditionally
22513 // dropping the File here closed that fd back to nothing, so an
22514 // external NULLCMD (`cat`) inherited a closed fd 0 and failed with
22515 // EBADF (`cat <<EOF` inside `$(...)` when exec 0<&- closed stdin).
22516 let read_fd = AsRawFd::as_raw_fd(&file);
22517 if read_fd != libc::STDIN_FILENO {
22518 // dup2 installs a fresh fd 0 with FD_CLOEXEC clear (dup2 never
22519 // copies the flag), then we close the CLOEXEC-tagged source.
22520 unsafe { libc::dup2(read_fd, libc::STDIN_FILENO) };
22521 drop(file); // c:redup zclose(x)
22522 } else {
22523 // File::open reused fd 0. Rust opens with O_CLOEXEC, so fd 0
22524 // now carries FD_CLOEXEC and would be auto-closed when an
22525 // external NULLCMD (`cat`) exec's — the child then reads a
22526 // closed fd 0 and fails with EBADF. zsh opens the heredoc temp
22527 // via `open(s, O_RDONLY|O_NOCTTY)` (no CLOEXEC), so its child
22528 // inherits the fd. Clear the flag to match, then keep fd 0 open
22529 // (redup's x==y arm: no dup2, no close).
22530 unsafe {
22531 let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFD);
22532 if flags >= 0 {
22533 libc::fcntl(libc::STDIN_FILENO, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
22534 }
22535 }
22536 std::mem::forget(file);
22537 }
22538 }
22539
22540 /// Spawn an external command using zshrs's full dispatch logic
22541 /// (intercepts, command_hash, redirect handling). Used by
22542 /// `ZshrsHost::exec` so the bytecode VM's `Op::Exec` and
22543 /// `Op::CallFunction` external fallback get the same semantics as
22544 /// the tree-walker's `execute_external` rather than a plain
22545 /// `Command::new` shortcut. Returns the exit status.
22546 pub fn host_exec_external(&mut self, args: &[String]) -> i32 {
22547 // c:Src/exec.c:772-776 — BINF_DASH reaches `execute()` only; every
22548 // other route below runs the command without it.
22549 let exec_dash = take_exec_carrier();
22550 PREFORK_CUT.with(|c| c.set(false)); // the words are complete
22551 // Native p10k API: the `p10k(){ zshrs-p10k-api "$@" }` stub's
22552 // body lands here (the name is neither function nor builtin).
22553 // Route into the engine instead of a PATH miss.
22554 if let Some(name) = args.first() {
22555 if let Some(status) = crate::p10k::maybe_intercept_command(name, &args[1..]) {
22556 self.set_last_status(status);
22557 return status;
22558 }
22559 }
22560 // If a glob expansion in this command's argv triggered the
22561 // nomatch error path, suppress the actual exec and return
22562 // status 1 — mirrors zsh's command-aborted-on-glob-error
22563 // behaviour. The flag is reset BEFORE returning so the next
22564 // command starts clean.
22565 //
22566 // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH sets
22567 // ERRFLAG_ERROR but C's execlist clears the bit per-sublist
22568 // so subsequent commands run. Symmetric with the builtin
22569 // dispatcher's clear at fusevm_bridge.rs:299 — clear it here
22570 // too at the external-command post-command-boundary.
22571 consume_tilde_globsubst_carrier();
22572 if self.current_command_glob_failed.get() {
22573 self.current_command_glob_failed.set(false);
22574 crate::ported::utils::errflag.fetch_and(
22575 !crate::ported::zsh_h::ERRFLAG_ERROR,
22576 std::sync::atomic::Ordering::Relaxed,
22577 );
22578 self.set_last_status(1);
22579 return 1;
22580 }
22581 // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the
22582 // NOMATCH gate above, same external-path semantics (skip
22583 // command, `no match`, clear ERRFLAG so the next sublist
22584 // runs).
22585 if consume_badcshglob() {
22586 crate::ported::utils::errflag.fetch_and(
22587 !crate::ported::zsh_h::ERRFLAG_ERROR,
22588 std::sync::atomic::Ordering::Relaxed,
22589 );
22590 self.set_last_status(1);
22591 return 1;
22592 }
22593 let Some((cmd, rest)) = args.split_first() else {
22594 return 0;
22595 };
22596 // Empty command name (e.g. result of an empty `$(false)`
22597 // command-sub being the only word) — zsh: no command runs,
22598 // exit status preserved from prior step. Was hitting the
22599 // "command not found: " path with empty name.
22600 if cmd.is_empty() && rest.is_empty() {
22601 return self.last_status();
22602 }
22603 let rest_vec: Vec<String> = rest.to_vec();
22604 // Update `$_` with the just-arriving argv so the next command
22605 // reads `_=<last_arg>`. Mirrors C zsh's writeback in
22606 // `execcmd_exec` (Src/exec.c). Per `args.last()` semantics,
22607 // when invoked as `cmd a b c`, `$_` becomes "c" — for a bare
22608 // command with no args, `$_` becomes the command name itself.
22609 crate::ported::params::set_zunderscore(args);
22610
22611 // Builtins not in fusevm's name→id table fall through to
22612 // host.exec. Catch them here before the OS-level exec attempts
22613 // to spawn a non-existent binary.
22614 match cmd.as_str() {
22615 "sched" => return dispatch_builtin("sched", rest_vec.clone()),
22616 "echotc" => return dispatch_builtin("echotc", rest_vec.clone()),
22617 "echoti" => return dispatch_builtin("echoti", rest_vec.clone()),
22618 "zpty" => return dispatch_builtin("zpty", rest_vec.clone()),
22619 "ztcp" => return dispatch_builtin("ztcp", rest_vec.clone()),
22620 "zsocket" => {
22621 // c:Src/Modules/socket.c:276 BUILTIN spec — BUILTINS["zsocket"]
22622 // optstr "ad:ltv" parsed by execbuiltin.
22623 return dispatch_builtin("zsocket", rest_vec.clone());
22624 }
22625 "private" => {
22626 // c:Src/Modules/param_private.c:217 — bin_private via
22627 // BUILTINS["private"]. The autoload require_module
22628 // (exec.c:2700-2717) fires inside
22629 // dispatch_builtin_raw, the chokepoint for all routes.
22630 return dispatch_builtin("private", rest_vec.clone());
22631 }
22632 "zformat" => return dispatch_builtin("zformat", rest_vec.clone()),
22633 "zregexparse" => return dispatch_builtin("zregexparse", rest_vec.clone()),
22634 // `unalias`/`unhash`/`unfunction` share `bin_unhash` but
22635 // each carries its own funcid (BIN_UNALIAS / BIN_UNHASH /
22636 // BIN_UNFUNCTION) — dispatch_builtin handles the BUILTINS
22637 // lookup + funcid propagation via execbuiltin.
22638 "unalias" | "unhash" | "unfunction" => {
22639 return dispatch_builtin(cmd.as_str(), rest_vec.clone());
22640 }
22641 // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
22642 // functions — implemented natively in Rust so `autoload -Uz zmv`
22643 // works without shipping the function source (and without the
22644 // fpath source hanging the parser). The stub guard keeps them command-not-found until autoloaded, exactly like zsh;
22645 // an un-guarded arm ran them for bare `zmv`, diverging from
22646 // `zsh -f; zmv` → "command not found: zmv".
22647 // Only a still-undefined autoload stub takes the native port; a
22648 // defined body runs as the shell function (see
22649 // `autoload_stub_pending`).
22650 "zmv" if autoload_stub_pending("zmv") => {
22651 return crate::extensions::ext_builtins::zmv(&rest_vec, "mv")
22652 }
22653 "zcp" if autoload_stub_pending("zcp") => {
22654 return crate::extensions::ext_builtins::zmv(&rest_vec, "cp")
22655 }
22656 "zln" if autoload_stub_pending("zln") => {
22657 return crate::extensions::ext_builtins::zmv(&rest_vec, "ln")
22658 }
22659 "zcalc" if autoload_stub_pending("zcalc") => {
22660 return crate::extensions::ext_builtins::zcalc(&rest_vec)
22661 }
22662 "zselect" => {
22663 // Route through canonical dispatch_builtin which goes
22664 // via execbuiltin → BUILTINS["zselect"] (zselect.c:272).
22665 return dispatch_builtin("zselect", rest_vec.clone());
22666 }
22667 "cap" => return dispatch_builtin("cap", rest_vec.clone()),
22668 "getcap" => return dispatch_builtin("getcap", rest_vec.clone()),
22669 "setcap" => return dispatch_builtin("setcap", rest_vec.clone()),
22670 "yes" => return self.builtin_yes(&rest_vec),
22671 "nl" => return self.builtin_nl(&rest_vec),
22672 "env" => return self.builtin_env(&rest_vec),
22673 "printenv" => return self.builtin_printenv(&rest_vec),
22674 "tty" => return self.builtin_tty(&rest_vec),
22675 // c:Src/Modules/files.c:806 — BUILTINS["chgrp"] with
22676 // BIN_CHGRP funcid + "hRs" optstr.
22677 "chgrp" => return dispatch_builtin("chgrp", rest_vec.clone()),
22678 "nproc" => return self.builtin_nproc(&rest_vec),
22679 "expr" => return self.builtin_expr(&rest_vec),
22680 "sha256sum" => return self.builtin_sha256sum(&rest_vec),
22681 "base64" => return self.builtin_base64(&rest_vec),
22682 "tac" => return self.builtin_tac(&rest_vec),
22683 "expand" => return self.builtin_expand(&rest_vec),
22684 "unexpand" => return self.builtin_unexpand(&rest_vec),
22685 "paste" => return self.builtin_paste(&rest_vec),
22686 "fold" => return self.builtin_fold(&rest_vec),
22687 "shuf" => return self.builtin_shuf(&rest_vec),
22688 "comm" => return self.builtin_comm(&rest_vec),
22689 "cksum" => return self.builtin_cksum(&rest_vec),
22690 "factor" => return self.builtin_factor(&rest_vec),
22691 "tsort" => return self.builtin_tsort(&rest_vec),
22692 "sum" => return self.builtin_sum(&rest_vec),
22693 "mkfifo" => return self.builtin_mkfifo(&rest_vec),
22694 "link" => return self.builtin_link(&rest_vec),
22695 "unlink" => return self.builtin_unlink(&rest_vec),
22696 "dircolors" => return self.builtin_dircolors(&rest_vec),
22697 "groups" => return self.builtin_groups(&rest_vec),
22698 "arch" => return self.builtin_arch(&rest_vec),
22699 "nice" => return self.builtin_nice(&rest_vec),
22700 "logname" => return self.builtin_logname(&rest_vec),
22701 "tput" => return self.builtin_tput(&rest_vec),
22702 "users" => return self.builtin_users(&rest_vec),
22703 // "sync" => return self.bin_sync(&rest_vec),
22704 "zbuild" => return self.builtin_zbuild(&rest_vec),
22705 // `zf_*` aliases from `zsh/files` (Src/Modules/files.c
22706 // BUILTIN table at line 816-824). The C source binds
22707 // both unprefixed (`chmod`) and prefixed (`zf_chmod`)
22708 // names to the SAME `bin_chmod` etc. handlers — the
22709 // prefixed forms exist so a script can portably reach
22710 // the builtin even when a function or alias has shadowed
22711 // the bare name. Each arm routes through the canonical
22712 // zf_* aliases route through canonical BUILTINS entries
22713 // (files.c:816-824) — execbuiltin parses each fn's optstr
22714 // automatically.
22715 "mkdir" | "zf_mkdir" | "zf_rm" | "zf_rmdir" | "zf_chmod" | "zf_chown" | "zf_chgrp"
22716 | "zf_ln" | "zf_mv" | "zf_sync"
22717 // `--zsh` parity gate: zsh -fc has zsh/files UNLOADED
22718 // — bare `mkdir` is /bin/mkdir (so `command mkdir -p`
22719 // honors the system flag set; zconvey.plugin.zsh:44
22720 // got "File exists" from the in-process bin_mkdir
22721 // that this arm intercepted) and `zf_*` names are
22722 // command-not-found 127 until `zmodload zsh/files`.
22723 // Fall through to the external/exec path in --zsh
22724 // mode; default zshrs mode keeps the anti-fork
22725 // intercept.
22726 if !crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) =>
22727 {
22728 return dispatch_builtin(cmd.as_str(), rest_vec.clone());
22729 }
22730 // `zstat` — port of zsh/stat module (Src/Modules/stat.c
22731 // BUILTIN("zstat", …)). Returns file metadata as
22732 // `field value` pairs / an assoc / a plus-separated
22733 // list depending on flags. zsh ALSO registers `stat`
22734 // bound to the same handler, but that name conflicts
22735 // with the system `stat(1)` binary (every script that
22736 // calls `stat -f '%Lp' …` would break). zsh resolves
22737 // this through opt-in `zmodload`; zshrs's modules are
22738 // statically linked so we keep `stat` routing to the
22739 // external command and only intercept the unambiguous
22740 // `zstat` name.
22741 "zstat" => {
22742 // Canonical bin_stat per stat.c:638 via BUILTINS["zstat"].
22743 return dispatch_builtin("zstat", rest_vec.clone());
22744 }
22745 _ => {}
22746 }
22747
22748 // AOP intercepts: when an `intercept :before/:around/:after foo` block
22749 // is registered, dynamic-command-name dispatch must consult it before
22750 // spawning. Without this, `cmd=ls; $cmd` bypasses every intercept that
22751 // a literal `ls` would trigger. The full_cmd string mirrors what the
22752 // tree-walker era passed (cmd + args joined by space) so existing
22753 // pattern matchers continue to work.
22754 if !self.intercepts.is_empty() {
22755 let full_cmd = if rest_vec.is_empty() {
22756 cmd.clone()
22757 } else {
22758 format!("{} {}", cmd, rest_vec.join(" "))
22759 };
22760 if let Some(intercept_result) = self.run_intercepts(cmd, &full_cmd, &rest_vec) {
22761 return intercept_result.unwrap_or(127);
22762 }
22763 }
22764
22765 // User-defined function lookup before OS-level exec. zsh's
22766 // dynamic-command-name dispatch (`cmd=hook1; $cmd`) checks
22767 // the function table FIRST — without this, `$f` for a
22768 // function-name `f` was always falling through to
22769 // `execute_external` and erroring "command not found".
22770 // Plugin code uses this pattern constantly:
22771 // for f in "${precmd_functions[@]}"; do "$f"; done
22772 if self.function_exists(cmd) {
22773 if let Some(status) = self.dispatch_function_call(cmd, &rest_vec) {
22774 return status;
22775 }
22776 }
22777
22778 EXEC_DASH.with(|c| c.set(exec_dash));
22779 self.execute_external(cmd, &rest_vec, &[]).unwrap_or(127)
22780 }
22781}
22782
22783#[cfg(test)]
22784mod word_assemble_tests {
22785 use super::{word_assemble_plan9, Value};
22786
22787 fn arr(xs: &[&str]) -> Value {
22788 Value::array(xs.iter().map(|s| Value::str(*s)).collect())
22789 }
22790 fn out(v: Value) -> Vec<String> {
22791 match v {
22792 Value::Array(items) => items.iter().map(|i| i.to_str()).collect(),
22793 other => vec![other.to_str()],
22794 }
22795 }
22796
22797 // The edge-tracking fold (c:Src/subst.c:4316-4437). A naive per-segment
22798 // operator gets s,p,p and p,s,p wrong because it forgets which trailing
22799 // elements are still the "growing edge". These pin the exact zsh output
22800 // (verified against zsh 5.9) for every plan9/splice permutation.
22801 #[test]
22802 fn plan9_then_splice() {
22803 // "${(@)^a}${(@)b}" a=(1 2) b=(A B) -> 1A 2A B
22804 let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[true, false]);
22805 assert_eq!(out(r), vec!["1A", "2A", "B"]);
22806 }
22807 #[test]
22808 fn splice_then_plan9() {
22809 // "${(@)a}${(@)^b}" -> 1 2A 2B
22810 let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[false, true]);
22811 assert_eq!(out(r), vec!["1", "2A", "2B"]);
22812 }
22813 #[test]
22814 fn plan9_then_plan9_is_full_cross() {
22815 let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[true, true]);
22816 assert_eq!(out(r), vec!["1A", "1B", "2A", "2B"]);
22817 }
22818 #[test]
22819 fn splice_then_splice() {
22820 let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[false, false]);
22821 assert_eq!(out(r), vec!["1", "2A", "B"]);
22822 }
22823 #[test]
22824 fn plan9_splice_plan9_growing_edge() {
22825 // "${(@)^a}${(@)b}${(@)^c}" -> 1A 2A Bp Bq (only B, the edge, distributes)
22826 let r = word_assemble_plan9(
22827 &[arr(&["1", "2"]), arr(&["A", "B"]), arr(&["p", "q"])],
22828 &[true, false, true],
22829 );
22830 assert_eq!(out(r), vec!["1A", "2A", "Bp", "Bq"]);
22831 }
22832 #[test]
22833 fn splice_plan9_plan9_keeps_frozen_prefix() {
22834 // "${(@)a}${(@)^b}${(@)^c}" -> 1 2Ap 2Aq 2Bp 2Bq (1 stays frozen)
22835 let r = word_assemble_plan9(
22836 &[arr(&["1", "2"]), arr(&["A", "B"]), arr(&["p", "q"])],
22837 &[false, true, true],
22838 );
22839 assert_eq!(out(r), vec!["1", "2Ap", "2Aq", "2Bp", "2Bq"]);
22840 }
22841 #[test]
22842 fn empty_plan9_array_deletes_word() {
22843 // "${(@)^a}${(@)b}" with a=() -> word deleted
22844 let r = word_assemble_plan9(&[Value::array(vec![]), arr(&["A", "B"])], &[true, false]);
22845 assert!(out(r).is_empty(), "plan9 empty array deletes the word");
22846 }
22847 #[test]
22848 fn leading_literal_then_mixed() {
22849 // "X${(@)^a}${(@)b}" -> X1A X2A B
22850 let r = word_assemble_plan9(
22851 &[Value::str("X"), arr(&["1", "2"]), arr(&["A", "B"])],
22852 &[false, true, false],
22853 );
22854 assert_eq!(out(r), vec!["X1A", "X2A", "B"]);
22855 }
22856
22857 // c:Src/subst.c:4261 — a NON-plan9 empty expansion collapses to the empty
22858 // string and the word SURVIVES; only plan9 (c:4362 `uremnode`) deletes it.
22859 // A leading empty segment used to leave `words` empty, so every following
22860 // segment cross-multiplied against nothing and the word vanished.
22861 // Verified against zsh 5.9:
22862 // n=""; a=(x y z); print -rl -- $n${^a} -> x / y / z
22863 #[test]
22864 fn leading_empty_splice_keeps_word_and_crosses() {
22865 let r = word_assemble_plan9(
22866 &[Value::array(vec![]), arr(&["x", "y", "z"])],
22867 &[false, true],
22868 );
22869 assert_eq!(out(r), vec!["x", "y", "z"]);
22870 }
22871
22872 // n=""; a=(x y z); print -rl -- $n"pre"${^a} -> prex / prey / prez
22873 #[test]
22874 fn leading_empty_then_literal_then_plan9() {
22875 let r = word_assemble_plan9(
22876 &[
22877 Value::array(vec![]),
22878 Value::str("pre"),
22879 arr(&["x", "y", "z"]),
22880 ],
22881 &[false, false, true],
22882 );
22883 assert_eq!(out(r), vec!["prex", "prey", "prez"]);
22884 }
22885
22886 // n=""; a=(x y z); print -rl -- $n$a${^a} -> x / y / zx / zy / zz
22887 // The leading empty must not consume the splice's first element.
22888 #[test]
22889 fn leading_empty_does_not_eat_first_splice_element() {
22890 let r = word_assemble_plan9(
22891 &[
22892 Value::array(vec![]),
22893 arr(&["x", "y", "z"]),
22894 arr(&["x", "y", "z"]),
22895 ],
22896 &[false, false, true],
22897 );
22898 assert_eq!(out(r), vec!["x", "y", "zx", "zy", "zz"]);
22899 }
22900
22901 // n=""; e=(); print -rl -- $n${^e} -> nothing (plan9 empty still wins)
22902 #[test]
22903 fn leading_empty_then_empty_plan9_still_deletes_word() {
22904 let r = word_assemble_plan9(
22905 &[Value::array(vec![]), Value::array(vec![])],
22906 &[false, true],
22907 );
22908 assert!(
22909 out(r).is_empty(),
22910 "plan9 empty array still deletes the word"
22911 );
22912 }
22913}
22914
22915
22916// ═══════════════════════════════════════════════════════════════════════
22917// Compiled-arithmetic value plumbing.
22918//
22919// !!! WARNING: RUST-ONLY HELPERS !!!
22920//
22921// C has no equivalent of these: `Src/math.c` keeps every operand in an
22922// `mnumber` on its own value stack, so an operator arm reads `a.u.l` /
22923// `b.u.d` directly and never converts anything. zshrs's compiled path
22924// keeps operands as `fusevm::Value` on the VM stack instead, so the two
22925// representations have to meet at each builtin boundary. `arith_div` /
22926// `arith_mod` / `arith_pow` below ARE line-by-line ports of their C
22927// arms; only the `Value` ↔ `mnumber` conversion is Rust-only.
22928// ═══════════════════════════════════════════════════════════════════════
22929
22930/// Pop one VM value as an `mnumber`.
22931///
22932/// `Bool` appears because the comparison ops (`Op::NumLt` and friends)
22933/// push one; c:Src/math.c:1200 gives those operators integer 1/0, so
22934/// that is what a `Bool` becomes here. A `Str` reaching this point is a
22935/// value the compiler could not prove numeric; c:Src/math.c:337
22936/// `getmathparam` coerces such a string through the math evaluator,
22937/// which `matheval_str_operand` mirrors.
22938pub(crate) fn arith_pop_mnumber(vm: &mut fusevm::VM) -> crate::ported::zsh_h::mnumber {
22939 use crate::ported::zsh_h::{mnumber, MN_FLOAT, MN_INTEGER};
22940 match vm.pop() {
22941 fusevm::Value::Int(n) => mnumber { l: n, d: 0.0, type_: MN_INTEGER },
22942 fusevm::Value::Float(f) => mnumber { l: 0, d: f, type_: MN_FLOAT },
22943 fusevm::Value::Bool(b) => mnumber { l: i64::from(b), d: 0.0, type_: MN_INTEGER },
22944 other => {
22945 let s = other.to_str();
22946 if s.is_empty() {
22947 // c:Src/math.c:345 — an unset/empty parameter is 0.
22948 mnumber { l: 0, d: 0.0, type_: MN_INTEGER }
22949 } else {
22950 // c:Src/params.c:2646-2647 `matheval(getstrvalue(v))` —
22951 // the default arm of getnumvalue, which re-lexes a
22952 // scalar's string form. A math error reads as 0, as C's
22953 // does after `zerr`.
22954 crate::ported::math::matheval(&s).unwrap_or(mnumber {
22955 l: 0,
22956 d: 0.0,
22957 type_: MN_INTEGER,
22958 })
22959 }
22960 }
22961 }
22962}
22963
22964/// The inverse: an `mnumber` back onto the VM stack.
22965pub(crate) fn arith_push(n: crate::ported::zsh_h::mnumber) -> fusevm::Value {
22966 if n.type_ == crate::ported::zsh_h::MN_FLOAT {
22967 fusevm::Value::Float(n.d)
22968 } else {
22969 fusevm::Value::Int(n.l)
22970 }
22971}
22972
22973/// c:Src/math.c:1144 `notzero` — an INTEGER zero is a math error; a
22974/// float zero is not (it divides to an infinity).
22975pub(crate) fn mn_notzero(a: crate::ported::zsh_h::mnumber) -> bool {
22976 if a.type_ & crate::ported::zsh_h::MN_INTEGER != 0 && a.l == 0 {
22977 crate::ported::utils::zerr("division by zero"); // c:1147
22978 return false; // c:1148
22979 }
22980 true // c:1150
22981}
22982
22983/// Truthiness of an `mnumber` — c:Src/math.c:1200's `BOOL` reading,
22984/// where the operand is compared against zero in its own type.
22985pub(crate) fn mn_is_true(a: crate::ported::zsh_h::mnumber) -> bool {
22986 if a.type_ == crate::ported::zsh_h::MN_FLOAT {
22987 a.d != 0.0
22988 } else {
22989 a.l != 0
22990 }
22991}
22992
22993/// c:Src/math.c:1178-1183 — the two operands of an `OP_A2` operator are
22994/// promoted to float if EITHER is float; the result carries that type.
22995pub(crate) fn mn_promote(
22996 a: crate::ported::zsh_h::mnumber,
22997 b: crate::ported::zsh_h::mnumber,
22998) -> (crate::ported::zsh_h::mnumber, crate::ported::zsh_h::mnumber, u32) {
22999 use crate::ported::zsh_h::{mnumber, MN_FLOAT};
23000 if a.type_ == MN_FLOAT || b.type_ == MN_FLOAT {
23001 let af = if a.type_ == MN_FLOAT { a.d } else { a.l as f64 };
23002 let bf = if b.type_ == MN_FLOAT { b.d } else { b.l as f64 };
23003 (
23004 mnumber { l: 0, d: af, type_: MN_FLOAT },
23005 mnumber { l: 0, d: bf, type_: MN_FLOAT },
23006 MN_FLOAT,
23007 )
23008 } else {
23009 (a, b, a.type_)
23010 }
23011}
23012
23013/// Port of the `DIV` / `DIVEQ` arm, c:Src/math.c:1237-1257.
23014pub(crate) fn arith_div(
23015 a: crate::ported::zsh_h::mnumber,
23016 b: crate::ported::zsh_h::mnumber,
23017) -> crate::ported::zsh_h::mnumber {
23018 use crate::ported::zsh_h::{mnumber, MN_FLOAT};
23019 let (a, b, ctype) = mn_promote(a, b);
23020 let mut c = mnumber { l: 0, d: 0.0, type_: ctype };
23021 if !mn_notzero(b) {
23022 return c; // c:1240
23023 }
23024 if ctype == MN_FLOAT {
23025 c.d = a.d / b.d; // c:1242
23026 } else if b.l == -1 {
23027 // c:1244-1251 — avoid the INT_MIN / -1 trap by treating it as
23028 // multiplication, exactly as C does.
23029 c.l = a.l.wrapping_neg(); // c:1253
23030 } else {
23031 c.l = a.l / b.l; // c:1255
23032 }
23033 c
23034}
23035
23036/// Port of the `MOD` / `MODEQ` arm, c:Src/math.c:1258-1272.
23037pub(crate) fn arith_mod(
23038 a: crate::ported::zsh_h::mnumber,
23039 b: crate::ported::zsh_h::mnumber,
23040) -> crate::ported::zsh_h::mnumber {
23041 use crate::ported::zsh_h::{mnumber, MN_FLOAT};
23042 let (a, b, ctype) = mn_promote(a, b);
23043 let mut c = mnumber { l: 0, d: 0.0, type_: ctype };
23044 if !mn_notzero(b) {
23045 return c; // c:1261
23046 }
23047 if ctype == MN_FLOAT {
23048 c.d = a.d % b.d; // c:1268 fmod
23049 } else if b.l == -1 {
23050 c.l = 0; // c:1270
23051 } else {
23052 c.l = a.l % b.l; // c:1272
23053 }
23054 c
23055}
23056
23057/// Port of the `POWER` / `POWEREQ` arm, c:Src/math.c:1335-1358.
23058pub(crate) fn arith_pow(
23059 a: crate::ported::zsh_h::mnumber,
23060 b: crate::ported::zsh_h::mnumber,
23061) -> crate::ported::zsh_h::mnumber {
23062 use crate::ported::zsh_h::{mnumber, MN_FLOAT, MN_INTEGER};
23063 let (mut a, mut b, mut ctype) = mn_promote(a, b);
23064 if ctype == MN_INTEGER && b.l < 0 {
23065 // c:1337-1342 — a negative integer exponent produces a real
23066 // result, so both operands are cast to real first.
23067 a = mnumber { l: 0, d: a.l as f64, type_: MN_FLOAT };
23068 b = mnumber { l: 0, d: b.l as f64, type_: MN_FLOAT };
23069 ctype = MN_FLOAT;
23070 }
23071 let mut c = mnumber { l: 0, d: 0.0, type_: ctype };
23072 if ctype == MN_INTEGER {
23073 // c:1344 `for (c.u.l = 1; b.u.l--; c.u.l *= a.u.l);`
23074 c.l = 1;
23075 let mut n = b.l;
23076 while n > 0 {
23077 c.l = c.l.wrapping_mul(a.l);
23078 n -= 1;
23079 }
23080 } else {
23081 if b.d <= 0.0 && !mn_notzero(a) {
23082 return c; // c:1346-1347
23083 }
23084 if a.d < 0.0 {
23085 // c:1348-1355 — a negative base to a non-integer power is
23086 // imaginary.
23087 let tst = b.d as i64 as f64;
23088 if tst != b.d {
23089 crate::ported::utils::zerr("bad math expression: imaginary power"); // c:1352
23090 return c; // c:1353
23091 }
23092 }
23093 c.d = a.d.powf(b.d); // c:1356
23094 }
23095 c
23096}
23097
23098/// Body of `BUILTIN_SET_LINENO`, factored out so
23099/// `BUILTIN_STMT_PROLOGUE_FAST` can perform the same write without a
23100/// second indirect call. c:Src/exec.c:1355/1451/2056 `lineno = code - 1`.
23101pub(crate) fn set_lineno_impl(n: i64) -> fusevm::Value {
23102 // c:Src/exec.c:1355 — `/* In evaluated traps, don't modify the
23103 // line number. */ if (!IN_EVAL_TRAP() && !ineval && code)
23104 // lineno = code - 1;` (same gate at c:1451 and c:2056).
23105 // `ineval` is set by `eval()` to `!isset(EVALLINENO)`
23106 // (Src/builtin.c:6155), so under NO_EVAL_LINENO the eval body
23107 // must NOT renumber $LINENO — the caller's line stands.
23108 //
23109 // c:1354 — `/* In evaluated traps, don't modify the line number. */`
23110 // The `IN_EVAL_TRAP()` half of the same gate was missing, so an
23111 // eval-form trap body (`trap 'print $LINENO' DEBUG`) renumbered
23112 // $LINENO to its own line 1 and — because nothing restores
23113 // `lineno` on the way out (C does, via execlist's oldlineno
23114 // save/restore at c:1429/1696) — every later statement in the
23115 // trapped scope reported line 1 as well.
23116 if crate::ported::zsh_h::IN_EVAL_TRAP()
23117 || crate::ported::builtin::ineval.load(std::sync::atomic::Ordering::Relaxed) != 0
23118 {
23119 return Value::Status(0);
23120 }
23121 // Provenance: mirror the line into the lineage ledger's own
23122 // counter. The param-write hooks run inside the parameter
23123 // table's lock, so they cannot read `$LINENO` back out of it.
23124 if crate::provenance::active() {
23125 crate::provenance::note_line(n.max(0) as usize);
23126 }
23127 // c:Src/exec.c:lineno = N — direct write to the param's
23128 // u_val. Cannot go through setsparam because LINENO carries
23129 // PM_READONLY (so `(t)LINENO` reads `integer-readonly-special`
23130 // per zsh); setsparam → assignstrvalue's PM_READONLY guard
23131 // would reject the internal write. C zsh handles this via the
23132 // PM_SPECIAL GSU vtable's setfn callback which bypasses the
23133 // generic readonly check; the Rust port writes the canonical
23134 // field directly instead.
23135 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
23136 if let Some(pm) = tab.get_mut("LINENO") {
23137 // c:Src/utils.c:121 `zlong lineno` — the value lives in the C
23138 // GLOBAL, reached through LINENO's GSU. A `typeset -h +g LINENO`
23139 // local shadow has no PM_SPECIAL and no GSU, so C's `lineno = N`
23140 // never touches it; skip the paramtab mirror for the same reason.
23141 if (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) != 0 {
23142 pm.u_val = n;
23143 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
23144 }
23145 }
23146 }
23147 // Mirror to the file-static `lineno` (utils.c:121) that
23148 // zerrmsg reads at utils.c:301 for the `:N: msg` prefix.
23149 crate::ported::utils::set_lineno(n as i32);
23150 // Also drive lex::LEX_LINENO — zerrmsg (utils.rs:376) reads
23151 // THAT counter for the `name:N:` prefix. C zsh interleaves
23152 // parse and execute per top-level list, so its single
23153 // `lineno` global serves both; zshrs compiles the whole
23154 // script before running, leaving LEX_LINENO parked at EOF.
23155 // Without this write, every runtime zwarn/zerr reported the
23156 // script's LAST line instead of the failing statement's.
23157 crate::ported::lex::set_lineno(n as u64);
23158 // DAP hook — checks breakpoints / step mode / pause-request
23159 // for the line we just landed on. O(1) no-op when DAP is off
23160 // (single atomic load on a OnceLock). Inside `--dap` mode
23161 // this is the call that blocks the executor on a Condvar
23162 // until the IDE sends `continue`. Mirrors strykelang's
23163 // `debugger.should_stop(line) → debugger.prompt(...)` flow.
23164 crate::extensions::dap::check_line(n as u32);
23165 fusevm::Value::Status(0)
23166}
23167
23168/// !!! WARNING: RUST-ONLY HELPER — NO DIRECT C COUNTERPART !!!
23169/// The `if (lastval) { errreturn / errexit }` half of execlist's
23170/// `sublist_done:` block (c:Src/exec.c:1660-1680), shared by the normal
23171/// check and the retflag-escape check of BUILTIN_ERREXIT_CHECK: C runs it
23172/// at EVERY sublist_done, including the one a `return 1` just ended.
23173/// Returns the builtin's result (1 = unwind this scope).
23174fn errexit_tail(last: i32) -> Value {
23175 use std::sync::atomic::Ordering;
23176 // c:Src/exec.c:1605-1610 — compute errreturn / errexit.
23177 // errreturn = ERRRETURN && (INTERACTIVE || locallevel || sourcelevel)
23178 // && !(noerrexit & NOERREXIT_RETURN)
23179 // errexit = (ERREXIT || (ERRRETURN && !errreturn))
23180 // && !(noerrexit & NOERREXIT_EXIT)
23181 let no_err = crate::ported::exec::noerrexit.load(Ordering::Relaxed);
23182 let locallvl = crate::ported::params::locallevel.load(Ordering::Relaxed);
23183 let sourcelvl = crate::ported::init::sourcelevel.load(Ordering::Relaxed);
23184 let errreturn_opt = isset(crate::ported::zsh_h::ERRRETURN);
23185 let in_unwindable_scope =
23186 isset(crate::ported::zsh_h::INTERACTIVE) || locallvl != 0 || sourcelvl != 0;
23187 let errreturn = errreturn_opt
23188 && in_unwindable_scope
23189 && (no_err & crate::ported::zsh_h::NOERREXIT_RETURN) == 0;
23190 let (errexit_on, in_subshell) = with_executor(|exec| {
23191 let on_canonical = isset(ERREXIT) || (errreturn_opt && !errreturn); // c:1608-1609
23192 let on_legacy = opt_state_get("errexit").unwrap_or(false);
23193 (
23194 (on_canonical || on_legacy) && (no_err & crate::ported::zsh_h::NOERREXIT_EXIT) == 0,
23195 !exec.subshell_snapshots.is_empty(),
23196 )
23197 });
23198 if !errexit_on {
23199 // c:1667-1674 — `if (errexit) { … realexit(); }` comes FIRST;
23200 // `if (errreturn) { retflag = 1; breaks = loops; }` only matters
23201 // when the shell did not exit. With both options set inside a
23202 // function, ERR_EXIT wins.
23203 if errreturn {
23204 // c:1675-1678 — unwind to the function boundary without
23205 // exiting the shell.
23206 crate::ported::builtin::RETFLAG.store(1, Ordering::Relaxed);
23207 let loops = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
23208 crate::ported::builtin::BREAKS.store(loops, Ordering::Relaxed);
23209 return Value::Int(1);
23210 }
23211 return Value::Int(0);
23212 }
23213 // c:Src/exec.c:1611-1618 — under ERR_EXIT a failing command exits the
23214 // whole shell via realexit() FROM THE POINT OF FAILURE, before any
23215 // enclosing `always` arm can run. zsh 5.9.2 (the reference) has no
23216 // `this_noerrexit` deferral, so at top-level / function scope the
23217 // faithful behavior is to process-exit here (zexit fires the SIGEXIT
23218 // trap and exits). This bypasses the always arm, fixing
23219 // `setopt errexit; { false } always { print A }` which wrongly ran the
23220 // always body: the deferred EXIT_PENDING routed the unwind through
23221 // always_entry (compile_zsh.rs re-points it there) and
23222 // SET_TRY_BLOCK_ERROR then cleared the pending exit so the body ran.
23223 if crate::ported::builtin::SUBSHELL_DEPTH.load(Ordering::Relaxed) == 0 {
23224 crate::ported::builtin::zexit(last, crate::ported::zsh_h::ZEXIT_NORMAL);
23225 // c:1618 realexit
23226 }
23227 // Subshell: zshrs runs subshells in-process, so it cannot process-exit
23228 // the whole shell here — defer to the subshell-end unwind.
23229 crate::ported::builtin::EXIT_VAL.store(last, Ordering::Relaxed);
23230 crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
23231 let _ = in_subshell;
23232 Value::Int(1)
23233}
23234
23235/// !!! WARNING: RUST-ONLY HELPER — NO DIRECT C COUNTERPART !!!
23236/// `sigtrapped[SIGZERR]` (c:Src/exec.c:1652) read through the port's
23237/// mutex-guarded `sigtrapped` table. C indexes the array inline.
23238fn zerr_sigtrapped() -> bool {
23239 crate::ported::signals::sigtrapped
23240 .lock()
23241 .ok()
23242 .is_some_and(|st| st.get(crate::ported::signals_h::SIGZERR as usize).is_some_and(|&s| s != 0))
23243}
23244
23245/// Body of `BUILTIN_DONETRAP_RESET`, factored out for the same reason.
23246/// c:Src/exec.c:1455 `donetrap = 0`.
23247pub(crate) fn donetrap_reset_impl() -> fusevm::Value {
23248 // c:Src/jobs.c:651-652 — a CHLD trap update_bg_job owed while the
23249 // shell was not in a wait loop. C runs it from the SIGCHLD handler;
23250 // a trap body run from zshrs's handler can block on a mutex the
23251 // interrupted statement holds, so it runs here, at the next
23252 // statement prologue (c:Src/exec.c:1451-1455).
23253 for _ in 0..crate::ported::jobs::CHLD_TRAP_PENDING.swap(0, std::sync::atomic::Ordering::SeqCst) {
23254 crate::ported::signals::dotrap(libc::SIGCHLD);
23255 }
23256 // c:Src/exec.c:1455 — `donetrap = 0;` at sublist start.
23257 // Reset before each top-level statement so the next
23258 // sublist's ERREXIT_CHECK fires the ZERR trap on its FIRST
23259 // non-zero command. Carries the "already fired" state
23260 // across function-call returns within the SAME outer
23261 // sublist (per C semantics — donetrap is process-global).
23262 // Bug #303 in docs/BUGS.md.
23263 crate::ported::exec::DONETRAP.store(0, std::sync::atomic::Ordering::Relaxed);
23264 // `${~spec}` carrier: C's `globsubst` is a paramsubst-LOCAL
23265 // int (c:Src/subst.c:1671 `int globsubst = isset(GLOBSUBST);`,
23266 // set to 2 by `${~}` at c:2597-2603) whose only effect is the
23267 // `shtokenize()` of THAT substitution's own result
23268 // (c:4419-4420). It can therefore never be observed by a later
23269 // statement. zshrs carries the flag on the global option table
23270 // (subst.rs:5125-5136) so the compile-emitted glob ops in the
23271 // same word pipeline can see it, and restores it at
23272 // command-dispatch boundaries — but a `${~}` sitting in a word
23273 // that dispatches NO command (a `for`/`select` word list, a
23274 // loop/`case` header) had no such boundary before the NEXT
23275 // statement's words were expanded, so GLOB_SUBST leaked into
23276 // them. This op is emitted exactly once per sublist, in
23277 // compile_list's prologue (compile_zsh.rs:557) — i.e. BEFORE
23278 // the sublist's words expand — which is the same "state is
23279 // gone by the next statement" guarantee C gets for free.
23280 // Without it, `_parameters`' `for i in ${…:#${~pfilt}*}` loop
23281 // globbed its `ary+=($i:"$val")` body word and died with
23282 // "bad pattern: HISTCHARS:!^#", killing `-<TAB>` completion.
23283 consume_tilde_globsubst_carrier();
23284 fusevm::Value::Status(0)
23285}
23286
23287#[cfg(test)]
23288mod foreground_wait_guard_tests {
23289 use super::ForegroundWaitGuard;
23290
23291 /// c:Src/jobs.c:1638-1669 — the mask a foreground wait presents.
23292 ///
23293 /// C blocks for a child inside `signal_suspend(SIGCHLD, wait_cmd)`
23294 /// (c:1658 / c:1710), and that call's sigsuspend mask is EMPTY —
23295 /// c:Src/signals.c:216-232 does `sigemptyset(&set)` and adds at most a
23296 /// conditional SIGINT. SIGCHLD is held off by `child_block()` (c:1642,
23297 /// "unblocked in signal_suspend()") rather than by userspace queueing.
23298 /// So while the shell waits: SIGCHLD blocked, SIGWINCH deliverable.
23299 ///
23300 /// This guard used to call `queue_signals()` and nothing else, which
23301 /// left the standing `winch_block()` (c:Src/init.c:1458, re-armed by
23302 /// preprompt at c:Src/utils.c:1541) in force for the whole of any
23303 /// command — so a resize was invisible to a completion however many
23304 /// children it forked, and `trashzle`'s repaint
23305 /// (c:Src/Zle/compresult.c:1922 → c:Src/Zle/zle_main.c:2071-2095) never
23306 /// saw the `resetneeded`/`winchanged` that c:Src/utils.c:1956-1958
23307 /// raises.
23308 #[test]
23309 fn foreground_wait_unblocks_sigwinch_and_blocks_sigchld() {
23310 let _g = crate::test_util::global_state_lock();
23311 let blocked = |sig: libc::c_int| -> bool {
23312 unsafe {
23313 let mut cur: libc::sigset_t = std::mem::zeroed();
23314 libc::sigemptyset(&mut cur);
23315 libc::pthread_sigmask(libc::SIG_BLOCK, std::ptr::null(), &mut cur);
23316 libc::sigismember(&cur, sig) == 1
23317 }
23318 };
23319 let saved_winch = blocked(libc::SIGWINCH);
23320 let saved_chld = blocked(libc::SIGCHLD);
23321
23322 // The standing mask between prompts (c:Src/utils.c:1541).
23323 crate::ported::signals_h::winch_block();
23324 crate::ported::signals_h::child_unblock();
23325 assert!(blocked(libc::SIGWINCH), "precondition: winch_block() holds");
23326
23327 let (winch_in, chld_in) = {
23328 let _w = ForegroundWaitGuard::enter();
23329 (blocked(libc::SIGWINCH), blocked(libc::SIGCHLD))
23330 };
23331 let winch_out = blocked(libc::SIGWINCH);
23332
23333 if !saved_winch {
23334 crate::ported::signals_h::winch_unblock();
23335 }
23336 if saved_chld {
23337 crate::ported::signals_h::child_block();
23338 } else {
23339 crate::ported::signals_h::child_unblock();
23340 }
23341
23342 assert!(
23343 !winch_in,
23344 "a foreground wait is C's one mid-command delivery point for \
23345 SIGWINCH (c:Src/signals.c:216-232 — signal_suspend's mask is empty)"
23346 );
23347 assert!(
23348 chld_in,
23349 "c:Src/jobs.c:1642 — `child_block(); /* unblocked in signal_suspend() */`"
23350 );
23351 assert!(
23352 winch_out,
23353 "c:Src/utils.c:1541 — the standing winch_block() comes back after the wait"
23354 );
23355 }
23356}
23357
23358#[cfg(test)]
23359mod builtin_completer_coverage_tests {
23360 use super::registered_builtin;
23361
23362 /// Every zshrs-original builtin ships a completer in `completions/`
23363 /// (bundled into `~/.zshrs/functions` by build.rs), so `name <TAB>` and
23364 /// `name -<TAB>` offer its arguments instead of nothing. zsh's own
23365 /// completion tree cannot cover these: the commands do not exist there.
23366 ///
23367 /// The set: the in-process builtins [`registered_builtin`] dispatches
23368 /// (taken from `EXT_BUILTIN_NAMES`), the ztest framework, and the
23369 /// daemon-backed `z*` builtins. `pgrep` is left out: it stands in for the
23370 /// external `pgrep`, which zsh's stock `_pgrep` already completes.
23371 #[test]
23372 fn every_zshrs_builtin_has_a_completer() {
23373 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("completions");
23374 let mut claimed = std::collections::HashSet::new();
23375 for entry in std::fs::read_dir(&dir).expect("completions/") {
23376 let path = entry.expect("dir entry").path();
23377 let Ok(text) = std::fs::read_to_string(&path) else { continue };
23378 if let Some(line) = text.lines().next().and_then(|l| l.strip_prefix("#compdef ")) {
23379 claimed.extend(line.split_whitespace().map(str::to_string));
23380 }
23381 }
23382 let names = crate::ext_builtins::EXT_BUILTIN_NAMES
23383 .iter()
23384 .filter(|n| registered_builtin(n).is_some() && **n != "pgrep")
23385 .chain(crate::extensions::ztest::ZTEST_BUILTIN_NAMES)
23386 .chain(crate::daemon::builtins::ZSHRS_BUILTIN_NAMES);
23387 let missing: Vec<&str> = names.copied().filter(|n| !claimed.contains(*n)).collect();
23388 assert!(
23389 missing.is_empty(),
23390 "zshrs builtins with no `#compdef` completer in completions/: {missing:?}"
23391 );
23392 }
23393}
23394
23395#[cfg(test)]
23396mod function_shadows_builtin_tests {
23397 //! c:Src/exec.c:3485-3489 — command lookup consults `shfunctab` before
23398 //! `builtintab`, so a shell function shadows a builtin of the same name.
23399 //! The zshrs-only builtins dispatched by name in `call_function` (daemon
23400 //! z* family, ztest_*, znative) used to be matched before that check.
23401 use crate::vm_helper::ShellExecutor;
23402
23403 fn run(script: &str) -> (ShellExecutor, i32) {
23404 let mut exec = ShellExecutor::new();
23405 let st = exec.execute_script(script).unwrap_or(-1);
23406 (exec, st)
23407 }
23408
23409 #[test]
23410 fn function_shadows_daemon_z_builtin() {
23411 let _g = crate::test_util::global_state_lock();
23412 let (exec, _) = run("hit=; zls() { hit=fn; return 42 }; zls; st=$?");
23413 assert_eq!(exec.scalar("hit").as_deref(), Some("fn"));
23414 assert_eq!(exec.scalar("st").as_deref(), Some("42"));
23415 }
23416
23417 #[test]
23418 fn function_shadows_extension_builtin() {
23419 let _g = crate::test_util::global_state_lock();
23420 let (exec, _) =
23421 run("hit=; basename() { hit=fn; return 43 }; basename a/b; st=$?");
23422 assert_eq!(exec.scalar("hit").as_deref(), Some("fn"));
23423 assert_eq!(exec.scalar("st").as_deref(), Some("43"));
23424 }
23425
23426 #[test]
23427 fn builtin_prefix_and_disable_f_bypass_the_function() {
23428 let _g = crate::test_util::global_state_lock();
23429 // c:Src/exec.c:3483-3487 — after `builtin`, shfunctab is skipped.
23430 let (exec, _) = run(
23431 "hit=; zls() { hit=fn; return 42 }; builtin zls >/dev/null 2>&1; st=$?",
23432 );
23433 assert_eq!(exec.scalar("hit").as_deref(), Some(""));
23434 assert_ne!(exec.scalar("st").as_deref(), Some("42"));
23435 // c:Src/hashtable.c:239 — getnode skips a DISABLED shfunc node.
23436 let (exec, _) = run(
23437 "hit=; zls() { hit=fn; return 42 }; disable -f zls; zls >/dev/null 2>&1; st=$?",
23438 );
23439 assert_eq!(exec.scalar("hit").as_deref(), Some(""));
23440 assert_ne!(exec.scalar("st").as_deref(), Some("42"));
23441 }
23442}
23443
23444#[cfg(test)]
23445mod zmv_native_autoload_tests {
23446 //! zmv/zcp/zln/zcalc are zsh autoload functions with native ports. The
23447 //! port stands in only for loading a PM_UNDEFINED stub (c:Src/exec.c:
23448 //! 5691-5695); a defined body is an ordinary shell function.
23449 use crate::vm_helper::ShellExecutor;
23450
23451 fn run(script: &str) -> ShellExecutor {
23452 let mut exec = ShellExecutor::new();
23453 let _ = exec.execute_script(script);
23454 exec
23455 }
23456
23457 #[test]
23458 fn user_defined_zmv_and_zcalc_bodies_run() {
23459 let _g = crate::test_util::global_state_lock();
23460 let exec = run("hit=; zmv() { hit=fn; return 44 }; zmv a b; st=$?; unfunction zmv");
23461 assert_eq!(exec.scalar("hit").as_deref(), Some("fn"));
23462 assert_eq!(exec.scalar("st").as_deref(), Some("44"));
23463 let exec = run("hit=; zcalc() { hit=fn; return 45 }; zcalc; st=$?; unfunction zcalc");
23464 assert_eq!(exec.scalar("hit").as_deref(), Some("fn"));
23465 assert_eq!(exec.scalar("st").as_deref(), Some("45"));
23466 }
23467
23468 #[test]
23469 fn undefined_autoload_stub_uses_native_port() {
23470 let _g = crate::test_util::global_state_lock();
23471 // The native zmv prints its own usage and returns 1 for a missing
23472 // pattern pair; the stub stays PM_UNDEFINED, which an fpath load
23473 // (stock zmv body) would have cleared.
23474 let exec = run("autoload -Uz zmv; zmv 2>/dev/null; st=$?");
23475 assert_eq!(exec.scalar("st").as_deref(), Some("1"));
23476 assert!(super::autoload_stub_pending("zmv"));
23477 run("unfunction zmv 2>/dev/null");
23478 }
23479
23480 #[test]
23481 fn disabled_or_absent_zmv_is_command_not_found() {
23482 let _g = crate::test_util::global_state_lock();
23483 let exec = run("zmv 2>/dev/null; st=$?");
23484 assert_eq!(exec.scalar("st").as_deref(), Some("127"));
23485 let exec = run("autoload -Uz zmv; disable -f zmv; zmv 2>/dev/null; st=$?");
23486 assert_eq!(exec.scalar("st").as_deref(), Some("127"));
23487 run("unfunction zmv 2>/dev/null");
23488 }
23489}