zsh/vm_helper.rs
1//! Shell executor state for zshrs.
2//!
3//! ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4//! !!! LAST-RESORT FILE — NOT FOR NEW LOGIC !!!
5//!
6//! This file holds the `ShellExecutor` runtime state struct + VM-adjacent
7//! helpers. It is **not** the place to add zsh logic — every line here that
8//! does real shell work is a tax we pay because zshrs uses fusevm bytecode
9//! instead of C zsh's wordcode walker.
10//!
11//! **Before adding code to this file, STOP and ask:**
12//!
13//! 1. Does the C source have a fn that does this? (Check `src/zsh/Src/*.c`)
14//! → Port it into `src/ported/<file>.rs` with line-by-line citations.
15//! Then call the canonical fn from here.
16//!
17//! 2. Does `src/ported/` already have a port?
18//! → Call it directly. Don't reimplement.
19//!
20//! 3. Is this purely a Rust-only state-struct accessor (getter/setter on
21//! ShellExecutor fields, VM init plumbing, executor-context guards)?
22//! → OK to put it here. Mark it `WARNING: RUST-ONLY HELPER` per memory
23//! `feedback_rust_only_helpers_need_warning`.
24//!
25//! **NEVER:** reinvent paramsubst/expansion/glob/typeset/redirect/scope
26//! management here. Every one of those has a canonical port in `src/ported/`.
27//! When a bridge-side fn grows past ~30 lines of shell logic, that's a
28//! signal the work belongs in `src/ported/` — port it, don't inline.
29//!
30//! This file should be SHRINKING over time. Every PR that adds lines here
31//! should justify it; every PR that moves lines OUT to `src/ported/` is
32//! aligned with the project direction.
33//!
34//! See also: memory `feedback_no_shortcuts_in_porting`, `feedback_true_port_pattern`,
35//! `feedback_no_shellexecutor_in_ported` (the inverse direction).
36//! ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
37//!
38//! **Not a port of Src/exec.c.** C zsh runs compiled programs on the native
39//! **wordcode walker** in `Src/exec.c` (`execlist` / `execpline` / `execcmd`).
40//! zshrs uses fusevm bytecode instead; the bridge lives in `src/fusevm_bridge.rs`.
41//! This file holds:
42//! - `ShellExecutor` — the runtime state struct that the VM and
43//! every ported builtin/utility threads through
44//! - VM-adjacent helpers that read/write that state
45//!
46//! Path-wise this file lives at the crate root (`src/vm_helper`) rather
47//! than in `src/ported/` because nothing here corresponds 1:1 to a
48//! `Src/*.c` source file. `crate::ported::exec` is kept as a
49//! re-export alias so existing call-sites continue to compile.
50
51use crate::compsys::cache::CompsysCache;
52use crate::compsys::CompInitResult;
53use crate::history::HistoryEngine;
54use crate::options::ZSH_OPTIONS_SET;
55use crate::ported::builtin::{BREAKS, CONTFLAG};
56use crate::ported::math::mathevali;
57use crate::ported::modules::parameter::*;
58use crate::ported::subst::singsub;
59use crate::ported::utils::{errflag, ERRFLAG_ERROR};
60use crate::ported::zsh_h::PM_UNDEFINED;
61use crate::ported::zsh_h::WC_SIMPLE;
62use crate::ported::zsh_h::{options, MAX_OPS};
63use crate::ported::zsh_h::{PM_ARRAY, PM_HASHED, PM_INTEGER, PM_READONLY};
64use parking_lot::Mutex;
65use std::collections::HashSet;
66use std::ffi::CStr;
67use std::ffi::CString;
68use std::fs;
69use std::io::Read;
70use std::os::unix::ffi::OsStrExt;
71use std::os::unix::fs::FileTypeExt;
72use std::os::unix::fs::PermissionsExt;
73use std::os::unix::io::FromRawFd;
74use std::sync::atomic::AtomicI32;
75use std::sync::atomic::Ordering;
76use std::time::{SystemTime, UNIX_EPOCH};
77use walkdir::WalkDir;
78
79// Backward-compat re-exports for free ported recently relocated to their
80// canonical-C-file Rust modules. Existing call-sites in this file (and
81// elsewhere) still reference these unqualified.
82#[allow(unused_imports)]
83pub(crate) use crate::func_body_fmt::FuncBodyFmt;
84#[allow(unused_imports)]
85pub(crate) use crate::ported::hist::bufferwords as bufferwords_z_tuple;
86#[allow(unused_imports)]
87pub(crate) use crate::ported::math::{parse_assign, parse_compound, parse_pre_inc};
88#[allow(unused_imports)]
89pub use crate::ported::params::convbase as format_int_in_base;
90pub use crate::ported::params::convbase_underscore;
91#[allow(unused_imports)]
92pub(crate) use crate::ported::params::getarrvalue;
93#[allow(unused_imports)]
94pub(crate) use crate::ported::utils::base64_decode;
95#[allow(unused_imports)]
96pub(crate) use crate::ported::utils::{ispwd, printprompt4, quotedzputs};
97
98pub(crate) use crate::intercepts::intercept_matches;
99/// AOP advice type — before, after, or around.
100pub use crate::intercepts::{AdviceKind, Intercept};
101
102/// Result from background compinit thread.
103pub use crate::compinit_bg::CompInitBgResult;
104use std::io::Write;
105use std::sync::LazyLock;
106
107/// State snapshot for plugin delta computation.
108pub(crate) use crate::plugin_cache::PluginSnapshot;
109
110/// Cached compiled regexes for hot paths
111pub(crate) static REGEX_CACHE: LazyLock<Mutex<HashMap<String, Regex>>> =
112 LazyLock::new(|| Mutex::new(HashMap::with_capacity(64)));
113
114// fusevm VM bridge (extension; not a port of Src/exec.c) lives in
115// src/fusevm_bridge.rs. Re-exports below let the rest of the codebase
116// reference symbols as `crate::ported::exec::X`.
117pub(crate) use crate::fusevm_bridge::ExecutorContext;
118pub use crate::fusevm_bridge::*;
119
120/// `ZSH_VERSION` / `ZSH_PATCHLEVEL` / `ZSH_VERSION_DATE` consts
121/// generated by `build.rs` from `src/zsh/Config/version.mk`. Use
122/// `zsh_version::ZSH_VERSION` etc. at call sites so version bumps
123/// pick up automatically.
124pub mod zsh_version {
125 include!(concat!(env!("OUT_DIR"), "/zsh_version.rs"));
126}
127
128/// Match an intercept pattern against a command name or full command string.
129/// Supports: exact match, glob ("git *", "_*", "*"), or "all".
130
131/// O(1) builtin-name lookup set derived from the canonical
132/// `BUILTINS` table (`src/ported/builtin.rs:122`, the 1:1 port of
133/// `static struct builtin builtins[]` at `Src/builtin.c:40-137`).
134/// Earlier incarnation hardcoded a separate 130-entry list which
135/// drifted whenever new builtins landed in the canonical table — and
136/// shadowed the `fusevm::shell_builtins::BUILTIN_SET` u16 opcode
137/// constant. Renaming to `BUILTIN_NAMES` removes the shadow; the
138/// initialiser walks `BUILTINS` so the set stays in sync.
139///
140/// The hardcoded entries inside `LazyLock::new` below are kept as
141/// the union of: (1) names from `BUILTINS` (walked at first access),
142/// (2) zshrs daemon-side builtins from `ZSHRS_BUILTIN_NAMES`. Both
143/// arms run once at static init.
144pub(crate) static BUILTIN_NAMES: LazyLock<HashSet<String>> = LazyLock::new(|| {
145 let mut s: HashSet<String> = HashSet::new();
146 // Walk the canonical `BUILTINS` table — the 1:1 port of
147 // `static struct builtin builtins[]` at `Src/builtin.c:40-137`
148 // (ported at `src/ported/builtin.rs:122`). Every name in there is
149 // a real zsh builtin; the set stays in sync as new ports land.
150 for b in crate::ported::builtin::BUILTINS.iter() {
151 s.insert(b.node.nam.clone());
152 }
153 // Daemon-side (zshrs-specific extensions).
154 for &n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.iter() {
155 s.insert(n.to_string());
156 }
157 s
158});
159
160use crate::exec_jobs::{JobState, JobTable};
161use crate::parse::{Redirect, RedirectOp, ShellCommand, ShellWord, VarModifier, ZshParamFlag};
162use indexmap::IndexMap;
163use std::collections::HashMap;
164use std::env;
165use std::fs::{File, OpenOptions};
166use std::io;
167use std::path::{Path, PathBuf};
168use std::process::{Child, Command, Stdio};
169
170// Re-exports for call-sites that reference `crate::ported::exec::<Name>`.
171pub use crate::bash_complete::CompSpec;
172pub use crate::ported::builtin::AutoloadFlags;
173pub use crate::ported::modules::zutil::zstyle_entry;
174
175/// Snapshot of subshell-isolated state. Captured at `(` entry, restored at
176/// `)` exit. zsh subshell semantics: assignments inside `(…)` don't leak to
177/// the outer scope — and that includes `export`. zsh forks a child for the
178/// subshell so the child's env::set_var dies with the child; without a fork
179/// (zshrs runs subshells in-process for perf), we snapshot+restore the OS
180/// env table around the subshell. Otherwise `(export y=v)` would leak `y`
181/// to the parent shell, breaking every script that uses a subshell to
182/// scope an env override.
183/// Snapshot of mutable executor state across a subshell
184/// boundary.
185/// Port of the `entersubsh()` save/restore Src/exec.c does at
186/// line 1084 — captures everything that must be replaced when a
187/// `(...)` group fires.
188pub struct SubshellSnapshot {
189 /// Snapshot of `paramtab` (the C-canonical parameter store) at
190 /// subshell entry. Step 1 of the unification mirrors writes to
191 /// paramtab, so subshell-scoped assignments now show up there
192 /// too — without this snapshot, restoring only `variables` /
193 /// `arrays` / `assoc_arrays` leaks the subshell's writes to the
194 /// parent via paramtab (e.g. `x=outer; (x=inner); echo $x` returned
195 /// `inner` because paramsubst reads through paramtab).
196 pub paramtab: HashMap<String, crate::ported::zsh_h::Param>,
197 /// `paramtab_hashed_storage` field.
198 pub paramtab_hashed_storage: HashMap<String, IndexMap<String, String>>,
199 /// `positional_params` field.
200 pub positional_params: Vec<String>,
201 /// `env_vars` field.
202 pub env_vars: HashMap<String, String>,
203 /// Process working directory at subshell entry. `cd` inside the
204 /// subshell shouldn't leak to the parent; we restore on End.
205 pub cwd: Option<PathBuf>,
206 /// File-creation mask at subshell entry. zsh forks for `(...)` so
207 /// `umask` set inside dies with the child; we run subshells in
208 /// process so we must restore the mask on End. Otherwise
209 /// `umask 022; (umask 077); umask` shows 077 in the parent.
210 pub umask: u32,
211 /// Parent's traps at subshell entry. zsh's `(trap "echo X" EXIT;
212 /// true)` runs the trap when the subshell exits — BEFORE the parent
213 /// continues. Without this snapshot, the trap inherited from parent
214 /// would fire, OR a trap set inside the subshell would leak to the
215 /// parent's process exit. Restored on subshell_end after the
216 /// subshell's own EXIT trap (if any) has fired. Stores a snapshot
217 /// of `crate::ported::builtin::traps_table()` (canonical).
218 pub traps: HashMap<String, String>,
219 /// Parent's shell options at subshell entry. `(set -e)` /
220 /// `(setopt extendedglob)` mustn't leak; zsh forks the subshell
221 /// so child options die with the child. We run in-process, so we
222 /// must restore the option store on subshell_end.
223 pub opts: HashMap<String, bool>,
224 /// Parent's alias entries at subshell entry. zsh forks for
225 /// `(...)` so `(alias x=y)` inside a subshell dies with the
226 /// child and doesn't leak to the parent. zshrs runs subshells
227 /// in-process, so we must restore the alias table on
228 /// subshell_end. Bug #209 in docs/BUGS.md. Stored as a flat
229 /// Vec<(name, text)> snapshot — the underlying alias_table holds
230 /// an IndexMap<String, alias> but `alias` carries hashnode
231 /// metadata we don't need to round-trip; only name + text are
232 /// observable via `alias NAME` lookup.
233 pub aliases: Vec<(String, String)>,
234 /// Parent's shell-function table at subshell entry. C zsh's
235 /// `entersubsh` (`Src/exec.c`) forks before running the
236 /// subshell body so `(f() { ... })` defining a function dies
237 /// with the child and never leaks to the parent. zshrs runs
238 /// subshells in-process, so we must clone `shfunctab` on entry
239 /// and restore on exit. Bug #208 in docs/BUGS.md. Stored as
240 /// the full `HashMap<String, Box<shfunc>>` clone — shfunc is
241 /// `Clone` and the table snapshot is bounded by the user's
242 /// declared function set.
243 pub shfuncs:
244 std::collections::HashMap<String, Box<crate::ported::zsh_h::shfunc>>,
245 /// Parent's compiled-function chunks at subshell entry. Companion
246 /// to `shfuncs` above — `ShellExecutor.functions_compiled` is the
247 /// runtime dispatch table that `Op::CallFunction` reads through;
248 /// without restoring it, a subshell `(g() { override; })` leaves
249 /// the override bytecode chunk in place so the parent's
250 /// `g` call still runs the override after `subshell_end`
251 /// restored shfunctab. Bug #208 in docs/BUGS.md.
252 pub functions_compiled: HashMap<String, fusevm::Chunk>,
253 /// Parent's function source map at subshell entry. Companion to
254 /// `functions_compiled` so `typeset -f` / `whence` show the
255 /// parent's source after subshell exit, not the subshell's
256 /// overridden body. Bug #208 in docs/BUGS.md.
257 pub function_source: HashMap<String, String>,
258 /// Parent's modulestab `modules` map at subshell entry. zsh forks
259 /// for `(...)` so a `(zmodload zsh/X)` inside the subshell sets
260 /// MOD_INIT_B on the child's modulestab; when the child exits the
261 /// flag dies with it and the parent's modulestab is untouched.
262 /// zshrs runs subshells in-process, so a subshell `zmodload`
263 /// would otherwise flip the parent's `${modules[zsh/X]}` from
264 /// unset to "loaded". Snapshot here and restore on subshell_end.
265 /// Bug #210 in docs/BUGS.md. Stored as `(name → flags)`
266 /// since `module` struct doesn't derive Clone (LinkList/
267 /// Linkedmod) — and the only thing `zmodload` mutates that
268 /// affects introspection is the flags bitmask (MOD_INIT_B
269 /// for loaded, MOD_UNLOAD for unloaded).
270 pub modules: HashMap<String, i32>,
271 /// Parent's THINGYTAB (ZLE widget registry) at subshell entry.
272 /// zsh forks for `(...)` so `zle -N w f` / `zle -D w` inside the
273 /// subshell flip widget bindings only in the child; when the
274 /// child exits the parent's widget table is untouched. zshrs runs
275 /// subshells in-process so a subshell's `zle -D w` would
276 /// otherwise unbind the parent's widget. Bug #453 in docs/BUGS.md.
277 pub thingytab: HashMap<String, crate::ported::zle::zle_thingy::Thingy>,
278 /// Parent's KEYMAPNAMTAB (named keymap registry) at subshell
279 /// entry. Same fork-copy semantics as THINGYTAB — a subshell's
280 /// `bindkey -N km` / `bindkey -D km` mutates only the child's
281 /// keymap registry in C zsh. Bug #454 in docs/BUGS.md.
282 pub keymapnamtab: HashMap<String, crate::ported::zle::zle_keymap::KeymapName>,
283 /// Parent's `$!` (clone::lastpid) at subshell entry. C zsh forks
284 /// for `(...)`, so a background job started INSIDE the subshell
285 /// sets the child's `lastpid` only — `( : & ); echo $!` prints 0
286 /// in zsh. zshrs runs subshells in-process, so restore on end.
287 pub lastpid: i32,
288 /// Job-control state at subshell entry: (JOBTAB clone, CURJOB,
289 /// PREVJOB, MAXJOB, THISJOB). C zsh forks for `(...)` so any
290 /// `disown` / `wait` / new `&` job inside the subshell mutates the
291 /// CHILD's copy of jobtab and dies with it (Src/exec.c::entersubsh
292 /// fork semantics); the parent's table is untouched. zshrs's
293 /// in-process subshell must snapshot/restore to match — without
294 /// this, `sleep 1 & (disown); jobs` shows an empty table where
295 /// zsh still lists the job. Bug #462.
296 pub jobtab: Vec<crate::ported::zsh_h::job>,
297 /// `curjob` at subshell entry (Src/jobs.c:75 global).
298 pub curjob: i32,
299 /// `prevjob` at subshell entry (Src/jobs.c:80 global).
300 pub prevjob: i32,
301 /// `maxjob` at subshell entry (Src/jobs.c:71 global).
302 pub maxjob: usize,
303 /// `thisjob` at subshell entry (Src/jobs.c:77 global).
304 pub thisjob: i32,
305 /// User-range fds (0-9) at subshell entry: `(fd, saved_dup)`
306 /// pairs where `saved_dup` is an `F_DUPFD >= 10` copy, or -1 when
307 /// the fd was closed at entry. C zsh forks for `(...)` so a bare
308 /// `exec >file` / `exec 3<&-` inside the child dies with it
309 /// (Src/exec.c entersubsh fork semantics); the in-process
310 /// subshell must restore the parent's fd table on End. Without
311 /// this, `(exec >t.log; ...); cat t.log` left the PARENT's fd 1
312 /// pointing at t.log and `cat` looped forever copying the file
313 /// into itself.
314 pub saved_fds: Vec<(i32, i32)>,
315}
316
317#[allow(unused_imports)]
318pub(crate) use crate::ported::pattern::{
319 extract_numeric_ranges, numeric_range_contains, numeric_ranges_to_star,
320};
321
322/// Top-level shell executor state.
323/// Fork-equivalent event counter — incremented on every external
324/// spawn and in-process subshell entry. C zsh's `time` keyword
325/// reports only for JOBS (forked work, Src/jobs.c printtime via the
326/// job table); zshrs runs builtins/braces/functions in-process with
327/// no job, so the TIME_SUBLIST handler compares this counter across
328/// the timed body to decide whether to emit the report.
329pub static FORK_EVENTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
330
331/// Port of the file-static globals + `Estate` chain Src/exec.c
332/// uses — `execlist()` (line 1349) drives every list, with
333/// `execpline()` (line 1668), `execpline2()` (line 1991),
334/// `execsimple()` (line 1290), and the per-`WC_*` `execfuncs[]`
335/// table (line 268) feeding off it. The Rust port collapses
336/// everything into one `ShellExecutor` so we don't need
337/// thread-local globals.
338pub struct ShellExecutor {
339 /// Mirrors C zsh's file-static `scriptname` (Src/init.c). Used by
340 /// PS4's `%N` and the `scriptname:line: …` prefix on error
341 /// messages. Inside a function, MUTATES to the function name
342 /// (Src/exec.c:5903 `scriptname = dupstring(name)`). Init sets
343 /// this in `-c` mode to the binary basename per init.c:479; when
344 /// sourcing a file via `source`/`bin_dot`, it becomes the
345 /// resolved file path; otherwise it falls back through `$0` →
346 /// `$ZSH_ARGZERO`.
347 pub scriptname: Option<String>,
348 /// Mirrors C zsh's `scriptfilename` global (Src/init.c). Tracks
349 /// the FILE BEING READ (vs scriptname which tracks the active
350 /// function name during a call). Used by PS4's `%x` and certain
351 /// error-message prefixes that want the file location, NOT the
352 /// function name.
353 ///
354 /// At -c-mode init, scriptname == scriptfilename == "zsh"
355 /// (Src/init.c:479). When entering a function, ONLY scriptname
356 /// updates (exec.c:5903); scriptfilename stays at the outer
357 /// file path, so `%x` inside a function still shows the file
358 /// the function was called from.
359 pub scriptfilename: Option<String>,
360 /// Stack of subshell-state snapshots. Each `(…)` subshell pushes a copy
361 /// of variables/arrays/assoc_arrays at entry and pops/restores at exit.
362 /// Without this, `(x=inner; …); echo $x` shows `inner` instead of the
363 /// outer-scope value.
364 pub subshell_snapshots: Vec<SubshellSnapshot>,
365 /// Stack of inline-assignment scopes — `X=foo Y=bar cmd` pushes
366 /// a frame at the start, the assigns run inside it, and `cmd`
367 /// returns into END_INLINE_ENV which restores both shell-vars
368 /// and process-env to the pre-frame state. Each frame holds
369 /// `(name, prev_var, prev_env)` per assigned name. zsh's
370 /// equivalent is the parser-level "addvar" list executed under
371 /// `addvars()` (Src/exec.c) right before the command exec.
372 pub inline_env_stack: Vec<Vec<(String, Option<String>, Option<String>)>>,
373 /// Set by `expand_glob`'s no-match arm when `nomatch` is on (zsh
374 /// default) — instructs the simple-command dispatcher to skip
375 /// executing the current command, set last_status=1, and continue
376 /// to the next command in the script. zsh's bin_simple uses the
377 /// errflag global for the same role: error printed, command
378 /// suppressed, script continues. Without this we were calling
379 /// `process::exit(1)` deep inside expand_glob, killing the whole
380 /// shell on any unmatched glob even with multi-statement input.
381 /// `Cell` because the no-match site only has a `&self` borrow.
382 pub current_command_glob_failed: std::cell::Cell<bool>,
383 /// `jobs` field.
384 pub jobs: JobTable,
385 /// `fpath` field.
386 pub fpath: Vec<PathBuf>,
387 /// `history` field.
388 pub history: Option<HistoryEngine>,
389 pub(crate) process_sub_counter: u32,
390 pub completions: HashMap<String, CompSpec>, // command -> completion spec
391 pub zstyles: Vec<zstyle_entry>, // zstyle configurations
392 /// Current function scope depth for `local` tracking.
393 pub local_scope_depth: usize,
394 /// Last arg of the currently-running command, deferred into `$_`
395 /// when the next command dispatches. zsh: `$_` reflects the LAST
396 /// command's last arg, so `echo hi; echo $_` prints `hi` (not the
397 /// `_` arg of `echo $_` itself). Promoted in `pop_args` and
398 /// `host.exec` before the command's args are read.
399 pub pending_underscore: Option<String>,
400 /// True while expanding inside a double-quoted context. Set by
401 /// `BUILTIN_EXPAND_TEXT` mode 1 around `expand_string` calls.
402 /// Used by parameter-flag application to suppress array-only flags
403 /// (`(o)`/`(O)`/`(n)`/`(i)`/`(M)`/`(u)`) — zsh's behaviour: those
404 /// flags only fire in array context.
405 pub in_dq_context: u32,
406 /// True (>0) while expanding the RHS of a scalar assignment.
407 /// Direct port of zsh's `PREFORK_SINGLE` bit set by
408 /// Src/exec.c::addvars line 2546 (`prefork(vl, isstr ?
409 /// (PREFORK_SINGLE|PREFORK_ASSIGN) : PREFORK_ASSIGN, ...)`).
410 /// Subst_port's paramsubst reads this via `ssub` and suppresses
411 /// `(f)` / `(s:STR:)` / `(0)` / `(z)` split flags per
412 /// Src/subst.c:1759 + 3902, so `y="${(f)x}"` preserves x's
413 /// original separator (newlines) instead of re-joining with
414 /// IFS-first-char (space).
415 pub in_scalar_assign: u32,
416 /// `profiling_enabled` field.
417 pub profiling_enabled: bool,
418 // compsys - completion system cache
419 /// `compsys_cache` field.
420 pub compsys_cache: Option<CompsysCache>,
421 // Background compinit — receiver for async fpath scan result
422 /// `compinit_pending` field.
423 pub compinit_pending: Option<(
424 std::sync::mpsc::Receiver<CompInitBgResult>,
425 std::time::Instant,
426 )>,
427 // Plugin source cache — stores side effects of source/. in SQLite
428 /// `plugin_cache` field.
429 pub plugin_cache: Option<crate::plugin_cache::PluginCache>,
430 // cdreplay - deferred compdef calls for zinit turbo mode
431 /// `deferred_compdefs` field.
432 pub deferred_compdefs: Vec<Vec<String>>,
433 // Control flow signals
434 pub returning: Option<i32>, // Set by return builtin, cleared after function returns
435 /// zsh compatibility mode - use .zcompdump, fpath scanning, etc.
436 /// Also serves as the `--zsh` parity-test flag: caches off, daemon
437 /// off, plugin_cache replay off so every `source` re-runs the file
438 /// fresh per Src/builtin.c:6080-6123 bin_dot semantics.
439 pub zsh_compat: bool,
440 /// bash compatibility mode (`--bash`). Same parity-mode semantics
441 /// as `zsh_compat` (caches/daemon/replay off) plus bash-specific
442 /// behavior tweaks where bash 5.x diverges from zsh — e.g.
443 /// `BASH_VERSION` / `BASH_REMATCH` exposed, `[[ =~ ]]` populates
444 /// match indices the bash way, mapfile/readarray as builtins.
445 pub bash_compat: bool,
446 /// POSIX sh strict mode — no SQLite, no worker pool, no zsh extensions
447 pub posix_mode: bool,
448 /// Worker thread pool for background tasks (compinit, process subs, etc.)
449 pub worker_pool: std::sync::Arc<crate::worker::WorkerPool>,
450 /// AOP intercept table: command/function name → advice chain.
451 /// Glob patterns supported (e.g. "git *", "*").
452 pub intercepts: Vec<Intercept>,
453 /// Async job handles: id → receiver for (status, stdout)
454 pub async_jobs: HashMap<u32, crossbeam_channel::Receiver<(i32, String)>>,
455 /// Next async job ID
456 pub next_async_id: u32,
457 /// Per-scope saved-fd stacks for `Op::WithRedirectsBegin/End`. Each entry
458 /// is a Vec of (fd, saved_dup_fd) pairs taken from `dup(fd)` before the
459 /// redirect was applied; `with_redirects_end` `dup2`s them back and closes.
460 pub redirect_scope_stack: Vec<Vec<(i32, i32)>>,
461 /// Per-scope MULTIOS tee state. Each entry is `(pipe_write_fd,
462 /// JoinHandle)`: the pipe write-end currently dup2'd onto the
463 /// command's fd, and the splitter thread that reads from the
464 /// pipe read-end and writes to every collected target. Closed
465 /// + joined by `host_redirect_scope_end` BEFORE the saved fds
466 /// are restored so the splitter drains every byte the body
467 /// wrote into the pipe. Bug #36 in docs/BUGS.md.
468 pub multios_scope_stack: Vec<Vec<(i32, std::thread::JoinHandle<()>)>>,
469 /// True while applying a bare `exec`'s redirect list (`exec 1>&-`,
470 /// `exec 2>/dev/null` — no command words). `host_apply_redirect`
471 /// then skips pushing the saved fd into the enclosing scope so the
472 /// fd change survives group/command teardown.
473 /// c:Src/exec.c:3978-3986 — nullexec==1: "we specifically *don't*
474 /// restore the original fd's before returning"; C's per-execcmd
475 /// `save[]` means exec's redirs never enter the enclosing group's
476 /// save list either. Toggled by `BUILTIN_EXEC_PERM_REDIRS`.
477 pub exec_redirs_permanent: bool,
478 /// Set in a forked pipeline-stage child right after its stdout is
479 /// dup2'd onto the pipe write-end. Consumed by the FIRST
480 /// `host_redirect_scope_begin` (the stage command's own redirect
481 /// list) into `pipe_output_scope`.
482 /// c:Src/exec.c:3722-3724 — `addfd(forked, save, mfds, 1, output,
483 /// 1, NULL)`: the pipe occupies mfds[1] in the SAME execcmd that
484 /// processes the stage command's redirect list.
485 pub pipe_output_pending: bool,
486 /// Index into `redirect_scope_stack` of the scope whose redirect
487 /// list shares an execcmd with the pipeline output on fd 1. A
488 /// write-side redirect of fd 1 applied at exactly this scope depth
489 /// MULTIOS-splits (tees) instead of replacing — c:Src/exec.c:
490 /// 2447-2480 addfd "split the stream". Cleared when that scope ends.
491 pub pipe_output_scope: Option<usize>,
492 /// Set by `host_apply_redirect` when a redirect target couldn't be
493 /// opened (permission denied, no such directory, etc). The next
494 /// builtin/command checks this at entry and short-circuits with
495 /// status 1 instead of running. Mirrors zsh's "command skip" on
496 /// redirect failure.
497 pub redirect_failed: bool,
498 /// Compiled function bodies — name → fusevm::Chunk. Populated by
499 /// `BUILTIN_REGISTER_FUNCTION` (from `FunctionDef` lowering) and lazily by
500 /// `ZshrsHost::call_function` when only an AST exists in `self.functions`
501 /// (autoloaded, sourced, etc.). `Op::CallFunction` dispatches through here.
502 pub functions_compiled: HashMap<String, fusevm::Chunk>,
503 /// Canonical source text for functions. Populated by autoload paths (the
504 /// raw file/cache body), runtime FuncDef compile (the parsed source span),
505 /// and `unfunction` removal. Used by introspection (`whence`, `which`,
506 /// `typeset -f`) instead of reconstructing from a ShellCommand AST. When a
507 /// function is in `functions_compiled` but not here, introspection falls
508 /// back to `text::getpermtext(self.functions[name])`.
509 pub function_source: HashMap<String, String>,
510 /// `first_body_line - 1` per compiled function — matches inner
511 /// `ZshCompiler::lineno_offset` / zsh `funcstack->flineno` combined with
512 /// relative `$LINENO` for Src/prompt.c:909 `%I`.
513 pub function_line_base: HashMap<String, i64>,
514 /// `scriptfilename` when `BUILTIN_REGISTER_COMPILED_FN` ran — `%x` inside
515 /// a function (prompt.c:931-934) reads `funcstack->filename`.
516 pub function_def_file: HashMap<String, Option<String>>,
517 /// Innermost-last stack of active compiled-call frames for prompt `%I` / `%x`.
518 pub prompt_funcstack: Vec<(String, i64, Option<String>)>,
519 /// Scalar→(array, sep) tie table set up by `typeset -T VAR var [SEP]`.
520 /// Array→(scalar, sep) reverse-tie table. Used by BUILTIN_SET_ARRAY to
521 /// join the array elements with `sep` and mirror to the scalar side.
522 pub tied_array_to_scalar: HashMap<String, (String, String)>,
523
524 // ── ztest framework counters (extensions/ztest.rs) ──────────────────
525 //
526 // Mirrors strykelang's per-VMHelper test counters
527 // (strykelang/builtins.rs:22292-22308 + builtins.rs::test_pass/_fail/_skip,
528 // builtins.rs::test_pass_count etc.). Each `zassert_*` builtin bumps
529 // the per-block counter; `ztest_run` rolls per-block into _total and
530 // resets the per-block side, so a single test file with multiple
531 // `ztest_run` calls can reuse the counters. The worker-pool runner in
532 // src/extensions/ztest.rs reads pass_total+pass_count and
533 // fail_total+fail_count after `execute_script` returns for the cumulative
534 // numbers (strykelang/cli_runners.rs:115-118). `ztest_run_failed` is a
535 // sticky bool the runner reads so a test that asserts but then exits
536 // 0 still flags as failed. `ztest_suppress_stdout` matches
537 // VMHelper::suppress_stdout — the runner sets it inside the forked
538 // grandchild so the per-test stderr capture stays clean.
539 /// Per-block pass count (reset by `ztest_run`).
540 pub ztest_pass_count: std::sync::atomic::AtomicUsize,
541 /// Per-block fail count (reset by `ztest_run`).
542 pub ztest_fail_count: std::sync::atomic::AtomicUsize,
543 /// Per-block skip count (reset by `ztest_run`).
544 pub ztest_skip_count: std::sync::atomic::AtomicUsize,
545 /// Cumulative pass total across the run.
546 pub ztest_pass_total: std::sync::atomic::AtomicUsize,
547 /// Cumulative fail total across the run.
548 pub ztest_fail_total: std::sync::atomic::AtomicUsize,
549 /// Cumulative skip total across the run.
550 pub ztest_skip_total: std::sync::atomic::AtomicUsize,
551 /// Sticky failure flag — set by any `ztest_run` that observed fails;
552 /// the CLI runner reads this so a test that asserts then exits 0
553 /// still counts as a failed file.
554 pub ztest_run_failed: std::sync::atomic::AtomicBool,
555 /// Suppress per-assertion `✓`/`✗` lines on stderr. Set by the worker
556 /// runner inside the forked child when it has already redirected
557 /// fd 2 to a tmp file (we still want the lines, but only after the
558 /// runner re-emits them under print_lock to avoid line-tearing).
559 pub ztest_suppress_stdout: bool,
560}
561
562/// Context-isolated nested parse — the AST-path bridge for C's
563/// `parse_string` (`Src/exec.c:283`). zshrs executes via the ZshProgram
564/// AST + `compile_zsh`, not wordcode, so this can't live in `src/ported/`
565/// (C's `parse_string` returns `Eprog`, and the build.rs port-gate rejects
566/// any non-C-named fn there). It's the bridge that wraps the AST
567/// `parse_init`+`parse` in the SAME isolation `parse_string` provides.
568///
569/// Why this exists: a runtime parse — command-substitution body,
570/// process-substitution argv — must not clobber the outer
571/// `loop()`/`parse_event` reader's live input when it interleaves parsing
572/// with execution (faithful single-event mode).
573///
574/// The load-bearing piece is `strinbeg(0)`/`strinend()` (c:290/298). It
575/// sets the `strin` flag so that when the nested lexer drains `cmd_str`,
576/// `ingetc` returns EOF (input.rs:391) instead of falling through to
577/// `inputline()`, which would STEAL the outer reader's next SHIN line —
578/// e.g. `echo A` / `v=$(echo hi)` / `echo B` had the cmd-subst swallow
579/// `echo B` off stdin, and the outer loop then hit EOF after one command.
580/// `strinbeg` also runs `hbegin`/`lexinit`, so it must execute on isolated
581/// history+lexer state — hence the surrounding `zcontext_save`/`restore`
582/// (c:288/300), which saves+restores `tok`/`tokstr`/`lexbuf`/`isnewlin`/
583/// `incmdpos`/heredocs/`lexstop`/`toklineno`/history.
584///
585/// Two zshrs-specific globals `zcontext` doesn't cover are saved here too:
586/// the lexer-input window (`LEX_INPUT`/`LEX_POS`/`LEX_UNGET_BUF`) that
587/// `lex_init` overwrites with `cmd_str`, and the line counter `LEX_LINENO`
588/// (C saves `oldlineno` explicitly at parse_string c:291/295).
589///
590/// NOTE: using `inpush` (the literal C input stack) instead does NOT work
591/// in zshrs's hybrid input model — the outer piped reader pulls from SHIN
592/// via `inputline`, and pushing/popping the `inbuf` stack severs that
593/// continuation. The `LEX_INPUT` window + `strin` flag is the working
594/// equivalent.
595pub(crate) fn parse_isolated(input: &str) -> crate::parse::ZshProgram {
596 use crate::ported::lex::{tok, LEXERR, LEX_INPUT, LEX_LINENO, LEX_POS, LEX_UNGET_BUF};
597
598 crate::ported::context::zcontext_save(); // c:288
599 // Save the zshrs-specific lexer window + line counter that lex_init
600 // overwrites but zcontext doesn't cover.
601 let saved_input = LEX_INPUT.with_borrow(|s| s.clone());
602 let saved_pos = LEX_POS.get();
603 let saved_unget = LEX_UNGET_BUF.with_borrow(|b| b.clone());
604 let saved_lineno = LEX_LINENO.get(); // c:291 oldlineno
605 // input.rs `lexstop` is the input-side half of C's single `lexstop`;
606 // draining the nested LEX_INPUT sets it true and zcontext only covers
607 // the lex.rs half (LEX_LEXSTOP). Restore it so the outer reader isn't
608 // left at EOF.
609 let saved_in_lexstop = crate::ported::input::lexstop.with(|c| c.get());
610
611 crate::ported::hist::strinbeg(0); // c:290 — strin++ → drained nested input EOFs (no SHIN steal)
612 crate::ported::parse::parse_init(input); // install cmd_str as LEX_INPUT (lex_init), LEX_LINENO=1
613 let program = crate::ported::parse::parse(); // c:294 (AST analog of par_list)
614
615 // Capture parse failure BEFORE the restores wipe the signals.
616 let parse_err = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0 || tok() == LEXERR;
617 if tok() == LEXERR && crate::ported::builtin::LASTVAL.load(Ordering::Relaxed) == 0 {
618 crate::ported::builtin::LASTVAL.store(1, Ordering::Relaxed); // c:296-297
619 }
620
621 crate::ported::hist::strinend(); // c:298 — strin--
622 // Restore the zshrs window, then the token/parse/history state.
623 LEX_INPUT.with_borrow_mut(|s| *s = saved_input);
624 LEX_POS.set(saved_pos);
625 LEX_UNGET_BUF.with_borrow_mut(|b| *b = saved_unget);
626 LEX_LINENO.set(saved_lineno); // c:295
627 crate::ported::input::lexstop.with(|c| c.set(saved_in_lexstop));
628 crate::ported::context::zcontext_restore(); // c:300
629 // zcontext_restore → parse_context_restore clears ERRFLAG_ERROR
630 // (parse.c:354); re-raise so callers gating on the bit still see it.
631 if parse_err {
632 errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
633 }
634 program
635}
636
637impl ShellExecutor {
638 /// Set a scalar parameter via the canonical `paramtab`
639 /// (`Src/params.c:3350 setsparam`). The single store.
640 pub fn set_scalar(&mut self, name: String, value: String) {
641 setsparam(&name, &value); // c:params.c:3350
642 }
643
644 /// Read positional parameters from canonical `PPARAMS`
645 /// `Mutex<Vec<String>>` (Src/init.c:pparams). The single store.
646 pub fn pparams(&self) -> Vec<String> {
647 crate::ported::builtin::PPARAMS
648 .lock()
649 .map(|p| p.clone())
650 .unwrap_or_default()
651 }
652
653 /// Write positional parameters to canonical `PPARAMS`.
654 pub fn set_pparams(&mut self, params: Vec<String>) {
655 if let Ok(mut p) = crate::ported::builtin::PPARAMS.lock() {
656 *p = params;
657 }
658 }
659
660 /// Read PM_* type flags from the paramtab Param entry. Used by
661 /// SET_VAR / `+=` arms (case-fold, integer-add, readonly guard).
662 /// Returns 0 when the name isn't in paramtab. Mirrors the C
663 /// source's direct `pm->node.flags & PM_INTEGER` checks.
664 pub fn param_flags(&self, name: &str) -> i32 {
665 paramtab()
666 .read()
667 .ok()
668 .and_then(|t| t.get(name).map(|p| p.node.flags))
669 .unwrap_or(0)
670 }
671
672 /// `readonly` / `typeset -r` / read-only-by-design (LINENO, PPID,
673 /// $$, $?, $!, ...) — match user-side rejection in C's
674 /// assignstrvalue at `Src/params.c:2699-2703` which gates on
675 /// `pm->node.flags & PM_READONLY` where the IPDEF4 family declares
676 /// `PM_READONLY_SPECIAL = PM_SPECIAL | PM_READONLY | PM_RO_BY_DESIGN`
677 /// (both bits set together). zshrs's special_params table carries
678 /// PM_RO_BY_DESIGN alone for IPDEF4 entries so internal direct-write
679 /// paths (BUILTIN_SET_LINENO bypasses via pm.u_val) don't trip the
680 /// readonly guard. User-facing checks must accept either bit. Bug
681 /// #418-family / test_lineno_intrinsic_readonly.
682 pub fn is_readonly_param(&self, name: &str) -> bool {
683 let (flags, pm_level) = crate::ported::params::paramtab()
684 .read()
685 .ok()
686 .and_then(|t| t.get(name).map(|p| (p.node.flags as u32, p.level)))
687 .unwrap_or((0, 0));
688 // c:Src/params.c assignsparam — a real PM_READONLY param always
689 // rejects writes.
690 if (flags & PM_READONLY) != 0 {
691 return true;
692 }
693 if (flags & crate::ported::zsh_h::PM_RO_BY_DESIGN) != 0 {
694 // c:Src/Modules/param_private.c pps_setfn (c:300-307) — a
695 // PRIVATE param (PM_RO_BY_DESIGN + PM_REMOVABLE) is NOT blanket
696 // read-only: a write is permitted iff it is in the SAME scope
697 // (`locallevel == pm->level`, e.g. `() { private p=1; p=2 }`
698 // → 2) or above the wrap level (`locallevel >
699 // private_wraplevel`). A deeper nested-scope write is rejected
700 // (setfn_error) — that is how a nested fn writing an OUTER
701 // function's private still errors and aborts. zshrs never
702 // wires the private GSU, so the level gate is enforced here.
703 if (flags & crate::ported::zsh_h::PM_REMOVABLE) != 0 {
704 let ll = crate::ported::params::locallevel.load(Ordering::Relaxed);
705 let wrap = crate::ported::modules::param_private::private_wraplevel
706 .load(Ordering::Relaxed);
707 return !(ll == pm_level || ll > wrap); // c:304 (negated: blocked)
708 }
709 // Non-removable PM_RO_BY_DESIGN = IPDEF4 special (LINENO/$?/$$…)
710 // whose PM_READONLY bit zshrs strips for internal writes (bug
711 // #297); RO_BY_DESIGN is their only remaining read-only marker.
712 return true;
713 }
714 false
715 }
716
717 /// Most-recent-command exit status. Reads canonical
718 /// `builtin::LASTVAL` AtomicI32 (`Src/builtin.c:6443`).
719 pub fn last_status(&self) -> i32 {
720 crate::ported::builtin::LASTVAL.load(Ordering::Relaxed)
721 }
722
723 /// Write the most-recent-command exit status. The canonical
724 /// store is `builtin::LASTVAL`; this is the single setter.
725 /// Used everywhere `$?` / `%?` / errexit / ZERR trap read.
726 pub fn set_last_status(&mut self, status: i32) {
727 crate::ported::builtin::LASTVAL.store(status, Ordering::Relaxed);
728 }
729
730 /// Set an indexed array parameter via canonical paramtab
731 /// (`setaparam`, `Src/params.c:3595`). The single store.
732 pub fn set_array(&mut self, name: String, value: Vec<String>) {
733 setaparam(&name, value); // c:params.c:3595
734 }
735
736 /// Set an associative array parameter via canonical
737 /// `sethparam` (`Src/params.c:3602`). The single store.
738 pub fn set_assoc(&mut self, name: String, value: IndexMap<String, String>) {
739 let mut flat: Vec<String> = Vec::with_capacity(value.len() * 2);
740 for (k, v) in &value {
741 flat.push(k.clone());
742 flat.push(v.clone());
743 }
744 sethparam(&name, flat); // c:params.c:3602
745 }
746
747 /// Read a scalar parameter. Mirrors C `getsparam` at
748 /// `Src/params.c:3076` — reads through paramtab, falls back to
749 /// special-var hooks and env.
750 pub fn scalar(&self, name: &str) -> Option<String> {
751 getsparam(name)
752 }
753
754 /// Read an array parameter via canonical `getaparam`
755 /// (`Src/params.c:3101`).
756 pub fn array(&self, name: &str) -> Option<Vec<String>> {
757 getaparam(name)
758 }
759
760 /// Read an associative array parameter from canonical
761 /// `paramtab_hashed_storage`. Mirrors C `gethparam` at
762 /// `Src/params.c:3115` — returns the typed `IndexMap`.
763 pub fn assoc(&self, name: &str) -> Option<IndexMap<String, String>> {
764 // c:Src/params.c:570-575 — nameref deref before the read.
765 let resolved = match crate::ported::params::resolve_nameref_name(name, None) {
766 crate::ported::params::nameref_resolution::Target { name: t_, .. } => t_,
767 _ => name.to_string(),
768 };
769 paramtab_hashed_storage()
770 .lock()
771 .ok()
772 .and_then(|m| m.get(resolved.as_str()).cloned())
773 }
774
775 /// Test whether a scalar parameter exists in paramtab.
776 /// Mirrors the C `paramtab->getnode(name) != NULL` check.
777 pub fn has_scalar(&self, name: &str) -> bool {
778 getsparam(name).is_some()
779 }
780
781 /// Test whether an array parameter exists in paramtab. Routes
782 /// through canonical `getaparam` (PM_TYPE check + digit-first-name
783 /// rejection).
784 pub fn has_array(&self, name: &str) -> bool {
785 getaparam(name).is_some()
786 }
787
788 /// Test whether an associative array parameter exists. Reads
789 /// canonical `paramtab_hashed_storage` (Src/params.c hashed
790 /// PM_HASHED slot).
791 pub fn has_assoc(&self, name: &str) -> bool {
792 // c:Src/params.c:570-575 — nameref deref before the read.
793 let resolved = match crate::ported::params::resolve_nameref_name(name, None) {
794 crate::ported::params::nameref_resolution::Target { name: t_, .. } => t_,
795 _ => name.to_string(),
796 };
797 paramtab_hashed_storage()
798 .lock()
799 .ok()
800 .map(|m| m.contains_key(resolved.as_str()))
801 .unwrap_or(false)
802 }
803
804 /// Unset an associative array parameter via canonical
805 /// `unsetparam` (Src/params.c:3819) — PM_READONLY rejection,
806 /// stdunsetfn dispatch, env clear. Also clears the zshrs-side
807 /// `paramtab_hashed_storage` parallel IndexMap shadow.
808 pub fn unset_assoc(&mut self, name: &str) {
809 unsetparam(name);
810 let _ = paramtab_hashed_storage()
811 .lock()
812 .ok()
813 .as_deref_mut()
814 .map(|m| m.remove(name));
815 }
816
817 /// Read a regular (non-global) alias value. Reads canonical
818 /// `aliastab` (Src/hashtable.c:1186). Filters out aliases that
819 /// have the ALIAS_GLOBAL flag set so the regular-alias slot is
820 /// distinct from the global-alias slot, mirroring C's two
821 /// separate dispatch paths via `aliasflags` checks.
822 pub fn alias(&self, name: &str) -> Option<String> {
823 let tab = crate::ported::hashtable::aliastab_lock().read().ok()?;
824 let a = tab.get(name)?;
825 if (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0 {
826 None
827 } else {
828 Some(a.text.clone())
829 }
830 }
831
832 /// Set a regular alias. Writes canonical aliastab with
833 /// ALIAS_GLOBAL bit cleared.
834 pub fn set_alias(&mut self, name: String, value: String) {
835 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
836 tab.add(crate::ported::hashtable::createaliasnode(&name, &value, 0));
837 }
838 }
839
840 /// Set a global alias (`alias -g`). Writes canonical aliastab
841 /// with ALIAS_GLOBAL bit set.
842 pub fn set_global_alias(&mut self, name: String, value: String) {
843 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
844 tab.add(crate::ported::hashtable::createaliasnode(
845 &name,
846 &value,
847 crate::ported::zsh_h::ALIAS_GLOBAL as u32,
848 ));
849 }
850 }
851
852 /// Set a suffix alias (`alias -s ext=cmd`). Writes canonical
853 /// sufaliastab with ALIAS_SUFFIX node flag — mirrors C
854 /// Src/builtin.c:4480-4481 (`flags1 |= ALIAS_SUFFIX; ht =
855 /// sufaliastab;`) → c:4527 (`createaliasnode(value, flags1)`).
856 /// Without ALIAS_SUFFIX in node.flags, `${saliases[k]}` /
857 /// `${(k)saliases}` introspection (parameter.c:1953/2018) fails
858 /// because both paths strict-equality-match flags == ALIAS_SUFFIX.
859 pub fn set_suffix_alias(&mut self, name: String, value: String) {
860 if let Ok(mut tab) = crate::ported::hashtable::sufaliastab_lock().write() {
861 tab.add(crate::ported::hashtable::createaliasnode(
862 &name,
863 &value,
864 crate::ported::zsh_h::ALIAS_SUFFIX as u32,
865 ));
866 }
867 }
868
869 /// Snapshot the alias map as a sorted `Vec<(name, value)>`,
870 /// only entries WITHOUT the ALIAS_GLOBAL flag (regular aliases).
871 pub fn alias_entries(&self) -> Vec<(String, String)> {
872 if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
873 tab.iter_sorted()
874 .into_iter()
875 .filter(|(_, a)| (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) == 0)
876 .map(|(k, a)| (k.clone(), a.text.clone()))
877 .collect()
878 } else {
879 Vec::new()
880 }
881 }
882
883 /// Snapshot the global-alias entries (ALIAS_GLOBAL flag set).
884 pub fn global_alias_entries(&self) -> Vec<(String, String)> {
885 if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
886 tab.iter_sorted()
887 .into_iter()
888 .filter(|(_, a)| (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0)
889 .map(|(k, a)| (k.clone(), a.text.clone()))
890 .collect()
891 } else {
892 Vec::new()
893 }
894 }
895
896 /// Snapshot the suffix-alias entries.
897 pub fn suffix_alias_entries(&self) -> Vec<(String, String)> {
898 if let Ok(tab) = crate::ported::hashtable::sufaliastab_lock().read() {
899 tab.iter_sorted()
900 .into_iter()
901 .map(|(k, a)| (k.clone(), a.text.clone()))
902 .collect()
903 } else {
904 Vec::new()
905 }
906 }
907
908 /// Unset an array parameter. Direct port of `unsetparam_pm` for
909 /// a PM_ARRAY Param. Mirrors are kept for now while the field
910 /// transitions.
911 /// Unset an array parameter via canonical `unsetparam`
912 /// (Src/params.c:3819). Routes through the C-faithful port
913 /// that runs PM_NAMEREF skip + PM_READONLY rejection via
914 /// unsetparam_pm + stdunsetfn dispatch + pm.old scope restore.
915 /// Inline `tab.remove(name)` skipped all four.
916 pub fn unset_array(&mut self, name: &str) {
917 unsetparam(name);
918 }
919
920 /// Unset a scalar parameter via canonical `unsetparam`. Same
921 /// C-faithful path as `unset_array`; the C `unsetparam` itself
922 /// is type-agnostic and dispatches through PM_TYPE inside.
923 pub fn unset_scalar(&mut self, name: &str) {
924 unsetparam(name);
925 }
926 /// `new` — see implementation.
927 pub fn new() -> Self {
928 tracing::debug!("ShellExecutor::new() initializing");
929
930 // c:Src/init.c:1236-1259 — setupvals' pwd/oldpwd init, ported
931 // here because the bin entry skips setupvals (see the
932 // init_bltinmods note below). The validated value lands in the
933 // live OS env: the bin entry's `$PWD` carrier (the analog of
934 // C's `pwd` global — see the subshell-snapshot comment at
935 // fusevm_bridge.rs `cwd:` field). set_pwd_env() pours it into
936 // paramtab after the env-import loop, same order as C
937 // (params.c:955).
938 //
939 // c:1242-1245 — "Try a cheap test to see if we can initialize
940 // `PWD' from `HOME'." EMULATE_ZSH reads the `home` global,
941 // which setupvals derives from getpwuid(getuid())->pw_dir
942 // (c:1222-1225), falling back to "/" (c:1230-1232).
943 let home = unsafe {
944 let pw = libc::getpwuid(libc::getuid());
945 if pw.is_null() {
946 None
947 } else {
948 Some(
949 std::ffi::CStr::from_ptr((*pw).pw_dir)
950 .to_string_lossy()
951 .into_owned(),
952 )
953 }
954 }
955 .unwrap_or_else(|| "/".to_string()); // c:1230-1232 EMULATE_ZSH home = "/"
956 // ispwd (src/zsh/Src/utils.c:809-829): a candidate is honored
957 // only when it (a) is absolute, (b) stat's to the same
958 // dev+inode as ".", and (c) has no `.`/`..` components.
959 // Without this chain, a child that inherits $PWD from a parent
960 // run in a different directory (cargo test setting
961 // current_dir(tempdir) while leaking PWD=/project/root) treats
962 // the stale PWD as the logical-path base, so `cd sub` resolves
963 // against the wrong directory.
964 let pwd_val = if ispwd(&home) {
965 home // c:1245-1246 — pwd = ztrdup(ptr) [HOME]
966 } else if let Some(p) = env::var("PWD")
967 .ok()
968 .filter(|p| p.len() < libc::PATH_MAX as usize && ispwd(p))
969 {
970 p // c:1247-1249 — pwd = ztrdup(getenv("PWD"))
971 } else {
972 crate::ported::compat::zgetcwd() // c:1250-1252 — pwd = zgetcwd()
973 };
974 env::set_var("PWD", &pwd_val);
975 // c:1255-1259 — oldpwd = getenv("OLDPWD") ?: ztrdup(pwd).
976 if env::var("OLDPWD").is_err() {
977 env::set_var("OLDPWD", &pwd_val); // c:1257
978 }
979
980 // Initialize fpath from FPATH env var or use defaults
981 let fpath = env::var("FPATH")
982 .unwrap_or_default()
983 .split(':')
984 .filter(|s| !s.is_empty())
985 .map(PathBuf::from)
986 .collect();
987
988 let history = HistoryEngine::new().ok();
989
990 // Seed canonical OPTS_LIVE with defaults BEFORE any setsparam
991 // call. assignstrvalue early-returns when `unset(EXECOPT)`
992 // (c:2701 guard); without the option table populated, EXECOPT
993 // reads false and every paramtab write below is a silent no-op.
994 if opt_state_len() == 0 {
995 for (k, v) in Self::default_options() {
996 opt_state_set(&k, v);
997 }
998 }
999
1000 // Standard zsh scalar param defaults — direct port of
1001 // `createparamtable` (Src/params.c:817-988) + the `setupvals`
1002 // tail. Writes through canonical `setsparam` (Src/params.c:3350).
1003 //
1004 // c:params.c:972-973 — ZSH_VERSION / ZSH_PATCHLEVEL.
1005 // `zsh_version::ZSH_VERSION` (emitted by build.rs from the
1006 // vendored `Config/version.mk`) is the development snapshot
1007 // tag `5.9.0.3-test`; shipped zsh binaries report the clean
1008 // release form (`5.9`). Bug #73 in docs/BUGS.md — cross-shell
1009 // scripts that gate on `[[ $ZSH_VERSION = 5.9 ]]` or split on
1010 // `.` expecting MAJOR.MINOR break on the `-test` suffix.
1011 //
1012 // Use the cleaned `patchlevel::ZSH_VERSION` here ("5.9") and
1013 // surface the full snapshot tag as `$ZSHRS_VERSION` for
1014 // zshrs-specific identity checks.
1015 setsparam(
1016 "ZSH_VERSION",
1017 crate::ported::patchlevel::ZSH_VERSION,
1018 );
1019 // c:Src/params.c:43 + Src/patchlevel.h — `ZSH_PATCHLEVEL` is
1020 // a git-describe-style identifier (`zsh-MAJOR.MINOR-N-gHASH`)
1021 // of the upstream commit zshrs targets. `build.rs` emits
1022 // "unknown" because the vendored zsh tarball doesn't ship a
1023 // CUSTOM_PATCHLEVEL define; use the canonical const in
1024 // `patchlevel.rs` instead (snapshot of `src/zsh/Src/patchlevel.h`).
1025 // Bug #90 in docs/BUGS.md — scripts that fingerprint by
1026 // $ZSH_PATCHLEVEL fell to the wildcard arm under "unknown".
1027 setsparam(
1028 "ZSH_PATCHLEVEL",
1029 crate::ported::patchlevel::ZSH_PATCHLEVEL,
1030 );
1031 // Skip ZSHRS_VERSION in `--zsh` parity mode so `${(k)parameters}`
1032 // doesn't carry a name zsh doesn't ship — matches the guard
1033 // in `ported::params::createparamtable`. Scripts running under
1034 // `--zsh` mode can still detect zshrs via `$ZSH_VERSION` which
1035 // carries a `-test` suffix.
1036 if !crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1037 setsparam(
1038 "ZSHRS_VERSION",
1039 crate::ported::patchlevel::ZSHRS_VERSION,
1040 );
1041 }
1042 setsparam("ZSH_NAME", "zsh");
1043 // c:params.c:971 — ZSH_ARGZERO from `posixzero` (Src/init.c:271):
1044 // the kernel-supplied argv[0] of THIS binary, in --zsh parity
1045 // mode too. The bin entrypoint overrides this with the script
1046 // path for -c / runscript invocations. (A previous revision
1047 // probed the system zsh install path and reported THAT as
1048 // ZSH_ARGZERO for byte-parity — faking the shell's identity.
1049 // Parity tests that compare the value must normalize the
1050 // machine-specific binary path in the test row instead.)
1051 let argzero_default = env::args().next().unwrap_or_else(|| "zsh".to_string());
1052 setsparam("ZSH_ARGZERO", &argzero_default);
1053 setsparam("WORDCHARS", "*?_-.[]~=/&;!#$%^(){}<>");
1054 let shlvl = env::var("SHLVL")
1055 .ok()
1056 .and_then(|v| v.parse::<i32>().ok())
1057 .map(|n| (n + 1).to_string())
1058 .unwrap_or_else(|| "1".to_string());
1059 setsparam("SHLVL", &shlvl);
1060 // POSIX/zsh default IFS: space + tab + newline + NUL.
1061 setsparam("IFS", " \t\n\0");
1062 // POSIX getopts: OPTIND starts at 1.
1063 setsparam("OPTIND", "1");
1064 // Note: OPTERR is NOT pre-initialised. zsh leaves it unset
1065 // even after `getopts` calls (verified: `getopts ":a" opt -a`
1066 // does not set it). It's a user-writable variable that
1067 // starts unset. Bug #150 in docs/BUGS.md.
1068 // zsh wipes inherited `$_` (unlike bash).
1069 setsparam("_", "");
1070 // c:params.c:5064 — histchars derives from bangchar+hatchar+
1071 // hashchar (defaults `!`, `^`, `#`). At init the special
1072 // entry may not exist yet — fall back to the literal default.
1073 let histchars_val = paramtab()
1074 .read()
1075 .ok()
1076 .and_then(|t| {
1077 t.get("histchars")
1078 .or_else(|| t.get("HISTCHARS"))
1079 .map(|pm| histcharsgetfn(pm))
1080 })
1081 .unwrap_or_else(|| "!^#".to_string());
1082 setsparam("histchars", &histchars_val);
1083
1084 // c:Src/params.c:870-871 — `setsparam("TIMEFMT", ...)` etc.
1085 // Seed TIMEFMT explicitly so `${(k)parameters}` lists it
1086 // (the createparamtable() ported in ported::params isn't
1087 // invoked from this bin entry — its setsparam calls don't
1088 // run, so TIMEFMT only existed via the lookup_special_var
1089 // fallback, which scanpmparameters can't see).
1090 setsparam(
1091 "TIMEFMT",
1092 crate::ported::zsh_system_h::DEFAULT_TIMEFMT,
1093 );
1094 // c:Src/init.c:1214-1215 — `nullcmd = ztrdup("cat");
1095 // readnullcmd = ztrdup(DEFAULT_READNULLCMD);`. Real paramtab
1096 // seeds (NOT read-time fallbacks) so `unset NULLCMD` truly
1097 // unsets — the bare-redirect "redirection with no command"
1098 // diagnostic depends on getsparam returning None afterwards.
1099 // c:config.h:48 DEFAULT_READNULLCMD "more" — the parity
1100 // floor agrees: scrubbed-env Homebrew zsh 5.9.1 -fc reports
1101 // READNULLCMD=more (probed; the previous macOS arm's "less"
1102 // guess came from the USER's env exporting READNULLCMD=less
1103 // — zpwr sets it). This block runs AFTER the env import, so
1104 // these are DEFAULT seeds only: an env-imported value must
1105 // win (C seeds before the import loop, c:854-885 vs c:893+).
1106 if getsparam("NULLCMD").map_or(true, |v| v.is_empty()) {
1107 setsparam("NULLCMD", "cat");
1108 }
1109 if getsparam("READNULLCMD").map_or(true, |v| v.is_empty()) {
1110 setsparam(
1111 "READNULLCMD",
1112 crate::ported::config_h::DEFAULT_READNULLCMD,
1113 );
1114 }
1115 // c:Src/init.c:963 — `setsparam("TTY", ttyname(0) ?: "")`.
1116 // Even in non-interactive -fc mode zsh creates the param;
1117 // mirror so ${(k)parameters} count matches.
1118 let tty_str = unsafe {
1119 let p = libc::ttyname(0);
1120 if p.is_null() {
1121 String::new()
1122 } else {
1123 std::ffi::CStr::from_ptr(p)
1124 .to_str()
1125 .unwrap_or("")
1126 .to_string()
1127 }
1128 };
1129 setsparam("TTY", &tty_str);
1130 // c:Src/params.c:968-979 — `setaparam("signals", ...)`.
1131 // Build the signal-name array. Mirror by inserting directly
1132 // into paramtab so ${(k)parameters} sees it.
1133 {
1134 use crate::ported::signals_h::SIGS;
1135 // c:signames.c sigs[] (generated) — index 0 is "EXIT",
1136 // entries 1..=SIGCOUNT are in PLATFORM SIGNAL-NUMBER
1137 // order, tail is "ZERR", "DEBUG" (zsh.h SIGZERR/SIGDEBUG).
1138 // SIGS is declared in Linux textual order, so sort by the
1139 // libc number to reproduce the generated table's order on
1140 // every platform. Same construction as params.rs — keep
1141 // in sync.
1142 let mut by_num: Vec<(&str, i32)> = SIGS.to_vec();
1143 by_num.sort_by_key(|&(_, n)| n);
1144 let mut signals_arr: Vec<String> = Vec::with_capacity(by_num.len() + 3);
1145 signals_arr.push("EXIT".to_string()); // c:sigs[0]
1146 signals_arr.extend(by_num.iter().map(|(n, _)| n.to_string()));
1147 signals_arr.push("ZERR".to_string()); // c:sigs tail
1148 signals_arr.push("DEBUG".to_string()); // c:sigs tail
1149 crate::ported::params::setaparam("signals", signals_arr);
1150 }
1151 // c:Src/init.c:1186-1193 — default prompt strings. zsh sets
1152 // PS4 to "+%N:%i> " for ZSH emulation ("+ " for KSH/SH).
1153 // Without seeding, PS4 reads empty and `set -x` output has
1154 // no prefix at all. Bug #92 in docs/BUGS.md.
1155 //
1156 // C zsh runs createparamtable's env-import loop (c:893-924)
1157 // BEFORE init.c:1186 fires, so an exported $PS4 in the parent
1158 // env wins over the default seed. zshrs's env import happens
1159 // further down in ShellExecutor::new() (at the createparamtable
1160 // call site), so getsparam() reads None here even when env has
1161 // a value, and the default would clobber the user's PS4.
1162 //
1163 // Additional wrinkle: C zsh's PROMPT / PROMPT2 / PROMPT3 /
1164 // PROMPT4 params are ALIASES for PS1..PS4 (Src/params.c:381,
1165 // 415-421 — both IPDEF7R entries bind to the same `prompt*`
1166 // global). So `export PROMPT4=...` in the parent env sets the
1167 // shared global, and `$PS4` reads the same string. The user's
1168 // interactive shell exports PROMPT4 (the form zsh's prompt
1169 // theme system uses), so when zshrs -x runs, PROMPT4 is in
1170 // env but PS4 is not. Without aliasing in the env-probe step,
1171 // zshrs seeds default PS4 and ignores the user's customised
1172 // prefix.
1173 //
1174 // Probe env::var directly for the name AND its alias; first
1175 // non-empty wins. Only fall through to the default seed when
1176 // every candidate is empty. Mirrors C zsh's behavior without
1177 // reshuffling the rest of new(). Bug: `zshrs -x` ignored the
1178 // user's custom PS4/PROMPT4 unless re-forwarded with
1179 // `PS4=$PROMPT4 zshrs -x`.
1180 let seed_prompt = |name: &str, alias: Option<&str>, default: &str| {
1181 let cur = crate::ported::params::getsparam(name);
1182 let have_param = cur.as_deref().map_or(false, |s| !s.is_empty());
1183 if have_param {
1184 return;
1185 }
1186 // Probe primary name first, then the C-side alias.
1187 for candidate in std::iter::once(name).chain(alias.into_iter()) {
1188 if let Ok(env_val) = std::env::var(candidate) {
1189 if !env_val.is_empty() {
1190 setsparam(name, &env_val);
1191 return;
1192 }
1193 }
1194 }
1195 setsparam(name, default);
1196 };
1197 seed_prompt("PS4", Some("PROMPT4"), "+%N:%i> ");
1198 // c:Src/init.c:1181-1190 —
1199 // if(unset(INTERACTIVE)) {
1200 // prompt = ztrdup("");
1201 // prompt2 = ztrdup("");
1202 // } else ... {
1203 // prompt = ztrdup("%m%# ");
1204 // prompt2 = ztrdup("%_> ");
1205 // }
1206 // Non-interactive shells get EMPTY primary/secondary prompts
1207 // — `zsh -fc 'typeset'` lists PS1='' — while interactive ones
1208 // get the %m%# defaults. PS3/PS4/SPROMPT are seeded
1209 // unconditionally in C (c:1191-1194). PS1 may be reset by the
1210 // prompt-theme layer; only seed when the slot is empty so any
1211 // prior theme write wins.
1212 let interactive = crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE);
1213 seed_prompt("PS1", Some("PROMPT"), if interactive { "%m%# " } else { "" });
1214 seed_prompt("PS2", Some("PROMPT2"), if interactive { "%_> " } else { "" });
1215 // c:Src/init.c:1191 — `prompt3 = ztrdup("?# ");`
1216 seed_prompt("PS3", Some("PROMPT3"), "?# ");
1217 // c:Src/init.c:1194 — `sprompt = ztrdup("zsh: correct '%R'
1218 // to '%r' [nyae]? ");` — spelling-correction prompt.
1219 seed_prompt("SPROMPT", None, "zsh: correct '%R' to '%r' [nyae]? ");
1220 // c:Src/params.c:417-422 — `PROMPT*` aliases for `PS*`.
1221 // C zsh's IPDEF7("PROMPT", &prompt), IPDEF7("PROMPT2",
1222 // &prompt2), IPDEF7("PROMPT3", &prompt3), IPDEF7("PROMPT4",
1223 // &prompt4) all point to the same C globals as the matching
1224 // IPDEF7("PS{1..4}", ...) entries — they're aliases in C,
1225 // sharing storage. zshrs's paramtab keeps them as separate
1226 // entries; mirror the alias by mirroring the value here.
1227 // Bug #274 in docs/BUGS.md (PROMPT3 was the visible report;
1228 // PROMPT/PROMPT2/PROMPT4 had the same gap silently).
1229 for (alias, source) in &[
1230 ("PROMPT", "PS1"),
1231 ("PROMPT2", "PS2"),
1232 ("PROMPT3", "PS3"),
1233 ("PROMPT4", "PS4"),
1234 ] {
1235 if crate::ported::params::getsparam(alias)
1236 .map_or(true, |s| s.is_empty())
1237 {
1238 if let Some(v) = crate::ported::params::getsparam(source) {
1239 setsparam(alias, &v);
1240 }
1241 }
1242 }
1243 // c:params.c:858-860 — standard non-special param defaults.
1244 // C uses `setiparam(...)` (PM_INTEGER) for these so
1245 // `(t)MAILCHECK` etc. report `integer`. zshrs previously
1246 // routed through `setsparam` (PM_SCALAR) — the value worked
1247 // but the type bit was wrong, breaking
1248 // `case "${(t)LISTMAX}" in *integer*)` and any path that
1249 // gates on arithmetic-typed semantics. Bug #268 in
1250 // docs/BUGS.md.
1251 crate::ported::params::setiparam("MAILCHECK", 60); // c:858
1252 // c:Src/params.c:859 — original `KEYTIMEOUT = 40` but
1253 // zsh 5.9.1 observably reports 10 (Homebrew arm-darwin).
1254 // Match the observed default so vi-mode / multi-key
1255 // bindings feel responsive. Bug #321 in docs/BUGS.md.
1256 crate::ported::params::setiparam("KEYTIMEOUT", 10); // c:859
1257 crate::ported::params::setiparam("LISTMAX", 100); // c:860
1258 // c:config.h:1004 — MAX_FUNCTION_DEPTH=500. Advisory cap;
1259 // dispatch_function_call enforces against this.
1260 crate::ported::params::setiparam("FUNCNEST", 500);
1261
1262 // Run setlocale(LC_ALL, "") so nl_langinfo() (used by the
1263 // `langinfo` module) returns the host's actual locale instead
1264 // of the C/POSIX default ("US-ASCII"). Direct port of zsh's
1265 // Src/init.c:1208 setlocale call. unsafe { } around libc is
1266 // standard for this exact use-case — setlocale is process-
1267 // global and must run once at startup.
1268 unsafe {
1269 libc::setlocale(libc::LC_ALL, c"".as_ptr());
1270 }
1271
1272 // c:hashtable.c:1206 createaliastables() — seeds aliastab with
1273 // the `run-help` / `which-command` defaults. Run once at shell
1274 // init so the canonical port owns the default-alias set; the
1275 // Executor's `aliases` HashMap then mirrors aliastab.
1276 crate::ported::hashtable::createaliastables();
1277 // Build the initial $path tied array as a local — fans out
1278 // to paramtab below; no ShellExecutor mirror anymore.
1279 let mut arrays: HashMap<String, Vec<String>> = HashMap::new();
1280 let path_dirs: Vec<String> = env::var("PATH")
1281 .unwrap_or_default()
1282 .split(':')
1283 .map(|s| s.to_string())
1284 .collect();
1285 arrays.insert("path".to_string(), path_dirs);
1286 let mut exec = Self {
1287 // c:Src/init.c:479 — `-c` mode: scriptname = scriptfilename
1288 // = ztrdup("zsh"). Both start at the literal "zsh".
1289 // dispatch_function_call overrides scriptname per c:5903;
1290 // scriptfilename stays at the outer file.
1291 scriptname: Some("zsh".to_string()),
1292 scriptfilename: Some("zsh".to_string()),
1293 subshell_snapshots: Vec::new(),
1294 inline_env_stack: Vec::new(),
1295 current_command_glob_failed: std::cell::Cell::new(false),
1296 jobs: JobTable::new(),
1297 fpath,
1298 history,
1299 completions: HashMap::new(),
1300 process_sub_counter: 0,
1301 zstyles: Vec::new(),
1302 local_scope_depth: 0,
1303 pending_underscore: None,
1304 in_dq_context: 0,
1305 in_scalar_assign: 0,
1306 profiling_enabled: false,
1307 compsys_cache: {
1308 let cache_path = crate::compsys::cache::default_cache_path();
1309 if cache_path.exists() {
1310 let db_size = fs::metadata(&cache_path).map(|m| m.len()).unwrap_or(0);
1311 match CompsysCache::open(&cache_path) {
1312 Ok(c) => {
1313 tracing::info!(
1314 db_bytes = db_size,
1315 path = %cache_path.display(),
1316 "compsys: sqlite mirror opened (dbview/SQL inspection only; rkyv shards are the authoritative cache)"
1317 );
1318 Some(c)
1319 }
1320 Err(e) => {
1321 tracing::warn!(error = %e, "compsys: failed to open cache");
1322 None
1323 }
1324 }
1325 } else {
1326 tracing::debug!("compsys: no cache at {}", cache_path.display());
1327 None
1328 }
1329 },
1330 compinit_pending: None, // (receiver, start_time)
1331 plugin_cache: {
1332 let pc_path = crate::plugin_cache::default_cache_path();
1333 if let Some(parent) = pc_path.parent() {
1334 let _ = fs::create_dir_all(parent);
1335 }
1336 match crate::plugin_cache::PluginCache::open(&pc_path) {
1337 Ok(pc) => {
1338 let (plugins, functions) = pc.stats();
1339 tracing::info!(
1340 plugins,
1341 cached_functions = functions,
1342 path = %pc_path.display(),
1343 "plugin_cache: sqlite opened"
1344 );
1345 Some(pc)
1346 }
1347 Err(e) => {
1348 tracing::warn!(error = %e, "plugin_cache: failed to open");
1349 None
1350 }
1351 }
1352 },
1353 deferred_compdefs: Vec::new(),
1354 returning: None,
1355 zsh_compat: false,
1356 bash_compat: false,
1357 posix_mode: false,
1358 worker_pool: {
1359 let config = crate::config::load();
1360 let pool_size = crate::config::resolve_pool_size(&config.worker_pool);
1361 std::sync::Arc::new(crate::worker::WorkerPool::new(pool_size))
1362 },
1363 intercepts: Vec::new(),
1364 async_jobs: HashMap::new(),
1365 next_async_id: 1,
1366 redirect_scope_stack: Vec::new(),
1367 multios_scope_stack: Vec::new(),
1368 exec_redirs_permanent: false,
1369 pipe_output_pending: false,
1370 pipe_output_scope: None,
1371 redirect_failed: false,
1372 functions_compiled: HashMap::new(),
1373 function_source: HashMap::new(),
1374 function_line_base: HashMap::new(),
1375 function_def_file: HashMap::new(),
1376 prompt_funcstack: Vec::new(),
1377 tied_array_to_scalar: HashMap::new(),
1378 ztest_pass_count: std::sync::atomic::AtomicUsize::new(0),
1379 ztest_fail_count: std::sync::atomic::AtomicUsize::new(0),
1380 ztest_skip_count: std::sync::atomic::AtomicUsize::new(0),
1381 ztest_pass_total: std::sync::atomic::AtomicUsize::new(0),
1382 ztest_fail_total: std::sync::atomic::AtomicUsize::new(0),
1383 ztest_skip_total: std::sync::atomic::AtomicUsize::new(0),
1384 ztest_run_failed: std::sync::atomic::AtomicBool::new(false),
1385 ztest_suppress_stdout: false,
1386 };
1387 // Mirror env-derived path arrays into the `arrays` table so
1388 // user-level `fpath` / `path` array reads see the inherited
1389 // entries. zsh: `fpath+=…` should append to the inherited
1390 // 43-entry array, not replace it. Same for `path` (PATH).
1391 let fpath_arr: Vec<String> = exec
1392 .fpath
1393 .iter()
1394 .map(|p| p.to_string_lossy().to_string())
1395 .collect();
1396 if !fpath_arr.is_empty() {
1397 exec.set_array("fpath".to_string(), fpath_arr);
1398 }
1399 if let Ok(path) = env::var("PATH") {
1400 let path_arr: Vec<String> = path
1401 .split(':')
1402 .filter(|s| !s.is_empty())
1403 .map(String::from)
1404 .collect();
1405 if !path_arr.is_empty() {
1406 exec.set_array("path".to_string(), path_arr);
1407 }
1408 }
1409 // Register the standard tied path-family pairs so `path+=` /
1410 // `fpath+=` / etc. mirror through the array→scalar sync hook
1411 // in BUILTIN_APPEND_ARRAY (and the SET_ARRAY tied path).
1412 // Direct port of the implicit ties that zsh wires up at
1413 // startup for PATH/path, FPATH/fpath, etc. Source-of-truth
1414 // for the pairs is Src/init.c's `setupvals()` PM_TIED entries.
1415 // c:Src/params.c:395-422 IPDEF8 — full PM_TIED colonarr list:
1416 // CDPATH, FIGNORE, FPATH, MAILPATH, PATH, PSVAR, MODULE_PATH,
1417 // MANPATH (ZSH_EVAL_CONTEXT is readonly-special, excluded).
1418 for (scalar, arr) in [
1419 ("PATH", "path"),
1420 ("FPATH", "fpath"),
1421 ("MANPATH", "manpath"),
1422 ("CDPATH", "cdpath"),
1423 ("MODULE_PATH", "module_path"),
1424 ("PSVAR", "psvar"),
1425 ("FIGNORE", "fignore"),
1426 ("MAILPATH", "mailpath"),
1427 ] {
1428 exec.tied_array_to_scalar
1429 .insert(arr.to_string(), (scalar.to_string(), ":".to_string()));
1430 }
1431
1432 // Pour `path` (from env PATH split) into paramtab before the
1433 // special_paramdef stamping loop below so the canonical tied
1434 // entry exists when PM_SPECIAL bits are applied.
1435 for (k, v) in &arrays {
1436 setaparam(k, v.clone()); // c:params.c:3595
1437 }
1438 // c:Src/params.c:384-394 — IPDEF8/IPDEF9 macros stamp
1439 // `PM_SCALAR|PM_SPECIAL` (IPDEF8 for `PATH`/`FPATH`/etc.) and
1440 // `PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT` (IPDEF9 for `path`/
1441 // `fpath`/etc.) on every entry in the createparamtable table.
1442 // setsparam/setaparam above create plain PM_SCALAR/PM_ARRAY
1443 // entries; this loop applies the PM_SPECIAL + PM_TIED bits
1444 // (plus the IPDEF9 PM_DONTIMPORT bit on the array side) so
1445 // `${(t)PATH}` reads `scalar-tied-export-special` and
1446 // `${(t)path}` reads `array-tied-special`.
1447 //
1448 // Walks the `special_params` table (params.rs:464+) which is
1449 // the Rust port of the C IPDEF list. For each entry: OR the
1450 // declared pm_flags onto the existing paramtab entry. The
1451 // tied-pair entries (PM_TIED) also need PM_SPECIAL OR'd in
1452 // since the IPDEF8/IPDEF9 macros add PM_SPECIAL implicitly;
1453 // the table declares only the per-entry-distinct flags.
1454 {
1455 use crate::ported::params::{paramtab, special_params};
1456 use crate::ported::zsh_h::{PM_ARRAY, PM_DONTIMPORT, PM_SCALAR, PM_SPECIAL, PM_TIED};
1457 if let Ok(mut tab) = paramtab().write() {
1458 // Stamp PM_SPECIAL onto every entry the special_params
1459 // table declares. For tied scalars (PATH/FPATH/etc),
1460 // also walks `tied_name` to apply IPDEF9-flag bits
1461 // (PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT|PM_TIED) onto the
1462 // partner array entry (path/fpath/etc) — those array
1463 // names aren't in the special_params table directly
1464 // but C zsh's createparamtable emits IPDEF9 rows for
1465 // them at Src/params.c:425-432.
1466 use crate::ported::zsh_h::{hashnode, param, PM_DONTIMPORT as PM_DI, PM_UNSET};
1467 for entry in special_params.iter() {
1468 // c:384/394 IPDEF8/9 — `D|PM_SCALAR|PM_SPECIAL` or
1469 // `D|PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT`.
1470 //
1471 // Mask `entry.pm_flags` to ONLY the attribute bits
1472 // safe to OR onto an existing Param without changing
1473 // assignment semantics. PM_READONLY is excluded
1474 // here because many internal-runtime writes go
1475 // through setsparam (subshell ZSH_SUBSHELL bump,
1476 // ZSH_EVAL_CONTEXT push, etc.) and would be
1477 // rejected by assignstrvalue's PM_READONLY guard.
1478 // C zsh's PM_SPECIAL GSU setfn bypasses the guard;
1479 // the Rust port lacks that vtable wiring, so keep
1480 // the entries writable.
1481 //
1482 // PM_UNSET is included: lookup_special_var arms for
1483 // TRY_BLOCK_ERROR / TRY_BLOCK_INTERRUPT (and other
1484 // PM_UNSET entries with sentinel defaults) check
1485 // this bit to decide between "stored value" vs
1486 // "uninitialized → return -1 sentinel". The flag
1487 // gets cleared by assignstrvalue at c:3660 on any
1488 // write, so it correctly tracks "ever assigned".
1489 // Bug #143 in docs/BUGS.md.
1490 let safe_pm_flags = entry.pm_flags & (PM_TIED | PM_DI | PM_UNSET);
1491 // c:Src/params.c — IPDEF macros set PM_TYPE bits
1492 // (PM_INTEGER for IPDEF5/6, PM_ARRAY for IPDEF9,
1493 // PM_HASHED for IPDEF-hash) along with PM_SPECIAL.
1494 // zshrs's previous init only ORed PM_SPECIAL +
1495 // tied/di/unset/readonly — never the type bit. If
1496 // setsparam ran BEFORE init_partab_params (it does
1497 // for OPTIND/SHLVL at vm_helper.rs:874/878), the
1498 // param entry stayed PM_SCALAR and `typeset -p
1499 // OPTIND` emitted `typeset OPTIND=1` instead of
1500 // zsh's `typeset -i10 OPTIND=1`. OR the pm_type
1501 // into the bits so the type attribute lands.
1502 let mut bits = safe_pm_flags | PM_SPECIAL | entry.pm_type;
1503 // c:Src/params.c — IPDEF4/IPDEF1 set
1504 // PM_READONLY_SPECIAL = PM_SPECIAL | PM_READONLY |
1505 // PM_RO_BY_DESIGN. zshrs masks PM_READONLY out
1506 // (see above) but the introspection bit can still
1507 // ride along. Replace dropped PM_READONLY with
1508 // PM_RO_BY_DESIGN so `typeset -r` recognises these
1509 // entries as logically-readonly without blocking
1510 // internal writes. The listing filter in
1511 // `bin_typeset` expands its PM_READONLY match to
1512 // also pick up PM_RO_BY_DESIGN. Bug #97 in
1513 // docs/BUGS.md.
1514 if (entry.pm_flags & crate::ported::zsh_h::PM_READONLY) != 0 {
1515 bits |= crate::ported::zsh_h::PM_RO_BY_DESIGN;
1516 }
1517 if entry.pm_type == PM_ARRAY {
1518 bits |= PM_DI;
1519 }
1520 let _ = PM_SCALAR;
1521 let _ = PM_DONTIMPORT;
1522 if let Some(pm) = tab.get_mut(entry.name) {
1523 let was_integer = (pm.node.flags as u32
1524 & crate::ported::zsh_h::PM_INTEGER)
1525 != 0;
1526 pm.node.flags |= bits as i32;
1527 // c:Src/params.c:344 IPDEF4 / c:353 IPDEF5 — the
1528 // C struct literal initialises the `base` field
1529 // to 10 for every PM_INTEGER special. zshrs's
1530 // initial paramtab seeding doesn't carry that
1531 // through (the special_paramdef table has no
1532 // `base` field). Set the default here so
1533 // `printparamnode`'s PMTF_USE_BASE arm at
1534 // params.rs:9341 emits "10" between
1535 // `integer` and the name (`integer 10 readonly
1536 // !=0`). Bug #297 in docs/BUGS.md.
1537 if entry.pm_type == crate::ported::zsh_h::PM_INTEGER
1538 && pm.base == 0
1539 {
1540 pm.base = 10;
1541 }
1542 // When OR-ing PM_INTEGER onto a param that
1543 // was previously PM_SCALAR (i.e. setsparam ran
1544 // BEFORE init_partab_params, storing the value
1545 // in u_str), parse the u_str into u_val so the
1546 // integer getter reads the correct value. C
1547 // zsh's setsparam-equivalent path detects the
1548 // pm's PM_TYPE first and routes through
1549 // intsetfn, but zshrs's setsparam at the bin
1550 // entry point predates init_partab_params, so
1551 // it lands as PM_SCALAR storage that the
1552 // type-flip needs to migrate.
1553 if !was_integer
1554 && entry.pm_type
1555 == crate::ported::zsh_h::PM_INTEGER
1556 && pm.u_val == 0
1557 {
1558 if let Some(ref s) = pm.u_str {
1559 pm.u_val = s.parse::<i64>().unwrap_or(0);
1560 pm.u_str = None;
1561 }
1562 }
1563 // c:Src/zsh.h IPDEF8/IPDEF9 — the third macro
1564 // arg is the tied partner name; mapped into
1565 // `pm->ename` so `typeset -p` can find the
1566 // peer for the PM_TIED swap. Bug #410.
1567 if let Some(peer) = entry.tied_name {
1568 pm.ename = Some(peer.to_string());
1569 }
1570 } else {
1571 // Param hasn't been created yet (e.g. PATH gets
1572 // imported lazily via the env fallback in
1573 // getsparam at params.rs:4104; array specials
1574 // like `pipestatus` / `funcstack` / `dirstack`
1575 // / `zsh_scheduled_events` aren't pre-populated).
1576 // Seed an empty placeholder carrying the
1577 // canonical flag set so subsequent setsparam /
1578 // `(t)X` / `${+X}` observers see the IPDEF
1579 // attribute bits AND `${+X}` returns 1.
1580 let u_arr = if entry.pm_type == PM_ARRAY {
1581 Some(Vec::new())
1582 } else {
1583 None
1584 };
1585 let pm: crate::ported::zsh_h::Param = Box::new(param {
1586 node: hashnode {
1587 next: None,
1588 nam: entry.name.to_string(),
1589 flags: (entry.pm_type as i32) | bits as i32,
1590 },
1591 u_data: 0,
1592 u_tied: None,
1593 u_arr,
1594 u_str: None,
1595 u_val: 0,
1596 u_dval: 0.0,
1597 u_hash: None,
1598 gsu_s: None,
1599 gsu_i: None,
1600 gsu_f: None,
1601 gsu_a: None,
1602 gsu_h: None,
1603 // c:Src/params.c:344 IPDEF4 / c:353 IPDEF5 —
1604 // PM_INTEGER specials default base=10.
1605 base: if entry.pm_type == crate::ported::zsh_h::PM_INTEGER {
1606 10
1607 } else {
1608 0
1609 },
1610 width: 0,
1611 env: None,
1612 // c:Src/zsh.h IPDEF8/IPDEF9 — tied partner
1613 // name. Bug #410.
1614 ename: entry.tied_name.map(|s| s.to_string()),
1615 old: None,
1616 level: 0,
1617 });
1618 tab.insert(entry.name.to_string(), pm);
1619 }
1620 // Tied partner side. The previous loop body ORed
1621 // PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT|PM_TIED onto the
1622 // partner indiscriminately, but for a SCALAR ↔
1623 // ARRAY tied pair (PATH ↔ path, FIGNORE ↔ fignore),
1624 // that incorrectly stamped PM_ARRAY onto the scalar
1625 // partner (FIGNORE, PATH, FPATH, MAILPATH, MANPATH,
1626 // PSVAR, CDPATH, MODULE_PATH). Result: `(t)PATH`
1627 // returned `array-tied-export-special` instead of
1628 // `scalar-tied-export-special`.
1629 //
1630 // Both partners are already listed in `special_params`
1631 // (the scalar at the IPDEF8 block, the array at the
1632 // IPDEF9 block past the sentinel), so each gets its
1633 // own pass through this loop and ends up with the
1634 // correct flags. No cross-stamping needed.
1635 let _ = entry.tied_name;
1636 }
1637 // c:Src/params.c:893-924 environment-import loop —
1638 // every env var gets either a fresh exported paramtab
1639 // entry OR (when the entry pre-exists from
1640 // special_params) PM_EXPORTED OR'd onto its flags.
1641 // Without this, `declare -p PATH` printed `typeset -T
1642 // PATH=''` and `declare -p USER` printed nothing at
1643 // all because USER was never in paramtab.
1644 use crate::ported::zsh_h::hashnode as _hn;
1645 use crate::ported::zsh_h::{PM_EXPORTED, PM_SCALAR};
1646 // c:Src/params.c:4329-4342 colonarrsetfn — assigning a
1647 // tied IPDEF8 scalar (MANPATH, CDPATH, MODULE_PATH, …)
1648 // colonsplit()s the value into the partner array,
1649 // preserving empty components. The env import below
1650 // bypasses the GSU setfn, so collect the tied pairs
1651 // here and pour them through setaparam after the
1652 // paramtab lock drops. PATH→path / FPATH→fpath are
1653 // seeded earlier (vm_helper ~1160-1199) and skipped.
1654 let mut tied_env_arrays: Vec<(String, Vec<String>)> = Vec::new();
1655 // c:Src/params.c:893 — walk the process-entry environ
1656 // snapshot, not the live env (frameworks can mutate it
1657 // before init — see params.rs `environ` static).
1658 let environ_vars: Vec<(String, String)> = crate::ported::params::environ
1659 .get()
1660 .cloned()
1661 .unwrap_or_else(|| std::env::vars().collect());
1662 for (env_name, env_value) in environ_vars {
1663 if env_name.is_empty() || env_name.contains('[') {
1664 continue;
1665 }
1666 if env_name.as_bytes()[0].is_ascii_digit() {
1667 continue;
1668 }
1669 if !crate::ported::params::isident(&env_name) {
1670 continue;
1671 }
1672 if let Some(pm) = tab.get_mut(&env_name) {
1673 // c:Src/params.c:902-906 — the import loop runs
1674 // `dontimport(pm->node.flags)` BEFORE doing
1675 // anything to the entry; PM_DONTIMPORT names
1676 // (`_`, IFS, GID/EGID, KEYBOARD_HACK — the
1677 // IPDEF7/IPDEF2 rows, c:796-800) are skipped
1678 // ENTIRELY: no PM_EXPORTED stamp, no value
1679 // seed. zshrs previously OR'd PM_EXPORTED
1680 // first, so an inherited env `_` made the
1681 // special `_` exported and it leaked into
1682 // `typeset +x -r` / `export -p` listings where
1683 // zsh shows nothing.
1684 if (pm.node.flags as u32 & crate::ported::zsh_h::PM_DONTIMPORT) != 0 {
1685 continue; // c:905 `continue;`
1686 }
1687 pm.node.flags |= PM_EXPORTED as i32;
1688 // c:Src/params.c:893-924 — C's env-import calls
1689 // `assignsparam(..., ASSPM_ENV_IMPORT)` which
1690 // routes through the param's GSU setfn. For
1691 // SPECIAL scalars with cached storage (HOME,
1692 // USERNAME, TERM, WORDCHARS, TERMINFO,
1693 // TERMINFO_DIRS, KEYBOARD_HACK, histchars) the
1694 // setfn writes to a separate `*_lock` global
1695 // (e.g. home_lock). Just OR'ing PM_EXPORTED
1696 // leaves those globals empty, so `$HOME` reads
1697 // back "" even though HOME is in env. Mirror
1698 // C by copying the env value into pm.u_str and
1699 // (for cached specials) the matching global.
1700 // Only seed cached state when the param was
1701 // still marked PM_UNSET — i.e. nothing has set
1702 // it yet. ShellExecutor::new's earlier init
1703 // block (vm_helper line 837+) already ran
1704 // setsparam for a few names (ZSH_ARGZERO,
1705 // WORDCHARS, SHLVL with the +1 increment, IFS,
1706 // OPTIND, …); those calls clear PM_UNSET so we
1707 // must not overwrite them with the raw env
1708 // value here. The PM_UNSET-still-set case is
1709 // the "C zsh would have called
1710 // assignsparam(...,ASSPM_ENV_IMPORT) and ours
1711 // didn't yet" gap that bug #599 (HOME=` `) and
1712 // %~ prompt expansion need.
1713 let still_unset = (pm.node.flags as u32
1714 & crate::ported::zsh_h::PM_UNSET)
1715 != 0;
1716 if still_unset {
1717 pm.u_str = Some(env_value.clone());
1718 pm.env = Some(format!("{}={}", env_name, env_value));
1719 // c:Src/params.c:3660 — `assignstrvalue`
1720 // clears PM_UNSET on any write. HOME / TERM
1721 // / TERMINFO / TERMINFO_DIRS / WORDCHARS
1722 // start life with PM_UNSET in
1723 // `special_params` (params.rs SPECIAL_PARAMS
1724 // table) so `lookup_special_var` skips the
1725 // getfn for uninitialized specials; env
1726 // import is the canonical "now it's set"
1727 // event, so clear the bit.
1728 pm.node.flags &= !(PM_UNSET as i32);
1729 // Cached-state specials: route through
1730 // the matching setfn so the global cache
1731 // (home_lock / wordchars_lock / etc.)
1732 // reflects the env value. Each setfn
1733 // ignores its `pm` arg (matches C's
1734 // UNUSED(Param pm)), so passing the
1735 // borrowed paramtab entry is safe.
1736 match env_name.as_str() {
1737 "HOME" => crate::ported::params::homesetfn(
1738 pm.as_mut(),
1739 env_value.clone(),
1740 ),
1741 "USERNAME" => crate::ported::params::usernamesetfn(
1742 pm.as_mut(),
1743 env_value.clone(),
1744 ),
1745 "TERM" => crate::ported::params::termsetfn(
1746 pm.as_mut(),
1747 env_value.clone(),
1748 ),
1749 "WORDCHARS" => crate::ported::params::wordcharssetfn(
1750 pm.as_mut(),
1751 env_value.clone(),
1752 ),
1753 "TERMINFO" => crate::ported::params::terminfosetfn(
1754 pm.as_mut(),
1755 env_value.clone(),
1756 ),
1757 "TERMINFO_DIRS" => crate::ported::params::terminfodirssetfn(
1758 pm.as_mut(),
1759 env_value.clone(),
1760 ),
1761 _ => {}
1762 }
1763 }
1764 // c:Src/params.c:907-908 — env import always
1765 // assigns through the GSU setfn; for tied
1766 // IPDEF8 scalars that is colonarrsetfn
1767 // (c:4329-4342), which colonsplit()s the value
1768 // into the partner array, empties preserved.
1769 // Not gated on still_unset: C re-assigns on
1770 // import regardless.
1771 if (pm.node.flags as u32 & crate::ported::zsh_h::PM_TIED) != 0 {
1772 if let Some(ref peer) = pm.ename {
1773 if peer != "path" && peer != "fpath" {
1774 tied_env_arrays.push((
1775 peer.clone(),
1776 env_value.split(':').map(String::from).collect(), // c:4339 colonsplit
1777 ));
1778 }
1779 }
1780 }
1781 } else {
1782 // Fresh entry — PM_SCALAR + PM_EXPORTED, value
1783 // taken from env. Mirrors C zsh's c:907-908
1784 // `assignsparam(..., ASSPM_ENV_IMPORT)` for
1785 // names not already in the special table.
1786 let pm: crate::ported::zsh_h::Param = Box::new(param {
1787 node: _hn {
1788 next: None,
1789 nam: env_name.clone(),
1790 flags: (PM_SCALAR | PM_EXPORTED) as i32,
1791 },
1792 u_data: 0,
1793 u_tied: None,
1794 u_arr: None,
1795 u_str: Some(env_value.clone()),
1796 u_val: 0,
1797 u_dval: 0.0,
1798 u_hash: None,
1799 gsu_s: None,
1800 gsu_i: None,
1801 gsu_f: None,
1802 gsu_a: None,
1803 gsu_h: None,
1804 base: 0,
1805 width: 0,
1806 env: Some(format!("{}={}", env_name, env_value)),
1807 ename: None,
1808 old: None,
1809 level: 0,
1810 });
1811 tab.insert(env_name, pm);
1812 }
1813 }
1814 // Apply the collected tied-pair splits after the env
1815 // walk. setaparam (the canonical store) needs the
1816 // same paramtab write lock held here, so write u_arr
1817 // directly on the peer entry — array reads route
1818 // through paramtab so this is the single store.
1819 for (peer, parts) in tied_env_arrays {
1820 if let Some(apm) = tab.get_mut(peer.as_str()) {
1821 apm.u_arr = Some(parts); // c:4339 — `*dptr = colonsplit(x, …)`
1822 apm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
1823 }
1824 }
1825 }
1826 }
1827 // c:Src/params.c:955 — `set_pwd_env();` runs AFTER the environ
1828 // import loop, overwriting the imported $PWD/$OLDPWD paramtab
1829 // entries with the ispwd()-validated values computed above
1830 // (c:Src/init.c:1242-1259). Without this, a stale inherited
1831 // $PWD (env-import snapshot taken at process entry) survives
1832 // in paramtab even though the live env was corrected.
1833 crate::ported::builtin::set_pwd_env();
1834
1835 // Populate paramtab with PM_SPECIAL placeholder Params for
1836 // every PARTAB / PARTAB_ARRAY magic-assoc name. Mirrors
1837 // what C's zsh/parameter module boot_ → handlefeatures
1838 // chain does at startup. Makes `${+aliases}` / `${(t)commands}`
1839 // / `typeset -p modules` etc. see the special entries.
1840 init_partab_params(); // c:Src/Modules/parameter.c:2341 boot_/enables_ chain
1841
1842 // c:Src/init.c:1703 init_bltinmods — must run before user
1843 // code so default-loaded modules (zsh/watch, …) get their
1844 // boot_ entry points called and their params (e.g. `watch`,
1845 // `WATCH`) seeded in paramtab. Without this, `${(t)watch}`
1846 // returned empty even though zsh treats zsh/watch as loaded
1847 // by default. The bin entry skips zsh_main → init_bltinmods,
1848 // so we run it here from ShellExecutor::new for the same
1849 // effect. Bug #270.
1850 crate::ported::init::init_bltinmods();
1851
1852 // c:Src/params.c:873-876 — `gethostname(hostnam,256);
1853 // setsparam("HOST", ztrdup_metafy(hostnam));`
1854 // Plain port of the createparamtable HOST init. Direct
1855 // libc::gethostname call; result written via canonical
1856 // setsparam. createparamtable() itself isn't called from the
1857 // bin entry yet (full init port pending); this is the minimum
1858 // for `$HOST` to read non-empty.
1859 let mut host_buf = [0u8; 256];
1860 let host_rc = unsafe { libc::gethostname(host_buf.as_mut_ptr() as *mut libc::c_char, 256) }; // c:874
1861 if host_rc == 0 {
1862 if let Ok(c) = std::ffi::CStr::from_bytes_until_nul(&host_buf) {
1863 if let Ok(name) = c.to_str() {
1864 crate::ported::params::setsparam("HOST", name); // c:875
1865 }
1866 }
1867 }
1868 // c:Src/init.c:479 — `-c` mode: scriptname = scriptfilename
1869 // = ztrdup("zsh"). Both globals start as the literal "zsh"
1870 // (not the binary path) so PS4's %x / %N print "zsh" not
1871 // "/path/to/zshrs" at the top level. Function dispatch
1872 // overrides scriptname per c:5903; scriptfilename stays.
1873 crate::ported::utils::set_scriptname(Some("zsh".to_string()));
1874 crate::ported::utils::set_scriptfilename(Some("zsh".to_string()));
1875
1876 // c:Src/params.c:961-970 — uname-derived host/arch
1877 // identification params: MACHTYPE / CPUTYPE / OSTYPE /
1878 // VENDOR. C zsh reads from compile-time `#define`s (set by
1879 // ./configure) for MACHTYPE / OSTYPE / VENDOR, and from
1880 // uname().machine at runtime for CPUTYPE.
1881 //
1882 // Rust port: probe uname() at startup for CPUTYPE, and use
1883 // const strings parameterized by build-target for the
1884 // others. Match homebrew zsh's values where possible.
1885 let mut uname_buf: libc::utsname = unsafe { std::mem::zeroed() };
1886 let _ = unsafe { libc::uname(&mut uname_buf) };
1887 let to_str = |b: &[libc::c_char]| -> String {
1888 // c-string → owned String, truncated at first NUL.
1889 let bytes: Vec<u8> = b
1890 .iter()
1891 .take_while(|&&c| c != 0)
1892 .map(|&c| c as u8)
1893 .collect();
1894 String::from_utf8_lossy(&bytes).into_owned()
1895 };
1896 let cputype = to_str(&uname_buf.machine);
1897 crate::ported::params::setsparam("CPUTYPE", &cputype); // c:961
1898 let sysname = to_str(&uname_buf.sysname).to_lowercase();
1899 let release = to_str(&uname_buf.release);
1900 let ostype = format!("{}{}", sysname, release); // c:968 (OSTYPE)
1901 crate::ported::params::setsparam("OSTYPE", &ostype);
1902 // MACHTYPE / VENDOR: hardcoded per platform. macOS uses
1903 // "arm" or "arm64" or "x86_64" for arm-derived MACHTYPE.
1904 // Approximate the canonical homebrew value: short-form of
1905 // the cputype.
1906 let machtype = if cputype.starts_with("arm") {
1907 "arm".to_string()
1908 } else {
1909 cputype.clone()
1910 };
1911 crate::ported::params::setsparam("MACHTYPE", &machtype); // c:967
1912 let vendor = if sysname == "darwin" {
1913 "apple"
1914 } else {
1915 "unknown"
1916 };
1917 crate::ported::params::setsparam("VENDOR", vendor); // c:970
1918
1919 // c:Src/params.c:878-882 — `setsparam("LOGNAME", getlogin() ?:
1920 // cached_username);`. C's createparamtable also assigns
1921 // USERNAME from the same source (cached_username) via the
1922 // special_paramdefs table. Here mirror the LOGNAME +
1923 // USERNAME seeding so the canonical paramtab entries exist
1924 // (usernamegetfn at c:4655 reads through Param.u_str).
1925 // Same one-shot init pattern as the HOST gethostname call
1926 // above — full createparamtable() port is pending.
1927 let logname = unsafe {
1928 let p = libc::getlogin();
1929 if p.is_null() {
1930 None
1931 } else {
1932 Some(std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned())
1933 }
1934 }; // c:880
1935 if let Some(name) = logname {
1936 crate::ported::params::setsparam("LOGNAME", &name); // c:881
1937 // DO NOT setsparam("USERNAME", ...) here. `$USERNAME` is
1938 // a special parameter whose SETTER (`usernamesetfn` in
1939 // params.rs) performs setgid(2) + setuid(2) to actually
1940 // change the effective user — that's a deliberate upstream
1941 // zsh feature for `USERNAME=other-user cmd`. Calling it at
1942 // init seeds the value AND tries to change uid/gid; when
1943 // the resolved pwd's pw_uid differs from `getuid()` (sudo
1944 // launches, macOS Keychain-helper inherited env, container
1945 // entry points, etc.) the setgid call fails with EPERM and
1946 // emits `zsh:1: failed to change group ID: Operation not
1947 // permitted`. Upstream seeds `$USERNAME` via the GETTER
1948 // path (`usernamegetfn` reads through `cached_username`
1949 // populated by `inittyptab` → `get_username`), no setter
1950 // call needed.
1951 }
1952
1953 // c:Src/init.c:1176 — `module_path = mkarray(MODULE_DIR)`.
1954 // The canonical init lives in `init::setupvals` (port of
1955 // `Src/init.c:setupvals`); the bin entry skips setupvals (per
1956 // the init_bltinmods comment above), so call the lightweight
1957 // module_path bootstrap exposed by init.rs from here. This
1958 // mirrors the HOST gethostname seeding pattern above:
1959 // duplicated init that should collapse into a full setupvals
1960 // call once that port is complete.
1961 crate::ported::init::module_path_init();
1962
1963 exec
1964 }
1965
1966 /// Execute a script file with bytecode caching — skips lex+parse+compile on cache hit.
1967 /// Bytecode is stored in rkyv keyed by (path, mtime).
1968 pub fn execute_script_file(&mut self, file_path: &str) -> Result<i32, String> {
1969 let path = Path::new(file_path);
1970 let abs_path = path
1971 .canonicalize()
1972 .unwrap_or_else(|_| path.to_path_buf())
1973 .to_string_lossy()
1974 .to_string();
1975
1976 // Try bytecode cache first — rkyv shard at ~/.zshrs/scripts.rkyv.
1977 // The cache validates path + mtime + zshrs binary mtime; on any
1978 // miss we fall through to lex/parse/compile. Cached path uses
1979 // `run_chunk` (the shared VM-execution helper); script-eval
1980 // path delegates to `execute_script_zsh_pipeline` so the
1981 // full parse/compile/cache-save/run flow stays in one place.
1982 if let Some(bc_blob) = crate::script_cache::try_load_bytes(path) {
1983 if let Ok(chunk) = bincode::deserialize::<fusevm::Chunk>(&bc_blob) {
1984 if !chunk.ops.is_empty() {
1985 tracing::trace!(
1986 path = %abs_path,
1987 ops = chunk.ops.len(),
1988 "execute_script_file: bytecode cache hit"
1989 );
1990 return self.run_chunk(chunk, &format!("execute_script_file:cache:{abs_path}"));
1991 }
1992 }
1993 }
1994
1995 // Cache miss — read, parse, compile via execute_script_zsh_pipeline,
1996 // then snapshot the resulting chunk into the cache for next
1997 // time. Direct port of Src/init.c source() which calls
1998 // `lex_init_buf` / `loop()` without engaging the history layer.
1999 // (zsh fires `!` history sub only on interactive input, so
2000 // sourced files run verbatim.)
2001 let content = fs::read_to_string(file_path).map_err(|e| format!("{}: {}", file_path, e))?;
2002 let status = self.execute_script_zsh_pipeline(&content)?;
2003
2004 // Best-effort cache save — failures don't block execution.
2005 // Re-parse/-compile here instead of trying to thread the chunk
2006 // back out of execute_script_zsh_pipeline; the cost is one extra
2007 // compile per CACHE MISS, paid back on every subsequent run.
2008 let saved_errflag = errflag.load(Ordering::Relaxed);
2009 errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
2010 // Context-isolated parse (c:Src/exec.c:283 parse_string) — this
2011 // post-exec re-parse for the bytecode cache also runs mid-stream
2012 // under the single-event reader; isolate it from the outer SHIN.
2013 let program = parse_isolated(&content);
2014 let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
2015 errflag.store(saved_errflag, Ordering::Relaxed);
2016 if !parse_failed {
2017 let compiler = crate::compile_zsh::ZshCompiler::new();
2018 let chunk = compiler.compile(&program);
2019 if let Ok(blob) = bincode::serialize(&chunk) {
2020 let _ = crate::script_cache::try_save_bytes(path, &blob);
2021 tracing::trace!(
2022 path = %abs_path,
2023 bytes = blob.len(),
2024 "execute_script_file: bytecode cached"
2025 );
2026 }
2027 }
2028
2029 Ok(status)
2030 }
2031
2032 /// Run a compiled `fusevm::Chunk` to completion inside this
2033 /// executor's context. Shared by `execute_script_zsh_pipeline`,
2034 /// `execute_script_file`'s bytecode-cache hit path, and the
2035 /// function-dispatch body_runner. Centralises the VM setup so
2036 /// `register_builtins` and `ExecutorContext::enter` invariants
2037 /// stay in lockstep.
2038 fn run_chunk(&mut self, chunk: fusevm::Chunk, label: &str) -> Result<i32, String> {
2039 if chunk.ops.is_empty() {
2040 return Ok(self.last_status());
2041 }
2042 crate::fusevm_disasm::maybe_print_stdout(label, &chunk);
2043 let mut vm = fusevm::VM::new(chunk);
2044 register_builtins(&mut vm);
2045 // Seed vm.last_status with the executor's current LASTVAL so
2046 // sub-VMs (EXIT trap bodies, eval, source) see the inherited
2047 // `$?` from the caller's last command — matching C zsh where
2048 // lastval is a process global. Without this, the new VM
2049 // started at 0 and BUILTIN_GET_VAR's sync_status would write
2050 // 0 back into LASTVAL on the first `$?` read.
2051 vm.last_status = self.last_status();
2052 let _ctx = ExecutorContext::enter(self);
2053 match vm.run() {
2054 fusevm::VMResult::Ok(_) | fusevm::VMResult::Halted => {
2055 self.set_last_status(vm.last_status);
2056 }
2057 fusevm::VMResult::Error(e) => return Err(format!("VM error: {}", e)),
2058 }
2059 Ok(self.last_status())
2060 }
2061
2062 /// Execute via the lex+parse free ported + ZshCompiler pipeline.
2063 /// This is the only execution path; `execute_script` delegates here.
2064 pub fn execute_script_zsh_pipeline(&mut self, script: &str) -> Result<i32, String> {
2065 // Skip history expansion for non-interactive script execution
2066 // (`zsh -c '…'`, internal eval, sourced files). zsh's `!`
2067 // history sub only fires on the REPL command line, never on
2068 // a pre-parsed script body. The interactive REPL has its
2069 // own dedicated path that calls expand_history before
2070 // dispatching here.
2071 // Save & clear errflag around the parse so a fresh syntax
2072 // error is distinguishable from one already in flight. Mirrors
2073 // Src/init.c loop()'s pre-parse `errflag &= ~ERRFLAG_ERROR;`.
2074 let saved_errflag = errflag.load(Ordering::Relaxed);
2075 errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
2076 // Context-isolated parse (c:Src/exec.c:283 parse_string). eval /
2077 // source / autoload-register / trap bodies all reach here and run
2078 // DURING execution; on the faithful single-event loop()/parse_event
2079 // reader, a bare parse_init/lex_init would steal the outer's next
2080 // SHIN line into this nested program (e.g. `eval "x=5"` swallowed the
2081 // following `echo $x` off stdin). parse_isolated sets `strin` so the
2082 // string drains to EOF; execution below stays in the current shell.
2083 let program = parse_isolated(script);
2084 let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
2085 errflag.store(saved_errflag, Ordering::Relaxed);
2086 if parse_failed {
2087 // c:Src/init.c — when the parser fires `zerr(...)`, the C
2088 // shell's `loop()` body skips the eval pass and continues;
2089 // there's no second "parse error" diagnostic. The Rust
2090 // binary's call sites print `zshrs: <e>` on Err, doubling
2091 // up on the message the parser already emitted via zerr.
2092 // Use a `__SILENCED__` sentinel that the binary's
2093 // execute_script wrapper recognizes as "already reported,
2094 // exit silently". Bug #142 in docs/BUGS.md (double-print
2095 // half).
2096 return Err("__SILENCED__".to_string());
2097 }
2098
2099 let compiler = crate::compile_zsh::ZshCompiler::new();
2100 let chunk = compiler.compile(&program);
2101 let status = self.run_chunk(chunk, "execute_script_zsh_pipeline")?;
2102
2103 // Fire EXIT trap if set. Two storage paths:
2104 // (a) `trap 'cmd' EXIT` writes the body text into
2105 // `traps_table` via bin_trap (Src/builtin.c) — fire
2106 // directly via execute_script.
2107 // (b) `TRAPEXIT() { ... }` function-named form goes
2108 // through settrap(SIGEXIT, None, ZSIG_FUNC) at
2109 // funcdef time (fusevm_bridge.rs BUILTIN_REGISTER_COMPILED_FN
2110 // arm) and lives in shfunctab + sigtrapped — fire
2111 // via dotrap(SIGEXIT) which dispatches the named
2112 // shfunc. Bug #157 in docs/BUGS.md.
2113 // Remove the trap from `traps_table` first to prevent
2114 // infinite recursion of `(a)`; `(b)`'s sigtrapped flag
2115 // is cleared by dotrap's own intrap guard.
2116 let exit_body = crate::ported::builtin::traps_table()
2117 .lock()
2118 .ok()
2119 .and_then(|mut t| t.remove("EXIT"));
2120 if let Some(action) = exit_body {
2121 tracing::debug!("firing EXIT trap (new pipeline)");
2122 // c:Src/signals.c — the EXIT trap body sees $? at the
2123 // value the script left off (so `trap 'echo $?' EXIT;
2124 // (exit 7)` prints 7), but the SHELL's final exit code
2125 // is still the pre-trap value (running `echo` inside
2126 // the trap doesn't reset the script's exit status).
2127 // Preserve `status` and re-apply it after the trap
2128 // body returns.
2129 let _ = self.execute_script_zsh_pipeline(&action);
2130 self.set_last_status(status);
2131 }
2132 // c:Src/signals.c::dotrap(SIGEXIT) — fire TRAPEXIT() shfunc
2133 // if installed via the function-name path. The TRAPEXIT()
2134 // form goes through settrap(SIGEXIT, None, ZSIG_FUNC) at
2135 // funcdef time (sets sigtrapped[SIGEXIT] |= ZSIG_FUNC).
2136 // Dispatching from here AFTER run_chunk returns means we're
2137 // outside the VM context — dotrap can't safely re-enter
2138 // via dispatch_function_call (which uses with_executor).
2139 // Route through execute_script_zsh_pipeline which sets up
2140 // a fresh VM context — invoke the function by name.
2141 let trapped = crate::ported::signals::sigtrapped
2142 .lock()
2143 .ok()
2144 .and_then(|g| g.get(crate::signals_h::SIGEXIT as usize).copied())
2145 .unwrap_or(0);
2146 if (trapped & crate::ported::zsh_h::ZSIG_FUNC as i32) != 0 {
2147 // The TRAP<SIG> function is stored in shfunctab as
2148 // "TRAPEXIT"; calling it by name re-enters
2149 // execute_script_zsh_pipeline with a fresh VM context.
2150 let _ = self.execute_script_zsh_pipeline("TRAPEXIT");
2151 }
2152 // c:Src/init.c::zexit — `callhookfunc("zshexit", NULL, 1, NULL)`.
2153 // Fire the `zshexit` shfunc + walk `zshexit_functions` array.
2154 // Routed through execute_script_zsh_pipeline calls because
2155 // we're outside the VM context here (post-run_chunk). Iterate
2156 // the array directly + call zshexit by name. Bug #215 in
2157 // docs/BUGS.md.
2158 //
2159 // Re-entry guard: each call to execute_script_zsh_pipeline
2160 // (whether top-level script or the named-fn dispatch below)
2161 // hits this code at its tail. Without a guard, the zshexit
2162 // hook recurses infinitely (calls itself at end via this
2163 // path). Use a thread-local depth counter and skip the
2164 // dispatch when depth > 0.
2165 thread_local! {
2166 static ZSHEXIT_HOOK_DEPTH: std::cell::Cell<u32> = const {
2167 std::cell::Cell::new(0)
2168 };
2169 }
2170 let hook_depth = ZSHEXIT_HOOK_DEPTH.with(|c| c.get());
2171 if hook_depth == 0 {
2172 ZSHEXIT_HOOK_DEPTH.with(|c| c.set(hook_depth + 1));
2173 if crate::ported::hashtable::shfunctab_lock()
2174 .read()
2175 .ok()
2176 .map(|t| t.contains_key("zshexit"))
2177 .unwrap_or(false)
2178 {
2179 let _ = self.execute_script_zsh_pipeline("zshexit");
2180 }
2181 let exit_arr = crate::ported::params::paramtab()
2182 .read()
2183 .ok()
2184 .and_then(|t| t.get("zshexit_functions").and_then(|p| p.u_arr.clone()))
2185 .unwrap_or_default();
2186 for fn_name in exit_arr {
2187 let exists = crate::ported::hashtable::shfunctab_lock()
2188 .read()
2189 .ok()
2190 .map(|t| t.contains_key(&fn_name))
2191 .unwrap_or(false);
2192 if exists {
2193 let _ = self.execute_script_zsh_pipeline(&fn_name);
2194 }
2195 }
2196 ZSHEXIT_HOOK_DEPTH.with(|c| c.set(hook_depth));
2197 }
2198 // Preserve script status; trap body shouldn't override it.
2199 self.set_last_status(status);
2200
2201 let _ = status;
2202 Ok(self.last_status())
2203 }
2204 /// `execute_script` — see implementation.
2205 #[tracing::instrument(skip(self, script), fields(len = script.len()))]
2206 pub fn execute_script(&mut self, script: &str) -> Result<i32, String> {
2207 // lex+parse free ported + ZshCompiler is the only execution path.
2208 self.execute_script_zsh_pipeline(script)
2209 }
2210
2211 /// Run an ALREADY-PARSED program (the back half of
2212 /// `execute_script_zsh_pipeline`): compile the `ZshProgram` to a
2213 /// fusevm Chunk and run it. Used by the ported `loop()` REPL
2214 /// (Src/init.c:220 `execode`), which parses via `parse_event` and
2215 /// hands the program here through the `execute_program` exec hook.
2216 /// Returns the resulting `$?` (1 on a compile/run error).
2217 pub fn execute_program(&mut self, program: &crate::parse::ZshProgram) -> i32 {
2218 let chunk = crate::compile_zsh::ZshCompiler::new().compile(program);
2219 match self.run_chunk(chunk, "loop") {
2220 Ok(status) => status,
2221 Err(_) => 1,
2222 }
2223 }
2224
2225 /// Whether `name` is a known function. Checks the compiled-functions
2226 /// table and the autoload-pending registry — `autoload foo` should
2227 /// make `whence foo`/`type foo`/`functions foo` recognize `foo` as
2228 /// a function before it's actually loaded. Doesn't trigger autoload
2229 /// itself; use `maybe_autoload` first if you need to load before
2230 /// introspecting.
2231 pub fn function_exists(&self, name: &str) -> bool {
2232 // Either compiled (already loaded) or shfunctab has an
2233 // autoload stub with PM_UNDEFINED set (pending). Matches C's
2234 // `lookupshfunc(name)` semantics at `Src/exec.c:5215`.
2235 if self.functions_compiled.contains_key(name) {
2236 return true;
2237 }
2238 crate::ported::hashtable::shfunctab_lock()
2239 .read()
2240 .ok()
2241 .map(|t| t.get(name).is_some())
2242 .unwrap_or(false)
2243 }
2244
2245 /// Sorted list of every known function name (union of compiled + source).
2246 pub fn function_names(&self) -> Vec<String> {
2247 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2248 for k in self.functions_compiled.keys() {
2249 set.insert(k.clone());
2250 }
2251 for k in self.function_source.keys() {
2252 set.insert(k.clone());
2253 }
2254 set.into_iter().collect()
2255 }
2256
2257 /// Dispatch a function by name. Thin passthru — autoload-materialize
2258 /// the body if needed, build a synthetic `shfunc`, and hand off to
2259 /// the canonical `doshfunc` port (`Src/exec.c:5823` →
2260 /// `src/ported/exec.rs::doshfunc`). doshfunc owns ALL scope
2261 /// management (starttrapscope/endtrapscope, startparamscope/
2262 /// endparamscope, funcdepth bump, pipestats save/restore, scriptname
2263 /// snapshot, BREAKS/CONTFLAG/LOOPS/RETFLAG snapshot+restore, `$0`
2264 /// override via FUNCTIONARGZERO, etc.). The body run itself is the
2265 /// Rust-only adaptation passed via the `body_runner` closure because
2266 /// zshrs runs function bodies through fusevm bytecode (not C zsh's
2267 /// wordcode walker via `runshfunc`).
2268 ///
2269 /// Returns `None` when the name isn't a known function so the caller
2270 /// can fall through to external dispatch.
2271 /// Body-only counterpart to [`dispatch_function_call`] — runs
2272 /// the function body WITHOUT wrapping in `doshfunc`. Used as the
2273 /// `body_runner` closure target by `src/ported/` callers that
2274 /// already wrap their own `crate::ported::exec::doshfunc(...)`
2275 /// call (so going back through `dispatch_function_call` would
2276 /// double-wrap the scope). Mirrors C's `runshfunc(prog, wrappers,
2277 /// name)` at `exec.c:6042` from doshfunc's perspective.
2278 pub fn run_function_body_only(&mut self, name: &str, args: &[String]) -> Option<i32> {
2279 // Same Rust-port short-circuit as dispatch_function_call,
2280 // sans the doshfunc wrap.
2281 if let Some(rust_fn) = crate::compsys::router::try_rust_dispatch(name) {
2282 return Some(rust_fn(args));
2283 }
2284 // Autoload prelude (same as dispatch_function_call's).
2285 if !self.functions_compiled.contains_key(name) {
2286 if let Some(stub) = crate::ported::utils::getshfunc(name) {
2287 if (stub.node.flags as u32 & PM_UNDEFINED) != 0 {
2288 let boxed = Box::new(stub.clone());
2289 let ptr = Box::into_raw(boxed);
2290 let _ = crate::ported::exec::loadautofn(ptr, 0, 0, 0);
2291 unsafe {
2292 let _ = Box::from_raw(ptr);
2293 }
2294 if let Some(body) = crate::ported::utils::getshfunc(name).and_then(|f| f.body) {
2295 let registered = autoload_register_source(name, &body);
2296 let _ = self.execute_script_zsh_pipeline(®istered);
2297 }
2298 } else if let Some(body) = stub.body.clone() {
2299 let registered = autoload_register_source(name, &body);
2300 let _ = self.execute_script_zsh_pipeline(®istered);
2301 }
2302 }
2303 }
2304 let chunk = self.functions_compiled.get(name).cloned()?;
2305 let seed_status = self.last_status();
2306 let _ = args; // fusevm body reads $1..$N from PPARAMS
2307 let mut vm = fusevm::VM::new(chunk);
2308 register_builtins(&mut vm);
2309 vm.last_status = seed_status;
2310 let _ = vm.run();
2311 Some(vm.last_status)
2312 }
2313
2314 pub fn dispatch_function_call(&mut self, name: &str, args: &[String]) -> Option<i32> {
2315 // Nested scope for `>(cmd)` fd ownership — builtins running
2316 // inside the function body must not close the CALLER's
2317 // pending psub fds (`myfn >(cmd)` keeps /dev/fd/N alive for
2318 // the whole function, like C's per-job filelist). See
2319 // PSUB_SCOPE_DEPTH in fusevm_bridge.rs.
2320 let _psub_scope = crate::fusevm_bridge::PsubScope::enter();
2321 // c:Src/exec.c — `disable -f NAME` flips the DISABLED flag on
2322 // the shfunctab entry. `lookupshfunc` (which dispatch consults)
2323 // returns NULL for DISABLED entries, falling through to PATH
2324 // lookup → "command not found". zshrs keeps the compiled body
2325 // in functions_compiled independently of the flag, so check
2326 // shfunctab and short-circuit when DISABLED is set. Bug #221
2327 // in docs/BUGS.md.
2328 let is_disabled = crate::ported::hashtable::shfunctab_lock()
2329 .read()
2330 .ok()
2331 .and_then(|t| {
2332 let entry = t.get_including_disabled(name)?;
2333 Some(
2334 (entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0,
2335 )
2336 })
2337 .unwrap_or(false);
2338 if is_disabled {
2339 return None;
2340 }
2341 // zshrs-original: `[compsys] backend = "rust"` short-circuit.
2342 // When a `_NAME` has a Rust port AND the user opted into the
2343 // rust backend, run the Rust fn directly here — but still
2344 // through the canonical doshfunc scope-management path below
2345 // (we synthesize a body_runner from the fn pointer). Router
2346 // returns None for names without a Rust port → graceful
2347 // fallback to the shfunc autoload path.
2348 //
2349 // Note: `compcore::callcompfunc` (the compsys entry hit by
2350 // Tab) wraps doshfunc itself per C `compcore.c:835`, so the
2351 // Rust _main_complete dispatch lands HERE only when called
2352 // from a non-compcore caller (e.g. a user shell script
2353 // directly invoking `_main_complete`). The doshfunc scope
2354 // wrap below applies uniformly to both.
2355 let direct_rust_fn: Option<fn(&[String]) -> i32> =
2356 crate::compsys::router::try_rust_dispatch(name);
2357 // Autoload prelude skipped when a Rust port wins — no upstream
2358 // shell function to load.
2359 if direct_rust_fn.is_none() && !self.functions_compiled.contains_key(name) {
2360 if let Some(stub) = crate::ported::utils::getshfunc(name) {
2361 if (stub.node.flags as u32 & PM_UNDEFINED) != 0 {
2362 let boxed = Box::new(stub.clone());
2363 let ptr = Box::into_raw(boxed);
2364 let load_rc = crate::ported::exec::loadautofn(ptr, 0, 0, 0);
2365 unsafe {
2366 let _ = Box::from_raw(ptr);
2367 }
2368 if let Some(body) = crate::ported::utils::getshfunc(name).and_then(|f| f.body) {
2369 let registered = autoload_register_source(name, &body);
2370 let _ = self.execute_script_zsh_pipeline(®istered);
2371 if !self.functions_compiled.contains_key(name) {
2372 // c:Src/exec.c:5742-5745 — ksh-style load ran
2373 // the file (`execode`, "evalautofunc") but it
2374 // didn't define NAME:
2375 // `zwarn("%s: function not defined by file", n);`
2376 // The wrap/strip zsh-style paths always define
2377 // NAME, so reaching here means the verbatim run
2378 // failed to — same condition as C.
2379 crate::ported::utils::zwarn(&format!(
2380 "{}: function not defined by file",
2381 name
2382 ));
2383 return Some(1);
2384 }
2385 } else if load_rc != 0 {
2386 // c:Src/exec.c:5713-5719 / 5635-5644 —
2387 // `execautofn`'s `if (!loadautofn(...)) return 1`
2388 // propagates the loadautofn failure as the
2389 // command's exit status. zshrs's previous
2390 // path returned None here, falling through to
2391 // execute_external which emitted a SECOND
2392 // diagnostic (`command not found: NAME`) on
2393 // top of loadautofn's `function definition
2394 // file not found`. Mirror C: when load failed
2395 // AND the stub still has no body, surface
2396 // status=1 so the caller does NOT fall back
2397 // to PATH search.
2398 return Some(1);
2399 }
2400 } else if let Some(body) = stub.body.clone() {
2401 // c:Src/Modules/parameter.c::setpmfunction — function
2402 // registered via `functions[name]=body` lives in
2403 // shfunctab with `body` set but `functions_compiled`
2404 // empty (the canonical port stores the parsed eprog,
2405 // not a fusevm Chunk). Lazy-compile here by feeding
2406 // the body through the standard funcdef pipeline so
2407 // the next CallFunction op finds the chunk.
2408 let registered = autoload_register_source(name, &body);
2409 let _ = self.execute_script_zsh_pipeline(®istered);
2410 }
2411 }
2412 }
2413 // When a Rust port is registered, skip the fusevm Chunk
2414 // lookup entirely — the body_runner closure below will run
2415 // the Rust fn pointer directly. Otherwise require a compiled
2416 // chunk for the autoloaded body.
2417 let chunk_opt = if direct_rust_fn.is_some() {
2418 None
2419 } else {
2420 Some(self.functions_compiled.get(name).cloned()?)
2421 };
2422
2423 // zshrs-specific bookkeeping that doshfunc doesn't own:
2424 // - prompt_funcstack (PS4 trace) push/pop
2425 // - local_scope_depth FUNCNEST guard
2426 //
2427 // c:Src/exec.c::funcnest_check — C zsh allows FUNCNEST=500 by
2428 // default and the per-call stack usage is small enough that
2429 // 500 nested calls fit comfortably in the default 8MB thread
2430 // stack. zshrs's per-call stack usage is heavier (vm_helper
2431 // state, fusevm closures, parse buffers) — empirically the
2432 // Rust stack overflows around depth 120. Cap the effective
2433 // check at a safe ceiling (80) so the user-visible
2434 // `maximum nested function level reached` diagnostic fires
2435 // before the SIGABRT crash. User-set FUNCNEST values above
2436 // 80 are silently clamped. Bug #519 — critical: previously
2437 // ANY infinite-recursion function crashed the shell with
2438 // `thread 'main' has overflowed its stack` exit 134.
2439 const FUNCNEST_RUST_CEILING: usize = 80;
2440 let funcnest_user: usize = self
2441 .scalar("FUNCNEST")
2442 .and_then(|s| s.parse().ok())
2443 .unwrap_or(100);
2444 let funcnest_limit = funcnest_user.min(FUNCNEST_RUST_CEILING);
2445 if self.local_scope_depth >= funcnest_limit {
2446 eprintln!(
2447 "{}: maximum nested function level reached; increase FUNCNEST?",
2448 name
2449 );
2450 return Some(1);
2451 }
2452 let display_name = if name.starts_with("_zshrs_anon_") {
2453 "(anon)".to_string()
2454 } else {
2455 name.to_string()
2456 };
2457 let line_base = self.function_line_base.get(name).copied().unwrap_or(0);
2458 let def_file = self.function_def_file.get(name).cloned().flatten();
2459 self.prompt_funcstack
2460 .push((name.to_string(), line_base, def_file));
2461 self.local_scope_depth += 1;
2462
2463 // Synthetic shfunc for doshfunc — carries the name + def-file
2464 // info so funcstack push gets a proper filename. funcdef/body
2465 // stay None because the wordcode body is irrelevant on this
2466 // path (body_runner runs the fusevm Chunk directly).
2467 // c:Src/exec.c:5390-5410 — execfuncdef records the
2468 // current `scriptfilename` on the shfunc at definition
2469 // time so funcsourcetrace can show file:line of the
2470 // function's source. The function_def_file map stores
2471 // this; fall back to the live scriptfilename so dynamic
2472 // / non-`compile_funcdef`-routed definitions still get a
2473 // sensible filename. Without the fallback, the synth_shf
2474 // saw None and the funcstack push at exec.rs:5719
2475 // defaulted to an empty string, which the funcsourcetrace
2476 // getfn rendered as `:N` (or worse, picked up the
2477 // function name from a parallel field). Bug #515.
2478 let synth_filename = self
2479 .function_def_file
2480 .get(name)
2481 .cloned()
2482 .flatten()
2483 .or_else(|| self.scriptfilename.clone());
2484 // c:Src/exec.c:5409 — `shf->lineno = lineno;` (def line).
2485 // `function_line_base[name]` carries compile_funcdef's
2486 // `lineno_offset = first_body_line - 1` — equals the def line
2487 // for multi-line `f() {\n body }` but underflows to 0 for
2488 // INLINE `f() { body }` (def and body share a line). zsh's
2489 // funcsourcetrace reports the def line as 1-based, so clamp
2490 // to >= 1 to handle the inline case without rebuilding
2491 // line tracking through the parser. Bug #396.
2492 let synth_lineno =
2493 std::cmp::max(1i64, self.function_line_base.get(name).copied().unwrap_or(0));
2494 let mut synth_shf = crate::ported::zsh_h::shfunc {
2495 node: crate::ported::zsh_h::hashnode {
2496 next: None,
2497 nam: display_name.clone(),
2498 flags: 0,
2499 },
2500 filename: synth_filename,
2501 lineno: synth_lineno,
2502 funcdef: None,
2503 redir: None,
2504 sticky: None,
2505 body: None,
2506 };
2507 // doshargs: C convention — argv[0] = function name (for
2508 // FUNCTIONARGZERO `$0`), argv[1..] = real positional args.
2509 let mut doshargs: Vec<String> = vec![display_name.clone()];
2510 doshargs.extend(args.iter().cloned());
2511
2512 // Seed `$?` with the parent's last status — C zsh's
2513 // doshfunc inherits lastval automatically because it's a
2514 // process-global; the fusevm VM creates a fresh
2515 // `vm.last_status = 0` per call, so we mirror the inherit
2516 // explicitly. Without this, a function reading `$?` BEFORE
2517 // running any command sees 0 instead of the caller's status.
2518 let seed_status = self.last_status();
2519 let body_args: Vec<String> = args.to_vec();
2520 let body_runner = move || -> i32 {
2521 // Branch: Rust port (direct fn call) or fusevm Chunk
2522 // (autoloaded shell body). Both run INSIDE doshfunc's
2523 // scope so prologue/epilogue applies identically.
2524 if let Some(f) = direct_rust_fn {
2525 return f(&body_args);
2526 }
2527 let chunk = chunk_opt
2528 .as_ref()
2529 .expect("chunk_opt must be Some when direct_rust_fn is None");
2530 crate::fusevm_disasm::maybe_print_stdout(
2531 &format!(
2532 "function:{}",
2533 body_args.first().map(|s| s.as_str()).unwrap_or("")
2534 ),
2535 chunk,
2536 );
2537 let mut vm = fusevm::VM::new(chunk.clone());
2538 register_builtins(&mut vm);
2539 vm.last_status = seed_status;
2540 let _ = vm.run();
2541 vm.last_status
2542 };
2543
2544 // Enter executor context BEFORE doshfunc so the body_runner's
2545 // VM builtins can `with_executor(...)` to reach this state.
2546 let _ctx = ExecutorContext::enter(self);
2547 let status = crate::ported::exec::doshfunc(&mut synth_shf, doshargs, false, body_runner);
2548 drop(_ctx);
2549
2550 self.prompt_funcstack.pop();
2551 self.local_scope_depth -= 1;
2552
2553 // Honor explicit `return N` from inside the function body.
2554 if let Some(ret) = self.returning.take() {
2555 self.set_last_status(ret);
2556 Some(ret)
2557 } else {
2558 self.set_last_status(status);
2559 Some(status)
2560 }
2561 }
2562
2563 pub(crate) fn execute_external(
2564 &mut self,
2565 cmd: &str,
2566 args: &[String],
2567 redirects: &[Redirect],
2568 ) -> Result<i32, String> {
2569 // FORK_EVENTS is bumped at the real spawn site inside
2570 // execute_external_bg — this entry is only ONE of several
2571 // callers of that spawn (the common static-head command path
2572 // calls execute_external_bg directly), so counting here would
2573 // miss `time sleep 0` while double-counting this path.
2574 self.execute_external_bg(cmd, args, redirects, false)
2575 }
2576
2577 fn execute_external_bg(
2578 &mut self,
2579 cmd: &str,
2580 args: &[String],
2581 _redirects: &[Redirect],
2582 background: bool,
2583 ) -> Result<i32, String> {
2584 tracing::trace!(cmd, bg = background, "exec external");
2585 // c:Src/exec.c:824-876 — when arg0 has no `/`, C zsh requires
2586 // a PATH search. With PATH unset, the search yields no hit
2587 // and C emits `command not found: <cmd>`. Rust's
2588 // `Command::new(name)` delegates to libc `execvp`, which on
2589 // many platforms falls back to a built-in default PATH when
2590 // the env entry is missing — so `unset PATH; ls` still finds
2591 // `/bin/ls` and runs it, breaking the security boundary the
2592 // unset is supposed to establish (#416). Gate explicitly:
2593 // when cmd is a bare name (no `/`) and zshrs's own PATH
2594 // param is unset OR empty, emit the canonical
2595 // "command not found" diagnostic and return 127 BEFORE
2596 // touching libc.
2597 if !cmd.contains('/') {
2598 let path_set_and_nonempty = crate::ported::params::getsparam("PATH")
2599 .map(|p| !p.is_empty())
2600 .unwrap_or(false);
2601 if !path_set_and_nonempty {
2602 let sn = crate::ported::utils::scriptname_get()
2603 .unwrap_or_else(|| "zshrs".to_string());
2604 eprintln!("{}:1: command not found: {}", sn, cmd);
2605 return Ok(127);
2606 }
2607 }
2608 // c:Src/exec.c:2700-2724 resolvebuiltin — names registered via
2609 // `zmodload -ab MOD NAME` resolve through builtintab BEFORE
2610 // PATH search in C (execcmd's builtin lookup precedes the
2611 // external fork). Names the compiler didn't know as builtins
2612 // land here; consult the autoload ledger, load the module,
2613 // and re-dispatch through the builtin chokepoint. Without
2614 // this, `zmodload -ab zsh/bogus mybltn; mybltn` skipped the
2615 // C autoload-fire entirely (PATH miss → 127 instead of the
2616 // load_module diagnostic → 1).
2617 if !cmd.contains('/') {
2618 if let Some(rc) = crate::ported::module::resolvebuiltin(cmd) {
2619 if rc != 0 {
2620 return Ok(1);
2621 }
2622 return Ok(crate::fusevm_bridge::dispatch_builtin_raw(
2623 cmd,
2624 args.to_vec(),
2625 ));
2626 }
2627 }
2628 let mut command = Command::new(cmd);
2629 // c:Src/exec.c execute — C unmetafies every arg before the
2630 // execve (the child must see raw bytes, not the shell's
2631 // internal Meta encoding). Args carrying Meta-char pairs
2632 // (from `$'\xff'` etc., vm_helper::meta_encode_byte) are
2633 // decoded to raw bytes via OsStr; plain args pass through
2634 // unchanged. Bug #127.
2635 for a in args {
2636 if a.contains('\u{83}') {
2637 use std::os::unix::ffi::OsStrExt as _;
2638 command.arg(std::ffi::OsStr::from_bytes(&unmetafy_str(a)));
2639 } else {
2640 command.arg(a);
2641 }
2642 }
2643
2644 // Redirect handling lives in fusevm's WithRedirectsBegin/End
2645 // ops at compile time; `_redirects` arrives empty here.
2646
2647 // c:Src/jobs.c — `time` reports only on JOBS (forked work). This
2648 // is the single chokepoint where an external process is actually
2649 // spawned (both fg and bg, all callers), AFTER the
2650 // command-not-found and resolvebuiltin early-returns above — so
2651 // counting here makes BUILTIN_TIME_SUBLIST report `time sleep 0`
2652 // / `time /usr/bin/true` (external → fork) while staying silent
2653 // for `time true` (builtin, never reaches this point). The
2654 // subshell entry counts separately (fusevm_bridge.rs:9573).
2655 FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2656
2657 if background {
2658 match command.spawn() {
2659 Ok(child) => {
2660 let pid = child.id();
2661 let cmd_str = format!("{} {}", cmd, args.join(" "));
2662 let job_id = self.jobs.add_job(child, cmd_str, JobState::Running);
2663 println!("[{}] {}", job_id, pid);
2664 Ok(0)
2665 }
2666 Err(e) => {
2667 let sn = crate::ported::utils::scriptname_get()
2668 .unwrap_or_else(|| "zshrs".to_string());
2669 if e.kind() == io::ErrorKind::NotFound {
2670 // zsh: absolute paths emit "no such file or
2671 // directory" (the OS error, since the path was
2672 // tried directly), not "command not found"
2673 // (which implies PATH search).
2674 if cmd.starts_with('/') {
2675 eprintln!("{}:1: no such file or directory: {}", sn, cmd);
2676 } else {
2677 eprintln!("{}:1: command not found: {}", sn, cmd);
2678 }
2679 Ok(127)
2680 } else {
2681 Err(format!("{}: {}: {}", sn, cmd, e))
2682 }
2683 }
2684 }
2685 } else {
2686 match command.status() {
2687 Ok(status) => Ok(status.code().unwrap_or(1)),
2688 Err(e) => {
2689 // Use scriptname (the user-visible shell identifier
2690 // — "zsh" in --zsh mode, "zshrs" otherwise) instead
2691 // of a hardcoded "zshrs:" prefix so --zsh-mode
2692 // diagnostics byte-match C zsh's stderr format.
2693 let sn = crate::ported::utils::scriptname_get()
2694 .unwrap_or_else(|| "zshrs".to_string());
2695 if e.kind() == io::ErrorKind::NotFound {
2696 // c:Src/exec.c — `command_not_found_handler` user
2697 // hook: when a command lookup fails AND a function
2698 // by that name is defined, call it with the cmd
2699 // name + original args and return its rc instead
2700 // of the default 127 + "command not found" error.
2701 // Documented in zshmisc(1) under "Special
2702 // Functions". Bug #426.
2703 //
2704 // The hook only fires for bare names (PATH search
2705 // failed); absolute paths skip it and emit the
2706 // OS-error path below — matches zsh behavior.
2707 if !cmd.starts_with('/') {
2708 let mut hook_args = Vec::with_capacity(args.len() + 1);
2709 hook_args.push(cmd.to_string());
2710 hook_args.extend_from_slice(args);
2711 if let Some(rc) = self.dispatch_function_call(
2712 "command_not_found_handler",
2713 &hook_args,
2714 ) {
2715 return Ok(rc);
2716 }
2717 }
2718 // zsh: absolute paths emit "no such file or
2719 // directory" (the OS error, since the path was
2720 // tried directly), not "command not found"
2721 // (which implies PATH search).
2722 if cmd.starts_with('/') {
2723 eprintln!("{}:1: no such file or directory: {}", sn, cmd);
2724 } else {
2725 eprintln!("{}:1: command not found: {}", sn, cmd);
2726 }
2727 Ok(127)
2728 } else if e.kind() == io::ErrorKind::PermissionDenied {
2729 // zsh: non-executable file → "permission denied"
2730 // on stderr and exit 126 (POSIX "command found
2731 // but not executable").
2732 eprintln!("{}:1: permission denied: {}", sn, cmd);
2733 Ok(126)
2734 } else {
2735 Err(format!("{}: {}: {}", sn, cmd, e))
2736 }
2737 }
2738 }
2739 }
2740 }
2741 /// Parse `cmd_str` via parse_init+parse and pull out the first Simple
2742 /// command's words, untokenized + variable-expanded, ready to spawn
2743 /// as argv. Used by process-substitution where we need raw argv to
2744 /// hand to `Command::new`. Returns empty vec if the cmd isn't a
2745 /// simple shape — pipelines / compound forms aren't process-sub
2746 /// friendly anyway.
2747 fn simple_cmd_words(&mut self, cmd_str: &str) -> Vec<String> {
2748 // Mirror Src/init.c-style errflag save/clear/check around the
2749 // parse. Process-sub argv extraction silently bails on syntax
2750 // errors (matches zsh's behavior when the inner command can't
2751 // be parsed).
2752 let saved_errflag = errflag.load(Ordering::Relaxed);
2753 errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
2754 // Context-isolated nested parse (c:Src/exec.c:283 parse_string) —
2755 // same rationale as run_command_substitution: process-sub argv
2756 // extraction runs during execution and must not clobber the outer
2757 // single-event reader's lexer/input position.
2758 let prog = parse_isolated(cmd_str);
2759 let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
2760 errflag.store(saved_errflag, Ordering::Relaxed);
2761 if parse_failed {
2762 return Vec::new();
2763 }
2764 let first = match prog.lists.first() {
2765 Some(l) => l,
2766 None => return Vec::new(),
2767 };
2768 let pipe = &first.sublist.pipe;
2769 if let crate::parse::ZshCommand::Simple(simple) = &pipe.cmd {
2770 simple
2771 .words
2772 .iter()
2773 .map(|w| {
2774 // Untokenize then variable-expand — text-based
2775 // word expansion for the spawned argv.
2776 let untoked = crate::lex::untokenize(w);
2777 singsub(&untoked)
2778 })
2779 .collect()
2780 } else {
2781 Vec::new()
2782 }
2783 }
2784 /// `run_command_substitution` — see implementation.
2785 pub fn run_command_substitution(&mut self, cmd_str: &str) -> String {
2786 // `$(< FILE)` — zsh shorthand for "read FILE contents". Faster
2787 // than spawning `cat`. The leading `<` (after stripping
2788 // whitespace) means "read this file". Trailing newline is
2789 // stripped (same as command-substitution).
2790 let trimmed = cmd_str.trim_start();
2791 // Only treat as `$(<file)` shorthand when the SINGLE leading `<`
2792 // is followed by a filename, not another `<`. `$(<<<"hi" cat)`
2793 // starts with `<<<` (here-string) and must go through the full
2794 // parse path, not the read-file shortcut.
2795 if let Some(rest) = trimmed.strip_prefix('<').filter(|s| !s.starts_with('<')) {
2796 let filename = rest.trim();
2797 // c:Src/lex.c — the `$(<file)` shortcut ONLY applies when
2798 // the body is exactly `<` + ONE word. Anything else (extra
2799 // args, redirects, semicolons, pipes) is a regular command
2800 // list and must go through the full parse path so `2>/dev/null`
2801 // / `>file` / `|cmd` / `; next` etc. work. Without this
2802 // gate, `$(< file 2>/dev/null)` treated `file 2>/dev/null`
2803 // as the literal filename and errored on the missing file.
2804 // Bug #615.
2805 let is_single_word = !filename.is_empty()
2806 && !filename.chars().any(|c| {
2807 matches!(c,
2808 ' ' | '\t' | '\n' | ';' | '&' | '|' |
2809 '<' | '>' | '(' | ')' | '`' | '"' | '\''
2810 )
2811 });
2812 if is_single_word {
2813 // Expand any leading $ / tilde in the filename so
2814 // `$(< $f)` and `$(< ~/x)` work.
2815 let resolved = if filename.contains('$') || filename.starts_with('~') {
2816 singsub(filename)
2817 } else {
2818 filename.to_string()
2819 };
2820 let resolved = resolved.to_string();
2821 match fs::read_to_string(&resolved) {
2822 Ok(contents) => {
2823 return contents.trim_end_matches('\n').to_string();
2824 }
2825 Err(_) => {
2826 eprintln!("zshrs:1: no such file or directory: {}", resolved);
2827 return String::new();
2828 }
2829 }
2830 }
2831 // Multi-word / has-redirects → fall through to full parse.
2832 }
2833
2834 // Port of getoutput(char *cmd, int qt) from Src/exec.c. Parse and compile via
2835 // the lex+parse free ported + ZshCompiler pipeline, run on a
2836 // sub-VM with the host wired up. Stdout is captured through
2837 // an in-process pipe via dup2 — no fork. The sub-VM emits
2838 // Op::Exec for unknown command names, which forks/execs
2839 // through the host.
2840
2841 // Set up the stdout-capture pipe. We dup the original stdout
2842 // so post-run we can restore it; the write end is dup2'd onto
2843 // STDOUT_FILENO so all output the sub-VM emits (including from
2844 // forked children, which inherit fd 1) lands in the pipe.
2845 //
2846 // c:Src/exec.c:4753 — `if (mpipe(pipes) < 0)`. mpipe (c:5160)
2847 // moves BOTH pipe ends to fd >= 10 via movefd and marks them
2848 // FDT_INTERNAL. This is load-bearing: zsh's invariant is that
2849 // shell-internal fds never live below 10, so user redirections
2850 // like `exec 9>&-` (which close fd<10 unconditionally, no
2851 // FDT_INTERNAL guard — c:Src/exec.c:3856-3868) can never hit
2852 // them. A raw pipe() here landed the read end on fd 9 when
2853 // fresh-HOME init held fds 3-7, and A04redirect's %prep
2854 // `exec 9>&-` closed our own capture pipe → SIGPIPE killed
2855 // the whole shell.
2856 let (read_fd, write_fd) = {
2857 let mut fds = [0i32; 2];
2858 if crate::ported::exec::mpipe(&mut fds) < 0 {
2859 return String::new();
2860 }
2861 (fds[0], fds[1])
2862 };
2863 // c:Src/utils.c:1996 — `movefd(dup(fd))`: saved copies of the
2864 // user-visible fds are shell-internal, so they too must live
2865 // at fd >= 10 / FDT_INTERNAL.
2866 let saved_stdout =
2867 crate::ported::utils::movefd(unsafe { libc::dup(libc::STDOUT_FILENO) });
2868 if saved_stdout < 0 {
2869 crate::ported::utils::zclose(read_fd);
2870 crate::ported::utils::zclose(write_fd);
2871 return String::new();
2872 }
2873 // Flush Rust's stdout BufWriter against the ORIGINAL fd before
2874 // dup2 swaps fd 1 to the capture pipe. Without this, bytes left
2875 // buffered by a prior `print -n` get drained to fd 1 AFTER the
2876 // dup2, which routes them into the cmd-subst's pipe — they end
2877 // up in the captured result and disappear from terminal output.
2878 //
2879 // Bug #10 in docs/BUGS.md — `print -n "A"; v=$(true); print -n
2880 // "B"; v=$(true); print -n "C"; echo` printed only `C` because
2881 // `A` and `B` were redirected into the empty cmd-subst's pipe
2882 // and discarded as its "output". C zsh's getoutput() forks, so
2883 // the child inherits the buffer COPY and the parent's buffer
2884 // stays untouched; zshrs runs cmd-subst in-process so the
2885 // parent buffer is the only one — must flush before the swap.
2886 let _ = io::stdout().flush();
2887 // c:Bug #56 — publish the saved outer stdout so a trap firing
2888 // during the nested run routes body output to the parent's
2889 // real stdout instead of the cmdsub's pipe-bound fd 1.
2890 // c:Src/utils.c:1996 — movefd(dup(fd)): internal fd, keep >= 10.
2891 let saved_stderr_for_trap =
2892 crate::ported::utils::movefd(unsafe { libc::dup(libc::STDERR_FILENO) });
2893 crate::fusevm_bridge::CMDSUBST_OUTER_FDS.with(|s| {
2894 s.borrow_mut()
2895 .push((saved_stdout, saved_stderr_for_trap))
2896 });
2897 unsafe {
2898 libc::dup2(write_fd, libc::STDOUT_FILENO);
2899 }
2900 // zclose (not raw close) so the FDT_INTERNAL mark set by mpipe
2901 // is cleared from fdtable — c:Src/utils.c:2137.
2902 crate::ported::utils::zclose(write_fd);
2903
2904 // c:Src/exec.c:1161 — forked cmdsub child runs entersubsh()
2905 // which does `zsh_subshell++`; in-process equivalent (RAII,
2906 // restored on every return path below).
2907 let _subshell_bump = crate::fusevm_bridge::CmdSubstSubshellBump::enter();
2908
2909 // Parse + compile + run.
2910 // Push CS_CMDSUBST for `%_` xtrace prefix — direct port of
2911 // Src/exec.c:4783 `cmdpush(CS_CMDSUBST);` around execode().
2912 // Trace lines emitted by the inner program inherit this token
2913 // so their PS4 prefix shows "cmdsubst" matching zsh -x.
2914 cmdpush(crate::ported::zsh_h::CS_CMDSUBST as u8); // c:zsh.h:2799
2915 // Save LINENO so the inner cmdsubst's line counter doesn't
2916 // leak into the outer trace — direct port of Src/exec.c:1407
2917 // `oldlineno = lineno;` followed by `lineno = oldlineno;`
2918 // restore at line 1640. Inner program parses fresh as line 1
2919 // and increments from there; once it returns, the outer
2920 // line at the `$(…)` site must read the original outer
2921 // lineno (so xtrace renders `+:5:> echo …` not `+:1:> …`).
2922 let saved_lineno = getsparam("LINENO");
2923 // Anchor the inner program's lineno to the outer's current
2924 // $LINENO so xtrace inside the cmdsubst renders the outer
2925 // line. zsh's execlist preserves lineno across the inner
2926 // exec — for our sub-VM (fresh compile) we use lineno_addend
2927 // to shift inner's line N → outer_lineno + (N - 1).
2928 let outer_lineno: u64 = self
2929 .scalar("LINENO")
2930 .and_then(|s| s.parse::<u64>().ok())
2931 .unwrap_or(0);
2932 // Mirror Src/init.c errflag save/clear/check pattern around
2933 // the nested parse so an inner syntax error doesn't bleed into
2934 // the outer execution.
2935 let saved_errflag = errflag.load(Ordering::Relaxed);
2936 errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
2937 // Context-isolated nested parse (c:Src/exec.c:283 parse_string).
2938 // The outer loop()/parse_event reader may be mid-stream when this
2939 // cmd-subst executes (single-event mode), so a destructive
2940 // parse_init/lex_init would clobber its next read. parse_isolated
2941 // brackets the parse with zcontext_save/restore + inpush/inpop.
2942 let parsed = parse_isolated(cmd_str);
2943 let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
2944 errflag.store(saved_errflag, Ordering::Relaxed);
2945 let prog = if parse_failed { None } else { Some(parsed) };
2946 let mut cmd_status: Option<i32> = None;
2947 if let Some(prog) = prog {
2948 let mut compiler = crate::compile_zsh::ZshCompiler::new();
2949 compiler.lineno_addend = outer_lineno.saturating_sub(1);
2950 let chunk = compiler.compile(&prog);
2951 if !chunk.ops.is_empty() {
2952 crate::fusevm_disasm::maybe_print_stdout("run_command_substitution", &chunk);
2953 // c:Src/exec.c:4783 — `$(...)` runs in a subshell, so
2954 // assignments / setopt / cd / trap changes inside
2955 // mustn't leak to the parent. zsh forks; we run
2956 // in-process and snapshot/restore manually. Same
2957 // snapshot shape used by host_subshell_begin/end for
2958 // the `(...)` subshell form.
2959 let paramtab_snap = crate::ported::params::paramtab()
2960 .read()
2961 .ok()
2962 .map(|t| t.clone())
2963 .unwrap_or_default();
2964 let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
2965 .lock()
2966 .ok()
2967 .map(|m| m.clone())
2968 .unwrap_or_default();
2969 let pparams_snap = self.pparams();
2970 let opts_snap = crate::ported::options::opt_state_snapshot();
2971 let traps_snap = crate::ported::builtin::traps_table()
2972 .lock()
2973 .map(|t| t.clone())
2974 .unwrap_or_default();
2975 // c:Src/exec.c:4783 — function definitions / unfunction
2976 // inside `$(...)` must also be isolated from the parent.
2977 // C zsh's getoutput() forks, so the child's shfunctab
2978 // mutations die with the child. zshrs's in-process
2979 // cmd-subst needs to snapshot/restore the function
2980 // tables manually alongside the param/opts/trap snaps
2981 // already in this block. Bug #455.
2982 let shfunctab_snap = crate::ported::hashtable::shfunctab_lock()
2983 .read()
2984 .ok()
2985 .map(|t| t.snapshot())
2986 .unwrap_or_default();
2987 let functions_compiled_snap = self.functions_compiled.clone();
2988 let function_source_snap = self.function_source.clone();
2989 let mut vm = fusevm::VM::new(chunk);
2990 register_builtins(&mut vm);
2991 vm.set_shell_host(Box::new(ZshrsHost));
2992 // Seed inner $? with the outer's last_status so the
2993 // sub-shell inherits the parent's exit code. Direct
2994 // port of Src/exec.c:4783 around execcmd_exec — the
2995 // child inherits `lastval` at fork time, so `false;
2996 // echo $(echo $?)` reads 1, not the freshly-zeroed
2997 // sub-VM default. Without this, every cmd-subst
2998 // started with $?==0 regardless of the parent's
2999 // last command.
3000 vm.last_status = self.last_status();
3001 // `exit N` inside a cmd-subst should terminate ONLY
3002 // the sub-shell (C zsh: cmd-subst forks, the child
3003 // `_exit(N)`s; status reaches the parent as
3004 // cmd-subst exit). zshrs runs in-process, so we
3005 // route through the SUBSHELL_DEPTH-gated deferred
3006 // path inside zexit (builtin.rs:7713): bump
3007 // SUBSHELL_DEPTH so `exit` sets EXIT_PENDING/
3008 // EXIT_VAL instead of calling realexit (which would
3009 // process::exit and kill the parent shell). After
3010 // the sub-VM returns, harvest EXIT_PENDING/EXIT_VAL
3011 // as the cmd-subst's status, then restore the
3012 // parent's flags so the outer VM continues normally.
3013 use crate::ported::builtin::{
3014 BREAKS, EXIT_PENDING, EXIT_VAL, RETFLAG, SHELL_EXITING, SUBSHELL_DEPTH,
3015 };
3016 use std::sync::atomic::Ordering::Relaxed;
3017 let saved_exit_pending = EXIT_PENDING.swap(0, Relaxed);
3018 let saved_exit_val = EXIT_VAL.swap(0, Relaxed);
3019 let saved_shell_exiting = SHELL_EXITING.swap(0, Relaxed);
3020 let saved_retflag = RETFLAG.swap(0, Relaxed);
3021 let saved_breaks = BREAKS.swap(0, Relaxed);
3022 SUBSHELL_DEPTH.fetch_add(1, Relaxed);
3023 let _ctx = ExecutorContext::enter(self);
3024 let _ = vm.run();
3025 let inner_exit_pending = EXIT_PENDING.load(Relaxed);
3026 let inner_exit_val = EXIT_VAL.load(Relaxed);
3027 let inner_status = if inner_exit_pending != 0 {
3028 inner_exit_val & 0xFF
3029 } else {
3030 vm.last_status
3031 };
3032 cmd_status = Some(inner_status);
3033 SUBSHELL_DEPTH.fetch_sub(1, Relaxed);
3034 // c:Src/exec.c — `$(…)` is a FORK in C: an errflag
3035 // abort inside the child ends the child (its lastval
3036 // becomes the cmd-subst status) and the flag dies
3037 // with the child process — the parent's lists keep
3038 // running. zsh 5.9: `v=$(typeset -A q; q=(odd));
3039 // echo "after $?"` prints `after 1`. Mirror the fork
3040 // isolation by clearing ERRFLAG_ERROR at the
3041 // cmd-subst boundary.
3042 //
3043 // ERRFLAG_HARD dies here too: `${u:?msg}` inside the
3044 // child sets it (c:Src/subst.c:3344) then `_exit(1)`s
3045 // (c:3353) — C's parent never sees the bit. A leaked
3046 // HARD bit makes every later zerr() silent
3047 // (c:Src/utils.c:175-177) and silently fails every
3048 // later parse. Same fix as subshell_end.
3049 errflag.fetch_and(
3050 !(ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
3051 Relaxed,
3052 );
3053 // c:Src/exec.c:4783 execcmdoutsubst — `$(...)` is a
3054 // subshell, and zsh fires the EXIT trap when the
3055 // subshell ends BUT only if the trap was installed
3056 // INSIDE the subshell. An EXIT trap inherited from
3057 // the parent fires when the parent shell exits, not
3058 // again at cmdsub end. Detect "installed inside" by
3059 // comparing the current traps_table["EXIT"] entry
3060 // against the pre-cmdsub snapshot — fire only when
3061 // the body differs (newly set, removed, or replaced).
3062 // Pop the body before execute_script to avoid the
3063 // re-fire inside execute_script_zsh_pipeline's own
3064 // EXIT-handler tail at vm_helper.rs:1490. Bug #354.
3065 let snap_exit = traps_snap.get("EXIT").cloned();
3066 let live_exit = crate::ported::builtin::traps_table()
3067 .lock()
3068 .ok()
3069 .and_then(|t| t.get("EXIT").cloned());
3070 if live_exit != snap_exit {
3071 if let Some(body) = live_exit {
3072 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
3073 t.remove("EXIT");
3074 }
3075 let _ = crate::ported::exec::execute_script(&body);
3076 }
3077 }
3078 // c:Src/signals.c::dotrap(SIGEXIT) — also fire the
3079 // TRAPEXIT() function-named form (ZSIG_FUNC) — but
3080 // only if it was defined INSIDE the subshell (the
3081 // parent's TRAPEXIT fires at parent exit, not here).
3082 // ZSIG_FUNC bit on sigtrapped[SIGEXIT] tells us
3083 // whether a TRAPEXIT function is registered; check
3084 // BEFORE the snapshot restore.
3085 // Skip for now — function-form detection mirrors the
3086 // raw-body check above; deferred until a clean
3087 // sigtrapped snapshot/restore pair exists.
3088 // Restore parent's exit / loop / function-return
3089 // state so the outer VM continues normally.
3090 EXIT_PENDING.store(saved_exit_pending, Relaxed);
3091 EXIT_VAL.store(saved_exit_val, Relaxed);
3092 SHELL_EXITING.store(saved_shell_exiting, Relaxed);
3093 RETFLAG.store(saved_retflag, Relaxed);
3094 BREAKS.store(saved_breaks, Relaxed);
3095 // Restore parent state. The inner cmd-subst's stdout
3096 // (the captured pipe contents) is the only thing
3097 // that leaks out.
3098 if let Ok(mut t) = crate::ported::params::paramtab().write() {
3099 *t = paramtab_snap;
3100 }
3101 if let Ok(mut m) = crate::ported::params::paramtab_hashed_storage().lock() {
3102 *m = paramtab_hashed_snap;
3103 }
3104 self.set_pparams(pparams_snap);
3105 crate::ported::options::opt_state_restore(opts_snap);
3106 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
3107 *t = traps_snap;
3108 }
3109 // Restore function tables (parallel to the trap/param
3110 // restore above). Bug #455.
3111 if let Ok(mut t) = crate::ported::hashtable::shfunctab_lock().write() {
3112 t.restore(shfunctab_snap);
3113 }
3114 self.functions_compiled = functions_compiled_snap;
3115 self.function_source = function_source_snap;
3116 }
3117 }
3118 // Restore LINENO so outer xtrace sees the outer line. LINENO
3119 // carries PM_READONLY (matching zsh's `integer-readonly-special`
3120 // GSU), so the restore must bypass the generic readonly guard
3121 // exactly like BUILTIN_SET_LINENO (fusevm_bridge.rs:5156) — write
3122 // the param's `u_val` directly and mirror the file-static /
3123 // lexer line counters. The previous `set_scalar` went through the
3124 // readonly-checked path: harmless on the `-c` route (LINENO not
3125 // yet flagged readonly there) but fatal on the faithful
3126 // loop()/zsh_main route, where every `$(...)` in piped/redirected
3127 // input died with `read-only variable: LINENO`.
3128 if let Some(ln) = saved_lineno {
3129 let n: crate::ported::zsh_h::zlong = ln.parse().unwrap_or(0);
3130 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
3131 if let Some(pm) = tab.get_mut("LINENO") {
3132 pm.u_val = n;
3133 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
3134 }
3135 }
3136 crate::ported::utils::set_lineno(n as i32);
3137 crate::ported::lex::set_lineno(n as u64);
3138 }
3139 cmdpop();
3140 // Propagate the inner cmd's status to the parent shell. zsh:
3141 // `a=$(false); echo $?` → 1 because cmd-subst status leaks to
3142 // $?. Set last_status on the executor so $? reads the right
3143 // value for callers that don't have a SetStatus(0) overwrite
3144 // (echo, test, etc.). Bare assignment paths still get the
3145 // SetStatus(0) from compile_simple — that's a separate gap.
3146 // Empty cmd-subst (`\`\``, `$()`) resets status to 0 per
3147 // Src/exec.c — the inner ran no command so the "last
3148 // command's exit" is the implicit success of "did nothing".
3149 // Without this branch, a prior command's non-zero status
3150 // leaked through the empty cmd-subst.
3151 let final_status = cmd_status.unwrap_or(0);
3152 self.set_last_status(final_status);
3153 // c:Src/exec.c:4775 — `getoutput` (the C cmd-subst path used by
3154 // both `$(…)` and `` `…` ``) propagates the inner exit through
3155 // `cmdoutval`, then the caller does `LASTVAL = cmdoutval`. Mirror
3156 // by writing the cmd-subst's exit into the ported `cmdoutval`
3157 // global so `getoutput()`'s post-call `LASTVAL = cmdoutval` (at
3158 // exec.rs:559-562) and the C-equivalent `cmdoutval = lastval`
3159 // bookkeeping in execcmd_exec's assignment paths both see the
3160 // real exit. Without this, backtick assignments (`a=\`false\`;
3161 // echo $?`) reported 0 because getoutput's caller path read a
3162 // cmdoutval that was never updated by the in-process hook.
3163 crate::ported::exec::cmdoutval.store(final_status, std::sync::atomic::Ordering::Relaxed);
3164
3165 // Flush any buffered Rust-side stdout so it reaches the pipe
3166 // before we restore.
3167 let _ = io::stdout().flush();
3168
3169 // Pop the trap-routing stack BEFORE restoring stdout so any
3170 // trap that fires during the restore goes to the cmdsub's
3171 // pipe (matching what zsh's forked cmdsub would do — the
3172 // child's fd 1 is the pipe right up until the child exits).
3173 crate::fusevm_bridge::CMDSUBST_OUTER_FDS.with(|s| {
3174 s.borrow_mut().pop();
3175 });
3176 // c:Bug #353 — restore fd 2 from the saved outer stderr. A
3177 // body that ran `exec 2>&1` (no command, just redirects)
3178 // would have committed fd 2 → the cmdsub's pipe write end.
3179 // In zsh's forked cmdsub the committed redirect dies with
3180 // the child; zshrs's in-process cmdsub would leak the dup
3181 // back to the parent and keep the pipe write-end alive,
3182 // blocking the parent's read on the read_end forever.
3183 // Always restoring fd 2 here rolls back any commit so the
3184 // pipe write-end count drops to zero when we drop the
3185 // local write_fd reference (which already happened above).
3186 if saved_stderr_for_trap >= 0 {
3187 unsafe {
3188 libc::dup2(saved_stderr_for_trap, libc::STDERR_FILENO);
3189 }
3190 crate::ported::utils::zclose(saved_stderr_for_trap);
3191 }
3192 // Restore stdout and read what was captured.
3193 unsafe {
3194 libc::dup2(saved_stdout, libc::STDOUT_FILENO);
3195 }
3196 crate::ported::utils::zclose(saved_stdout);
3197 let mut output = String::new();
3198 {
3199 // Borrow the fd for reading without letting File's Drop
3200 // close it — the close must go through zclose so the
3201 // FDT_INTERNAL mark from mpipe is cleared (c:Src/utils.c:2137).
3202 let mut read_file = unsafe { File::from_raw_fd(read_fd) };
3203 let _ = io::BufReader::new(&mut read_file).read_to_string(&mut output);
3204 use std::os::unix::io::IntoRawFd;
3205 let _ = read_file.into_raw_fd();
3206 }
3207 crate::ported::utils::zclose(read_fd);
3208
3209 // POSIX: trailing newlines stripped from cmd-sub result.
3210 while output.ends_with('\n') {
3211 output.pop();
3212 }
3213 output
3214 }
3215}
3216
3217#[cfg(test)]
3218mod tests {
3219 use super::*;
3220
3221 #[test]
3222 fn test_simple_echo() {
3223 let _g = crate::test_util::global_state_lock();
3224 let mut exec = ShellExecutor::new();
3225 let status = exec.execute_script("true").unwrap();
3226 assert_eq!(status, 0);
3227 }
3228
3229 #[test]
3230 fn test_if_true() {
3231 let _g = crate::test_util::global_state_lock();
3232 let mut exec = ShellExecutor::new();
3233 let status = exec.execute_script("if true; then true; fi").unwrap();
3234 assert_eq!(status, 0);
3235 }
3236
3237 #[test]
3238 fn test_if_false() {
3239 let _g = crate::test_util::global_state_lock();
3240 let mut exec = ShellExecutor::new();
3241 let status = exec
3242 .execute_script("if false; then true; else false; fi")
3243 .unwrap();
3244 assert_eq!(status, 1);
3245 }
3246
3247 #[test]
3248 fn test_for_loop() {
3249 let _g = crate::test_util::global_state_lock();
3250 let mut exec = ShellExecutor::new();
3251 exec.execute_script("for i in a b c; do true; done")
3252 .unwrap();
3253 assert_eq!(exec.last_status(), 0);
3254 }
3255
3256 #[test]
3257 fn test_and_list() {
3258 let _g = crate::test_util::global_state_lock();
3259 let mut exec = ShellExecutor::new();
3260 let status = exec.execute_script("true && true").unwrap();
3261 assert_eq!(status, 0);
3262
3263 let status = exec.execute_script("true && false").unwrap();
3264 assert_eq!(status, 1);
3265 }
3266
3267 #[test]
3268 fn test_or_list() {
3269 let _g = crate::test_util::global_state_lock();
3270 let mut exec = ShellExecutor::new();
3271 let status = exec.execute_script("false || true").unwrap();
3272 assert_eq!(status, 0);
3273 }
3274
3275 /// Pin: `forklevel` matches the C global declared at
3276 /// `Src/exec.c:1052` (`int forklevel;`). Like `int` in C, the
3277 /// Rust port is an AtomicI32 starting at 0 (no fork has occurred
3278 /// at process start). Per `Src/exec.c:1221` (`forklevel =
3279 /// locallevel;`), every subshell entry copies `locallevel` into
3280 /// the global; the SIGPIPE handler at `Src/signals.c:808` reads
3281 /// it back to distinguish the top-level shell from a subshell.
3282 #[test]
3283 fn test_forklevel_default_zero_and_roundtrip() {
3284 let _g = crate::test_util::global_state_lock();
3285 use std::sync::atomic::Ordering;
3286 let prev = crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed);
3287 // Default state at process start: zero (matches C's BSS init
3288 // of `int forklevel;` to 0).
3289 crate::ported::exec::FORKLEVEL.store(0, Ordering::Relaxed);
3290 assert_eq!(crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed), 0);
3291 // Simulate the c:1221 store: `forklevel = locallevel;`.
3292 crate::ported::exec::FORKLEVEL.store(3, Ordering::Relaxed);
3293 assert_eq!(crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed), 3);
3294 crate::ported::exec::FORKLEVEL.store(prev, Ordering::Relaxed);
3295 }
3296}
3297
3298// Plugin-Framework-Agnostic State-Modification Recorder hook helpers.
3299/// Recorder helper: emit one record for an array/scalar mutation
3300/// targeting a path-family parameter (path/fpath/manpath/module_path/
3301/// cdpath, lower- or upper-cased), or one `assign` record for any
3302/// other name. Centralises the path-family list so `BUILTIN_SET_ARRAY`,
3303/// `BUILTIN_APPEND_ARRAY`, and `BUILTIN_APPEND_SCALAR_OR_PUSH` share
3304/// the same routing.
3305///
3306/// `is_append` distinguishes `arr=(...)` from `arr+=(...)` so the
3307/// emitted event carries the APPEND attr bit and replay can choose
3308/// between fresh-set and extend semantics.
3309///
3310/// `attrs` carries any pre-existing type info from
3311/// `recorder_attrs_for(name)` (readonly/export/global) — array shape
3312/// and APPEND get OR'd in by emit_array_assign.
3313#[cfg(feature = "recorder")]
3314pub(crate) fn emit_path_or_assign(
3315 name: &str,
3316 values: &[String],
3317 attrs: crate::recorder::ParamAttrs,
3318 is_append: bool,
3319 ctx: &crate::recorder::RecordCtx,
3320) {
3321 let lower = name.to_ascii_lowercase();
3322 let kind_name: Option<&'static str> = match lower.as_str() {
3323 "path" => Some("path"),
3324 "fpath" => Some("fpath"),
3325 "manpath" => Some("manpath"),
3326 "module_path" => Some("module_path"),
3327 "cdpath" => Some("cdpath"),
3328 _ => None,
3329 };
3330 match kind_name {
3331 Some(k) => {
3332 for v in values {
3333 crate::recorder::emit_path_mod(v, k, ctx.clone());
3334 // Each fpath addition also surfaces every `_completion`
3335 // file inside the directory — matches zinit-report's
3336 // per-plugin "Completions:" listing. Only fpath dirs
3337 // get this treatment; PATH dirs hold executables, not
3338 // completion functions.
3339 if k == "fpath" {
3340 crate::recorder::discover_completions_in_fpath_dir(v, ctx);
3341 }
3342 }
3343 }
3344 None => {
3345 // Non-path arrays: emit ONE `assign` event with the
3346 // ordered element list preserved in value_array. Replay
3347 // reconstructs `name=(elem1 elem2 ...)` exactly without
3348 // having to re-split a joined string.
3349 crate::recorder::emit_array_assign(
3350 name,
3351 values.to_vec(),
3352 attrs,
3353 is_append,
3354 ctx.clone(),
3355 );
3356 }
3357 }
3358}
3359
3360use std::os::unix::fs::MetadataExt;
3361
3362bitflags::bitflags! {
3363 /// Flags for zfork()
3364 #[derive(Debug, Clone, Copy, Default)]
3365 pub struct ForkFlags: u32 {
3366 const NOJOB = 1 << 0; // Don't add to job table
3367 const NEWGRP = 1 << 1; // Create new process group
3368 const FGTTY = 1 << 2; // Take foreground terminal
3369 const KEEPSIGS = 1 << 3; // Keep signal handlers
3370 }
3371}
3372
3373bitflags::bitflags! {
3374 /// Flags for entersubsh()
3375 #[derive(Debug, Clone, Copy, Default)]
3376 pub struct SubshellFlags: u32 {
3377 const NOMONITOR = 1 << 0; // Disable job control
3378 const KEEPFDS = 1 << 1; // Keep file descriptors
3379 const KEEPTRAPS = 1 << 2; // Keep trap handlers
3380 }
3381}
3382
3383/// Result of fork operation
3384#[derive(Debug)]
3385/// `fork()` outcome (parent / child / error).
3386/// Mirrors the integer return of `zfork()` from Src/exec.c:349.
3387pub enum ForkResult {
3388 /// `Parent` variant.
3389 Parent(i32), // Contains child PID
3390 /// `Child` variant.
3391 Child,
3392}
3393
3394/// Redirection mode
3395#[derive(Debug, Clone, Copy)]
3396/// File-redirection mode (`>` / `>>` / `<` / etc.).
3397/// Mirrors the `REDIR_*` enum from Src/zsh.h.
3398pub enum RedirMode {
3399 /// `Dup` variant.
3400 Dup,
3401 /// `Close` variant.
3402 Close,
3403}
3404
3405/// Builtin command type
3406#[derive(Debug, Clone, Copy)]
3407/// Builtin classification.
3408/// Mirrors the `BINF_*` flag set Src/builtin.c uses to
3409/// classify special vs regular builtins.
3410pub enum BuiltinType {
3411 /// `Normal` variant.
3412 Normal,
3413 /// `Disabled` variant.
3414 Disabled,
3415}
3416
3417use crate::fusevm_bridge::with_executor;
3418use crate::ported::glob::*;
3419use crate::ported::hist::*;
3420use crate::ported::jobs::*;
3421use crate::ported::math::*;
3422use crate::ported::module::*;
3423use crate::ported::modules::cap::*;
3424use crate::ported::modules::terminfo::*;
3425use crate::ported::options::*;
3426use crate::ported::params::*;
3427use crate::ported::pattern::*;
3428use crate::ported::prompt::*;
3429use crate::ported::signals::*;
3430use crate::ported::subst::*;
3431use crate::ported::utils::{zerr, zerrnam, zwarn, zwarnnam};
3432use ::regex::{Error as RegexError, Regex, RegexBuilder};
3433
3434
3435
3436pub use crate::ported::modules::regex::posix_ere_bracket_escape;
3437
3438
3439impl ShellExecutor {
3440 /// Every option name in `ZSH_OPTIONS_SET` (port of `optns[]` at
3441 /// `Src/options.c:79+`).
3442 pub(crate) fn all_zsh_options() -> Vec<&'static str> {
3443 ZSH_OPTIONS_SET.iter().copied().collect()
3444 }
3445
3446 /// `name → default-on` map via canonical `default_on_options`
3447 /// (port of `defset()` macro at `Src/options.c:73`).
3448 pub(crate) fn default_options() -> HashMap<String, bool> {
3449 let on = default_on_options();
3450 Self::all_zsh_options()
3451 .into_iter()
3452 .map(|n| (n.to_string(), on.contains(n)))
3453 .collect()
3454 }
3455}
3456impl ShellExecutor {
3457 /// PURE PASSTHRU to the canonical `params::getsparam` (C port of
3458 /// `Src/params.c::getsparam`). Every special-name case the old
3459 /// 316-line body handled lives in `params::lookup_special_var` +
3460 /// `getsparam`'s paramtab/env walk. Returns an empty string for
3461 /// unset names (matching the old fn's signature; callers that
3462 /// need the set/unset distinction call `scalar` / `has_scalar`
3463 /// directly).
3464 pub(crate) fn get_variable(&self, name: &str) -> String {
3465 getsparam(name).unwrap_or_default()
3466 }
3467}
3468
3469// Source-form registration step used by the autoload-load path
3470// (`dispatch_function_call` / `run_function_body_only`). Decides
3471// whether to feed the file body to the funcdef pipeline VERBATIM
3472// (zsh-style: the body's own `function NAME() {...}` definition
3473// registers the function on execution) or to WRAP it in
3474// `NAME() {...}` (ksh-style or multi-statement: the body is just
3475// commands or includes additional statements past the def).
3476//
3477// The classification mirrors c:Src/exec.c:5725 + 5750 — KSHAUTOLOAD-
3478// equivalent vs zsh-style autoload. The structural check is the
3479// canonical `stripkshdef` (Src/exec.c:6291, ported at exec.rs:10548):
3480// parse the body to Eprog, run stripkshdef, and check whether it
3481// returned a stripped (different-length wordcode) Eprog. When it
3482// did, the file is the single-funcdef shape `[function] NAME [()] {
3483// INNER }`; running the file source directly through the funcdef
3484// pipeline registers NAME via the WC_FUNCDEF opcode at
3485// fusevm_bridge.rs:6330, matching C's `shf->funcdef = stripkshdef(
3486// prog, name)` semantics (the inner body becomes the function's
3487// body). When it didn't strip — single statement that isn't a
3488// funcdef, or multiple list nodes (e.g. `function ztm() {...}` +
3489// trailing `ztm "$@"` self-call) — we fall back to wrap-and-run so
3490// the canonical funcdef opcode still fires and any extra
3491// statements run inside the registered body, matching C's
3492// behavior of using the whole prog as funcdef in that case.
3493fn autoload_register_source(name: &str, body: &str) -> String {
3494 // c:Src/exec.c:5725 — `if (ksh == 2 || (ksh == 1 && isset(KSHAUTOLOAD)))`
3495 // — the ksh-style load branch. ksh derives from the stub's
3496 // stored-style bits per c:5708-5709 (`PM_KSHSTORED ? 2 :
3497 // PM_ZSHSTORED ? 0 : 1`; a decisive `.zwc` header flag was
3498 // already folded into these bits by loadautofn). A ksh-style
3499 // load executes the file contents at top level (c:5740-5746
3500 // `execode(prog, 1, 0, "evalautofunc")`) and expects the file
3501 // itself to define the function — so the body goes through the
3502 // pipeline VERBATIM, never wrapped.
3503 let flags = crate::ported::utils::getshfunc(name)
3504 .map(|f| f.node.flags as u32)
3505 .unwrap_or(0);
3506 let ksh = if flags & crate::ported::zsh_h::PM_KSHSTORED != 0 {
3507 2
3508 } else if flags & crate::ported::zsh_h::PM_ZSHSTORED != 0 {
3509 0
3510 } else {
3511 1
3512 };
3513 if ksh == 2 || (ksh == 1 && crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHAUTOLOAD)) {
3514 return body.to_string(); // c:5746 execode(prog, ..., "evalautofunc")
3515 }
3516 let stripped = crate::ported::exec::parse_string(body, 0)
3517 .map(|prog| {
3518 let original_len = prog.prog.len();
3519 // stripkshdef returns the input untouched when the prog
3520 // doesn't match the single-`function NAME` shape, and a
3521 // shorter (body-only) prog when it does. Compare the
3522 // wordcode length to detect the strip without owning the
3523 // post-strip Eprog (we only need the yes/no answer here).
3524 let prog_box = Box::new(prog);
3525 crate::ported::exec::stripkshdef(Some(prog_box), name)
3526 .map(|p| p.prog.len() != original_len)
3527 .unwrap_or(false)
3528 })
3529 .unwrap_or(false);
3530 if stripped {
3531 body.to_string()
3532 } else {
3533 format!("{name}() {{\n{body}\n}}")
3534 }
3535}
3536
3537// zsh_eval_context push/pop/sync relocated 2026-06-12 INTO doshfunc
3538// (src/ported/exec.rs) — its sole caller, and `zsh_eval_context` is
3539// that module's own static. The shell-visible mirror writes inline
3540// at the push site + the guard's Drop. No bridge indirection.
3541
3542impl ShellExecutor {
3543 /// Execute the trap body for a signal name from the REPL signal
3544 /// loop (bins/zshrs.rs CtrlC/CtrlD dispatch). Thin passthru to
3545 /// `traps_table` lookup + `execute_script` — kept as a method
3546 /// because the REPL loop owns `&mut ShellExecutor` and needs a
3547 /// single call point. The async signal-handler dispatch path
3548 /// goes through `crate::ported::signals::dotrap` instead.
3549 pub fn run_trap(&mut self, signal: &str) {
3550 let action = crate::ported::builtin::traps_table()
3551 .lock()
3552 .ok()
3553 .and_then(|t| t.get(signal).cloned());
3554 if let Some(body) = action {
3555 if !body.is_empty() {
3556 let _ = self.execute_script(&body);
3557 }
3558 }
3559 }
3560}
3561
3562impl ShellExecutor {
3563 pub(crate) fn apply_prompt_theme(&mut self, theme: &str, preview: bool) {
3564 let (ps1, rps1) = match theme {
3565 "minimal" => ("%# ", ""),
3566 "off" => ("$ ", ""),
3567 "adam1" => (
3568 "%B%F{cyan}%n@%m %F{blue}%~%f%b %# ",
3569 "%F{yellow}%D{%H:%M}%f",
3570 ),
3571 "redhat" => ("[%n@%m %~]$ ", ""),
3572 _ => ("%n@%m %~ %# ", ""),
3573 };
3574 if preview {
3575 println!("PS1={:?}", ps1);
3576 println!("RPS1={:?}", rps1);
3577 } else {
3578 self.set_scalar("PS1".to_string(), ps1.to_string());
3579 self.set_scalar("RPS1".to_string(), rps1.to_string());
3580 self.set_scalar("prompt_theme".to_string(), theme.to_string());
3581 }
3582 }
3583}
3584impl ShellExecutor {
3585 /// Expand glob pattern via canonical `glob_path` (port of
3586 /// `Src/glob.c::zglob`). Adds executor-side `current_command_glob_failed`
3587 /// cell so the dispatch layer skips the current command on NOMATCH +
3588 /// looks_like_glob instead of exiting the shell.
3589 pub fn expand_glob(&self, pattern: &str) -> Vec<String> {
3590 let expanded = glob_path(pattern);
3591 if !expanded.is_empty() {
3592 // c:Src/glob.c:1871-1872 — `if (matchct) badcshglob |= 2;`
3593 // (at least one expansion on this command line worked).
3594 // Only real glob patterns count — C's zglob early-returns
3595 // before the matchct accounting for non-wild words, so
3596 // gate on haswilds like the failure path below. Consumed
3597 // per command by fusevm_bridge::consume_badcshglob.
3598 if crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLGLOB) {
3599 let mut pattern_tok = pattern.to_string();
3600 crate::ported::glob::tokenize(&mut pattern_tok);
3601 if crate::ported::pattern::haswilds(&pattern_tok) {
3602 crate::ported::glob::BADCSHGLOB
3603 .fetch_or(2, std::sync::atomic::Ordering::Relaxed);
3604 }
3605 }
3606 return expanded;
3607 }
3608 // No matches. Mirror zsh's `setopt nullglob` / `nomatch`
3609 // dispatch (Src/glob.c:1873-1886) here because glob_path
3610 // returns an empty Vec without knowing executor state.
3611 // c:Src/glob.c:1567-1569 `gf_nullglob` per-glob — the `(N)`
3612 // qualifier acts like `setopt nullglob` for this expression
3613 // alone. parse_qualifiers detects the suffix `(...)` block;
3614 // the resulting `qualifiers.nullglob` mirrors C's gf_nullglob
3615 // carrier.
3616 let per_glob_nullglob = crate::ported::glob::parse_qualifiers(pattern)
3617 .1
3618 .map(|q| q.nullglob)
3619 .unwrap_or(false);
3620 let nullglob = opt_state_get("nullglob").unwrap_or(false) || per_glob_nullglob;
3621 if nullglob {
3622 return Vec::new();
3623 }
3624 let nomatch = opt_state_get("nomatch").unwrap_or(true);
3625 // Use canonical `haswilds` (port of Src/pattern.c:4306-4376)
3626 // instead of the Rust-only `looks_like_glob`. C zsh's
3627 // `Src/glob.c:1876` NOMATCH branch fires whenever the input
3628 // tripped haswilds during the `zglob` entry check —
3629 // including patterns whose internal `(` / `)` form a group
3630 // or alternation but don't end with `)` (e.g. `abc(a)def`,
3631 // `(abc`). The previous `looks_like_glob` only caught
3632 // trailing-`(...)` qualifiers, leaving mid-word groups and
3633 // unclosed parens to fall through to the literal-passthrough
3634 // branch. #170 in docs/BUGS.md.
3635 //
3636 // haswilds scans TOKENIZED strings (C's zglob gets the
3637 // lexer-tokenized word at Src/glob.c:1230); this entry point
3638 // receives untokenized fast-path patterns, so tokenize a
3639 // local copy first — the same preparation C applies to
3640 // runtime-built strings (compcore.c:2231 tokenizes fignore
3641 // entries before its haswilds call). tokenize Bnull's
3642 // backslash-escaped metachars, so `\*` stays literal here
3643 // exactly as in C. Bug #627: plain multibyte text (`↔`)
3644 // passes through tokenize unchanged and matches no token.
3645 let mut pattern_tok = pattern.to_string();
3646 crate::ported::glob::tokenize(&mut pattern_tok); // c:Src/glob.c:3548
3647 let is_glob = crate::ported::pattern::haswilds(&pattern_tok);
3648 // c:Src/glob.c:1874-1875 — `if (isset(CSHNULLGLOB)) {
3649 // badcshglob |= 1; }` — the else-if chain means neither the
3650 // NOMATCH error nor the literal passthrough runs: the failed
3651 // word is silently DROPPED here, and the per-command boundary
3652 // (fusevm_bridge::consume_badcshglob, Src/subst.c:505-507)
3653 // emits the csh-style `no match` iff NO glob on the line
3654 // matched.
3655 if is_glob && crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLGLOB) {
3656 crate::ported::glob::BADCSHGLOB.fetch_or(1, std::sync::atomic::Ordering::Relaxed);
3657 return Vec::new();
3658 }
3659 if nomatch && is_glob {
3660 // c:Src/glob.c:1876-1880 — `else if (isset(NOMATCH)) {`
3661 // `zerr("no matches found: %s", ostr);`
3662 // `zfree(matchbuf, 0);`
3663 // `restore_globstate(saved);`
3664 // `return;`
3665 // `}`
3666 // C aborts via ERRFLAG_ERROR set by zerr() at c:Src/utils.c
3667 // and the matchbuf/state cleanup. The Rust port mirrors
3668 // both: zerr() in utils.rs sets ERRFLAG_ERROR via
3669 // `errflag.fetch_or(ERRFLAG_ERROR, ...)` already; we then
3670 // re-set explicitly (defensive — historically this line
3671 // had `fetch_and(!ERRFLAG_ERROR)` which CLEARED the flag
3672 // immediately after zerr, making `echo /never/*` print
3673 // the literal and exit 0 instead of erroring like zsh —
3674 // parity bug #13).
3675 zerr(&format!("no matches found: {}", pattern)); // c:1877
3676 self.current_command_glob_failed.set(true);
3677 // c:Src/glob.c:1876-1880 — zerr sets ERRFLAG_ERROR and
3678 // glob_failed cell carries the signal. The ERRFLAG_ERROR
3679 // clear (so subsequent sublists run) now lives at the
3680 // dispatcher's post-command-boundary at
3681 // fusevm_bridge.rs:299 where current_command_glob_failed
3682 // is consumed — matches C's execlist behavior of clearing
3683 // command-error errflag between sublists.
3684 return Vec::new(); // c:1880 return
3685 }
3686 // Pattern has no glob meta — pass through literally.
3687 vec![pattern.to_string()]
3688 }
3689 /// True iff the literal `pattern` actually contains a glob metachar
3690 /// in a position that would have triggered globbing. Used to avoid
3691 /// spurious "no matches" errors when expand_glob is called on a
3692 /// plain path that happened to route through this code (e.g. some
3693 /// fast paths bridge unconditionally).
3694 pub(crate) fn looks_like_glob(pattern: &str) -> bool {
3695 // A trailing `(qualifier)` is itself a glob trigger — e.g.
3696 // `path(L+10)` should be treated as a glob even when the
3697 // body has no `*`/`?`/`[...]`.
3698 let has_qual_suffix = if let Some(open) = pattern.rfind('(') {
3699 pattern.ends_with(')') && open + 1 < pattern.len() - 1
3700 } else {
3701 false
3702 };
3703 // Strip trailing `(...)` qualifier so we test the pattern body.
3704 let body = if let Some(open) = pattern.rfind('(') {
3705 if pattern.ends_with(')') {
3706 &pattern[..open]
3707 } else {
3708 pattern
3709 }
3710 } else {
3711 pattern
3712 };
3713 // Walk character-by-character so escaped metachars (`\*`, `\?`,
3714 // `\[`) are NOT counted as glob triggers. zsh: `echo \*` prints
3715 // a literal `*`; without the unescaped check, looks_like_glob
3716 // returned true on the bare `*` and the runtime glob expansion
3717 // aborted with NOMATCH.
3718 let chars: Vec<char> = body.chars().collect();
3719 let mut i = 0;
3720 let mut has_unescaped_star = false;
3721 let mut has_unescaped_question = false;
3722 let mut has_unescaped_bracket_open: Option<usize> = None;
3723 while i < chars.len() {
3724 let c = chars[i];
3725 if c == '\\' && i + 1 < chars.len() {
3726 // Escaped char — skip both.
3727 i += 2;
3728 continue;
3729 }
3730 match c {
3731 '*' => has_unescaped_star = true,
3732 '?' => has_unescaped_question = true,
3733 '[' if has_unescaped_bracket_open.is_none() => {
3734 has_unescaped_bracket_open = Some(i);
3735 }
3736 _ => {}
3737 }
3738 i += 1;
3739 }
3740 // `[` only counts when there's a matching `]` after it.
3741 let has_bracket_class = has_unescaped_bracket_open
3742 .map(|i| body[i + 1..].contains(']'))
3743 .unwrap_or(false);
3744 // `<N-M>` numeric range glob is also a trigger — match shape
3745 // `<` + optional digits + `-` + optional digits + `>` outside
3746 // any bracket expression.
3747 let has_numeric_range =
3748 body.contains('<') && body.contains('>') && !extract_numeric_ranges(body).is_empty();
3749 has_unescaped_star
3750 || has_unescaped_question
3751 || has_bracket_class
3752 || has_qual_suffix
3753 || has_numeric_range
3754 }
3755}
3756
3757impl ShellExecutor {
3758 pub(crate) fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
3759 if !dest.exists() {
3760 fs::create_dir_all(dest)?;
3761 }
3762 for entry in fs::read_dir(src)? {
3763 let entry = entry?;
3764 let file_type = entry.file_type()?;
3765 let src_path = entry.path();
3766 let dest_path = dest.join(entry.file_name());
3767
3768 if file_type.is_dir() {
3769 Self::copy_dir_recursive(&src_path, &dest_path)?;
3770 } else {
3771 fs::copy(&src_path, &dest_path)?;
3772 }
3773 }
3774 Ok(())
3775 }
3776}
3777
3778// Magic-assoc scan-by-name aggregator. C's per-table getfn/scanfn
3779// pointers in paramdef[] (Src/Modules/parameter.c:825+) handle this
3780// indirectly via paramtab dispatch; this Rust-only helper exposes a
3781// single `partab_get` / `partab_scan_keys` entry that the bridge
3782// uses for name → keys lookup.
3783use std::cell::RefCell;
3784thread_local! {
3785 static SCAN_KEYS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
3786}
3787
3788/// Lookup helper for `${name[key]}` magic-assoc reads — dispatches
3789/// through canonical `PARTAB` (Src/Modules/parameter.c:2235 ports).
3790/// Returns `None` if name isn't a known magic-assoc.
3791pub fn partab_get(name: &str, key: &str) -> Option<String> {
3792 // c:Src/Modules/system.c:902,904 — `sysparams` and `errnos` are
3793 // bound by zsh/system's boot_/setup_ chain. Same for `mapfile`
3794 // from zsh/mapfile. Without explicit `zmodload`, these names
3795 // are unset in zsh; gate the PARTAB dispatch here so they
3796 // resolve via the empty-fallback path (matching ${sysparams[k]:-x}
3797 // taking the default). Bug #69 in docs/BUGS.md.
3798 if let Some(modname) = module_gated_partab_module(name) {
3799 if !crate::ported::module::MODULESTAB.lock().unwrap().is_loaded(modname) {
3800 return None;
3801 }
3802 }
3803 for entry in PARTAB.iter() {
3804 if entry.name == name {
3805 return (entry.getfn)(std::ptr::null_mut(), key).and_then(|p| p.u_str);
3806 }
3807 }
3808 None
3809}
3810
3811/// Returns the owning module name for partab entries that are
3812/// bound by an explicit zmodload — `sysparams`/`errnos` from
3813/// zsh/system, `mapfile` from zsh/mapfile. Other partab entries
3814/// (aliases/commands/functions/...) are part of zsh/main and
3815/// always available.
3816fn module_gated_partab_module(name: &str) -> Option<&'static str> {
3817 match name {
3818 "sysparams" | "errnos" => Some("zsh/system"),
3819 "mapfile" => Some("zsh/mapfile"),
3820 "langinfo" => Some("zsh/langinfo"),
3821 _ => None,
3822 }
3823}
3824
3825/// PM_ARRAY lookup for `${name}` / `${name[N]}` — walks
3826/// PARTAB_ARRAY and dispatches the whole-array getfn (Src/Modules/
3827/// parameter.c:2239-2291 ports). Returns `None` if name isn't a
3828/// known PM_ARRAY magic-assoc.
3829pub fn partab_array_get(name: &str) -> Option<Vec<String>> {
3830 // Bug #69 — gate module-bound PARTAB names on the owning
3831 // module's MOD_LINKED && !MOD_UNLOAD state.
3832 if let Some(modname) = module_gated_partab_module(name) {
3833 if !crate::ported::module::MODULESTAB.lock().unwrap().is_loaded(modname) {
3834 return None;
3835 }
3836 }
3837 for entry in PARTAB_ARRAY.iter() {
3838 if entry.name == name {
3839 return Some((entry.getfn)(std::ptr::null_mut()));
3840 }
3841 }
3842 None
3843}
3844
3845
3846
3847
3848
3849/// Scan helper for `${(k)name}` — enumerates keys via canonical
3850/// scanfn, collected into Vec via SCAN_KEYS thread-local.
3851pub fn partab_scan_keys(name: &str) -> Option<Vec<String>> {
3852 // Bug #69 — gate module-bound PARTAB names on the owning
3853 // module's MOD_LINKED && !MOD_UNLOAD state.
3854 if let Some(modname) = module_gated_partab_module(name) {
3855 if !crate::ported::module::MODULESTAB.lock().unwrap().is_loaded(modname) {
3856 return None;
3857 }
3858 }
3859 for entry in PARTAB.iter() {
3860 if entry.name == name {
3861 SCAN_KEYS.with(|k| k.borrow_mut().clear());
3862 fn cb(node: &crate::ported::zsh_h::HashNode, _flags: i32) {
3863 SCAN_KEYS.with(|k| k.borrow_mut().push(node.nam.clone()));
3864 }
3865 (entry.scanfn)(std::ptr::null_mut(), Some(cb), 0);
3866 return Some(SCAN_KEYS.with(|k| k.borrow().clone()));
3867 }
3868 }
3869 None
3870}
3871/// Populate paramtab with PM_SPECIAL placeholder Params for every
3872/// PARTAB / PARTAB_ARRAY entry — Rust-only init helper, no direct
3873/// C counterpart (closest is `handlefeatures` walking `partab[]`
3874/// in `Src/Modules/parameter.c:2341` boot/enables chain).
3875///
3876/// Each magic-assoc name gets a Param with `entry.flags | PM_SPECIAL`.
3877/// Value reads still route through `partab_get` / `partab_array_get`;
3878/// having the Param in paramtab makes `paramtab.get(name)` return
3879/// Some(Param) so `${+name}` / `${(t)name}` / `typeset -p name` see
3880/// the entry. Without this, those reads returned empty for every
3881/// magic-assoc (aliases, commands, functions, etc.).
3882///
3883/// Called from ShellExecutor::new() since zshrs's bin entry skips
3884/// the canonical module-bootstrap chain.
3885pub fn init_partab_params() {
3886 use crate::ported::modules::parameter::{PARTAB, PARTAB_ARRAY};
3887 use crate::ported::zsh_h::{
3888 hashnode, param, Param, PM_HIDE, PM_HIDEVAL, PM_READONLY, PM_SPECIAL,
3889 };
3890 let mut tab = match paramtab().write() {
3891 Ok(t) => t,
3892 Err(_) => return,
3893 };
3894 // c:Src/zsh.h SPECIALPMDEF macro: `flags | PM_SPECIAL | PM_HIDE |
3895 // PM_HIDEVAL`. All magic-assoc/array params get HIDE+HIDEVAL added
3896 // by the macro itself.
3897 //
3898 // PM_READONLY is preserved on the stub for params that legitimately
3899 // need user-write protection (reswords, dis_reswords, patchars,
3900 // dis_patchars — all compute via getfn and have no legitimate
3901 // internal-write path). Other specials that DO have internal-write
3902 // paths (e.g. funcstack from function-call tracking) get the bit
3903 // stripped so the runtime can mutate their u_arr. Bug #374.
3904 let user_protected: &[&str] = &[
3905 "reswords",
3906 "dis_reswords",
3907 "patchars",
3908 "dis_patchars",
3909 "historywords",
3910 "errnos",
3911 "keymaps",
3912 ];
3913 let mk_pm = |name: &str, flags: i32| -> Param {
3914 let keep_readonly = user_protected.contains(&name);
3915 let pre_readonly_mask = if keep_readonly {
3916 !0i32
3917 } else {
3918 !(PM_READONLY as i32)
3919 };
3920 Box::new(param {
3921 node: hashnode {
3922 next: None,
3923 nam: name.to_string(),
3924 flags: (flags & pre_readonly_mask)
3925 | PM_SPECIAL as i32
3926 | PM_HIDE as i32
3927 | PM_HIDEVAL as i32,
3928 },
3929 u_data: 0,
3930 u_tied: None,
3931 u_arr: None,
3932 u_str: None,
3933 u_val: 0,
3934 u_dval: 0.0,
3935 u_hash: None,
3936 gsu_s: None,
3937 gsu_i: None,
3938 gsu_f: None,
3939 gsu_a: None,
3940 gsu_h: None,
3941 base: 0,
3942 width: 0,
3943 env: None,
3944 ename: None,
3945 old: None,
3946 level: 0,
3947 })
3948 };
3949 // c:Src/Modules/system.c:902,904 + Src/Modules/mapfile.c — these
3950 // params are provided by modules that real zsh requires explicit
3951 // `zmodload` for. Seeding them unconditionally makes
3952 // `${+sysparams}` return 1 by default (bug #69 in docs/BUGS.md),
3953 // diverging from zsh which returns 0 until the user runs
3954 // `zmodload zsh/system`. Skip here; `seed_partab_param` below adds
3955 // them on demand from the module's load path.
3956 let module_gated: &[&str] = &[
3957 "sysparams", // zsh/system
3958 "errnos", // zsh/system
3959 "mapfile", // zsh/mapfile
3960 "langinfo", // zsh/langinfo
3961 ];
3962 for entry in PARTAB.iter() {
3963 if module_gated.contains(&entry.name) {
3964 continue;
3965 }
3966 tab.insert(entry.name.to_string(), mk_pm(entry.name, entry.flags));
3967 }
3968 for entry in PARTAB_ARRAY.iter() {
3969 if module_gated.contains(&entry.name) {
3970 continue;
3971 }
3972 tab.insert(entry.name.to_string(), mk_pm(entry.name, entry.flags));
3973 }
3974}
3975
3976/// Insert a single PARTAB / PARTAB_ARRAY entry into paramtab. Called
3977/// from `zmodload <module>` once the module's boot completes, so that
3978/// `${+sysparams}` (etc.) flip from 0 → 1 only after explicit load.
3979/// No direct C counterpart — the C path runs through the module's
3980/// `setup_/boot_` chain which adds the SPECIALPMDEF entry via the
3981/// general hashtable machinery. Bug #69 in docs/BUGS.md.
3982pub fn seed_partab_param(name: &str) {
3983 use crate::ported::modules::parameter::{PARTAB, PARTAB_ARRAY};
3984 use crate::ported::zsh_h::{hashnode, param, PM_HIDE, PM_HIDEVAL, PM_READONLY, PM_SPECIAL};
3985 let mut tab = match crate::ported::params::paramtab().write() {
3986 Ok(t) => t,
3987 Err(_) => return,
3988 };
3989 if tab.contains_key(name) {
3990 return; // already seeded
3991 }
3992 let flags = PARTAB
3993 .iter()
3994 .find(|e| e.name == name)
3995 .map(|e| e.flags)
3996 .or_else(|| PARTAB_ARRAY.iter().find(|e| e.name == name).map(|e| e.flags));
3997 let Some(flags) = flags else {
3998 return;
3999 };
4000 let pm = Box::new(param {
4001 node: hashnode {
4002 next: None,
4003 nam: name.to_string(),
4004 flags: (flags & !(PM_READONLY as i32))
4005 | PM_SPECIAL as i32
4006 | PM_HIDE as i32
4007 | PM_HIDEVAL as i32,
4008 },
4009 u_data: 0,
4010 u_tied: None,
4011 u_arr: None,
4012 u_str: None,
4013 u_val: 0,
4014 u_dval: 0.0,
4015 u_hash: None,
4016 gsu_s: None,
4017 gsu_i: None,
4018 gsu_f: None,
4019 gsu_a: None,
4020 gsu_h: None,
4021 base: 0,
4022 width: 0,
4023 env: None,
4024 ename: None,
4025 old: None,
4026 level: 0,
4027 });
4028 tab.insert(name.to_string(), pm);
4029}
4030
4031/// Default autoloadable parameters: name → owning module. Port of the
4032/// `autofeatures` `p:` rows in Src/Modules/parameter.mdd, watch.mdd,
4033/// termcap.mdd, terminfo.mdd, Src/Zle/zleparameter.mdd and
4034/// Src/Builtins/sched.mdd, registered at startup through
4035/// `setautofeatures` → `add_autoparam` (Src/module.c:1198-1229): each
4036/// name becomes a scalar paramtab stub whose VALUE is the module name,
4037/// flagged PM_AUTOLOAD (module.c:1218-1219). Matches `zmodload -ap`
4038/// output of the reference zsh build.
4039pub const AUTOLOAD_PARAMS: &[(&str, &str)] = &[
4040 // Src/Modules/watch.mdd:5 autofeatures
4041 ("WATCH", "zsh/watch"),
4042 ("watch", "zsh/watch"),
4043 // Src/Modules/parameter.mdd:5 autofeatures
4044 ("aliases", "zsh/parameter"),
4045 ("builtins", "zsh/parameter"),
4046 ("commands", "zsh/parameter"),
4047 ("dirstack", "zsh/parameter"),
4048 ("dis_aliases", "zsh/parameter"),
4049 ("dis_builtins", "zsh/parameter"),
4050 ("dis_functions", "zsh/parameter"),
4051 ("dis_functions_source", "zsh/parameter"),
4052 ("dis_galiases", "zsh/parameter"),
4053 ("dis_patchars", "zsh/parameter"),
4054 ("dis_reswords", "zsh/parameter"),
4055 ("dis_saliases", "zsh/parameter"),
4056 ("funcfiletrace", "zsh/parameter"),
4057 ("funcsourcetrace", "zsh/parameter"),
4058 ("funcstack", "zsh/parameter"),
4059 ("functions", "zsh/parameter"),
4060 ("functions_source", "zsh/parameter"),
4061 ("functrace", "zsh/parameter"),
4062 ("galiases", "zsh/parameter"),
4063 ("history", "zsh/parameter"),
4064 ("historywords", "zsh/parameter"),
4065 ("jobdirs", "zsh/parameter"),
4066 ("jobstates", "zsh/parameter"),
4067 ("jobtexts", "zsh/parameter"),
4068 ("modules", "zsh/parameter"),
4069 ("nameddirs", "zsh/parameter"),
4070 ("options", "zsh/parameter"),
4071 ("parameters", "zsh/parameter"),
4072 ("patchars", "zsh/parameter"),
4073 ("reswords", "zsh/parameter"),
4074 ("saliases", "zsh/parameter"),
4075 ("userdirs", "zsh/parameter"),
4076 ("usergroups", "zsh/parameter"),
4077 // Src/Zle/zleparameter.mdd:5 autofeatures
4078 ("keymaps", "zsh/zleparameter"),
4079 ("widgets", "zsh/zleparameter"),
4080 // Src/Modules/termcap.mdd:5 / terminfo.mdd:5 autofeatures
4081 ("termcap", "zsh/termcap"),
4082 ("terminfo", "zsh/terminfo"),
4083 // Src/Builtins/sched.mdd:5 autofeatures
4084 ("zsh_scheduled_events", "zsh/sched"),
4085];
4086
4087/// Autoload stubs whose owning module is NOT loaded — the rows zsh's
4088/// `typeset` listings print as `undefined NAME` (printparamnode's
4089/// PM_AUTOLOAD pmtypes row, Src/params.c:6011 + the PM_AUTOLOAD
4090/// NAMEONLY arm at Src/params.c:6146-6155). Once a module loads, its
4091/// stubs drop out and the real params list instead.
4092pub fn autoload_param_stubs() -> Vec<(&'static str, &'static str)> {
4093 use crate::ported::zsh_h::{MOD_INIT_B, MOD_UNLOAD};
4094 let tab = crate::ported::module::MODULESTAB.lock().unwrap();
4095 AUTOLOAD_PARAMS
4096 .iter()
4097 .copied()
4098 .filter(|(_, m)| {
4099 // "Boot ran" is MOD_INIT_B && !MOD_UNLOAD (the criterion
4100 // printmodulenode uses, src/ported/module.rs:246 — C's
4101 // `m->u.handle` union check at Src/module.c:218-241).
4102 // modulestab::is_loaded checks MOD_LINKED which
4103 // register_builtin_modules pre-seeds for EVERY compiled-in
4104 // module, so it would report zsh/parameter "loaded" in a
4105 // fresh `zsh -f` where real zsh still shows the stubs.
4106 !tab.modules.get(*m).is_some_and(|md| {
4107 (md.node.flags & MOD_INIT_B) != 0 && (md.node.flags & MOD_UNLOAD) == 0
4108 })
4109 })
4110 .collect()
4111}
4112
4113/// Names provided by `zsh/system` / `zsh/mapfile` etc. that are
4114/// gated on explicit `zmodload`. Used by the bin_zmodload path to
4115/// re-seed paramtab after the module's boot completes.
4116pub fn module_gated_params_for(module: &str) -> &'static [&'static str] {
4117 match module {
4118 "zsh/system" => &["sysparams", "errnos"],
4119 "zsh/mapfile" => &["mapfile"],
4120 "zsh/langinfo" => &["langinfo"],
4121 _ => &[],
4122 }
4123}
4124impl ShellExecutor {
4125 /// `enter_posix_mode` — see implementation.
4126 pub fn enter_posix_mode(&mut self) {
4127 self.posix_mode = true;
4128 self.plugin_cache = None;
4129 self.compsys_cache = None;
4130 self.compinit_pending = None;
4131 self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
4132 // Direct call to the canonical `emulate()` port
4133 // (Src/options.c:533) — `-R` semantics = fully=true.
4134 // bin_emulate goes through dispatch_builtin which needs an
4135 // ExecutorContext that isn't set up yet at apply_cli_flags
4136 // time; the underlying emulate() doesn't need one.
4137 crate::ported::options::emulate("sh", true);
4138 }
4139 /// `enter_ksh_mode` — see implementation.
4140 pub fn enter_ksh_mode(&mut self) {
4141 self.plugin_cache = None;
4142 self.compsys_cache = None;
4143 self.compinit_pending = None;
4144 self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
4145 crate::ported::options::emulate("ksh", true);
4146 }
4147}
4148
4149/// Thin (text, pattern) → bool wrapper over the canonical
4150/// `patcompile()` + `pattry()` pair from `Src/pattern.c`. Argument
4151/// order is flipped so callers read naturally. Lives in vm_helper.rs
4152/// (non-port file) as the public convenience entry for extensions
4153/// and the VM bridge; `src/ported/*` files inline the compile+match
4154/// idiom directly to preserve PORT.md Rule 1 faithfulness.
4155pub fn glob_match_static(s: &str, pattern: &str) -> bool {
4156 let Some(prog) = patcompile(&{ let mut __pat_tok = (pattern).to_string(); crate::ported::glob::tokenize(&mut __pat_tok); __pat_tok }, PAT_HEAPDUP as i32, None) else {
4157 return false;
4158 };
4159 // (#b) (GF_BACKREF) — capture-aware path. Use pattryrefs so the
4160 // per-group begin/end offsets surface, then write $match /
4161 // $mbegin / $mend (c:Src/pattern.c GF_BACKREF handling at
4162 // c:775 + c:2417). Falls through to the basic pattry path when
4163 // (#b) isn't on — that matches the previous behaviour exactly
4164 // and avoids the small extra cost (state clone + Vec<i32> alloc)
4165 // for the (#b)-free common case.
4166 // c:Src/pattern.c:2543 / 2570 — the C gate for capture reporting is
4167 // `prog->patnpar` (count of NUMBERED groups), not a globflags bit.
4168 // patcomppiece (pattern.rs, c:775 arm) only numbers a group when
4169 // GF_BACKREF was live at the point the `(` compiled, so patnpar>0
4170 // ⇔ the pattern had an effective `(#b)` REGARDLESS of position.
4171 // The old `globflags & GF_BACKREF` gate only saw flags hoisted from
4172 // a LEADING `(#...)` spec, so `"%"(#b)(test)*` (literal prefix
4173 // before the flag) never populated $match. Bug: gap #1 2026-06-12.
4174 let has_backref = prog.0.patnpar > 0;
4175 let matched;
4176 if has_backref {
4177 let mut nump: i32 = 0;
4178 let mut begp: Vec<i32> = Vec::new();
4179 let mut endp: Vec<i32> = Vec::new();
4180 matched = crate::ported::pattern::pattryrefs(
4181 &prog,
4182 s,
4183 s.len() as i32,
4184 -1,
4185 None,
4186 0,
4187 Some(&mut nump),
4188 Some(&mut begp),
4189 Some(&mut endp),
4190 );
4191 if matched {
4192 let n = (nump as usize).min(begp.len()).min(endp.len());
4193 let mut match_arr: Vec<String> = Vec::with_capacity(n);
4194 let mut begin_arr: Vec<String> = Vec::with_capacity(n);
4195 let mut end_arr: Vec<String> = Vec::with_capacity(n);
4196 // KSHARRAYS off → 1-based; on → 0-based. C path:
4197 // `setiparam("MBEGIN", patoffset + !isset(KSHARRAYS))`
4198 // — so the base offset added to each begin/end index.
4199 let ksharrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
4200 let base = if ksharrays { 0 } else { 1 };
4201 for i in 0..n {
4202 // c:2607-2613 — unset group (unmatched alternation
4203 // branch / hashed paren): matcharr[i]="",
4204 // mbeginarr[i]="-1", mendarr[i]="-1". pattryrefs
4205 // reports these as begp/endp = -1 (c:2563-2566).
4206 if begp[i] < 0 || endp[i] < 0 {
4207 match_arr.push(String::new());
4208 begin_arr.push("-1".to_string());
4209 end_arr.push("-1".to_string());
4210 continue;
4211 }
4212 let b = begp[i] as usize;
4213 let e = endp[i] as usize;
4214 let lo = b.min(s.len());
4215 let hi = e.min(s.len()).max(lo);
4216 match_arr.push(s[lo..hi].to_string());
4217 begin_arr.push((b + base).to_string());
4218 // mend is the INDEX of the last matched char (inclusive),
4219 // so end - 1 + base. For an empty span use the begin
4220 // offset (zsh's setiparam("MEND", mlen + patoffset + ...
4221 // - 1) shape from c:2444-2446).
4222 end_arr.push(((e + base).saturating_sub(1)).to_string());
4223 }
4224 crate::ported::params::setaparam("match", match_arr);
4225 crate::ported::params::setaparam("mbegin", begin_arr);
4226 crate::ported::params::setaparam("mend", end_arr);
4227 }
4228 } else {
4229 matched = pattry(&prog, s);
4230 }
4231 // c:Src/pattern.c GF_MATCHREF — `(#m)pat` writes the matched
4232 // substring to $MATCH on success. In `[[ str == pat ]]` cond
4233 // context the pattern matches the whole string, so on success
4234 // $MATCH = the input.
4235 if matched && pattern.contains("(#m)") {
4236 crate::ported::params::setsparam("MATCH", s);
4237 crate::ported::params::setiparam("MBEGIN", 1);
4238 crate::ported::params::setiparam("MEND", s.chars().count() as i64);
4239 }
4240 matched
4241}
4242
4243pub use crate::ported::lex::untokenize_ztokens;
4244
4245
4246
4247
4248pub use crate::ported::utils::unmetafy_str;
4249
4250
4251pub use crate::ported::utils::zsh_errno_msg;
4252
4253
4254// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4255// PM_NAMEREF bridge helpers (typeset -n / named references).
4256//
4257// Rust-only adapters around the canonical C nameref machinery in
4258// Src/params.c (resolve_nameref_rec c:6332, setscope c:6382,
4259// upscope c:6455) and Src/builtin.c (bin_typeset nameref arm
4260// c:3117-3150). zshrs's paramtab is a name-keyed HashMap handing
4261// out clones, so the chain walk operates by NAME against the live
4262// table instead of by Param pointer — same hop rule, same loop
4263// detection, same upscope old-chain walk. The ported fns in
4264// params.rs/builtin.rs call into these at the exact C deref points
4265// (getparamnode c:570-575, getvalue/fetchvalue c:2247-2270,
4266// assignsparam c:3252/3258, assignaparam c:3392-3398, bin_unset
4267// c:3939-3951, typeset_single c:2032-2050).
4268// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4269
4270pub use crate::ported::params::*;