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