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 // c:Src/params.c:1090-1115 createparam — a `local NAME` / `typeset
119 // NAME` replaces a special's paramtab node with a plain one, so the
120 // name is no longer a hash. Answering here would strand the read on
121 // the O(1) assoc fast path and never reach paramsubst's scalar
122 // subscript arm (`${options[1]}` came back empty for a local).
123 if magic_special_shadowed(resolved.as_str()) {
124 return None;
125 }
126 // c:Src/Zle/complete.c:1272/1411 — `compstate[nmatches]` is a LIVE gsu
127 // integer (`get_nmatches` = `permmatches(0) ? 0 : nmatches`), not stored
128 // data, so the hashed store never held it and every shell-side read
129 // returned the EMPTY string. `_parameters` (`local -i nm=$compstate[
130 // nmatches]` … `(( compstate[nmatches] > nm ))`) therefore always
131 // reported "added nothing" and returned 1 — `unset <TAB>` offered 197
132 // names against zsh's 496 — and the same idiom in `_alternative`,
133 // `_describe` and `_arguments` mis-fired the same way.
134 // The same applies to the other NINE gsu-backed rows
135 // (c:complete.c:1261-1300): list_lines, list_max, unambiguous,
136 // unambiguous_cursor, unambiguous_positions, insert_positions, vared,
137 // all_quotes, ignored. Only `nmatches` was served live here, so
138 // `$compstate[list_lines]` and friends read empty from shell code
139 // where zsh reports a value.
140 if resolved == "compstate" && crate::ported::zle::compcore::LIVE_COMPSTATE_KEYS.contains(&key) {
141 return Some((
142 true,
143 Some(
144 crate::ported::zle::compcore::get_compstate_str(key).unwrap_or_else(|| {
145 if key == "nmatches" {
146 "0".to_string()
147 } else {
148 String::new()
149 }
150 }),
151 ),
152 ));
153 }
154 crate::ported::params::paramtab_hashed_storage()
155 .lock()
156 .ok()
157 .and_then(|s| {
158 s.get(resolved.as_str())
159 .map(|m| (m.contains_key(key), m.get(key).cloned()))
160 })
161}
162use crate::ported::utils::{errflag, ERRFLAG_ERROR};
163use crate::ported::zsh_h::PM_UNDEFINED;
164use crate::ported::zsh_h::WC_SIMPLE;
165use crate::ported::zsh_h::{options, MAX_OPS};
166use crate::ported::zsh_h::{PM_ARRAY, PM_HASHED, PM_INTEGER, PM_READONLY};
167use parking_lot::Mutex;
168use std::collections::HashSet;
169use std::ffi::CStr;
170use std::ffi::CString;
171use std::fs;
172use std::io::Read;
173use std::os::unix::ffi::OsStrExt;
174use std::os::unix::fs::FileTypeExt;
175use std::os::unix::fs::PermissionsExt;
176use std::os::unix::io::FromRawFd;
177use std::sync::atomic::AtomicI32;
178use std::sync::atomic::Ordering;
179use std::time::{SystemTime, UNIX_EPOCH};
180use walkdir::WalkDir;
181
182// Backward-compat re-exports for free ported recently relocated to their
183// canonical-C-file Rust modules. Existing call-sites in this file (and
184// elsewhere) still reference these unqualified.
185#[allow(unused_imports)]
186pub(crate) use crate::func_body_fmt::FuncBodyFmt;
187#[allow(unused_imports)]
188pub(crate) use crate::ported::hist::bufferwords as bufferwords_z_tuple;
189#[allow(unused_imports)]
190pub(crate) use crate::ported::math::{parse_assign, parse_compound, parse_pre_inc};
191#[allow(unused_imports)]
192pub use crate::ported::params::convbase as format_int_in_base;
193pub use crate::ported::params::convbase_underscore;
194// `getarrvalue` is already re-exported by `pub use crate::ported::params::*`
195// below; an explicit `pub(crate) use` here only shadowed that public export.
196#[allow(unused_imports)]
197pub(crate) use crate::ported::utils::base64_decode;
198#[allow(unused_imports)]
199pub(crate) use crate::ported::utils::{ispwd, printprompt4, quotedzputs};
200
201pub(crate) use crate::intercepts::intercept_matches;
202/// AOP advice type — before, after, or around.
203pub use crate::intercepts::{AdviceKind, Intercept};
204
205/// Result from background compinit thread.
206pub use crate::compinit_bg::CompInitBgResult;
207use std::io::Write;
208use std::sync::LazyLock;
209
210/// State snapshot for plugin delta computation.
211pub(crate) use crate::plugin_cache::PluginSnapshot;
212
213/// Cached compiled regexes for hot paths
214pub(crate) static REGEX_CACHE: LazyLock<Mutex<HashMap<String, Regex>>> =
215 LazyLock::new(|| Mutex::new(HashMap::with_capacity(64)));
216
217// fusevm VM bridge (extension; not a port of Src/exec.c) lives in
218// src/fusevm_bridge.rs. Re-exports below let the rest of the codebase
219// reference symbols as `crate::ported::exec::X`.
220pub(crate) use crate::fusevm_bridge::ExecutorContext;
221pub use crate::fusevm_bridge::*;
222
223/// `ZSH_VERSION` / `ZSH_PATCHLEVEL` / `ZSH_VERSION_DATE` consts
224/// generated by `build.rs` from `src/zsh/Config/version.mk`. Use
225/// `zsh_version::ZSH_VERSION` etc. at call sites so version bumps
226/// pick up automatically.
227pub mod zsh_version {
228 include!(concat!(env!("OUT_DIR"), "/zsh_version.rs"));
229}
230
231/// Match an intercept pattern against a command name or full command string.
232/// Supports: exact match, glob ("git *", "_*", "*"), or "all".
233
234/// O(1) builtin-name lookup set derived from the canonical
235/// `BUILTINS` table (`src/ported/builtin.rs:122`, the 1:1 port of
236/// `static struct builtin builtins[]` at `Src/builtin.c:40-137`).
237/// Earlier incarnation hardcoded a separate 130-entry list which
238/// drifted whenever new builtins landed in the canonical table — and
239/// shadowed the `fusevm::shell_builtins::BUILTIN_SET` u16 opcode
240/// constant. Renaming to `BUILTIN_NAMES` removes the shadow; the
241/// initialiser walks `BUILTINS` so the set stays in sync.
242///
243/// The hardcoded entries inside `LazyLock::new` below are kept as
244/// the union of: (1) names from `BUILTINS` (walked at first access),
245/// (2) zshrs daemon-side builtins from `ZSHRS_BUILTIN_NAMES`. Both
246/// arms run once at static init.
247pub(crate) static BUILTIN_NAMES: LazyLock<HashSet<String>> = LazyLock::new(|| {
248 let mut s: HashSet<String> = HashSet::new();
249 // Walk the canonical `BUILTINS` table — the 1:1 port of
250 // `static struct builtin builtins[]` at `Src/builtin.c:40-137`
251 // (ported at `src/ported/builtin.rs:122`). Every name in there is
252 // a real zsh builtin; the set stays in sync as new ports land.
253 for b in crate::ported::builtin::BUILTINS.iter() {
254 s.insert(b.node.nam.clone());
255 }
256 // Daemon-side (zshrs-specific extensions).
257 for &n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.iter() {
258 s.insert(n.to_string());
259 }
260 s
261});
262
263use crate::exec_jobs::{JobState, JobTable};
264use crate::parse::{Redirect, RedirectOp, ShellCommand, ShellWord, VarModifier, ZshParamFlag};
265use indexmap::IndexMap;
266use std::collections::HashMap;
267use std::env;
268use std::fs::{File, OpenOptions};
269use std::io;
270use std::path::{Path, PathBuf};
271use std::process::{Child, Command, Stdio};
272
273// Re-exports for call-sites that reference `crate::ported::exec::<Name>`.
274pub use crate::bash_complete::CompSpec;
275pub use crate::ported::builtin::AutoloadFlags;
276pub use crate::ported::modules::zutil::zstyle_entry;
277
278/// One inline-assignment scope (`X=foo Y=bar cmd`).
279///
280/// `saved` holds `(name, prev_var, prev_env)` for each name the
281/// PREFIX assignments touched, so END_INLINE_ENV can put the shell
282/// var and the process env back.
283///
284/// `recording` is what keeps the frame from swallowing assignments
285/// the *command itself* performs. zsh's `addvars()` (Src/exec.c:4142)
286/// walks only the parsed WC_ASSIGN chain, and `save_params`
287/// (Src/exec.c:4410) snapshots only those names; once the command
288/// runs, the save list is closed. The bytecode emits the prefix
289/// assignments between BEGIN_INLINE_ENV and SEAL_INLINE_ENV, and
290/// SEAL clears this flag, so `X=y . file` no longer records (and
291/// then reverts) every global the sourced file assigns.
292pub struct InlineEnvFrame {
293 /// Per-name pre-assignment state: `(name, prev_var, prev_env)`.
294 pub saved: Vec<(String, Option<String>, Option<String>)>,
295 /// True only while the prefix assignments are being executed.
296 pub recording: bool,
297}
298
299impl InlineEnvFrame {
300 /// New frame, open for recording until SEAL_INLINE_ENV runs.
301 pub fn new() -> Self {
302 Self {
303 saved: Vec::new(),
304 recording: true,
305 }
306 }
307}
308
309impl Default for InlineEnvFrame {
310 fn default() -> Self {
311 Self::new()
312 }
313}
314
315/// Snapshot of subshell-isolated state. Captured at `(` entry, restored at
316/// `)` exit. zsh subshell semantics: assignments inside `(…)` don't leak to
317/// the outer scope — and that includes `export`. zsh forks a child for the
318/// subshell so the child's env::set_var dies with the child; without a fork
319/// (zshrs runs subshells in-process for perf), we snapshot+restore the OS
320/// env table around the subshell. Otherwise `(export y=v)` would leak `y`
321/// to the parent shell, breaking every script that uses a subshell to
322/// scope an env override.
323/// Snapshot of mutable executor state across a subshell
324/// boundary.
325/// Port of the `entersubsh()` save/restore Src/exec.c does at
326/// line 1084 — captures everything that must be replaced when a
327/// `(...)` group fires.
328pub struct SubshellSnapshot {
329 /// Snapshot of `paramtab` (the C-canonical parameter store) at
330 /// subshell entry. Step 1 of the unification mirrors writes to
331 /// paramtab, so subshell-scoped assignments now show up there
332 /// too — without this snapshot, restoring only `variables` /
333 /// `arrays` / `assoc_arrays` leaks the subshell's writes to the
334 /// parent via paramtab (e.g. `x=outer; (x=inner); echo $x` returned
335 /// `inner` because paramsubst reads through paramtab).
336 /// Same node storage as the live table (`Src/params.c:854`
337 /// `newparamtable(151, "paramtab")`) so restoring a snapshot restores
338 /// C's bucket-walk order too, not just the name→value mapping.
339 pub paramtab: crate::ported::hashtable::hashtable_nodes<crate::ported::zsh_h::Param>,
340 /// `paramtab_hashed_storage` field.
341 pub paramtab_hashed_storage: crate::cow_map::CowHashMap<String, IndexMap<String, String>>,
342 /// `positional_params` field.
343 pub positional_params: Vec<String>,
344 /// `env_vars` field.
345 pub env_vars: HashMap<String, String>,
346 /// Values of the special parameters whose backing store is a process
347 /// GLOBAL rather than the parameter table — `Src/params.c`'s `char *ifs`
348 /// (IFS), `wordchars`, `home`, `histsiz`, … — each reached through a GSU
349 /// getfn/setfn pair (the dispatch list at params.rs:12548).
350 ///
351 /// The `paramtab` snapshot above restores the param NODE, but the node only
352 /// carries the GSU pair; the value itself lives in the global, which a
353 /// paramtab restore doesn't touch. C forks for `(...)`, so a child's writes
354 /// to those globals die with it. zshrs runs subshells in-process, so
355 /// `(IFS=,; :)` left the PARENT's IFS as `,` — and every later word-split
356 /// in the parent silently used it. Same fork-copy reasoning as `opts` /
357 /// `umask` / `aliases` above.
358 pub special_globals: Vec<(String, String)>,
359 /// Parent's `zstyletab` at subshell entry (Src/Modules/zutil.c:106
360 /// `static HashTable zstyletab`). C forks for `(...)`, so a
361 /// `zstyle` set inside the subshell dies with the child. zshrs runs
362 /// subshells in-process, so a subshell-scoped `zstyle` leaked into
363 /// the parent AND — because `setstypat` (c:388-396) inserts a
364 /// same-weight pattern AFTER the already-present ones — a second
365 /// subshell re-defining the same (context, style) pair only
366 /// REPLACED the leaked entry instead of establishing a fresh
367 /// definition order. Same fork-copy reasoning as `aliases` /
368 /// `shfuncs` / `modules`.
369 pub zstyles: crate::ported::modules::zutil::style_table,
370 /// Flock fds (`Src/utils.c:2111` `addlockfd`) live at subshell
371 /// entry. `zsystem flock FILE` keeps the fd open for the life of
372 /// the shell; under C's forked `(...)` the child's fd — and hence
373 /// the lock — dies when the subshell exits. zshrs runs subshells
374 /// in-process, so the lock outlived the subshell and every later
375 /// `zsystem flock` on that file (from a real forked background job)
376 /// blocked forever. Recorded here so `subshell_end` can close the
377 /// fds the subshell itself opened.
378 pub flock_fds: Vec<i32>,
379 /// `loops` / `breaks` / `contflag` at subshell entry
380 /// (c:Src/loop.c, c:Src/builtin.c bin_break). C forks for `(...)`,
381 /// so a `break` executed inside dies with the child and the parent's
382 /// loop runs on: `for i in 1 2; do (break); print after; done` prints
383 /// `after` twice. zshrs runs subshells in-process, so the three
384 /// counters have to be restored by hand at the boundary.
385 pub loop_flags: (i32, i32, i32),
386 /// Process working directory at subshell entry. `cd` inside the
387 /// subshell shouldn't leak to the parent; we restore on End.
388 pub cwd: Option<PathBuf>,
389 /// File-creation mask at subshell entry. zsh forks for `(...)` so
390 /// `umask` set inside dies with the child; we run subshells in
391 /// process so we must restore the mask on End. Otherwise
392 /// `umask 022; (umask 077); umask` shows 077 in the parent.
393 pub umask: u32,
394 /// Parent's traps at subshell entry. zsh's `(trap "echo X" EXIT;
395 /// true)` runs the trap when the subshell exits — BEFORE the parent
396 /// continues. Without this snapshot, the trap inherited from parent
397 /// would fire, OR a trap set inside the subshell would leak to the
398 /// parent's process exit. Restored on subshell_end after the
399 /// subshell's own EXIT trap (if any) has fired. Stores a snapshot
400 /// of `crate::ported::builtin::traps_table()` (canonical).
401 pub traps: HashMap<String, String>,
402 /// Parent's shell options at subshell entry. `(set -e)` /
403 /// `(setopt extendedglob)` mustn't leak; zsh forks the subshell
404 /// so child options die with the child. We run in-process, so we
405 /// must restore the option store on subshell_end.
406 pub opts: HashMap<String, bool>,
407 /// Parent's alias entries at subshell entry. zsh forks for
408 /// `(...)` so `(alias x=y)` inside a subshell dies with the
409 /// child and doesn't leak to the parent. zshrs runs subshells
410 /// in-process, so we must restore the alias table on
411 /// subshell_end. Bug #209 in docs/BUGS.md. Stored as a flat
412 /// Vec<(name, text, flags)> snapshot. The node FLAGS must
413 /// round-trip: ALIAS_GLOBAL / DISABLED distinguish global and
414 /// disabled aliases in the shared aliastab — the previous
415 /// (name, text) shape restored every entry with flags=0, so ANY
416 /// subshell (`(true)`, zsh-z's `(zshz --add … &)` precmd)
417 /// reflagged every global alias to REGULAR in the parent:
418 /// `alias -g` listed nothing and `${+galiases[x]}` went 0 one
419 /// prompt after every define.
420 pub aliases: Vec<(String, String, i32)>,
421 /// Parent's shell-function table at subshell entry. C zsh's
422 /// `entersubsh` (`Src/exec.c`) forks before running the
423 /// subshell body so `(f() { ... })` defining a function dies
424 /// with the child and never leaks to the parent. zshrs runs
425 /// subshells in-process, so we must clone `shfunctab` on entry
426 /// and restore on exit. Bug #208 in docs/BUGS.md. Stored as a
427 /// clone of the whole `shfunc_table` — the bucket layout is part
428 /// of the state, because `${(k)functions}` / `compadd -k functions`
429 /// emit C's bucket-walk order verbatim
430 /// (`Src/Modules/parameter.c:480-481`); rebuilding the table from
431 /// an unordered map on restore would reshuffle that order after
432 /// every `( … )` / `$( … )`.
433 pub shfuncs: std::sync::Arc<crate::ported::hashtable::shfunc_table>,
434 /// Parent's compiled-function chunks at subshell entry. Companion
435 /// to `shfuncs` above — `ShellExecutor.functions_compiled` is the
436 /// runtime dispatch table that `Op::CallFunction` reads through;
437 /// without restoring it, a subshell `(g() { override; })` leaves
438 /// the override bytecode chunk in place so the parent's
439 /// `g` call still runs the override after `subshell_end`
440 /// restored shfunctab. Bug #208 in docs/BUGS.md.
441 pub functions_compiled: HashMap<String, fusevm::Chunk>,
442 /// Parent's function source map at subshell entry. Companion to
443 /// `functions_compiled` so `typeset -f` / `whence` show the
444 /// parent's source after subshell exit, not the subshell's
445 /// overridden body. Bug #208 in docs/BUGS.md.
446 pub function_source: HashMap<String, String>,
447 /// Parent's modulestab `modules` map at subshell entry. zsh forks
448 /// for `(...)` so a `(zmodload zsh/X)` inside the subshell sets
449 /// MOD_INIT_B on the child's modulestab; when the child exits the
450 /// flag dies with it and the parent's modulestab is untouched.
451 /// zshrs runs subshells in-process, so a subshell `zmodload`
452 /// would otherwise flip the parent's `${modules[zsh/X]}` from
453 /// unset to "loaded". Snapshot here and restore on subshell_end.
454 /// Bug #210 in docs/BUGS.md. Stored as `(name → flags)`
455 /// since `module` struct doesn't derive Clone (LinkList/
456 /// Linkedmod) — and the only thing `zmodload` mutates that
457 /// affects introspection is the flags bitmask (MOD_INIT_B
458 /// for loaded, MOD_UNLOAD for unloaded).
459 pub modules: HashMap<String, i32>,
460 /// Parent's THINGYTAB (ZLE widget registry) at subshell entry.
461 /// zsh forks for `(...)` so `zle -N w f` / `zle -D w` inside the
462 /// subshell flip widget bindings only in the child; when the
463 /// child exits the parent's widget table is untouched. zshrs runs
464 /// subshells in-process so a subshell's `zle -D w` would
465 /// otherwise unbind the parent's widget. Bug #453 in docs/BUGS.md.
466 pub thingytab: HashMap<String, crate::ported::zle::zle_thingy::Thingy>,
467 /// Parent's KEYMAPNAMTAB (named keymap registry) at subshell
468 /// entry. Same fork-copy semantics as THINGYTAB — a subshell's
469 /// `bindkey -N km` / `bindkey -D km` mutates only the child's
470 /// keymap registry in C zsh. Bug #454 in docs/BUGS.md.
471 pub keymapnamtab:
472 crate::ported::hashtable::hashtable_nodes<crate::ported::zle::zle_keymap::KeymapName>,
473 /// Parent's `$!` (clone::lastpid) at subshell entry. C zsh forks
474 /// for `(...)`, so a background job started INSIDE the subshell
475 /// sets the child's `lastpid` only — `( : & ); echo $!` prints 0
476 /// in zsh. zshrs runs subshells in-process, so restore on end.
477 pub lastpid: i32,
478 /// Job-control state at subshell entry: (JOBTAB clone, CURJOB,
479 /// PREVJOB, MAXJOB, THISJOB). C zsh forks for `(...)` so any
480 /// `disown` / `wait` / new `&` job inside the subshell mutates the
481 /// CHILD's copy of jobtab and dies with it (Src/exec.c::entersubsh
482 /// fork semantics); the parent's table is untouched. zshrs's
483 /// in-process subshell must snapshot/restore to match — without
484 /// this, `sleep 1 & (disown); jobs` shows an empty table where
485 /// zsh still lists the job. Bug #462.
486 pub jobtab: Vec<crate::ported::zsh_h::job>,
487 /// `curjob` at subshell entry (Src/jobs.c:75 global).
488 pub curjob: i32,
489 /// `prevjob` at subshell entry (Src/jobs.c:80 global).
490 pub prevjob: i32,
491 /// `maxjob` at subshell entry (Src/jobs.c:71 global).
492 pub maxjob: usize,
493 /// `thisjob` at subshell entry (Src/jobs.c:77 global).
494 pub thisjob: i32,
495 /// User-range fds (0-9) at subshell entry: `(fd, saved_dup)`
496 /// pairs where `saved_dup` is an `F_DUPFD >= 10` copy, or -1 when
497 /// the fd was closed at entry. C zsh forks for `(...)` so a bare
498 /// `exec >file` / `exec 3<&-` inside the child dies with it
499 /// (Src/exec.c entersubsh fork semantics); the in-process
500 /// subshell must restore the parent's fd table on End. Without
501 /// this, `(exec >t.log; ...); cat t.log` left the PARENT's fd 1
502 /// pointing at t.log and `cat` looped forever copying the file
503 /// into itself.
504 pub saved_fds: Vec<(i32, i32)>,
505 /// `sigtrapped[]` at subshell entry (Src/signals.c:39). C's
506 /// `entersubsh` clears per-signal trap STATE via `unsettrap(sig)`
507 /// (c:Src/exec.c:1088-1092), which zeroes both the body and the
508 /// sigtrapped flags. zshrs cleared only the body table, so the flags
509 /// desynced: a subshell that dropped a trap body still reported the
510 /// signal as trapped. Snapshot the whole vector so subshell_end can
511 /// restore the parent's exact state (including an inherited
512 /// ZSIG_IGNORED on SIGQUIT).
513 pub sigtrapped: Vec<i32>,
514 /// `subsh` at subshell entry (Src/exec.c:160 global). C's
515 /// `entersubsh` sets `subsh = 1` for a real (non-ESUB_FAKE)
516 /// subshell at c:Src/exec.c:1192-1193, and the forked child
517 /// carries it for the whole body. PRINT_EXIT_VALUE reads it
518 /// (c:4309 `&& !subsh`), which is why zsh prints nothing for
519 /// `setopt printexitvalue; (false)` while still reporting a
520 /// bare `false`. zshrs runs `( … )` in-process, so the flag has
521 /// to be set on entry and restored by hand on End.
522 pub subsh: i32,
523 /// Names of builtins carrying `DISABLED` in `builtintab` at
524 /// subshell entry (c:Src/builtin.c:541-547 `enable`/`disable`
525 /// flip `node.flags & DISABLED`; c:Src/hashtable.c:1097
526 /// `builtintab`). C forks for `(...)`, so a `(disable typeset)`
527 /// marks the flag only in the child's copy of `builtintab` and
528 /// the parent still sees the builtin. zshrs runs subshells
529 /// in-process against the process-global `BUILTINS_DISABLED`
530 /// set, so `( disable typeset ); typeset x=1` reported
531 /// `command not found: typeset` in the PARENT.
532 pub builtins_disabled: std::collections::HashSet<String>,
533 /// Names of reserved words carrying `DISABLED` in `reswdtab` at
534 /// subshell entry (c:Src/builtin.c:541-547 `disable -r`;
535 /// c:Src/hashtable.c:1124 `reswdtab = newhashtable(23,
536 /// "reswdtab", NULL)`). Same fork-copy reasoning as
537 /// `builtins_disabled` — `(disable -r typeset)` must not change
538 /// how the parent PARSES `typeset foo=`cmd``.
539 pub reswds_disabled: std::collections::HashSet<String>,
540}
541
542#[allow(unused_imports)]
543pub(crate) use crate::ported::pattern::{
544 extract_numeric_ranges, numeric_range_contains, numeric_ranges_to_star,
545};
546
547/// Top-level shell executor state.
548/// Fork-equivalent event counter — incremented on every external
549/// spawn and in-process subshell entry. C zsh's `time` keyword
550/// reports only for JOBS (forked work, Src/jobs.c printtime via the
551/// job table); zshrs runs builtins/braces/functions in-process with
552/// no job, so the TIME_SUBLIST handler compares this counter across
553/// the timed body to decide whether to emit the report.
554pub static FORK_EVENTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
555
556/// Port of the file-static globals + `Estate` chain Src/exec.c
557/// uses — `execlist()` (line 1349) drives every list, with
558/// `execpline()` (line 1668), `execpline2()` (line 1991),
559/// `execsimple()` (line 1290), and the per-`WC_*` `execfuncs[]`
560/// table (line 268) feeding off it. The Rust port collapses
561/// everything into one `ShellExecutor` so we don't need
562/// thread-local globals.
563pub struct ShellExecutor {
564 /// Mirrors C zsh's file-static `scriptname` (Src/init.c). Used by
565 /// PS4's `%N` and the `scriptname:line: …` prefix on error
566 /// messages. Inside a function, MUTATES to the function name
567 /// (Src/exec.c:5903 `scriptname = dupstring(name)`). Init sets
568 /// this in `-c` mode to the binary basename per init.c:479; when
569 /// sourcing a file via `source`/`bin_dot`, it becomes the
570 /// resolved file path; otherwise it falls back through `$0` →
571 /// `$ZSH_ARGZERO`.
572 pub scriptname: Option<String>,
573 /// Mirrors C zsh's `scriptfilename` global (Src/init.c). Tracks
574 /// the FILE BEING READ (vs scriptname which tracks the active
575 /// function name during a call). Used by PS4's `%x` and certain
576 /// error-message prefixes that want the file location, NOT the
577 /// function name.
578 ///
579 /// At -c-mode init, scriptname == scriptfilename == "zsh"
580 /// (Src/init.c:479). When entering a function, ONLY scriptname
581 /// updates (exec.c:5903); scriptfilename stays at the outer
582 /// file path, so `%x` inside a function still shows the file
583 /// the function was called from.
584 pub scriptfilename: Option<String>,
585 /// Stack of subshell-state snapshots. Each `(…)` subshell pushes a copy
586 /// of variables/arrays/assoc_arrays at entry and pops/restores at exit.
587 /// Without this, `(x=inner; …); echo $x` shows `inner` instead of the
588 /// outer-scope value.
589 pub subshell_snapshots: Vec<SubshellSnapshot>,
590 /// Stack of inline-assignment scopes — `X=foo Y=bar cmd` pushes
591 /// a frame at the start, the assigns run inside it, and `cmd`
592 /// returns into END_INLINE_ENV which restores both shell-vars
593 /// and process-env to the pre-frame state. Each frame holds
594 /// `(name, prev_var, prev_env)` per assigned name. zsh's
595 /// equivalent is the parser-level "addvar" list executed under
596 /// `addvars()` (Src/exec.c) right before the command exec.
597 pub inline_env_stack: Vec<InlineEnvFrame>,
598 /// Set by `expand_glob`'s no-match arm when `nomatch` is on (zsh
599 /// default) — instructs the simple-command dispatcher to skip
600 /// executing the current command, set last_status=1, and continue
601 /// to the next command in the script. zsh's bin_simple uses the
602 /// errflag global for the same role: error printed, command
603 /// suppressed, script continues. Without this we were calling
604 /// `process::exit(1)` deep inside expand_glob, killing the whole
605 /// shell on any unmatched glob even with multi-statement input.
606 /// `Cell` because the no-match site only has a `&self` borrow.
607 pub current_command_glob_failed: std::cell::Cell<bool>,
608 /// `jobs` field.
609 pub jobs: JobTable,
610 /// `fpath` field.
611 pub fpath: Vec<PathBuf>,
612 /// `history` field.
613 pub history: Option<HistoryEngine>,
614 pub(crate) process_sub_counter: u32,
615 pub completions: HashMap<String, CompSpec>, // command -> completion spec
616 pub zstyles: Vec<zstyle_entry>, // zstyle configurations
617 /// Current function scope depth for `local` tracking.
618 pub local_scope_depth: usize,
619 /// Last arg of the currently-running command, deferred into `$_`
620 /// when the next command dispatches. zsh: `$_` reflects the LAST
621 /// command's last arg, so `echo hi; echo $_` prints `hi` (not the
622 /// `_` arg of `echo $_` itself). Promoted in `pop_args` and
623 /// `host.exec` before the command's args are read.
624 pub pending_underscore: Option<String>,
625 /// True while expanding inside a double-quoted context. Set by
626 /// `BUILTIN_EXPAND_TEXT` mode 1 around `expand_string` calls.
627 /// Used by parameter-flag application to suppress array-only flags
628 /// (`(o)`/`(O)`/`(n)`/`(i)`/`(M)`/`(u)`) — zsh's behaviour: those
629 /// flags only fire in array context.
630 pub in_dq_context: u32,
631 /// True (>0) while expanding the RHS of a scalar assignment.
632 /// Direct port of zsh's `PREFORK_SINGLE` bit set by
633 /// Src/exec.c::addvars line 2546 (`prefork(vl, isstr ?
634 /// (PREFORK_SINGLE|PREFORK_ASSIGN) : PREFORK_ASSIGN, ...)`).
635 /// Subst_port's paramsubst reads this via `ssub` and suppresses
636 /// `(f)` / `(s:STR:)` / `(0)` / `(z)` split flags per
637 /// Src/subst.c:1759 + 3902, so `y="${(f)x}"` preserves x's
638 /// original separator (newlines) instead of re-joining with
639 /// IFS-first-char (space).
640 pub in_scalar_assign: u32,
641 /// `profiling_enabled` field.
642 pub profiling_enabled: bool,
643 // compsys - completion system cache
644 /// `compsys_cache` field.
645 /// SQLite mirror, opened on FIRST USE via [`ShellExecutor::compsys_cache`].
646 ///
647 /// It is a dbview/FTS mirror for inspection — the authoritative completion
648 /// cache is the rkyv shards — so nothing on a normal command path touches
649 /// it. Opening it in the constructor still cost every shell three file
650 /// opens (`compsys.db`, `-wal`, `-shm`) plus WAL setup, including
651 /// `zshrs -f -c exit`, which cannot consult it at all.
652 pub compsys_cache: std::cell::OnceCell<Option<CompsysCache>>,
653 // Background compinit — receiver for async fpath scan result
654 /// `compinit_pending` field.
655 pub compinit_pending: Option<(
656 std::sync::mpsc::Receiver<CompInitBgResult>,
657 std::time::Instant,
658 )>,
659 // Plugin source cache — stores side effects of source/. in SQLite
660 /// `plugin_cache` field.
661 pub plugin_cache: Option<crate::plugin_cache::PluginCache>,
662 // cdreplay - deferred compdef calls for zinit turbo mode
663 /// `deferred_compdefs` field.
664 pub deferred_compdefs: Vec<Vec<String>>,
665 // Control flow signals
666 pub returning: Option<i32>, // Set by return builtin, cleared after function returns
667 /// zsh compatibility mode - use .zcompdump, fpath scanning, etc.
668 /// Also serves as the `--zsh` parity-test flag: caches off, daemon
669 /// off, plugin_cache replay off so every `source` re-runs the file
670 /// fresh per Src/builtin.c:6080-6123 bin_dot semantics.
671 pub zsh_compat: bool,
672 /// bash compatibility mode (`--bash`). Same parity-mode semantics
673 /// as `zsh_compat` (caches/daemon/replay off) plus bash-specific
674 /// behavior tweaks where bash 5.x diverges from zsh — e.g.
675 /// `BASH_VERSION` / `BASH_REMATCH` exposed, `[[ =~ ]]` populates
676 /// match indices the bash way, mapfile/readarray as builtins.
677 pub bash_compat: bool,
678 /// POSIX sh strict mode — no SQLite, no worker pool, no zsh extensions
679 pub posix_mode: bool,
680 /// Worker thread pool for background tasks (compinit, process subs, etc.)
681 pub worker_pool: std::sync::Arc<crate::worker::WorkerPool>,
682 /// AOP intercept table: command/function name → advice chain.
683 /// Glob patterns supported (e.g. "git *", "*").
684 pub intercepts: Vec<Intercept>,
685 /// Async job handles: id → receiver for (status, stdout)
686 pub async_jobs: HashMap<u32, crossbeam_channel::Receiver<(i32, String)>>,
687 /// Next async job ID
688 pub next_async_id: u32,
689 /// Per-scope saved-fd stacks for `Op::WithRedirectsBegin/End`. Each entry
690 /// is a Vec of (fd, saved_dup_fd) pairs taken from `dup(fd)` before the
691 /// redirect was applied; `with_redirects_end` `dup2`s them back and closes.
692 pub redirect_scope_stack: Vec<Vec<(i32, i32)>>,
693 /// Per-scope MULTIOS tee state. Each entry is `(pipe_write_fd,
694 /// JoinHandle)`: the pipe write-end currently dup2'd onto the
695 /// command's fd, and the splitter thread that reads from the
696 /// pipe read-end and writes to every collected target. Closed
697 /// + joined by `host_redirect_scope_end` BEFORE the saved fds
698 /// are restored so the splitter drains every byte the body
699 /// wrote into the pipe. Bug #36 in docs/BUGS.md.
700 pub multios_scope_stack: Vec<Vec<(i32, std::thread::JoinHandle<()>)>>,
701 /// True while applying a bare `exec`'s redirect list (`exec 1>&-`,
702 /// `exec 2>/dev/null` — no command words). `host_apply_redirect`
703 /// then skips pushing the saved fd into the enclosing scope so the
704 /// fd change survives group/command teardown.
705 /// c:Src/exec.c:3978-3986 — nullexec==1: "we specifically *don't*
706 /// restore the original fd's before returning"; C's per-execcmd
707 /// `save[]` means exec's redirs never enter the enclosing group's
708 /// save list either. Toggled by `BUILTIN_EXEC_PERM_REDIRS`.
709 pub exec_redirs_permanent: bool,
710 /// Set in a forked pipeline-stage child right after its stdout is
711 /// dup2'd onto the pipe write-end. Consumed by the FIRST
712 /// `host_redirect_scope_begin` (the stage command's own redirect
713 /// list) into `pipe_output_scope`.
714 /// c:Src/exec.c:3722-3724 — `addfd(forked, save, mfds, 1, output,
715 /// 1, NULL)`: the pipe occupies mfds[1] in the SAME execcmd that
716 /// processes the stage command's redirect list.
717 pub pipe_output_pending: bool,
718 /// Index into `redirect_scope_stack` of the scope whose redirect
719 /// list shares an execcmd with the pipeline output on fd 1. A
720 /// write-side redirect of fd 1 applied at exactly this scope depth
721 /// MULTIOS-splits (tees) instead of replacing — c:Src/exec.c:
722 /// 2447-2480 addfd "split the stream". Cleared when that scope ends.
723 pub pipe_output_scope: Option<usize>,
724 /// Set by `host_apply_redirect` when a redirect target couldn't be
725 /// opened (permission denied, no such directory, etc). The next
726 /// builtin/command checks this at entry and short-circuits with
727 /// status 1 instead of running. Mirrors zsh's "command skip" on
728 /// redirect failure.
729 pub redirect_failed: bool,
730 /// Compiled function bodies — name → fusevm::Chunk. Populated by
731 /// `BUILTIN_REGISTER_FUNCTION` (from `FunctionDef` lowering) and lazily by
732 /// `ZshrsHost::call_function` when only an AST exists in `self.functions`
733 /// (autoloaded, sourced, etc.). `Op::CallFunction` dispatches through here.
734 pub functions_compiled: HashMap<String, fusevm::Chunk>,
735 /// Canonical source text for functions. Populated by autoload paths (the
736 /// raw file/cache body), runtime FuncDef compile (the parsed source span),
737 /// and `unfunction` removal. Used by introspection (`whence`, `which`,
738 /// `typeset -f`) instead of reconstructing from a ShellCommand AST. When a
739 /// function is in `functions_compiled` but not here, introspection falls
740 /// back to `text::getpermtext(self.functions[name])`.
741 pub function_source: HashMap<String, String>,
742 /// `first_body_line - 1` per compiled function — matches inner
743 /// `ZshCompiler::lineno_offset` / zsh `funcstack->flineno` combined with
744 /// relative `$LINENO` for Src/prompt.c:909 `%I`.
745 pub function_line_base: HashMap<String, i64>,
746 /// `scriptfilename` when `BUILTIN_REGISTER_COMPILED_FN` ran — `%x` inside
747 /// a function (prompt.c:931-934) reads `funcstack->filename`.
748 pub function_def_file: HashMap<String, Option<String>>,
749 /// Innermost-last stack of active compiled-call frames for prompt `%I` / `%x`.
750 pub prompt_funcstack: Vec<(String, i64, Option<String>)>,
751 /// Scalar→(array, sep) tie table set up by `typeset -T VAR var [SEP]`.
752 /// Array→(scalar, sep) reverse-tie table. Used by BUILTIN_SET_ARRAY to
753 /// join the array elements with `sep` and mirror to the scalar side.
754 pub tied_array_to_scalar: HashMap<String, (String, String)>,
755
756 // ── ztest framework counters (extensions/ztest.rs) ──────────────────
757 //
758 // Mirrors strykelang's per-VMHelper test counters
759 // (strykelang/builtins.rs:22292-22308 + builtins.rs::test_pass/_fail/_skip,
760 // builtins.rs::test_pass_count etc.). Each `zassert_*` builtin bumps
761 // the per-block counter; `ztest_run` rolls per-block into _total and
762 // resets the per-block side, so a single test file with multiple
763 // `ztest_run` calls can reuse the counters. The worker-pool runner in
764 // src/extensions/ztest.rs reads pass_total+pass_count and
765 // fail_total+fail_count after `execute_script` returns for the cumulative
766 // numbers (strykelang/cli_runners.rs:115-118). `ztest_run_failed` is a
767 // sticky bool the runner reads so a test that asserts but then exits
768 // 0 still flags as failed. `ztest_suppress_stdout` matches
769 // VMHelper::suppress_stdout — the runner sets it inside the forked
770 // grandchild so the per-test stderr capture stays clean.
771 /// Per-block pass count (reset by `ztest_run`).
772 pub ztest_pass_count: std::sync::atomic::AtomicUsize,
773 /// Per-block fail count (reset by `ztest_run`).
774 pub ztest_fail_count: std::sync::atomic::AtomicUsize,
775 /// Per-block skip count (reset by `ztest_run`).
776 pub ztest_skip_count: std::sync::atomic::AtomicUsize,
777 /// Cumulative pass total across the run.
778 pub ztest_pass_total: std::sync::atomic::AtomicUsize,
779 /// Cumulative fail total across the run.
780 pub ztest_fail_total: std::sync::atomic::AtomicUsize,
781 /// Cumulative skip total across the run.
782 pub ztest_skip_total: std::sync::atomic::AtomicUsize,
783 /// Sticky failure flag — set by any `ztest_run` that observed fails;
784 /// the CLI runner reads this so a test that asserts then exits 0
785 /// still counts as a failed file.
786 pub ztest_run_failed: std::sync::atomic::AtomicBool,
787 /// Suppress per-assertion `✓`/`✗` lines on stderr. Set by the worker
788 /// runner inside the forked child when it has already redirected
789 /// fd 2 to a tmp file (we still want the lines, but only after the
790 /// runner re-emits them under print_lock to avoid line-tearing).
791 pub ztest_suppress_stdout: bool,
792}
793
794/// Context-isolated nested parse — the AST-path bridge for C's
795/// `parse_string` (`Src/exec.c:283`). zshrs executes via the ZshProgram
796/// AST + `compile_zsh`, not wordcode, so this can't live in `src/ported/`
797/// (C's `parse_string` returns `Eprog`, and the build.rs port-gate rejects
798/// any non-C-named fn there). It's the bridge that wraps the AST
799/// `parse_init`+`parse` in the SAME isolation `parse_string` provides.
800///
801/// Why this exists: a runtime parse — command-substitution body,
802/// process-substitution argv — must not clobber the outer
803/// `loop()`/`parse_event` reader's live input when it interleaves parsing
804/// with execution (faithful single-event mode).
805///
806/// The load-bearing piece is `strinbeg(0)`/`strinend()` (c:290/298). It
807/// sets the `strin` flag so that when the nested lexer drains `cmd_str`,
808/// `ingetc` returns EOF (input.rs:391) instead of falling through to
809/// `inputline()`, which would STEAL the outer reader's next SHIN line —
810/// e.g. `echo A` / `v=$(echo hi)` / `echo B` had the cmd-subst swallow
811/// `echo B` off stdin, and the outer loop then hit EOF after one command.
812/// `strinbeg` also runs `hbegin`/`lexinit`, so it must execute on isolated
813/// history+lexer state — hence the surrounding `zcontext_save`/`restore`
814/// (c:288/300), which saves+restores `tok`/`tokstr`/`lexbuf`/`isnewlin`/
815/// `incmdpos`/heredocs/`lexstop`/`toklineno`/history.
816///
817/// Two zshrs-specific globals `zcontext` doesn't cover are saved here too:
818/// the lexer-input window (`LEX_INPUT`/`LEX_POS`/`LEX_UNGET_BUF`) that
819/// `lex_init` overwrites with `cmd_str`, and the line counter `LEX_LINENO`
820/// (C saves `oldlineno` explicitly at parse_string c:291/295).
821///
822/// NOTE: using `inpush` (the literal C input stack) instead does NOT work
823/// in zshrs's hybrid input model — the outer piped reader pulls from SHIN
824/// via `inputline`, and pushing/popping the `inbuf` stack severs that
825/// continuation. The `LEX_INPUT` window + `strin` flag is the working
826/// equivalent.
827pub(crate) fn parse_isolated(input: &str) -> crate::parse::ZshProgram {
828 use crate::ported::lex::{tok, LEXERR, LEX_INPUT, LEX_LINENO, LEX_POS, LEX_UNGET_BUF};
829
830 // Inline Rust FFI: rewrite every `rust { ... }` block into a
831 // `__rust_compile '<base64>' <line>` command before it reaches the lexer.
832 // This is the shared source-string chokepoint for `-c`, script files, and
833 // nested (command/process-substitution) parses. The `.contains("rust")`
834 // gate keeps the common case (no FFI block) allocation-free — the vast
835 // majority of nested parses never mention `rust`.
836 let ffi_desugared = input
837 .contains("rust")
838 .then(|| crate::rust_ffi::desugar(input));
839 let input: &str = ffi_desugared.as_deref().unwrap_or(input);
840
841 crate::ported::context::zcontext_save(); // c:288
842 // Save the zshrs-specific lexer window + line counter that lex_init
843 // overwrites but zcontext doesn't cover.
844 let saved_input = LEX_INPUT.with_borrow(|s| s.clone());
845 let saved_pos = LEX_POS.get();
846 let saved_unget = LEX_UNGET_BUF.with_borrow(|b| b.clone());
847 let saved_lineno = LEX_LINENO.get(); // c:291 oldlineno
848 // input.rs `lexstop` is the input-side half of C's single `lexstop`;
849 // draining the nested LEX_INPUT sets it true and zcontext only covers
850 // the lex.rs half (LEX_LEXSTOP). Restore it so the outer reader isn't
851 // left at EOF.
852 let saved_in_lexstop = crate::ported::input::lexstop.with(|c| c.get());
853
854 crate::ported::hist::strinbeg(0); // c:290 — strin++ → drained nested input EOFs (no SHIN steal)
855 crate::ported::parse::parse_init(input); // install cmd_str as LEX_INPUT (lex_init), LEX_LINENO=1
856 let program = crate::ported::parse::parse(); // c:294 (AST analog of par_list)
857
858 // Capture parse failure BEFORE the restores wipe the signals.
859 let parse_err = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0 || tok() == LEXERR;
860 if tok() == LEXERR && crate::ported::builtin::LASTVAL.load(Ordering::Relaxed) == 0 {
861 crate::ported::builtin::LASTVAL.store(1, Ordering::Relaxed); // c:296-297
862 }
863
864 crate::ported::hist::strinend(); // c:298 — strin--
865 // Restore the zshrs window, then the token/parse/history state.
866 LEX_INPUT.with_borrow_mut(|s| *s = saved_input);
867 LEX_POS.set(saved_pos);
868 LEX_UNGET_BUF.with_borrow_mut(|b| *b = saved_unget);
869 LEX_LINENO.set(saved_lineno); // c:295
870 crate::ported::input::lexstop.with(|c| c.set(saved_in_lexstop));
871 crate::ported::context::zcontext_restore(); // c:300
872 // zcontext_restore → parse_context_restore clears ERRFLAG_ERROR
873 // (parse.c:354); re-raise so callers gating on the bit still see it.
874 if parse_err {
875 errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
876 }
877 program
878}
879
880/// Build the `scriptname[:lineno]` prefix zsh puts on an execution error.
881///
882/// c:Src/utils.c:301 — `zerrmsg` prints the line number ONLY when it is
883/// non-zero: `if ((unset(SHINSTDIN) || locallevel) && lineno) fprintf(file,
884/// "%lld: ", lineno);`. The command-not-found / no-such-file / permission-denied
885/// sites below emit DIRECTLY rather than through `zerr` (deliberately — see
886/// their comments, routing through zerr would set errflag and abort a script
887/// that zsh continues), but they hand-rolled `"{}:{}"` and so printed a bare
888/// `:0` inside a one-line function where zsh prints no line number at all:
889/// `f(){ nosuchcmd }; f` gave `f:0: command not found:` vs zsh's `f: command
890/// not found:`. Mirrors the C condition exactly. Bug #1070.
891fn zerr_prefix(sn: &str) -> String {
892 let lineno = crate::ported::lex::lineno();
893 let ll = crate::ported::params::locallevel.load(std::sync::atomic::Ordering::Relaxed);
894 if (crate::ported::zsh_h::unset(crate::ported::zsh_h::SHINSTDIN) || ll != 0) && lineno != 0 {
895 format!("{}:{}", sn, lineno)
896 } else {
897 sn.to_string()
898 }
899}
900
901thread_local! {
902 /// `(function name, its source file)` while that function's AUTOLOAD body
903 /// is being run. Empty at every other moment.
904 pub static AUTOLOAD_DEF_FILE: std::cell::RefCell<Vec<(String, String)>> =
905 const { std::cell::RefCell::new(Vec::new()) };
906}
907
908/// Scope guard for [`AUTOLOAD_DEF_FILE`].
909pub struct AutoloadFileGuard(bool);
910
911impl AutoloadFileGuard {
912 fn enter(name: &str) -> Self {
913 match crate::ported::hashtable::getshfuncfile(name) {
914 Some(f) => {
915 AUTOLOAD_DEF_FILE.with(|s| s.borrow_mut().push((name.to_string(), f)));
916 Self(true)
917 }
918 None => Self(false),
919 }
920 }
921}
922
923impl Drop for AutoloadFileGuard {
924 fn drop(&mut self) {
925 if self.0 {
926 AUTOLOAD_DEF_FILE.with(|s| {
927 s.borrow_mut().pop();
928 });
929 }
930 }
931}
932
933/// The file `name` is being autoloaded from, if that is happening right now.
934pub fn autoload_def_file(name: &str) -> Option<String> {
935 AUTOLOAD_DEF_FILE.with(|s| {
936 s.borrow()
937 .iter()
938 .rev()
939 .find(|(n, _)| n == name)
940 .map(|(_, f)| f.clone())
941 })
942}
943
944/// !!! WARNING: RUST-ONLY HELPER — NO DIRECT C COUNTERPART !!!
945///
946/// C's `zexecve` (Src/exec.c:504-643) performs the whole `#!` recovery
947/// in place: it is only ever reached in the already-forked child that is
948/// about to BECOME the command, so at c:566/571/581/585/627 it simply
949/// calls `execve()` a second time and never returns. zshrs reaches the
950/// same decision from a second call site — the `std::process::Command`
951/// spawn in `vm_helper::execute_external_bg` — which hands argv to the
952/// kernel from the PARENT process and therefore cannot "re-exec in
953/// place". This helper is c:534-634 verbatim with each of those five
954/// `execve(prog, argv)` calls replaced by `Ok((prog, argv))`, so both
955/// call sites share one implementation of the shebang rules.
956///
957/// `Err(eno)` is what C's `return eno` (c:643) would hand back — either
958/// the original `eno` or the `errno` from a failed open/read (c:632/634).
959#[allow(non_snake_case)]
960pub fn zexecve_recover(pth: &str, argv: &[String], eno: i32) -> Result<(String, Vec<String>), i32> {
961 if eno == libc::ENOEXEC || eno == libc::ENOENT {
962 // c:534
963 let cpth = match std::ffi::CString::new(pth) {
964 Ok(c) => c,
965 Err(_) => return Err(libc::ENOENT),
966 };
967 let fd = unsafe { libc::open(cpth.as_ptr(), libc::O_RDONLY | libc::O_NOCTTY) }; // c:538
968 if fd < 0 {
969 // c:633-634 — `} else eno = errno;` then fall through to `return eno`.
970 return Err(std::io::Error::last_os_error()
971 .raw_os_error()
972 .unwrap_or(libc::ENOENT));
973 }
974 let mut buf = vec![0u8; crate::ported::exec::POUNDBANGLIMIT + 1]; // c:541
975 let ct = unsafe {
976 libc::read(
977 fd,
978 buf.as_mut_ptr() as *mut libc::c_void,
979 crate::ported::exec::POUNDBANGLIMIT as libc::size_t,
980 )
981 }; // c:542
982 unsafe {
983 libc::close(fd);
984 } // c:543
985 if ct >= 0 {
986 // c:544
987 let ct = ct as usize;
988 if ct >= 2 && buf[0] == b'#' && buf[1] == b'!' {
989 // c:545
990 let mut t0 = 0;
991 while t0 < ct && buf[t0] != b'\n' {
992 t0 += 1;
993 } // c:546-548
994 if t0 == ct {
995 // c:549
996 // c:550 `zerr(...)`. C is inside the forked child that is
997 // about to `_exit`, so the errflag `zerr` raises is
998 // irrelevant there. This runs in the PARENT, where a raised
999 // errflag aborts the enclosing script — `( if
1000 // bad-interp-cmd; then exit 0; else exit 1; fi )` returned
1001 // 127 instead of running its else branch. `zwarn`
1002 // (utils.rs:260) emits the identical text without the flag.
1003 crate::ported::utils::zwarn(&format!(
1004 // c:550
1005 "{}: bad interpreter: {}: {}",
1006 pth,
1007 String::from_utf8_lossy(&buf[2..t0.min(ct)]),
1008 std::io::Error::from_raw_os_error(eno)
1009 ));
1010 } else {
1011 // c:552
1012 while t0 > 0 && (buf[t0] == b' ' || buf[t0] == b'\t' || buf[t0] == b'\n') {
1013 buf[t0] = 0;
1014 t0 -= 1;
1015 } // c:553-554
1016 let mut ptr_lo: usize = 2;
1017 while ptr_lo < buf.len() && buf[ptr_lo] == b' ' {
1018 ptr_lo += 1;
1019 } // c:555
1020 let ptr2_lo = ptr_lo;
1021 let mut ptr_hi = ptr2_lo;
1022 while ptr_hi < buf.len() && buf[ptr_hi] != 0 && buf[ptr_hi] != b' ' {
1023 ptr_hi += 1;
1024 } // c:556
1025 let interp_str = String::from_utf8_lossy(&buf[ptr2_lo..ptr_hi]).into_owned();
1026 if eno == libc::ENOENT {
1027 // c:557 — pathprog rewrite path.
1028 let pprog = if !interp_str.starts_with('/') {
1029 // c:561
1030 crate::ported::utils::pathprog(&interp_str)
1031 .map(|p| p.display().to_string())
1032 } else {
1033 None
1034 };
1035 if let Some(pprog) = pprog {
1036 // c:562
1037 let mut argv_new: Vec<String> = Vec::with_capacity(argv.len() + 2);
1038 argv_new.push(interp_str.clone()); // c:564
1039 if ptr_hi >= buf.len() || buf[ptr_hi] == 0 {
1040 argv_new.push(pth.to_string());
1041 } else {
1042 // c:567
1043 let mut rest_lo = ptr_hi + 1;
1044 while rest_lo < buf.len() && buf[rest_lo] == b' ' {
1045 rest_lo += 1;
1046 }
1047 let mut rest_hi = rest_lo;
1048 while rest_hi < buf.len() && buf[rest_hi] != 0 {
1049 rest_hi += 1;
1050 }
1051 let arg_str =
1052 String::from_utf8_lossy(&buf[rest_lo..rest_hi]).into_owned();
1053 argv_new.push(arg_str);
1054 argv_new.push(pth.to_string());
1055 }
1056 for orig in argv.iter().skip(1) {
1057 argv_new.push(orig.clone());
1058 }
1059 crate::ported::signals_h::winch_unblock(); // c:565/c:570
1060 return Ok((pprog, argv_new)); // c:566/c:571
1061 }
1062 crate::ported::utils::zwarn(&format!(
1063 // c:574 — `zerr`; see the c:550 note above for why
1064 // this is `zwarn` in the parent-side port.
1065 "{}: bad interpreter: {}: {}",
1066 pth,
1067 interp_str,
1068 std::io::Error::from_raw_os_error(eno)
1069 ));
1070 } else if ptr_hi < buf.len() && buf[ptr_hi] != 0 {
1071 // c:576
1072 let mut rest_lo = ptr_hi + 1;
1073 while rest_lo < buf.len() && buf[rest_lo] == b' ' {
1074 rest_lo += 1;
1075 }
1076 let mut rest_hi = rest_lo;
1077 while rest_hi < buf.len() && buf[rest_hi] != 0 {
1078 rest_hi += 1;
1079 }
1080 let arg_str = String::from_utf8_lossy(&buf[rest_lo..rest_hi]).into_owned();
1081 let mut argv_new: Vec<String> =
1082 vec![interp_str.clone(), arg_str, pth.to_string()];
1083 for orig in argv.iter().skip(1) {
1084 argv_new.push(orig.clone());
1085 }
1086 crate::ported::signals_h::winch_unblock(); // c:580
1087 return Ok((interp_str, argv_new)); // c:581
1088 } else {
1089 // c:582
1090 let mut argv_new: Vec<String> = vec![interp_str.clone(), pth.to_string()];
1091 for orig in argv.iter().skip(1) {
1092 argv_new.push(orig.clone());
1093 }
1094 crate::ported::signals_h::winch_unblock(); // c:584
1095 return Ok((interp_str, argv_new)); // c:585
1096 }
1097 }
1098 } else if eno == libc::ENOEXEC {
1099 // c:588 — binary-safety + /bin/sh fallback.
1100 let nul_pos = buf[..ct].iter().position(|&b| b == 0); // c:597
1101 let isbinary = match nul_pos {
1102 None => false, // c:598
1103 Some(npos) => {
1104 let mut has_letter = false;
1105 let mut binary = true;
1106 for &b in &buf[..npos] {
1107 // c:602-609
1108 if (b as char).is_ascii_lowercase() || b == b'$' || b == b'`' {
1109 has_letter = true;
1110 }
1111 if has_letter && b == b'\n' {
1112 binary = false; // c:606
1113 break;
1114 }
1115 }
1116 binary
1117 }
1118 };
1119 if !isbinary {
1120 // c:611
1121 let mut argv_new: Vec<String> = Vec::with_capacity(argv.len() + 2);
1122 argv_new.push("sh".to_string()); // c:625
1123 if !argv.is_empty() && (argv[0].starts_with('-') || argv[0].starts_with('+')) {
1124 argv_new.push("-".to_string()); // c:623
1125 }
1126 for orig in argv.iter() {
1127 argv_new.push(orig.clone());
1128 }
1129 crate::ported::signals_h::winch_unblock(); // c:626
1130 return Ok(("/bin/sh".to_string(), argv_new)); // c:627
1131 }
1132 }
1133 }
1134 }
1135 Err(eno) // c:643
1136}
1137
1138impl ShellExecutor {
1139 /// Set a scalar parameter via the canonical `paramtab`
1140 /// (`Src/params.c:3350 setsparam`). The single store.
1141 pub fn set_scalar(&mut self, name: String, value: String) {
1142 setsparam(&name, &value); // c:params.c:3350
1143 }
1144
1145 /// Read positional parameters from canonical `PPARAMS`
1146 /// `Mutex<Vec<String>>` (Src/init.c:pparams). The single store.
1147 pub fn pparams(&self) -> Vec<String> {
1148 crate::ported::builtin::PPARAMS
1149 .lock()
1150 .map(|p| p.clone())
1151 .unwrap_or_default()
1152 }
1153
1154 /// Write positional parameters to canonical `PPARAMS`.
1155 pub fn set_pparams(&mut self, params: Vec<String>) {
1156 if let Ok(mut p) = crate::ported::builtin::PPARAMS.lock() {
1157 *p = params;
1158 }
1159 }
1160
1161 /// Read PM_* type flags from the paramtab Param entry. Used by
1162 /// SET_VAR / `+=` arms (case-fold, integer-add, readonly guard).
1163 /// Returns 0 when the name isn't in paramtab. Mirrors the C
1164 /// source's direct `pm->node.flags & PM_INTEGER` checks.
1165 pub fn param_flags(&self, name: &str) -> i32 {
1166 paramtab()
1167 .read()
1168 .ok()
1169 .and_then(|t| t.get(name).map(|p| p.node.flags))
1170 .unwrap_or(0)
1171 }
1172
1173 /// `readonly` / `typeset -r` / read-only-by-design (LINENO, PPID,
1174 /// $$, $?, $!, ...) — match user-side rejection in C's
1175 /// assignstrvalue at `Src/params.c:2699-2703` which gates on
1176 /// `pm->node.flags & PM_READONLY` where the IPDEF4 family declares
1177 /// `PM_READONLY_SPECIAL = PM_SPECIAL | PM_READONLY | PM_RO_BY_DESIGN`
1178 /// (all three bits set together), which `init_partab_params` now
1179 /// stamps in full. The PM_RO_BY_DESIGN arm below is therefore no
1180 /// longer the IPDEF4 rows' only read-only marker — it remains
1181 /// because `private` params (c:Src/Modules/param_private.c:174)
1182 /// carry PM_RO_BY_DESIGN WITHOUT PM_READONLY and need the
1183 /// scope-gated test. Bug #418-family / test_lineno_intrinsic_readonly.
1184 pub fn is_readonly_param(&self, name: &str) -> bool {
1185 let (flags, pm_level) = crate::ported::params::paramtab()
1186 .read()
1187 .ok()
1188 .and_then(|t| t.get(name).map(|p| (p.node.flags as u32, p.level)))
1189 .unwrap_or((0, 0));
1190 // c:Src/params.c assignsparam — a real PM_READONLY param always
1191 // rejects writes.
1192 if (flags & PM_READONLY) != 0 {
1193 return true;
1194 }
1195 if (flags & crate::ported::zsh_h::PM_RO_BY_DESIGN) != 0 {
1196 // c:Src/Modules/param_private.c pps_setfn (c:300-307) — a
1197 // PRIVATE param (PM_RO_BY_DESIGN + PM_REMOVABLE) is NOT blanket
1198 // read-only: a write is permitted iff it is in the SAME scope
1199 // (`locallevel == pm->level`, e.g. `() { private p=1; p=2 }`
1200 // → 2) or above the wrap level (`locallevel >
1201 // private_wraplevel`). A deeper nested-scope write is rejected
1202 // (setfn_error) — that is how a nested fn writing an OUTER
1203 // function's private still errors and aborts. zshrs never
1204 // wires the private GSU, so the level gate is enforced here.
1205 if (flags & crate::ported::zsh_h::PM_REMOVABLE) != 0 {
1206 let ll = crate::ported::params::locallevel.load(Ordering::Relaxed);
1207 let wrap = crate::ported::modules::param_private::private_wraplevel
1208 .load(Ordering::Relaxed);
1209 return !(ll == pm_level || ll > wrap); // c:304 (negated: blocked)
1210 }
1211 // Non-removable PM_RO_BY_DESIGN = IPDEF4-family special
1212 // (LINENO/$?/$$…). These now also carry PM_READONLY and so
1213 // return true from the branch above; this arm stays as the
1214 // c:Src/zsh.h:1923 "readonly by design" fallback for any row
1215 // reached before `init_partab_params` has stamped the flag.
1216 return true;
1217 }
1218 false
1219 }
1220
1221 /// Most-recent-command exit status. Reads canonical
1222 /// `builtin::LASTVAL` AtomicI32 (`Src/builtin.c:6443`).
1223 pub fn last_status(&self) -> i32 {
1224 crate::ported::builtin::LASTVAL.load(Ordering::Relaxed)
1225 }
1226
1227 /// Write the most-recent-command exit status. The canonical
1228 /// store is `builtin::LASTVAL`; this is the single setter.
1229 /// Used everywhere `$?` / `%?` / errexit / ZERR trap read.
1230 pub fn set_last_status(&mut self, status: i32) {
1231 crate::ported::builtin::LASTVAL.store(status, Ordering::Relaxed);
1232 }
1233
1234 /// Set an indexed array parameter via canonical paramtab
1235 /// (`setaparam`, `Src/params.c:3595`). The single store.
1236 pub fn set_array(&mut self, name: String, value: Vec<String>) {
1237 setaparam(&name, value); // c:params.c:3595
1238 }
1239
1240 /// Set an associative array parameter via canonical
1241 /// `sethparam` (`Src/params.c:3602`). The single store.
1242 pub fn set_assoc(&mut self, name: String, value: IndexMap<String, String>) {
1243 let mut flat: Vec<String> = Vec::with_capacity(value.len() * 2);
1244 for (k, v) in &value {
1245 flat.push(k.clone());
1246 flat.push(v.clone());
1247 }
1248 sethparam(&name, flat); // c:params.c:3602
1249 }
1250
1251 /// Read a scalar parameter. Mirrors C `getsparam` at
1252 /// `Src/params.c:3076` — reads through paramtab, falls back to
1253 /// special-var hooks and env.
1254 pub fn scalar(&self, name: &str) -> Option<String> {
1255 getsparam(name)
1256 }
1257
1258 /// Read an array parameter via canonical `getaparam`
1259 /// (`Src/params.c:3101`).
1260 pub fn array(&self, name: &str) -> Option<Vec<String>> {
1261 getaparam(name)
1262 }
1263
1264 /// Read an associative array parameter from canonical
1265 /// `paramtab_hashed_storage`. Mirrors C `gethparam` at
1266 /// `Src/params.c:3115` — returns the typed `IndexMap`.
1267 pub fn assoc(&self, name: &str) -> Option<IndexMap<String, String>> {
1268 // c:Src/params.c:570-575 — nameref deref before the read.
1269 let resolved = match crate::ported::params::resolve_nameref_name(name, None) {
1270 crate::ported::params::nameref_resolution::Target { name: t_, .. } => t_,
1271 _ => name.to_string(),
1272 };
1273 // The live param's TYPE is authoritative — paramtab_hashed_storage is
1274 // keyed by NAME ONLY (no scope), so a local ARRAY that shadows a special
1275 // assoc (`local -a options` / `local -a commands` over the hidden
1276 // `options`/`commands` specials) leaves a stale (emptied) hashed_storage
1277 // entry behind. Without this guard `exec.assoc("options")` returned
1278 // Some(empty), so a subsequent bare `options=(-a -b -c)` routed through
1279 // sethparam → "bad set of key/value pairs for associative array"
1280 // (odd count), breaking `_sqlite`'s `local -a options; options=(…)`
1281 // (separate statements — the one-statement `local -a commands=(…)` form
1282 // that openssl uses is typed correctly by bin_typeset). Consult the
1283 // current param: if it exists and is NOT PM_HASHED, it's an array/scalar
1284 // shadow and the stale assoc storage must not be seen.
1285 if let Some(flags) = crate::ported::params::paramtab()
1286 .read()
1287 .ok()
1288 .and_then(|t| t.get(resolved.as_str()).map(|p| p.node.flags as u32))
1289 {
1290 if (flags & PM_HASHED) == 0 {
1291 return None;
1292 }
1293 }
1294 paramtab_hashed_storage()
1295 .lock()
1296 .ok()
1297 .and_then(|m| m.get(resolved.as_str()).cloned())
1298 }
1299
1300 /// Test whether a scalar parameter exists in paramtab.
1301 /// Mirrors the C `paramtab->getnode(name) != NULL` check.
1302 pub fn has_scalar(&self, name: &str) -> bool {
1303 getsparam(name).is_some()
1304 }
1305
1306 /// Test whether an array parameter exists in paramtab. Mirrors
1307 /// `getaparam(name).is_some()` (PM_ARRAY + populated `u_arr`, with
1308 /// digit-first-name rejection and nameref deref) WITHOUT cloning the
1309 /// backing vector — `getaparam` returns an owned `Vec<String>`, so a
1310 /// bare existence probe on a large array copied every element. Hot in
1311 /// the subscript-store dispatch (`a[i]=v` in a loop), so keep it a
1312 /// flag read.
1313 pub fn has_array(&self, name: &str) -> bool {
1314 if name.starts_with(|c: char| c.is_ascii_digit()) {
1315 return false;
1316 }
1317 let resolved = match crate::ported::params::resolve_nameref_name(name, None) {
1318 crate::ported::params::nameref_resolution::Target { name: t_, .. } => t_,
1319 _ => name.to_string(),
1320 };
1321 crate::ported::params::paramtab()
1322 .read()
1323 .ok()
1324 .and_then(|t| {
1325 t.get(resolved.as_str()).map(|p| {
1326 (p.node.flags as u32 & crate::ported::zsh_h::PM_ARRAY) != 0 && p.u_arr.is_some()
1327 })
1328 })
1329 .unwrap_or(false)
1330 }
1331
1332 /// Test whether an associative array parameter exists. Reads
1333 /// canonical `paramtab_hashed_storage` (Src/params.c hashed
1334 /// PM_HASHED slot).
1335 pub fn has_assoc(&self, name: &str) -> bool {
1336 // c:Src/params.c:570-575 — nameref deref before the read.
1337 let resolved = match crate::ported::params::resolve_nameref_name(name, None) {
1338 crate::ported::params::nameref_resolution::Target { name: t_, .. } => t_,
1339 _ => name.to_string(),
1340 };
1341 // Live-param type is authoritative (see `assoc` above): a non-PM_HASHED
1342 // shadow (e.g. `local -a options`) hides the stale name-keyed
1343 // hashed_storage entry.
1344 if let Some(flags) = crate::ported::params::paramtab()
1345 .read()
1346 .ok()
1347 .and_then(|t| t.get(resolved.as_str()).map(|p| p.node.flags as u32))
1348 {
1349 if (flags & PM_HASHED) == 0 {
1350 return false;
1351 }
1352 }
1353 paramtab_hashed_storage()
1354 .lock()
1355 .ok()
1356 .map(|m| m.contains_key(resolved.as_str()))
1357 .unwrap_or(false)
1358 }
1359
1360 /// Unset an associative array parameter via canonical
1361 /// `unsetparam` (Src/params.c:3819) — PM_READONLY rejection,
1362 /// stdunsetfn dispatch, env clear. Also clears the zshrs-side
1363 /// `paramtab_hashed_storage` parallel IndexMap shadow.
1364 pub fn unset_assoc(&mut self, name: &str) {
1365 unsetparam(name);
1366 let _ = paramtab_hashed_storage()
1367 .lock()
1368 .ok()
1369 .as_deref_mut()
1370 .map(|m| m.remove(name));
1371 }
1372
1373 /// Read a regular (non-global) alias value. Reads canonical
1374 /// `aliastab` (Src/hashtable.c:1186). Filters out aliases that
1375 /// have the ALIAS_GLOBAL flag set so the regular-alias slot is
1376 /// distinct from the global-alias slot, mirroring C's two
1377 /// separate dispatch paths via `aliasflags` checks.
1378 pub fn alias(&self, name: &str) -> Option<String> {
1379 let tab = crate::ported::hashtable::aliastab_lock().read().ok()?;
1380 let a = tab.get(name)?;
1381 if (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0 {
1382 None
1383 } else {
1384 Some(a.text.clone())
1385 }
1386 }
1387
1388 /// Set a regular alias. Writes canonical aliastab with
1389 /// ALIAS_GLOBAL bit cleared.
1390 pub fn set_alias(&mut self, name: String, value: String) {
1391 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
1392 tab.add(crate::ported::hashtable::createaliasnode(&name, &value, 0));
1393 }
1394 }
1395
1396 /// Set a global alias (`alias -g`). Writes canonical aliastab
1397 /// with ALIAS_GLOBAL bit set.
1398 pub fn set_global_alias(&mut self, name: String, value: String) {
1399 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
1400 tab.add(crate::ported::hashtable::createaliasnode(
1401 &name,
1402 &value,
1403 crate::ported::zsh_h::ALIAS_GLOBAL as u32,
1404 ));
1405 }
1406 }
1407
1408 /// Set a suffix alias (`alias -s ext=cmd`). Writes canonical
1409 /// sufaliastab with ALIAS_SUFFIX node flag — mirrors C
1410 /// Src/builtin.c:4480-4481 (`flags1 |= ALIAS_SUFFIX; ht =
1411 /// sufaliastab;`) → c:4527 (`createaliasnode(value, flags1)`).
1412 /// Without ALIAS_SUFFIX in node.flags, `${saliases[k]}` /
1413 /// `${(k)saliases}` introspection (parameter.c:1953/2018) fails
1414 /// because both paths strict-equality-match flags == ALIAS_SUFFIX.
1415 pub fn set_suffix_alias(&mut self, name: String, value: String) {
1416 if let Ok(mut tab) = crate::ported::hashtable::sufaliastab_lock().write() {
1417 tab.add(crate::ported::hashtable::createaliasnode(
1418 &name,
1419 &value,
1420 crate::ported::zsh_h::ALIAS_SUFFIX as u32,
1421 ));
1422 }
1423 }
1424
1425 /// Snapshot the alias map as a sorted `Vec<(name, value)>`,
1426 /// only entries WITHOUT the ALIAS_GLOBAL flag (regular aliases).
1427 pub fn alias_entries(&self) -> Vec<(String, String)> {
1428 if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
1429 tab.iter_sorted()
1430 .into_iter()
1431 .filter(|(_, a)| (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) == 0)
1432 .map(|(k, a)| (k.clone(), a.text.clone()))
1433 .collect()
1434 } else {
1435 Vec::new()
1436 }
1437 }
1438
1439 /// Snapshot the global-alias entries (ALIAS_GLOBAL flag set).
1440 pub fn global_alias_entries(&self) -> Vec<(String, String)> {
1441 if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
1442 tab.iter_sorted()
1443 .into_iter()
1444 .filter(|(_, a)| (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0)
1445 .map(|(k, a)| (k.clone(), a.text.clone()))
1446 .collect()
1447 } else {
1448 Vec::new()
1449 }
1450 }
1451
1452 /// Snapshot the suffix-alias entries.
1453 pub fn suffix_alias_entries(&self) -> Vec<(String, String)> {
1454 if let Ok(tab) = crate::ported::hashtable::sufaliastab_lock().read() {
1455 tab.iter_sorted()
1456 .into_iter()
1457 .map(|(k, a)| (k.clone(), a.text.clone()))
1458 .collect()
1459 } else {
1460 Vec::new()
1461 }
1462 }
1463
1464 /// Unset an array parameter. Direct port of `unsetparam_pm` for
1465 /// a PM_ARRAY Param. Mirrors are kept for now while the field
1466 /// transitions.
1467 /// Unset an array parameter via canonical `unsetparam`
1468 /// (Src/params.c:3819). Routes through the C-faithful port
1469 /// that runs PM_NAMEREF skip + PM_READONLY rejection via
1470 /// unsetparam_pm + stdunsetfn dispatch + pm.old scope restore.
1471 /// Inline `tab.remove(name)` skipped all four.
1472 pub fn unset_array(&mut self, name: &str) {
1473 unsetparam(name);
1474 }
1475
1476 /// Unset a scalar parameter via canonical `unsetparam`. Same
1477 /// C-faithful path as `unset_array`; the C `unsetparam` itself
1478 /// is type-agnostic and dispatches through PM_TYPE inside.
1479 pub fn unset_scalar(&mut self, name: &str) {
1480 unsetparam(name);
1481 }
1482 /// Lightweight executor for a POOL WORKER THREAD. Unlike [`new`] (a full
1483 /// session bootstrap that re-derives PWD, imports the environment, seeds
1484 /// `OPTS_LIVE`, and writes ~30 default params into the GLOBAL param table),
1485 /// this constructs ONLY the per-executor struct fields and touches NO
1486 /// global state. A worker shares the already-populated, `RwLock`-synchronized
1487 /// globals (params / functions / options); re-seeding them here would clobber
1488 /// the live main session's values (IFS, OPTIND, `$_`, user options, …).
1489 ///
1490 /// The worker pool is shared (Arc) — a worker never spins up its own pool.
1491 /// Per-worker SQLite caches (compsys / plugin) and the history engine are
1492 /// left `None`: a worker runs short compute bodies, not interactive editing.
1493 ///
1494 /// Phase 1 of the in-process thread-execution model (replaces the
1495 /// subprocess-forking parallel builtins). The caller runs the body under
1496 /// `ExecutorContext::enter(&mut wex)` so the VM's thread_local executor
1497 /// resolves on the worker thread; param writes flow to the shared globals.
1498 pub fn new_worker(pool: std::sync::Arc<crate::worker::WorkerPool>) -> Self {
1499 // fpath from the inherited env, same as new() — pure read, no global write.
1500 let fpath = env::var("FPATH")
1501 .unwrap_or_default()
1502 .split(':')
1503 .filter(|s| !s.is_empty())
1504 .map(PathBuf::from)
1505 .collect();
1506 Self {
1507 scriptname: Some("zsh".to_string()),
1508 scriptfilename: Some("zsh".to_string()),
1509 subshell_snapshots: Vec::new(),
1510 inline_env_stack: Vec::new(),
1511 current_command_glob_failed: std::cell::Cell::new(false),
1512 jobs: JobTable::new(),
1513 fpath,
1514 history: None, // worker: no interactive history engine
1515 completions: HashMap::new(),
1516 process_sub_counter: 0,
1517 zstyles: Vec::new(),
1518 local_scope_depth: 0,
1519 pending_underscore: None,
1520 in_dq_context: 0,
1521 in_scalar_assign: 0,
1522 profiling_enabled: false,
1523 compsys_cache: std::cell::OnceCell::from(None), // worker: no per-thread SQLite mirror
1524 compinit_pending: None,
1525 plugin_cache: None, // worker: no per-thread plugin cache
1526 deferred_compdefs: Vec::new(),
1527 returning: None,
1528 zsh_compat: false,
1529 bash_compat: false,
1530 posix_mode: false,
1531 worker_pool: pool, // SHARED — never spawn a nested pool
1532 intercepts: Vec::new(),
1533 async_jobs: HashMap::new(),
1534 next_async_id: 1,
1535 redirect_scope_stack: Vec::new(),
1536 multios_scope_stack: Vec::new(),
1537 exec_redirs_permanent: false,
1538 pipe_output_pending: false,
1539 pipe_output_scope: None,
1540 redirect_failed: false,
1541 functions_compiled: HashMap::new(),
1542 function_source: HashMap::new(),
1543 function_line_base: HashMap::new(),
1544 function_def_file: HashMap::new(),
1545 prompt_funcstack: Vec::new(),
1546 tied_array_to_scalar: HashMap::new(),
1547 ztest_pass_count: std::sync::atomic::AtomicUsize::new(0),
1548 ztest_fail_count: std::sync::atomic::AtomicUsize::new(0),
1549 ztest_skip_count: std::sync::atomic::AtomicUsize::new(0),
1550 ztest_pass_total: std::sync::atomic::AtomicUsize::new(0),
1551 ztest_fail_total: std::sync::atomic::AtomicUsize::new(0),
1552 ztest_skip_total: std::sync::atomic::AtomicUsize::new(0),
1553 ztest_run_failed: std::sync::atomic::AtomicBool::new(false),
1554 ztest_suppress_stdout: false,
1555 }
1556 }
1557
1558 /// `new` — see implementation.
1559 pub fn new() -> Self {
1560 tracing::debug!("ShellExecutor::new() initializing");
1561
1562 // c:Src/init.c:1236-1259 — setupvals' pwd/oldpwd init, ported
1563 // here because the bin entry skips setupvals (see the
1564 // init_bltinmods note below). The validated value lands in the
1565 // live OS env: the bin entry's `$PWD` carrier (the analog of
1566 // C's `pwd` global — see the subshell-snapshot comment at
1567 // fusevm_bridge.rs `cwd:` field). set_pwd_env() pours it into
1568 // paramtab after the env-import loop, same order as C
1569 // (params.c:955).
1570 //
1571 // c:1242-1245 — "Try a cheap test to see if we can initialize
1572 // `PWD' from `HOME'." EMULATE_ZSH reads the `home` global,
1573 // which setupvals derives from getpwuid(getuid())->pw_dir
1574 // (c:1222-1225), falling back to "/" (c:1230-1232).
1575 let home = unsafe {
1576 let pw = libc::getpwuid(libc::getuid());
1577 if pw.is_null() {
1578 None
1579 } else {
1580 Some(
1581 std::ffi::CStr::from_ptr((*pw).pw_dir)
1582 .to_string_lossy()
1583 .into_owned(),
1584 )
1585 }
1586 }
1587 .unwrap_or_else(|| "/".to_string()); // c:1230-1232 EMULATE_ZSH home = "/"
1588 // ispwd (src/zsh/Src/utils.c:809-829): a candidate is honored
1589 // only when it (a) is absolute, (b) stat's to the same
1590 // dev+inode as ".", and (c) has no `.`/`..` components.
1591 // Without this chain, a child that inherits $PWD from a parent
1592 // run in a different directory (cargo test setting
1593 // current_dir(tempdir) while leaking PWD=/project/root) treats
1594 // the stale PWD as the logical-path base, so `cd sub` resolves
1595 // against the wrong directory.
1596 let pwd_val = if ispwd(&home) {
1597 home // c:1245-1246 — pwd = ztrdup(ptr) [HOME]
1598 } else if let Some(p) = env::var("PWD")
1599 .ok()
1600 .filter(|p| p.len() < libc::PATH_MAX as usize && ispwd(p))
1601 {
1602 p // c:1247-1249 — pwd = ztrdup(getenv("PWD"))
1603 } else {
1604 crate::ported::compat::zgetcwd() // c:1250-1252 — pwd = zgetcwd()
1605 };
1606 env::set_var("PWD", &pwd_val);
1607 // c:1255-1259 — oldpwd = getenv("OLDPWD") ?: ztrdup(pwd).
1608 if env::var("OLDPWD").is_err() {
1609 env::set_var("OLDPWD", &pwd_val); // c:1257
1610 }
1611
1612 // Initialize fpath from FPATH env var or use defaults
1613 let fpath = env::var("FPATH")
1614 .unwrap_or_default()
1615 .split(':')
1616 .filter(|s| !s.is_empty())
1617 .map(PathBuf::from)
1618 .collect();
1619
1620 let history = HistoryEngine::new().ok();
1621
1622 // Seed canonical OPTS_LIVE with defaults BEFORE any setsparam
1623 // call. assignstrvalue early-returns when `unset(EXECOPT)`
1624 // (c:2701 guard); without the option table populated, EXECOPT
1625 // reads false and every paramtab write below is a silent no-op.
1626 if opt_state_len() == 0 {
1627 for (k, v) in Self::default_options() {
1628 opt_state_set(&k, v);
1629 }
1630 }
1631
1632 // c:Src/params.c:838-847 — `for (ip = special_params; ip->node.nam;
1633 // ip++) paramtab->addnode(paramtab, ztrdup(ip->node.nam), ip);`
1634 // The specials go into paramtab FIRST, ahead of every non-special
1635 // seed below, because creation ORDER is observable: a new key is
1636 // front-inserted into its bucket chain (c:Src/hashtable.c:214-215)
1637 // and `${(k)parameters}` prints that chain walk verbatim
1638 // (c:Src/hashtable.c:420-434). Seeding NULLCMD / FUNCNEST / PS1 /
1639 // … before this loop put them AHEAD of the specials they follow in
1640 // the C table (`#` at c:304 vs NULLCMD at c:378; UID at c:312 vs
1641 // FUNCNEST at c:366), which is exactly where zshrs's parameter
1642 // order diverged from zsh's. With the table seeded first, those
1643 // later `setsparam`/`setiparam` calls hit an existing node and
1644 // replace it IN PLACE (c:187-203 `replacing:`), keeping C's slot.
1645 // c:Src/params.c:384-394 — IPDEF8/IPDEF9 macros stamp
1646 // `PM_SCALAR|PM_SPECIAL` (IPDEF8 for `PATH`/`FPATH`/etc.) and
1647 // `PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT` (IPDEF9 for `path`/
1648 // `fpath`/etc.) on every entry in the createparamtable table.
1649 // setsparam/setaparam above create plain PM_SCALAR/PM_ARRAY
1650 // entries; this loop applies the PM_SPECIAL + PM_TIED bits
1651 // (plus the IPDEF9 PM_DONTIMPORT bit on the array side) so
1652 // `${(t)PATH}` reads `scalar-tied-export-special` and
1653 // `${(t)path}` reads `array-tied-special`.
1654 //
1655 // Walks the `special_params` table (params.rs:464+) which is
1656 // the Rust port of the C IPDEF list. For each entry: OR the
1657 // declared pm_flags onto the existing paramtab entry. The
1658 // tied-pair entries (PM_TIED) also need PM_SPECIAL OR'd in
1659 // since the IPDEF8/IPDEF9 macros add PM_SPECIAL implicitly;
1660 // the table declares only the per-entry-distinct flags.
1661 let stamp_special_params = || {
1662 use crate::ported::params::{paramtab, special_params};
1663 use crate::ported::zsh_h::{PM_ARRAY, PM_DONTIMPORT, PM_SCALAR, PM_SPECIAL, PM_TIED};
1664 if let Ok(mut tab) = paramtab().write() {
1665 // Stamp PM_SPECIAL onto every entry the special_params
1666 // table declares. For tied scalars (PATH/FPATH/etc),
1667 // also walks `tied_name` to apply IPDEF9-flag bits
1668 // (PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT|PM_TIED) onto the
1669 // partner array entry (path/fpath/etc) — those array
1670 // names aren't in the special_params table directly
1671 // but C zsh's createparamtable emits IPDEF9 rows for
1672 // them at Src/params.c:425-432.
1673 use crate::ported::zsh_h::{hashnode, param, PM_DONTIMPORT as PM_DI, PM_UNSET};
1674 for entry in special_params.iter() {
1675 // c:384/394 IPDEF8/9 — `D|PM_SCALAR|PM_SPECIAL` or
1676 // `D|PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT`.
1677 //
1678 // Mask `entry.pm_flags` to the attribute bits that
1679 // may be OR'd onto an existing Param.
1680 //
1681 // PM_READONLY IS included. C declares it on 16 rows
1682 // via `PM_READONLY_SPECIAL` (c:Src/zsh.h:1925 —
1683 // `PM_SPECIAL|PM_READONLY|PM_RO_BY_DESIGN`): the
1684 // IPDEF1 pair `#`/`TTYIDLE` (c:304,314), IPDEF2 `-`
1685 // (c:318), the IPDEF4 block `!`/`$`/`?`/`HISTCMD`/
1686 // `LINENO`/`PPID`/`ZSH_SUBSHELL` (c:351-358) plus
1687 // `status` (c:424), IPDEF9 `*`/`@` (c:392-393) and
1688 // `zsh_eval_context` (c:438), and IPDEF8
1689 // `ZSH_EVAL_CONTEXT` (c:408). `special_params`
1690 // (params.rs:477+) declares exactly those 16 and no
1691 // others, so the bit lands on precisely C's set.
1692 //
1693 // An earlier revision stripped PM_READONLY here to
1694 // keep internal-runtime writes from tripping
1695 // `assignstrvalue`'s guard. Nearly all of those
1696 // writers already mutate the paramtab node in place
1697 // — exactly as C writes the backing C global behind
1698 // a no-op GSU (c:Src/params.c:351, IPDEF4 uses
1699 // `varint_readonly_gsu` =
1700 // `{intvargetfn, nullintsetfn, stdunsetfn}`). The
1701 // in-place sites are `fusevm_bridge.rs:242,261,13045`
1702 // (ZSH_SUBSHELL bump on `u_val`) and
1703 // `fusevm_bridge.rs:12590,12594,12714,12718` +
1704 // `exec.rs:7651,7655` (zsh_eval_context /
1705 // ZSH_EVAL_CONTEXT push+pop). None call
1706 // `setsparam`/`setiparam`.
1707 //
1708 // The ONE writer that did route through `setsparam`
1709 // was `endparamscope`'s deferred scope-pop restore
1710 // (`params.rs`, the `None =>` arm of the `deferred`
1711 // loop): with no GSU wired it name-routes the
1712 // restore and so re-entered the guard, emitting a
1713 // spurious `read-only variable: NAME` while
1714 // unwinding a scope that had shadowed one. C cannot
1715 // reach the guard there because c:5915-5933 calls
1716 // the setfn directly. That arm now drops the bit for
1717 // the duration of the restore, matching C.
1718 //
1719 // With that handled, restoring the flag costs the
1720 // runtime nothing while making `typeset X=v`,
1721 // `readonly X=v` and `X+=v` reject the way
1722 // c:Src/params.c:3216 does, and making
1723 // `paramtypestr` (c:Src/Modules/parameter.c:75-76)
1724 // emit the `-readonly` component that
1725 // `${parameters[X]}` is read for.
1726 //
1727 // PM_UNSET is included: lookup_special_var arms for
1728 // TRY_BLOCK_ERROR / TRY_BLOCK_INTERRUPT (and other
1729 // PM_UNSET entries with sentinel defaults) check
1730 // this bit to decide between "stored value" vs
1731 // "uninitialized → return -1 sentinel". The flag
1732 // gets cleared by assignstrvalue at c:3660 on any
1733 // write, so it correctly tracks "ever assigned".
1734 // Bug #143 in docs/BUGS.md.
1735 let safe_pm_flags = entry.pm_flags
1736 & (PM_TIED | PM_DI | PM_UNSET | crate::ported::zsh_h::PM_READONLY);
1737 // c:Src/params.c — IPDEF macros set PM_TYPE bits
1738 // (PM_INTEGER for IPDEF5/6, PM_ARRAY for IPDEF9,
1739 // PM_HASHED for IPDEF-hash) along with PM_SPECIAL.
1740 // zshrs's previous init only ORed PM_SPECIAL +
1741 // tied/di/unset/readonly — never the type bit. If
1742 // setsparam ran BEFORE init_partab_params (it does
1743 // for OPTIND/SHLVL at vm_helper.rs:874/878), the
1744 // param entry stayed PM_SCALAR and `typeset -p
1745 // OPTIND` emitted `typeset OPTIND=1` instead of
1746 // zsh's `typeset -i10 OPTIND=1`. OR the pm_type
1747 // into the bits so the type attribute lands.
1748 let mut bits = safe_pm_flags | PM_SPECIAL | entry.pm_type;
1749 // c:Src/zsh.h:1925 — `PM_READONLY_SPECIAL` is the
1750 // three-bit set `PM_SPECIAL|PM_READONLY|
1751 // PM_RO_BY_DESIGN`. `special_params` stores only
1752 // PM_READONLY per row (the other two are implied by
1753 // the IPDEF macro), so complete the triple here:
1754 // PM_SPECIAL is already OR'd into `bits` above, and
1755 // this adds the PM_RO_BY_DESIGN companion that
1756 // distinguishes a by-design readonly special from a
1757 // user `readonly` (c:Src/zsh.h:1923).
1758 if (entry.pm_flags & crate::ported::zsh_h::PM_READONLY) != 0 {
1759 bits |= crate::ported::zsh_h::PM_RO_BY_DESIGN;
1760 }
1761 if entry.pm_type == PM_ARRAY {
1762 bits |= PM_DI;
1763 }
1764 let _ = PM_SCALAR;
1765 let _ = PM_DONTIMPORT;
1766 if let Some(pm) = tab.get_mut(entry.name) {
1767 let was_integer =
1768 (pm.node.flags as u32 & crate::ported::zsh_h::PM_INTEGER) != 0;
1769 pm.node.flags |= bits as i32;
1770 // c:Src/params.c:344 IPDEF4 / c:353 IPDEF5 — the
1771 // C struct literal initialises the `base` field
1772 // to 10 for every PM_INTEGER special. zshrs's
1773 // initial paramtab seeding doesn't carry that
1774 // through (the special_paramdef table has no
1775 // `base` field). Set the default here so
1776 // `printparamnode`'s PMTF_USE_BASE arm at
1777 // params.rs:9341 emits "10" between
1778 // `integer` and the name (`integer 10 readonly
1779 // !=0`). Bug #297 in docs/BUGS.md.
1780 if entry.pm_type == crate::ported::zsh_h::PM_INTEGER && pm.base == 0 {
1781 pm.base = 10;
1782 }
1783 // When OR-ing PM_INTEGER onto a param that
1784 // was previously PM_SCALAR (i.e. setsparam ran
1785 // BEFORE init_partab_params, storing the value
1786 // in u_str), parse the u_str into u_val so the
1787 // integer getter reads the correct value. C
1788 // zsh's setsparam-equivalent path detects the
1789 // pm's PM_TYPE first and routes through
1790 // intsetfn, but zshrs's setsparam at the bin
1791 // entry point predates init_partab_params, so
1792 // it lands as PM_SCALAR storage that the
1793 // type-flip needs to migrate.
1794 if !was_integer
1795 && entry.pm_type == crate::ported::zsh_h::PM_INTEGER
1796 && pm.u_val == 0
1797 {
1798 if let Some(ref s) = pm.u_str {
1799 pm.u_val = s.parse::<i64>().unwrap_or(0);
1800 pm.u_str = None;
1801 }
1802 }
1803 // c:Src/zsh.h IPDEF8/IPDEF9 — the third macro
1804 // arg is the tied partner name; mapped into
1805 // `pm->ename` so `typeset -p` can find the
1806 // peer for the PM_TIED swap. Bug #410.
1807 if let Some(peer) = entry.tied_name {
1808 pm.ename = Some(peer.to_string());
1809 }
1810 } else {
1811 // Param hasn't been created yet (e.g. PATH gets
1812 // imported lazily via the env fallback in
1813 // getsparam at params.rs:4104; array specials
1814 // like `pipestatus` / `funcstack` / `dirstack`
1815 // / `zsh_scheduled_events` aren't pre-populated).
1816 // Seed an empty placeholder carrying the
1817 // canonical flag set so subsequent setsparam /
1818 // `(t)X` / `${+X}` observers see the IPDEF
1819 // attribute bits AND `${+X}` returns 1.
1820 let u_arr = if entry.pm_type == PM_ARRAY {
1821 Some(Vec::new())
1822 } else {
1823 None
1824 };
1825 let pm: crate::ported::zsh_h::Param = Box::new(param {
1826 node: hashnode {
1827 next: None,
1828 nam: entry.name.to_string(),
1829 flags: (entry.pm_type as i32) | bits as i32,
1830 },
1831 u_data: 0,
1832 u_tied: None,
1833 u_arr,
1834 u_str: None,
1835 u_val: 0,
1836 u_dval: 0.0,
1837 u_hash: None,
1838 gsu_s: None,
1839 gsu_i: None,
1840 gsu_f: None,
1841 gsu_a: None,
1842 gsu_h: None,
1843 // c:Src/params.c:344 IPDEF4 / c:353 IPDEF5 —
1844 // PM_INTEGER specials default base=10.
1845 base: if entry.pm_type == crate::ported::zsh_h::PM_INTEGER {
1846 10
1847 } else {
1848 0
1849 },
1850 width: 0,
1851 env: None,
1852 // c:Src/zsh.h IPDEF8/IPDEF9 — tied partner
1853 // name. Bug #410.
1854 ename: entry.tied_name.map(|s| s.to_string()),
1855 old: None,
1856 level: 0,
1857 });
1858 tab.insert(entry.name.to_string(), pm);
1859 }
1860 // Tied partner side. The previous loop body ORed
1861 // PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT|PM_TIED onto the
1862 // partner indiscriminately, but for a SCALAR ↔
1863 // ARRAY tied pair (PATH ↔ path, FIGNORE ↔ fignore),
1864 // that incorrectly stamped PM_ARRAY onto the scalar
1865 // partner (FIGNORE, PATH, FPATH, MAILPATH, MANPATH,
1866 // PSVAR, CDPATH, MODULE_PATH). Result: `(t)PATH`
1867 // returned `array-tied-export-special` instead of
1868 // `scalar-tied-export-special`.
1869 //
1870 // Both partners are already listed in `special_params`
1871 // (the scalar at the IPDEF8 block, the array at the
1872 // IPDEF9 block past the sentinel), so each gets its
1873 // own pass through this loop and ends up with the
1874 // correct flags. No cross-stamping needed.
1875 let _ = entry.tied_name;
1876 }
1877 }
1878 };
1879 // c:Src/init.c:1277 — `inittyptab(); /* initialize the ztypes table */`
1880 // runs inside setupvals BEFORE `createparamtable()` (c:Src/init.c:1286).
1881 // This executor is the fusevm runtime's createparamtable entry point,
1882 // and the seeding below reaches `isident()` (WORDCHARS, …), which is
1883 // typtab-driven — with a zeroed typtab every name fails IIDENT and the
1884 // seed aborts with "not an identifier: WORDCHARS".
1885 crate::ported::utils::inittyptab(); // c:1277
1886 stamp_special_params(); // c:838-847 — create in C's order
1887 // Standard zsh scalar param defaults — direct port of
1888 // `createparamtable` (Src/params.c:817-988) + the `setupvals`
1889 // tail. Writes through canonical `setsparam` (Src/params.c:3350).
1890 //
1891 // c:params.c:972-973 — ZSH_VERSION / ZSH_PATCHLEVEL.
1892 // `zsh_version::ZSH_VERSION` (emitted by build.rs from the
1893 // vendored `Config/version.mk`) is the development snapshot
1894 // tag `5.9.0.3-test`; shipped zsh binaries report the clean
1895 // release form (`5.9`). Bug #73 in docs/BUGS.md — cross-shell
1896 // scripts that gate on `[[ $ZSH_VERSION = 5.9 ]]` or split on
1897 // `.` expecting MAJOR.MINOR break on the `-test` suffix.
1898 //
1899 // Use the cleaned `patchlevel::ZSH_VERSION` here ("5.9") and
1900 // surface the full snapshot tag as `$ZSHRS_VERSION` for
1901 // zshrs-specific identity checks.
1902 // ZSH_VERSION / ZSH_PATCHLEVEL / ZSHRS_VERSION / ZSH_NAME /
1903 // ZSH_ARGZERO are NOT seeded here: C creates them at the END of
1904 // `createparamtable` (c:970-973, after the environ import) and
1905 // ZSH_NAME at `Src/init.c:1364` (setupvals, later still). They
1906 // are seeded at those C positions further down, because a name
1907 // created before the import lands in a different chain slot —
1908 // `ZSH_NAME` seeded here came out BEHIND every same-bucket
1909 // environment variable in `${(k)parameters}` instead of ahead
1910 // of them (c:Src/hashtable.c:214-215 front-insert).
1911 setsparam("WORDCHARS", "*?_-.[]~=/&;!#$%^(){}<>");
1912 // SHLVL is NOT seeded here. c:Src/params.c:948-951 increments it
1913 // AFTER the environ-import loop, so the +1 lives at the end of that
1914 // loop below — see the `c:948-951` block. Doing it here instead meant
1915 // parsing the raw env string with `parse::<i32>()`, which mis-read
1916 // every non-decimal form C accepts via zstrtol_underscore
1917 // (SHLVL=0x10 must give 17, 010 → 9, 1_0 → 11, 9abc → 10, abc → 1).
1918 // POSIX/zsh default IFS: space + tab + newline + NUL.
1919 setsparam("IFS", " \t\n\0");
1920 // POSIX getopts: OPTIND starts at 1.
1921 setsparam("OPTIND", "1");
1922 // Note: OPTERR is NOT pre-initialised. zsh leaves it unset
1923 // even after `getopts` calls (verified: `getopts ":a" opt -a`
1924 // does not set it). It's a user-writable variable that
1925 // starts unset. Bug #150 in docs/BUGS.md.
1926 // zsh wipes inherited `$_` (unlike bash).
1927 setsparam("_", "");
1928 // c:params.c:5064 — histchars derives from bangchar+hatchar+
1929 // hashchar (defaults `!`, `^`, `#`). At init the special
1930 // entry may not exist yet — fall back to the literal default.
1931 let histchars_val = paramtab()
1932 .read()
1933 .ok()
1934 .and_then(|t| {
1935 t.get("histchars")
1936 .or_else(|| t.get("HISTCHARS"))
1937 .map(|pm| histcharsgetfn(pm))
1938 })
1939 .unwrap_or_else(|| "!^#".to_string());
1940 setsparam("histchars", &histchars_val);
1941
1942 // c:Src/params.c:870-871 — `setsparam("TIMEFMT", ...)` etc.
1943 // Seed TIMEFMT explicitly so `${(k)parameters}` lists it
1944 // (the createparamtable() ported in ported::params isn't
1945 // invoked from this bin entry — its setsparam calls don't
1946 // run, so TIMEFMT only existed via the lookup_special_var
1947 // fallback, which scanpmparameters can't see).
1948 setsparam("TIMEFMT", crate::ported::zsh_system_h::DEFAULT_TIMEFMT);
1949 // c:Src/params.c:892 — `setsparam("TMPPREFIX",
1950 // ztrdup_metafy(DEFAULT_TMPPREFIX));`, the line immediately
1951 // before the TIMEFMT seed above. `DEFAULT_TMPPREFIX` is
1952 // "/tmp/zsh" (c:configure.ac:3030 → config.h). Same reason as
1953 // TIMEFMT: createparamtable() is not reached from this bin
1954 // entry, so without this seed `$TMPPREFIX` existed only when
1955 // the environment happened to export it — every scrubbed-env
1956 // launch (cron, launchd/systemd unit, container entrypoint,
1957 // `env -i`) left it unset and every temp-file path derived
1958 // from it fell back per-call-site.
1959 //
1960 // C seeds unconditionally BEFORE the import loop (c:870 vs
1961 // c:893+) and the import then overwrites via assignsparam, so
1962 // an exported $TMPPREFIX still wins. zshrs's import only
1963 // rewrites an entry that is still PM_UNSET, so the env value is
1964 // resolved HERE instead — same end state, and the node is
1965 // created at C's position in the bucket chain. Skipping the
1966 // seed when the environment had TMPPREFIX (the previous shape)
1967 // deferred creation into the import loop, which put TMPPREFIX
1968 // behind every environment variable that hashes to its bucket.
1969 //
1970 // The lookup reads the process-entry environ snapshot, the same
1971 // source the import loop below walks (see the `environ` static
1972 // in ported::params for why the live environment is not it).
1973 let env_at_entry = |name: &str| -> Option<String> {
1974 crate::ported::params::environ
1975 .get()
1976 .and_then(|v| {
1977 v.iter()
1978 .find(|(k, _)| k == name)
1979 .map(|(_, val)| val.clone())
1980 })
1981 .or_else(|| std::env::var(name).ok())
1982 };
1983 setsparam(
1984 "TMPPREFIX",
1985 env_at_entry("TMPPREFIX")
1986 .as_deref()
1987 .unwrap_or(crate::ported::config_h::DEFAULT_TMPPREFIX),
1988 ); // c:870
1989 // c:Src/init.c:1214-1215 — `nullcmd = ztrdup("cat");
1990 // readnullcmd = ztrdup(DEFAULT_READNULLCMD);`. Real paramtab
1991 // seeds (NOT read-time fallbacks) so `unset NULLCMD` truly
1992 // unsets — the bare-redirect "redirection with no command"
1993 // diagnostic depends on getsparam returning None afterwards.
1994 // c:config.h:48 DEFAULT_READNULLCMD "more" — the parity
1995 // floor agrees: scrubbed-env Homebrew zsh 5.9.1 -fc reports
1996 // READNULLCMD=more (probed; the previous macOS arm's "less"
1997 // guess came from the USER's env exporting READNULLCMD=less
1998 // — zpwr sets it). This block runs AFTER the env import, so
1999 // these are DEFAULT seeds only: an env-imported value must
2000 // win (C seeds before the import loop, c:854-885 vs c:893+).
2001 if getsparam("NULLCMD").map_or(true, |v| v.is_empty()) {
2002 setsparam("NULLCMD", "cat");
2003 }
2004 if getsparam("READNULLCMD").map_or(true, |v| v.is_empty()) {
2005 setsparam("READNULLCMD", crate::ported::config_h::DEFAULT_READNULLCMD);
2006 }
2007 // c:Src/params.c:873-876 — `gethostname(hostnam, 256);
2008 // setsparam("HOST", ztrdup_metafy(hostnam));`
2009 // Seeded HERE, before the import loop, exactly like C; it used
2010 // to run at the very end of this constructor, which put HOST
2011 // ahead of same-bucket specials (PROMPT) that C creates first.
2012 // The env value is resolved up front for the same reason as
2013 // TMPPREFIX above (C's import would overwrite it).
2014 let mut host_buf = [0u8; 256];
2015 let host_rc = unsafe { libc::gethostname(host_buf.as_mut_ptr() as *mut libc::c_char, 256) }; // c:874
2016 let hostname = if host_rc == 0 {
2017 std::ffi::CStr::from_bytes_until_nul(&host_buf)
2018 .ok()
2019 .and_then(|c| c.to_str().ok())
2020 .unwrap_or("")
2021 .to_string()
2022 } else {
2023 String::new()
2024 };
2025 setsparam("HOST", env_at_entry("HOST").as_deref().unwrap_or(&hostname)); // c:875
2026 // c:Src/params.c:878-882 — `setsparam("LOGNAME", (str = getlogin())
2027 // && *str ? ztrdup_metafy(str) : ztrdup(cached_username));`
2028 // Also pre-import in C (c:878 vs c:893+); creating it during the
2029 // import instead put LOGNAME behind the environment variables
2030 // sharing its bucket.
2031 let logname_default = {
2032 let from_getlogin = unsafe {
2033 let p = libc::getlogin(); // c:880
2034 if p.is_null() {
2035 String::new()
2036 } else {
2037 std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
2038 }
2039 };
2040 if from_getlogin.is_empty() {
2041 crate::ported::utils::get_username() // c:882 cached_username
2042 } else {
2043 from_getlogin
2044 }
2045 };
2046 setsparam(
2047 "LOGNAME",
2048 env_at_entry("LOGNAME")
2049 .as_deref()
2050 .unwrap_or(&logname_default),
2051 ); // c:878
2052 // c:Src/init.c:1186-1193 — default prompt strings. zsh sets
2053 // PS4 to "+%N:%i> " for ZSH emulation ("+ " for KSH/SH).
2054 // Without seeding, PS4 reads empty and `set -x` output has
2055 // no prefix at all. Bug #92 in docs/BUGS.md.
2056 //
2057 // C zsh runs createparamtable's env-import loop (c:893-924)
2058 // BEFORE init.c:1186 fires, so an exported $PS4 in the parent
2059 // env wins over the default seed. zshrs's env import happens
2060 // further down in ShellExecutor::new() (at the createparamtable
2061 // call site), so getsparam() reads None here even when env has
2062 // a value, and the default would clobber the user's PS4.
2063 //
2064 // Additional wrinkle: C zsh's PROMPT / PROMPT2 / PROMPT3 /
2065 // PROMPT4 params are ALIASES for PS1..PS4 (Src/params.c:381,
2066 // 415-421 — both IPDEF7R entries bind to the same `prompt*`
2067 // global). So `export PROMPT4=...` in the parent env sets the
2068 // shared global, and `$PS4` reads the same string. The user's
2069 // interactive shell exports PROMPT4 (the form zsh's prompt
2070 // theme system uses), so when zshrs -x runs, PROMPT4 is in
2071 // env but PS4 is not. Without aliasing in the env-probe step,
2072 // zshrs seeds default PS4 and ignores the user's customised
2073 // prefix.
2074 //
2075 // Probe env::var directly for the name AND its alias; first
2076 // non-empty wins. Only fall through to the default seed when
2077 // every candidate is empty. Mirrors C zsh's behavior without
2078 // reshuffling the rest of new(). Bug: `zshrs -x` ignored the
2079 // user's custom PS4/PROMPT4 unless re-forwarded with
2080 // `PS4=$PROMPT4 zshrs -x`.
2081 let seed_prompt = |name: &str, alias: Option<&str>, default: &str| {
2082 let cur = crate::ported::params::getsparam(name);
2083 let have_param = cur.as_deref().map_or(false, |s| !s.is_empty());
2084 if have_param {
2085 return;
2086 }
2087 // Probe primary name first, then the C-side alias.
2088 // An EMPTY exported value counts: C's env import (c:893-924)
2089 // assigns whatever `environ` holds, empty string included, and
2090 // it runs before the c:1196 defaults, so `export PS1=` yields
2091 // an empty prompt rather than `%m%# `. Testing only for a
2092 // NON-empty value skipped that case and re-seeded the default.
2093 for candidate in std::iter::once(name).chain(alias.into_iter()) {
2094 if let Ok(env_val) = std::env::var(candidate) {
2095 setsparam(name, &env_val);
2096 return;
2097 }
2098 }
2099 setsparam(name, default);
2100 };
2101 seed_prompt("PS4", Some("PROMPT4"), "+%N:%i> ");
2102 // c:Src/init.c:1181-1190 —
2103 // if(unset(INTERACTIVE)) {
2104 // prompt = ztrdup("");
2105 // prompt2 = ztrdup("");
2106 // } else ... {
2107 // prompt = ztrdup("%m%# ");
2108 // prompt2 = ztrdup("%_> ");
2109 // }
2110 // Non-interactive shells get EMPTY primary/secondary prompts
2111 // — `zsh -fc 'typeset'` lists PS1='' — while interactive ones
2112 // get the %m%# defaults. PS3/PS4/SPROMPT are seeded
2113 // unconditionally in C (c:1191-1194). PS1 may be reset by the
2114 // prompt-theme layer; only seed when the slot is empty so any
2115 // prior theme write wins.
2116 let interactive = crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE);
2117 seed_prompt(
2118 "PS1",
2119 Some("PROMPT"),
2120 if interactive { "%m%# " } else { "" },
2121 );
2122 seed_prompt(
2123 "PS2",
2124 Some("PROMPT2"),
2125 if interactive { "%_> " } else { "" },
2126 );
2127 // c:Src/init.c:1191 — `prompt3 = ztrdup("?# ");`
2128 seed_prompt("PS3", Some("PROMPT3"), "?# ");
2129 // c:Src/init.c:1194 — `sprompt = ztrdup("zsh: correct '%R'
2130 // to '%r' [nyae]? ");` — spelling-correction prompt.
2131 seed_prompt("SPROMPT", None, "zsh: correct '%R' to '%r' [nyae]? ");
2132 // c:Src/params.c:417-422 — `PROMPT*` aliases for `PS*`.
2133 // C zsh's IPDEF7("PROMPT", &prompt), IPDEF7("PROMPT2",
2134 // &prompt2), IPDEF7("PROMPT3", &prompt3), IPDEF7("PROMPT4",
2135 // &prompt4) all point to the same C globals as the matching
2136 // IPDEF7("PS{1..4}", ...) entries — they're aliases in C,
2137 // sharing storage. zshrs's paramtab keeps them as separate
2138 // entries; mirror the alias by mirroring the value here.
2139 // Bug #274 in docs/BUGS.md (PROMPT3 was the visible report;
2140 // PROMPT/PROMPT2/PROMPT4 had the same gap silently).
2141 for (alias, source) in &[
2142 ("PROMPT", "PS1"),
2143 ("PROMPT2", "PS2"),
2144 ("PROMPT3", "PS3"),
2145 ("PROMPT4", "PS4"),
2146 ] {
2147 if crate::ported::params::getsparam(alias).map_or(true, |s| s.is_empty()) {
2148 if let Some(v) = crate::ported::params::getsparam(source) {
2149 setsparam(alias, &v);
2150 }
2151 }
2152 }
2153 // c:params.c:858-860 — standard non-special param defaults.
2154 // C uses `setiparam(...)` (PM_INTEGER) for these so
2155 // `(t)MAILCHECK` etc. report `integer`. zshrs previously
2156 // routed through `setsparam` (PM_SCALAR) — the value worked
2157 // but the type bit was wrong, breaking
2158 // `case "${(t)LISTMAX}" in *integer*)` and any path that
2159 // gates on arithmetic-typed semantics. Bug #268 in
2160 // docs/BUGS.md.
2161 crate::ported::params::setiparam("MAILCHECK", 60); // c:858
2162 crate::ported::params::setiparam("KEYTIMEOUT", 40); // c:859
2163 crate::ported::params::setiparam("LISTMAX", 100); // c:860
2164 // c:config.h:1004 — MAX_FUNCTION_DEPTH=500. Advisory cap;
2165 // dispatch_function_call enforces against this.
2166 crate::ported::params::setiparam("FUNCNEST", 500);
2167
2168 // Run setlocale(LC_ALL, "") so nl_langinfo() (used by the
2169 // `langinfo` module) returns the host's actual locale instead
2170 // of the C/POSIX default ("US-ASCII"). Direct port of zsh's
2171 // Src/init.c:1208 setlocale call. unsafe { } around libc is
2172 // standard for this exact use-case — setlocale is process-
2173 // global and must run once at startup.
2174 unsafe {
2175 libc::setlocale(libc::LC_ALL, c"".as_ptr());
2176 }
2177
2178 // c:hashtable.c:1206 createaliastables() — seeds aliastab with
2179 // the `run-help` / `which-command` defaults. Run once at shell
2180 // init so the canonical port owns the default-alias set; the
2181 // Executor's `aliases` HashMap then mirrors aliastab.
2182 crate::ported::hashtable::createaliastables();
2183 // Build the initial $path tied array as a local — fans out
2184 // to paramtab below; no ShellExecutor mirror anymore.
2185 let mut arrays: HashMap<String, Vec<String>> = HashMap::new();
2186 let path_dirs: Vec<String> = env::var("PATH")
2187 .unwrap_or_default()
2188 .split(':')
2189 .map(|s| s.to_string())
2190 .collect();
2191 arrays.insert("path".to_string(), path_dirs);
2192 let mut exec = Self {
2193 // c:Src/init.c:479 — `-c` mode: scriptname = scriptfilename
2194 // = ztrdup("zsh"). Both start at the literal "zsh".
2195 // dispatch_function_call overrides scriptname per c:5903;
2196 // scriptfilename stays at the outer file.
2197 scriptname: Some("zsh".to_string()),
2198 scriptfilename: Some("zsh".to_string()),
2199 subshell_snapshots: Vec::new(),
2200 inline_env_stack: Vec::new(),
2201 current_command_glob_failed: std::cell::Cell::new(false),
2202 jobs: JobTable::new(),
2203 fpath,
2204 history,
2205 completions: HashMap::new(),
2206 process_sub_counter: 0,
2207 zstyles: Vec::new(),
2208 local_scope_depth: 0,
2209 pending_underscore: None,
2210 in_dq_context: 0,
2211 in_scalar_assign: 0,
2212 profiling_enabled: false,
2213 compsys_cache: std::cell::OnceCell::new(),
2214 compinit_pending: None, // (receiver, start_time)
2215 plugin_cache: {
2216 let pc_path = crate::plugin_cache::default_cache_path();
2217 if let Some(parent) = pc_path.parent() {
2218 let _ = fs::create_dir_all(parent);
2219 }
2220 match crate::plugin_cache::PluginCache::open(&pc_path) {
2221 Ok(pc) => {
2222 let (plugins, functions) = pc.stats();
2223 tracing::info!(
2224 plugins,
2225 cached_functions = functions,
2226 path = %pc_path.display(),
2227 "plugin_cache: sqlite opened"
2228 );
2229 Some(pc)
2230 }
2231 Err(e) => {
2232 tracing::warn!(error = %e, "plugin_cache: failed to open");
2233 None
2234 }
2235 }
2236 },
2237 deferred_compdefs: Vec::new(),
2238 returning: None,
2239 zsh_compat: false,
2240 bash_compat: false,
2241 posix_mode: false,
2242 worker_pool: {
2243 let config = crate::config::load();
2244 let pool_size = crate::config::resolve_pool_size(&config.worker_pool);
2245 std::sync::Arc::new(crate::worker::WorkerPool::new(pool_size))
2246 },
2247 intercepts: Vec::new(),
2248 async_jobs: HashMap::new(),
2249 next_async_id: 1,
2250 redirect_scope_stack: Vec::new(),
2251 multios_scope_stack: Vec::new(),
2252 exec_redirs_permanent: false,
2253 pipe_output_pending: false,
2254 pipe_output_scope: None,
2255 redirect_failed: false,
2256 functions_compiled: HashMap::new(),
2257 function_source: HashMap::new(),
2258 function_line_base: HashMap::new(),
2259 function_def_file: HashMap::new(),
2260 prompt_funcstack: Vec::new(),
2261 tied_array_to_scalar: HashMap::new(),
2262 ztest_pass_count: std::sync::atomic::AtomicUsize::new(0),
2263 ztest_fail_count: std::sync::atomic::AtomicUsize::new(0),
2264 ztest_skip_count: std::sync::atomic::AtomicUsize::new(0),
2265 ztest_pass_total: std::sync::atomic::AtomicUsize::new(0),
2266 ztest_fail_total: std::sync::atomic::AtomicUsize::new(0),
2267 ztest_skip_total: std::sync::atomic::AtomicUsize::new(0),
2268 ztest_run_failed: std::sync::atomic::AtomicBool::new(false),
2269 ztest_suppress_stdout: false,
2270 };
2271 // Publish the session worker pool so preprompt-time async hooks
2272 // (async_precmd) can reach it without an entered executor context.
2273 crate::async_precmd::set_session_pool(std::sync::Arc::clone(&exec.worker_pool));
2274 // Mirror env-derived path arrays into the `arrays` table so
2275 // user-level `fpath` / `path` array reads see the inherited
2276 // entries. zsh: `fpath+=…` should append to the inherited
2277 // 43-entry array, not replace it. Same for `path` (PATH).
2278 let fpath_arr: Vec<String> = exec
2279 .fpath
2280 .iter()
2281 .map(|p| p.to_string_lossy().to_string())
2282 .collect();
2283 if !fpath_arr.is_empty() {
2284 exec.set_array("fpath".to_string(), fpath_arr);
2285 }
2286 if let Ok(path) = env::var("PATH") {
2287 let path_arr: Vec<String> = path
2288 .split(':')
2289 .filter(|s| !s.is_empty())
2290 .map(String::from)
2291 .collect();
2292 if !path_arr.is_empty() {
2293 exec.set_array("path".to_string(), path_arr);
2294 }
2295 }
2296 // Register the standard tied path-family pairs so `path+=` /
2297 // `fpath+=` / etc. mirror through the array→scalar sync hook
2298 // in BUILTIN_APPEND_ARRAY (and the SET_ARRAY tied path).
2299 // Direct port of the implicit ties that zsh wires up at
2300 // startup for PATH/path, FPATH/fpath, etc. Source-of-truth
2301 // for the pairs is Src/init.c's `setupvals()` PM_TIED entries.
2302 // c:Src/params.c:395-422 IPDEF8 — full PM_TIED colonarr list:
2303 // CDPATH, FIGNORE, FPATH, MAILPATH, PATH, PSVAR, MODULE_PATH,
2304 // MANPATH (ZSH_EVAL_CONTEXT is readonly-special, excluded).
2305 for (scalar, arr) in [
2306 ("PATH", "path"),
2307 ("FPATH", "fpath"),
2308 ("MANPATH", "manpath"),
2309 ("CDPATH", "cdpath"),
2310 ("MODULE_PATH", "module_path"),
2311 ("PSVAR", "psvar"),
2312 ("FIGNORE", "fignore"),
2313 ("MAILPATH", "mailpath"),
2314 ] {
2315 exec.tied_array_to_scalar
2316 .insert(arr.to_string(), (scalar.to_string(), ":".to_string()));
2317 }
2318
2319 // Pour `path` (from env PATH split) into paramtab. The IPDEF9
2320 // flag set was stamped by the c:838-847 pass above and survives
2321 // the assignment (`assignaparam` keeps PM_DONTIMPORT on a
2322 // PM_SPECIAL node — c:3374 + params.rs), so no re-stamp is
2323 // needed here.
2324 for (k, v) in &arrays {
2325 setaparam(k, v.clone()); // c:params.c:3595
2326 }
2327
2328 // c:Src/params.c:893-924 — the environment import runs AFTER the
2329 // specials table (moved above, c:838-847) and after the c:854-885
2330 // non-special seeds, exactly as `createparamtable` sequences them.
2331 {
2332 use crate::ported::params::paramtab;
2333 if let Ok(mut tab) = paramtab().write() {
2334 use crate::ported::zsh_h::{param, PM_UNSET};
2335 // c:Src/params.c:893-924 environment-import loop —
2336 // every env var gets either a fresh exported paramtab
2337 // entry OR (when the entry pre-exists from
2338 // special_params) PM_EXPORTED OR'd onto its flags.
2339 // Without this, `declare -p PATH` printed `typeset -T
2340 // PATH=''` and `declare -p USER` printed nothing at
2341 // all because USER was never in paramtab.
2342 use crate::ported::zsh_h::hashnode as _hn;
2343 use crate::ported::zsh_h::{PM_EXPORTED, PM_SCALAR};
2344 // c:Src/params.c:4329-4342 colonarrsetfn — assigning a
2345 // tied IPDEF8 scalar (MANPATH, CDPATH, MODULE_PATH, …)
2346 // colonsplit()s the value into the partner array,
2347 // preserving empty components. The env import below
2348 // bypasses the GSU setfn, so collect the tied pairs
2349 // here and pour them through setaparam after the
2350 // paramtab lock drops. PATH→path / FPATH→fpath are
2351 // seeded earlier (vm_helper ~1160-1199) and skipped.
2352 let mut tied_env_arrays: Vec<(String, Vec<String>)> = Vec::new();
2353 // c:Src/params.c:893 — walk the process-entry environ
2354 // snapshot, not the live env (frameworks can mutate it
2355 // before init — see params.rs `environ` static).
2356 let environ_vars: Vec<(String, String)> = crate::ported::params::environ
2357 .get()
2358 .cloned()
2359 .unwrap_or_else(|| std::env::vars().collect());
2360 for (env_name, env_value) in environ_vars {
2361 if env_name.is_empty() || env_name.contains('[') {
2362 continue;
2363 }
2364 if env_name.as_bytes()[0].is_ascii_digit() {
2365 continue;
2366 }
2367 if !crate::ported::params::isident(&env_name) {
2368 continue;
2369 }
2370 if let Some(pm) = tab.get_mut(&env_name) {
2371 // c:Src/params.c:902-906 — the import loop runs
2372 // `dontimport(pm->node.flags)` BEFORE doing
2373 // anything to the entry; PM_DONTIMPORT names
2374 // (`_`, IFS, GID/EGID, KEYBOARD_HACK — the
2375 // IPDEF7/IPDEF2 rows, c:796-800) are skipped
2376 // ENTIRELY: no PM_EXPORTED stamp, no value
2377 // seed. zshrs previously OR'd PM_EXPORTED
2378 // first, so an inherited env `_` made the
2379 // special `_` exported and it leaked into
2380 // `typeset +x -r` / `export -p` listings where
2381 // zsh shows nothing.
2382 if (pm.node.flags as u32 & crate::ported::zsh_h::PM_DONTIMPORT) != 0 {
2383 continue; // c:905 `continue;`
2384 }
2385 pm.node.flags |= PM_EXPORTED as i32;
2386 // c:Src/params.c:2769-2776 — assignstrvalue's
2387 // PM_INTEGER arm, which C's env import reaches via
2388 // `assignsparam(..., ASSPM_ENV_IMPORT)` (c:907-908;
2389 // assignsparam forwards its `flags` verbatim at
2390 // c:params.c assignstrvalue(v, val, flags)):
2391 // if (flags & ASSPM_ENV_IMPORT) {
2392 // char *ptr;
2393 // ival = zstrtol_underscore(val, &ptr, 0, 1);
2394 // } else
2395 // ival = mathevali(val);
2396 // v->pm->gsu.i->setfn(v->pm, ival);
2397 // An integer param keeps its value in `u.val`, NOT in
2398 // the scalar slot, so the `pm.u_str = env_value` seed
2399 // below stored the digits somewhere no integer reader
2400 // ever looks and EVERY pre-existing PM_INTEGER param
2401 // silently ignored the environment: COLUMNS/LINES
2402 // (IPDEF5, c:355-356) read back 0, while HISTSIZE,
2403 // SAVEHIST, LISTMAX, MAILCHECK, KEYTIMEOUT and
2404 // FUNCNEST kept their built-in defaults — i.e.
2405 // `HISTSIZE=5000 zshrs -c ...` was a no-op.
2406 //
2407 // Base 0 + underscore=1 is not incidental: it is what
2408 // makes `COLUMNS=0x10` 16, `COLUMNS=0b101` 5,
2409 // `COLUMNS=010` 8 (octal — c:utils.c:2452-2461 takes
2410 // the leading `0` then falls to `base = 8`),
2411 // `COLUMNS=1_0` 10, and a trailing-garbage value like
2412 // `9abc` a silent 9. mathevali would instead ERROR on
2413 // `9abc`, which is precisely why C splits the two
2414 // paths — importing a hostile environment must not
2415 // abort the shell (upstream 546203a770, "33276: safer
2416 // import of numerical variables from environment").
2417 if (pm.node.flags as u32 & crate::ported::zsh_h::PM_INTEGER) != 0 {
2418 let (ival, _) =
2419 crate::ported::utils::zstrtol_underscore(&env_value, 0, true); // c:2773
2420 // c:3660 — any assignstrvalue write clears PM_UNSET.
2421 pm.node.flags &= !(PM_UNSET as i32);
2422 // c:2774 — `v->pm->gsu.i->setfn(v->pm, ival)`.
2423 // intsetfn is this port's stand-in for the gsu_i
2424 // vtable: it name-dispatches the specials whose
2425 // setter has side effects (SECONDS, RANDOM,
2426 // HISTSIZE, …) and writes u.val otherwise.
2427 crate::ported::params::intsetfn(pm.as_mut(), ival);
2428 pm.env = Some(format!("{env_name}={env_value}"));
2429 continue;
2430 }
2431 // c:Src/params.c:893-924 — C's env-import calls
2432 // `assignsparam(..., ASSPM_ENV_IMPORT)` which
2433 // routes through the param's GSU setfn. For
2434 // SPECIAL scalars with cached storage (HOME,
2435 // USERNAME, TERM, WORDCHARS, TERMINFO,
2436 // TERMINFO_DIRS, KEYBOARD_HACK, histchars) the
2437 // setfn writes to a separate `*_lock` global
2438 // (e.g. home_lock). Just OR'ing PM_EXPORTED
2439 // leaves those globals empty, so `$HOME` reads
2440 // back "" even though HOME is in env. Mirror
2441 // C by copying the env value into pm.u_str and
2442 // (for cached specials) the matching global.
2443 // Only seed cached state when the param was
2444 // still marked PM_UNSET — i.e. nothing has set
2445 // it yet. ShellExecutor::new's earlier init
2446 // block (vm_helper line 837+) already ran
2447 // setsparam for a few names (ZSH_ARGZERO,
2448 // WORDCHARS, SHLVL with the +1 increment, IFS,
2449 // OPTIND, …); those calls clear PM_UNSET so we
2450 // must not overwrite them with the raw env
2451 // value here. The PM_UNSET-still-set case is
2452 // the "C zsh would have called
2453 // assignsparam(...,ASSPM_ENV_IMPORT) and ours
2454 // didn't yet" gap that bug #599 (HOME=` `) and
2455 // %~ prompt expansion need.
2456 let still_unset =
2457 (pm.node.flags as u32 & crate::ported::zsh_h::PM_UNSET) != 0;
2458 if still_unset {
2459 pm.u_str = Some(env_value.clone());
2460 pm.env = Some(format!("{}={}", env_name, env_value));
2461 // c:Src/params.c:3660 — `assignstrvalue`
2462 // clears PM_UNSET on any write. HOME / TERM
2463 // / TERMINFO / TERMINFO_DIRS / WORDCHARS
2464 // start life with PM_UNSET in
2465 // `special_params` (params.rs SPECIAL_PARAMS
2466 // table) so `lookup_special_var` skips the
2467 // getfn for uninitialized specials; env
2468 // import is the canonical "now it's set"
2469 // event, so clear the bit.
2470 pm.node.flags &= !(PM_UNSET as i32);
2471 // Cached-state specials: route through
2472 // the matching setfn so the global cache
2473 // (home_lock / wordchars_lock / etc.)
2474 // reflects the env value. Each setfn
2475 // ignores its `pm` arg (matches C's
2476 // UNUSED(Param pm)), so passing the
2477 // borrowed paramtab entry is safe.
2478 match env_name.as_str() {
2479 "HOME" => {
2480 crate::ported::params::homesetfn(pm.as_mut(), env_value.clone())
2481 }
2482 "USERNAME" => crate::ported::params::usernamesetfn(
2483 pm.as_mut(),
2484 env_value.clone(),
2485 ),
2486 "TERM" => {
2487 crate::ported::params::termsetfn(pm.as_mut(), env_value.clone())
2488 }
2489 "WORDCHARS" => crate::ported::params::wordcharssetfn(
2490 pm.as_mut(),
2491 env_value.clone(),
2492 ),
2493 "TERMINFO" => crate::ported::params::terminfosetfn(
2494 pm.as_mut(),
2495 env_value.clone(),
2496 ),
2497 "TERMINFO_DIRS" => crate::ported::params::terminfodirssetfn(
2498 pm.as_mut(),
2499 env_value.clone(),
2500 ),
2501 _ => {}
2502 }
2503 }
2504 // c:Src/params.c:907-908 — env import always
2505 // assigns through the GSU setfn; for tied
2506 // IPDEF8 scalars that is colonarrsetfn
2507 // (c:4329-4342), which colonsplit()s the value
2508 // into the partner array, empties preserved.
2509 // Not gated on still_unset: C re-assigns on
2510 // import regardless.
2511 if (pm.node.flags as u32 & crate::ported::zsh_h::PM_TIED) != 0 {
2512 if let Some(ref peer) = pm.ename {
2513 if peer != "path" && peer != "fpath" {
2514 tied_env_arrays.push((
2515 peer.clone(),
2516 env_value.split(':').map(String::from).collect(), // c:4339 colonsplit
2517 ));
2518 }
2519 }
2520 }
2521 } else {
2522 // Fresh entry — PM_SCALAR + PM_EXPORTED, value
2523 // taken from env. Mirrors C zsh's c:907-908
2524 // `assignsparam(..., ASSPM_ENV_IMPORT)` for
2525 // names not already in the special table.
2526 let pm: crate::ported::zsh_h::Param = Box::new(param {
2527 node: _hn {
2528 next: None,
2529 nam: env_name.clone(),
2530 flags: (PM_SCALAR | PM_EXPORTED) as i32,
2531 },
2532 u_data: 0,
2533 u_tied: None,
2534 u_arr: None,
2535 u_str: Some(env_value.clone()),
2536 u_val: 0,
2537 u_dval: 0.0,
2538 u_hash: None,
2539 gsu_s: None,
2540 gsu_i: None,
2541 gsu_f: None,
2542 gsu_a: None,
2543 gsu_h: None,
2544 base: 0,
2545 width: 0,
2546 env: Some(format!("{}={}", env_name, env_value)),
2547 ename: None,
2548 old: None,
2549 level: 0,
2550 });
2551 tab.insert(env_name, pm);
2552 }
2553 }
2554 // Apply the collected tied-pair splits after the env
2555 // walk. setaparam (the canonical store) needs the
2556 // same paramtab write lock held here, so write u_arr
2557 // directly on the peer entry — array reads route
2558 // through paramtab so this is the single store.
2559 for (peer, parts) in tied_env_arrays {
2560 if let Some(apm) = tab.get_mut(peer.as_str()) {
2561 apm.u_arr = Some(parts); // c:4339 — `*dptr = colonsplit(x, …)`
2562 apm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
2563 }
2564 }
2565 // c:Src/params.c:948-951 — runs AFTER the import loop:
2566 // pm = (Param) paramtab->getnode(paramtab, "SHLVL");
2567 // sprintf(buf, "%d", (int)++shlvl);
2568 // /* shlvl value in environment needs updating unconditionally */
2569 // addenv(pm, buf);
2570 // SHLVL is `IPDEF5("SHLVL", &shlvl, varinteger_gsu)` (c:358), so
2571 // the loop above has already parsed any inherited value into it
2572 // with zstrtol_underscore; C then increments THAT, in place.
2573 // Ordering is the whole point: the increment must observe the
2574 // imported value, and the import must not clobber the
2575 // increment. When SHLVL is absent from the environment the
2576 // param is still 0 here, so ++ yields 1 — matching C, whose
2577 // `shlvl` global starts at 0.
2578 //
2579 // addenv also exports the INCREMENTED value, which is why a
2580 // forked child sees 6 for `SHLVL=5 zsh -fc 'printenv SHLVL; true'`.
2581 // (A bare `printenv SHLVL` shows 5 because zsh exec's the last
2582 // command in place and backs the increment out — the shell is
2583 // being replaced, not nested. That is a separate mechanism.)
2584 if let Some(pm) = tab.get_mut("SHLVL") {
2585 let next = pm.u_val + 1; // c:949 `++shlvl`
2586 crate::ported::params::intsetfn(pm.as_mut(), next); // c:949
2587 pm.node.flags &= !(PM_UNSET as i32);
2588 }
2589 }
2590 }
2591
2592 // c:Src/params.c:960-965 — HOME wiring, which C runs right
2593 // after the environment-import loop:
2594 // pm = (Param) realparamtab->getnode2(realparamtab, "HOME");
2595 // if (EMULATION(EMULATE_ZSH))
2596 // {
2597 // pm->node.flags &= ~PM_UNSET;
2598 // if (!(pm->node.flags & PM_EXPORTED))
2599 // addenv(pm, home);
2600 // } else if (!home)
2601 // pm->node.flags |= PM_UNSET;
2602 // `home` itself was synthesised from the password database
2603 // back in setupvals (c:Src/init.c:1237-1250) BEFORE the import
2604 // loop, so an inherited $HOME wins: the import calls
2605 // `homesetfn` and overwrites the synthesised value.
2606 //
2607 // zshrs reaches neither of those C sites from this bin entry,
2608 // so `$HOME` was whatever the environment supplied and nothing
2609 // else — a scrubbed launch (cron, launchd/systemd unit,
2610 // container entrypoint, `env -i`) got no $HOME at all, and
2611 // every `~`, rc-file path and cache path derived from it
2612 // silently resolved to "" (`~/x` expanded to `/x`).
2613 //
2614 // Ordering is preserved by only synthesising when the import
2615 // produced nothing: `var_os` is the exact "was it in the
2616 // environment" test C's loop keys off, so an explicitly empty
2617 // `HOME=` still stays empty (reference binary: `env -i … HOME=
2618 // zsh -f -c 'print -r -- "[${HOME-UNSET}]"'` prints `[]`).
2619 //
2620 // `home` itself comes from c:Src/init.c:1237-1250, inlined here
2621 // because C has no function to port — it is straight-line code
2622 // inside `setupvals()`:
2623 // #ifdef USE_GETPWUID
2624 // if ((pswd = getpwuid(cached_uid))) {
2625 // if (EMULATION(EMULATE_ZSH))
2626 // home = ztrdup_metafy(pswd->pw_dir);
2627 // cached_username = ztrdup_metafy(pswd->pw_name);
2628 // }
2629 // else
2630 // #endif /* USE_GETPWUID */
2631 // {
2632 // if (EMULATION(EMULATE_ZSH))
2633 // home = ztrdup("/");
2634 // cached_username = ztrdup("");
2635 // }
2636 // Both arms are guarded on EMULATE_ZSH: under sh/ksh emulation
2637 // the C global stays NULL and `$HOME` can only come from the
2638 // environment. Reference binary agrees — `env -i TERM=dumb
2639 // PATH=/usr/bin:/bin zsh --emulate sh -f -c 'echo
2640 // "HOME=${HOME-UNSET}"'` prints `HOME=UNSET`, while the same
2641 // command without `--emulate sh` prints the password-database
2642 // home. `cached_username` is seeded separately (see the
2643 // getlogin() block below), so only the `home` half is here.
2644 if std::env::var_os("HOME").is_none()
2645 && crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_ZSH)
2646 {
2647 // c:Src/init.c:1239 — `getpwuid(cached_uid)`, cached_uid
2648 // being `getuid()` from c:1235.
2649 let pswd = unsafe { libc::getpwuid(libc::getuid()) };
2650 let pw_dir = if pswd.is_null() {
2651 std::ptr::null()
2652 } else {
2653 unsafe { (*pswd).pw_dir }
2654 };
2655 let h = if pw_dir.is_null() {
2656 // c:1248 — password lookup failed: `home = ztrdup("/")`.
2657 // A NULL `pw_dir` on a present entry is the same
2658 // "no usable home" case.
2659 "/".to_string()
2660 } else {
2661 // c:1241 — `home = ztrdup_metafy(pswd->pw_dir)`.
2662 crate::ported::utils::metafy(
2663 &unsafe { std::ffi::CStr::from_ptr(pw_dir) }.to_string_lossy(),
2664 )
2665 };
2666 // Routes through `homesetfn`, so the `home` global and the
2667 // paramtab entry agree (c:Src/params.c:5118).
2668 crate::ported::params::setsparam("HOME", &h);
2669 // c:964-965 — the param is not PM_EXPORTED (it was not
2670 // imported), so C addenv's it. The reference binary
2671 // confirms the synthesised value reaches children:
2672 // `env -i TERM=dumb PATH=/usr/bin:/bin zsh -f -c
2673 // '/usr/bin/env'` lists `HOME=/Users/…`.
2674 crate::ported::params::addenv("HOME", &h);
2675 }
2676
2677 // NOT DONE HERE: c:Src/params.c:951 `addenv(pm, buf)`, which zputenv's
2678 // the INCREMENTED SHLVL into the process environment so a forked child
2679 // sees 6 for `SHLVL=5 zsh -fc 'printenv SHLVL; true'`. zshrs still
2680 // exports the inherited 5 there.
2681 //
2682 // Adding the addenv alone makes parity WORSE, not better, because it
2683 // is only half of a pair. C hands an exec'd command the DECREMENTED
2684 // value (c:Src/exec.c:4276-4281 — "for either implicit or explicit
2685 // exec, decrease $SHLVL as we're now done as a shell", guarded by
2686 // `!subsh && !forked`), which is why a bare `SHLVL=5 zsh -fc 'printenv
2687 // SHLVL'` prints 5 while `'printenv SHLVL; true'` prints 6 — the first
2688 // is exec'd in place, the second forked. Exporting 6 without that
2689 // decrement turns one divergence into four: the exec'd cases and every
2690 // nested-shell count start reading one too high.
2691 //
2692 // The decrement IS ported, at exec.rs:11195-11199, but on the
2693 // `ported::exec` path — not the fusevm path that actually runs `-c`.
2694 // Wiring both belongs in one change, with the exec side first.
2695 // c:Src/init.c:1907-1909 — `SHTTY = -1; init_io(cmd); setupvals(...)`.
2696 // zsh_main runs those three in that order, and this constructor stands
2697 // in for setupvals's param setup on the drivers that never reach
2698 // zsh_main: `zshrs -c CODE` dispatches at bins/zshrs.rs:1716 (after
2699 // --zsh/-f are stripped) straight into ShellExecutor::new + exit. So
2700 // init_io has to happen here, or SHTTY is still -1 and the winsize
2701 // probe below silently no-ops (adjustwinsize early-returns at
2702 // c:1900-1901). setupvals's own adjustwinsize(0) covers the zsh_main
2703 // path; init_io is idempotent (c:615-618 closes and reopens SHTTY), so
2704 // that path just re-establishes it a moment later.
2705 crate::ported::init::init_io(None); // c:1908
2706
2707 // c:Src/init.c:1274-1276 — `adjustwinsize(0)`, the first thing after
2708 // createparamtable (c:1270). Probes the tty via TIOCGWINSZ and
2709 // publishes the geometry to $COLUMNS/$LINES.
2710 //
2711 // Ordering is C's, and load-bearing in both directions: it must FOLLOW
2712 // init_io (which sets SHTTY) and it must FOLLOW the environ import
2713 // above, because the tty geometry OVERRIDES an inherited COLUMNS — a
2714 // 97-column terminal reports 97 even when COLUMNS=10 was exported in.
2715 // (C gets that via the c:1906-1907 "Signal missed while a job owned the
2716 // tty?" promotion of from=0 to from=1, which makes adjustcolumns take
2717 // the signalled path and overwrite zterm_columns with ws_col.)
2718 //
2719 // With no terminal at all, SHTTY stays -1 and the imported value (or 0)
2720 // survives untouched — which is what C's zterm_columns does there, and
2721 // why a piped `COLUMNS=20 zshrs -c` still reports 20. Note SHTTY does
2722 // NOT require stdin/stdout to be a tty: init_io's last resort is
2723 // `open("/dev/tty")` (c:667-670), so a piped-but-still-attached shell
2724 // gets the real width, matching `zsh -fc 'print $COLUMNS | cat'`.
2725 let _ = crate::ported::utils::adjustwinsize(0); // c:1276
2726
2727 // c:Src/params.c:955 — `set_pwd_env();` runs AFTER the environ
2728 // import loop, overwriting the imported $PWD/$OLDPWD paramtab
2729 // entries with the ispwd()-validated values computed above
2730 // (c:Src/init.c:1242-1259). Without this, a stale inherited
2731 // $PWD (env-import snapshot taken at process entry) survives
2732 // in paramtab even though the live env was corrected.
2733 crate::ported::builtin::set_pwd_env();
2734
2735 // c:Src/params.c:975-992 — host/arch identification params:
2736 // CPUTYPE / MACHTYPE / OSTYPE / VENDOR. C zsh reads from
2737 // compile-time `#define`s (set by ./configure) for MACHTYPE /
2738 // OSTYPE / VENDOR, and from uname().machine at runtime for
2739 // CPUTYPE.
2740 //
2741 // Rust port: probe uname() at startup for CPUTYPE, and use
2742 // const strings parameterized by build-target for the
2743 // others. Match homebrew zsh's values where possible.
2744 let mut uname_buf: libc::utsname = unsafe { std::mem::zeroed() };
2745 let _ = unsafe { libc::uname(&mut uname_buf) };
2746 let to_str = |b: &[libc::c_char]| -> String {
2747 // c-string → owned String, truncated at first NUL.
2748 let bytes: Vec<u8> = b
2749 .iter()
2750 .take_while(|&&c| c != 0)
2751 .map(|&c| c as u8)
2752 .collect();
2753 String::from_utf8_lossy(&bytes).into_owned()
2754 };
2755 let cputype = to_str(&uname_buf.machine);
2756 crate::ported::params::setsparam("CPUTYPE", &cputype); // c:961
2757 // OSTYPE: configure's `$host_os`, resolved on the build host and
2758 // frozen into config.h — C never re-derives it from uname() at
2759 // startup. Deriving it here made the two writers disagree, so the
2760 // same binary answered `darwin25.5.0` under -c and `darwin23.6.0`
2761 // under -i. Single source of truth: config_h::OSTYPE, exactly as
2762 // MACHTYPE below.
2763 crate::ported::params::setsparam("OSTYPE", crate::ported::config_h::OSTYPE); // c:990
2764 // MACHTYPE: configure's `$host_cpu`, i.e. the config.guess
2765 // canonical arch name — NOT uname's `machine`. The two differ
2766 // on Apple Silicon (uname says `arm64`, config.guess says
2767 // `aarch64`), and zsh reports the latter. Single source of
2768 // truth: config_h::MACHTYPE (= build target arch).
2769 crate::ported::params::setsparam("MACHTYPE", crate::ported::config_h::MACHTYPE); // c:967
2770 // VENDOR: configure's `$host_vendor`. Deriving it from uname's
2771 // `sysname` here was a second, non-C writer that disagreed with
2772 // `config_h::VENDOR` off Darwin: config.guess emits `pc` for x86_64
2773 // Linux (config.guess:1222) and `unknown` for aarch64 Linux
2774 // (config.guess:1009), a distinction `sysname` cannot make. Single
2775 // source of truth, exactly as OSTYPE/MACHTYPE above.
2776 crate::ported::params::setsparam("VENDOR", crate::ported::config_h::VENDOR); // c:992
2777
2778 // c:Src/init.c:963 — `setsparam("TTY", ttyname(0) ?: "")`, which
2779 // C reaches at c:969 in the createparamtable tail. Even a
2780 // non-interactive -fc shell creates the param.
2781 let tty_str = unsafe {
2782 let p = libc::ttyname(0);
2783 if p.is_null() {
2784 String::new()
2785 } else {
2786 std::ffi::CStr::from_ptr(p)
2787 .to_str()
2788 .unwrap_or("")
2789 .to_string()
2790 }
2791 };
2792 crate::ported::params::setsparam("TTY", &tty_str); // c:969
2793 // c:Src/params.c:971 — `setsparam("ZSH_ARGZERO", ztrdup(posixzero))`:
2794 // the kernel-supplied argv[0] of THIS binary, in --zsh parity mode
2795 // too. The bin entrypoint overrides this with the script path for
2796 // -c / runscript invocations. (A previous revision probed the
2797 // system zsh install path and reported THAT as ZSH_ARGZERO for
2798 // byte-parity — faking the shell's identity. Parity tests that
2799 // compare the value must normalize the machine-specific binary
2800 // path in the test row instead.)
2801 let argzero_default = env::args().next().unwrap_or_else(|| "zsh".to_string());
2802 crate::ported::params::setsparam("ZSH_ARGZERO", &argzero_default); // c:971
2803 // c:Src/params.c:972 — ZSH_VERSION. `zsh_version::ZSH_VERSION`
2804 // (emitted by build.rs from the vendored `Config/version.mk`) is
2805 // the development snapshot tag `5.9.0.3-test`; shipped zsh
2806 // binaries report the clean release form (`5.9`). Bug #73 in
2807 // docs/BUGS.md — cross-shell scripts that gate on
2808 // `[[ $ZSH_VERSION = 5.9 ]]` or split on `.` expecting
2809 // MAJOR.MINOR break on the `-test` suffix. Use the cleaned
2810 // `patchlevel::ZSH_VERSION` here ("5.9") and surface the full
2811 // snapshot tag as `$ZSHRS_VERSION` for zshrs identity checks.
2812 crate::ported::params::setsparam("ZSH_VERSION", crate::ported::patchlevel::ZSH_VERSION); // c:972
2813 // c:Src/params.c:973 + Src/patchlevel.h — `ZSH_PATCHLEVEL` is a
2814 // git-describe-style identifier (`zsh-MAJOR.MINOR-N-gHASH`) of
2815 // the upstream commit zshrs targets. `build.rs` emits "unknown"
2816 // because the vendored zsh tarball ships no CUSTOM_PATCHLEVEL
2817 // define; use the canonical const in `patchlevel.rs` instead.
2818 // Bug #90 in docs/BUGS.md — scripts that fingerprint by
2819 // $ZSH_PATCHLEVEL fell to the wildcard arm under "unknown".
2820 crate::ported::params::setsparam(
2821 "ZSH_PATCHLEVEL",
2822 crate::ported::patchlevel::ZSH_PATCHLEVEL,
2823 ); // c:973
2824 // Skip ZSHRS_VERSION whenever the zsh-compatible namespace must
2825 // stay free of zshrs-original names, so `${(k)parameters}`
2826 // doesn't carry a name zsh doesn't ship — same predicate and
2827 // reasoning as the guard in `ported::params::createparamtable`.
2828 // `hide_ext_builtins()` is `--zsh` OR `ZSHRS_HIDE_EXT_BUILTINS`
2829 // (the parity harnesses' knob). Scripts can still detect zshrs
2830 // via `$ZSH_VERSION`, which carries a `-test` suffix.
2831 if !crate::ext_builtins::hide_ext_builtins() {
2832 crate::ported::params::setsparam(
2833 "ZSHRS_VERSION",
2834 crate::ported::patchlevel::ZSHRS_VERSION,
2835 );
2836 }
2837 // c:Src/params.c:974-979 — `setaparam("signals", …)`.
2838 {
2839 use crate::ported::signals_h::SIGS;
2840 // c:signames.c sigs[] (generated) — index 0 is "EXIT",
2841 // entries 1..=SIGCOUNT are in PLATFORM SIGNAL-NUMBER
2842 // order, tail is "ZERR", "DEBUG" (zsh.h SIGZERR/SIGDEBUG).
2843 // SIGS is declared in Linux textual order, so sort by the
2844 // libc number to reproduce the generated table's order on
2845 // every platform. Same construction as params.rs — keep
2846 // in sync.
2847 let mut by_num: Vec<(&str, i32)> = SIGS.to_vec();
2848 by_num.sort_by_key(|&(_, n)| n);
2849 let mut signals_arr: Vec<String> = Vec::with_capacity(by_num.len() + 3);
2850 signals_arr.push("EXIT".to_string()); // c:sigs[0]
2851 signals_arr.extend(by_num.iter().map(|(n, _)| n.to_string()));
2852 signals_arr.push("ZERR".to_string()); // c:sigs tail
2853 signals_arr.push("DEBUG".to_string()); // c:sigs tail
2854 crate::ported::params::setaparam("signals", signals_arr); // c:974
2855 }
2856 // c:Src/init.c:1364 — `setsparam("ZSH_NAME", ztrdup(zsh_name))`,
2857 // which setupvals runs AFTER createparamtable, so the node lands
2858 // ahead of the imported environment in its bucket chain.
2859 crate::ported::params::setsparam("ZSH_NAME", "zsh"); // c:Src/init.c:1364
2860 // LOGNAME is seeded pre-import now (c:878) — see the block by the
2861 // TMPPREFIX/HOST seeds above.
2862 //
2863 // DO NOT setsparam("USERNAME", ...) anywhere in init. `$USERNAME`
2864 // is a special parameter whose SETTER (`usernamesetfn` in
2865 // params.rs) performs setgid(2) + setuid(2) to actually change
2866 // the effective user — a deliberate upstream zsh feature for
2867 // `USERNAME=other-user cmd`. Calling it at init seeds the value
2868 // AND tries to change uid/gid; when the resolved pwd's pw_uid
2869 // differs from `getuid()` (sudo launches, macOS Keychain-helper
2870 // inherited env, container entry points, etc.) the setgid call
2871 // fails with EPERM and emits `zsh:1: failed to change group ID:
2872 // Operation not permitted`. Upstream seeds `$USERNAME` via the
2873 // GETTER path (`usernamegetfn` reads through `cached_username`
2874 // populated by `inittyptab` → `get_username`), no setter call.
2875
2876 // c:Src/init.c:1176 — `module_path = mkarray(MODULE_DIR)`.
2877 // The canonical init lives in `init::setupvals` (port of
2878 // `Src/init.c:setupvals`); the bin entry skips setupvals (per
2879 // the init_bltinmods comment above), so call the lightweight
2880 // module_path bootstrap exposed by init.rs from here. This
2881 // mirrors the HOST gethostname seeding pattern above:
2882 // duplicated init that should collapse into a full setupvals
2883
2884 // c:Src/init.c:1945 init_bltinmods — runs right after setupvals
2885 // (c:1942), i.e. after createparamtable's import, so the module
2886 // autoload stubs (`WATCH`, `watch`, …) are created HERE. The bin
2887 // entry skips zsh_main → init_bltinmods, so run it from
2888 // ShellExecutor::new for the same effect. Bug #270.
2889 crate::ported::init::init_bltinmods(); // c:Src/init.c:1945
2890
2891 // Populate paramtab with PM_SPECIAL Params for every PARTAB /
2892 // PARTAB_ARRAY magic-assoc name. Mirrors what C's zsh/parameter
2893 // module boot_ → handlefeatures chain does — which happens when
2894 // the module LOADS, after init_bltinmods planted its autoload
2895 // stubs, and `addparamdef` unsets the stub before creating the
2896 // real param (c:Src/module.c addparamdef → unsetparam_pm +
2897 // createparam), so these names take a FRESH chain slot ahead of
2898 // the stubs. Running this before init_bltinmods put `usergroups`
2899 // and friends behind `WATCH` in `${(k)parameters}`.
2900 init_partab_params(); // c:Src/Modules/parameter.c:2341 boot_/enables_ chain
2901
2902 // HOST is seeded pre-import now (c:875) — see the block next to
2903 // the TMPPREFIX/LOGNAME seeds above.
2904 // bash startup delta: bash defines TERM itself when the
2905 // environment does not carry one, and exports it. zsh leaves
2906 // TERM unset in that case, so `zshrs --bash` inherited zsh's
2907 // behavior and diverged from the reference shell:
2908 //
2909 // $ env -u TERM /bin/bash -c 'printf "%s\n" "${TERM+set}"'
2910 // set
2911 // $ env -u TERM /bin/bash -c 'echo "$TERM"'
2912 // dumb
2913 // $ env -u TERM /bin/zsh -f -c 'printf "%s\n" "${TERM+set}"'
2914 // (empty — zsh leaves it unset)
2915 //
2916 // Same on bash 3.2.57 (macOS /bin/bash) and 5.3.15, so it is
2917 // not a version artifact. Only the bare `--bash` drop-in takes
2918 // it: `--bash --zsh` asks for zsh-STYLE emulation, where zsh's
2919 // leave-it-unset behavior is the correct answer. Guarded on the
2920 // environment so an inherited TERM always wins.
2921 if crate::extensions::dash_mode::bash_mode() && std::env::var_os("TERM").is_none() {
2922 crate::ported::params::setsparam("TERM", "dumb");
2923 // bash exports it (`declare -x TERM` shows up in `export -p`);
2924 // addenv stamps PM_EXPORTED and pushes it into the child env.
2925 crate::ported::params::addenv("TERM", "dumb");
2926 }
2927
2928 // c:Src/init.c:479 — `-c` mode: scriptname = scriptfilename
2929 // = ztrdup("zsh"). Both globals start as the literal "zsh"
2930 // (not the binary path) so PS4's %x / %N print "zsh" not
2931 // "/path/to/zshrs" at the top level. Function dispatch
2932 // overrides scriptname per c:5903; scriptfilename stays.
2933 crate::ported::utils::set_scriptname(Some("zsh".to_string()));
2934 // c:Src/init.c:470-479 — `scriptname = scriptfilename =
2935 // ztrdup("zsh")` sits INSIDE the `-c` branch of the option parse.
2936 // An interactive shell (or one running a script file) leaves
2937 // `scriptfilename` NULL, and exec.c:5383 copies it onto every
2938 // Shfunc it defines — which is why zsh reports an EMPTY
2939 // `$functions_source[f]` for a function typed at the prompt.
2940 // Stamping "zsh" unconditionally made zshrs answer "zsh" there.
2941 let dash_c = std::env::args()
2942 .skip(1)
2943 .any(|a| a.starts_with('-') && !a.starts_with("--") && a.contains('c'));
2944 if dash_c {
2945 crate::ported::utils::set_scriptfilename(Some("zsh".to_string())); // c:479
2946 }
2947
2948 // call once that port is complete.
2949 crate::ported::init::module_path_init();
2950
2951 exec
2952 }
2953
2954 /// Execute a script file with bytecode caching — skips lex+parse+compile on cache hit.
2955 /// Bytecode is stored in rkyv keyed by (path, mtime).
2956 pub fn execute_script_file(&mut self, file_path: &str) -> Result<i32, String> {
2957 let path = Path::new(file_path);
2958 let abs_path = path
2959 .canonicalize()
2960 .unwrap_or_else(|_| path.to_path_buf())
2961 .to_string_lossy()
2962 .to_string();
2963
2964 // Try bytecode cache first — rkyv shard at ~/.zshrs/scripts.rkyv.
2965 // The cache validates path + mtime + zshrs binary mtime; on any
2966 // miss we fall through to lex/parse/compile. Cached path uses
2967 // `run_chunk` (the shared VM-execution helper); script-eval
2968 // path delegates to `execute_script_zsh_pipeline` so the
2969 // full parse/compile/cache-save/run flow stays in one place.
2970 if let Some(bc_blob) = crate::script_cache::try_load_bytes(path) {
2971 if let Ok(chunk) = bincode::deserialize::<fusevm::Chunk>(&bc_blob) {
2972 if !chunk.ops.is_empty() {
2973 tracing::trace!(
2974 path = %abs_path,
2975 ops = chunk.ops.len(),
2976 "execute_script_file: bytecode cache hit"
2977 );
2978 return self.run_chunk(chunk, &format!("execute_script_file:cache:{abs_path}"));
2979 }
2980 }
2981 }
2982
2983 // Cache miss — read, parse, compile via execute_script_zsh_pipeline,
2984 // then snapshot the resulting chunk into the cache for next
2985 // time. Direct port of Src/init.c source() which calls
2986 // `lex_init_buf` / `loop()` without engaging the history layer.
2987 // (zsh fires `!` history sub only on interactive input, so
2988 // sourced files run verbatim.)
2989 let content = fs::read_to_string(file_path).map_err(|e| format!("{}: {}", file_path, e))?;
2990 let status = self.execute_script_zsh_pipeline(&content)?;
2991
2992 // Best-effort cache save — failures don't block execution.
2993 // Re-parse/-compile here instead of trying to thread the chunk
2994 // back out of execute_script_zsh_pipeline; the cost is one extra
2995 // compile per CACHE MISS, paid back on every subsequent run.
2996 let saved_errflag = errflag.load(Ordering::Relaxed);
2997 errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
2998 // Context-isolated parse (c:Src/exec.c:283 parse_string) — this
2999 // post-exec re-parse for the bytecode cache also runs mid-stream
3000 // under the single-event reader; isolate it from the outer SHIN.
3001 let program = parse_isolated(&content);
3002 let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
3003 errflag.store(saved_errflag, Ordering::Relaxed);
3004 if !parse_failed {
3005 let compiler = crate::compile_zsh::ZshCompiler::new();
3006 let chunk = compiler.compile(&program);
3007 if let Ok(blob) = bincode::serialize(&chunk) {
3008 let _ = crate::script_cache::try_save_bytes(path, &blob);
3009 tracing::trace!(
3010 path = %abs_path,
3011 bytes = blob.len(),
3012 "execute_script_file: bytecode cached"
3013 );
3014 }
3015 }
3016
3017 Ok(status)
3018 }
3019
3020 /// Run a compiled `fusevm::Chunk` to completion inside this
3021 /// executor's context. Shared by `execute_script_zsh_pipeline`,
3022 /// `execute_script_file`'s bytecode-cache hit path, and the
3023 /// function-dispatch body_runner. Centralises the VM setup so
3024 /// `register_builtins` and `ExecutorContext::enter` invariants
3025 /// stay in lockstep.
3026 fn run_chunk(&mut self, chunk: fusevm::Chunk, label: &str) -> Result<i32, String> {
3027 if chunk.ops.is_empty() {
3028 return Ok(self.last_status());
3029 }
3030 crate::fusevm_disasm::maybe_print_stdout(label, &chunk);
3031 let mut vm = crate::vm_pool::acquire(chunk);
3032 // Seed vm.last_status with the executor's current LASTVAL so
3033 // sub-VMs (EXIT trap bodies, eval, source) see the inherited
3034 // `$?` from the caller's last command — matching C zsh where
3035 // lastval is a process global. Without this, the new VM
3036 // started at 0 and BUILTIN_GET_VAR's sync_status would write
3037 // 0 back into LASTVAL on the first `$?` read.
3038 vm.last_status = self.last_status();
3039 let _ctx = ExecutorContext::enter(self);
3040 // c:Src/loop.c — `loops` is bracketed by the C interpreter's own
3041 // recursion, so a `return` or an errflag abort out of a loop
3042 // unwinds it for free. A compiled chunk instead jumps straight to
3043 // its end, skipping the loop's `loops--`. Restoring the count the
3044 // chunk started with makes that structurally impossible to leak:
3045 // whatever loops this chunk opened are closed when it finishes.
3046 let loops_entry = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
3047 let result = vm.run();
3048 crate::ported::builtin::LOOPS.store(loops_entry, Ordering::Relaxed);
3049 match result {
3050 fusevm::VMResult::Ok(_) | fusevm::VMResult::Halted => {
3051 self.set_last_status(vm.last_status);
3052 }
3053 fusevm::VMResult::Error(e) => return Err(format!("VM error: {}", e)),
3054 }
3055 Ok(self.last_status())
3056 }
3057
3058 /// Execute via the lex+parse free ported + ZshCompiler pipeline.
3059 /// This is the only execution path; `execute_script` delegates here.
3060 /// Parse + compile `script` in an isolated lexer context, without
3061 /// running it.
3062 ///
3063 /// Split out of [`ShellExecutor::execute_script_zsh_pipeline`] so the
3064 /// autoload loader can get its hands on the compiled chunk: that chunk
3065 /// is what lands in `~/.zshrs/autoloads.rkyv`, so the next process can
3066 /// install the same function without re-parsing the definition file.
3067 fn compile_script_isolated(&mut self, script: &str) -> Result<fusevm::Chunk, String> {
3068 // Skip history expansion for non-interactive script execution
3069 // (`zsh -c '…'`, internal eval, sourced files). zsh's `!`
3070 // history sub only fires on the REPL command line, never on
3071 // a pre-parsed script body. The interactive REPL has its
3072 // own dedicated path that calls expand_history before
3073 // dispatching here.
3074 // Save & clear errflag around the parse so a fresh syntax
3075 // error is distinguishable from one already in flight. Mirrors
3076 // Src/init.c loop()'s pre-parse `errflag &= ~ERRFLAG_ERROR;`.
3077 let saved_errflag = errflag.load(Ordering::Relaxed);
3078 errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
3079 // Context-isolated parse (c:Src/exec.c:283 parse_string). eval /
3080 // source / autoload-register / trap bodies all reach here and run
3081 // DURING execution; on the faithful single-event loop()/parse_event
3082 // reader, a bare parse_init/lex_init would steal the outer's next
3083 // SHIN line into this nested program (e.g. `eval "x=5"` swallowed the
3084 // following `echo $x` off stdin). parse_isolated sets `strin` so the
3085 // string drains to EOF; execution below stays in the current shell.
3086 let program = parse_isolated(script);
3087 let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
3088 errflag.store(saved_errflag, Ordering::Relaxed);
3089 if parse_failed {
3090 // c:Src/init.c — when the parser fires `zerr(...)`, the C
3091 // shell's `loop()` body skips the eval pass and continues;
3092 // there's no second "parse error" diagnostic. The Rust
3093 // binary's call sites print `zshrs: <e>` on Err, doubling
3094 // up on the message the parser already emitted via zerr.
3095 // Use a `__SILENCED__` sentinel that the binary's
3096 // execute_script wrapper recognizes as "already reported,
3097 // exit silently". Bug #142 in docs/BUGS.md (double-print
3098 // half).
3099 return Err("__SILENCED__".to_string());
3100 }
3101
3102 let compiler = crate::compile_zsh::ZshCompiler::new();
3103 Ok(compiler.compile(&program))
3104 }
3105
3106 /// Run an already-compiled top-level chunk, then fire the end-of-script
3107 /// hooks (`EXIT` trap, `TRAPEXIT`, `zshexit` + `zshexit_functions`) the
3108 /// script pipeline owes them.
3109 fn run_chunk_with_exit_hooks(
3110 &mut self,
3111 chunk: fusevm::Chunk,
3112 label: &str,
3113 ) -> Result<i32, String> {
3114 let status = self.run_chunk(chunk, label)?;
3115
3116 // Fire EXIT trap if set. Two storage paths:
3117 // (a) `trap 'cmd' EXIT` writes the body text into
3118 // `traps_table` via bin_trap (Src/builtin.c) — fire
3119 // directly via execute_script.
3120 // (b) `TRAPEXIT() { ... }` function-named form goes
3121 // through settrap(SIGEXIT, None, ZSIG_FUNC) at
3122 // funcdef time (fusevm_bridge.rs BUILTIN_REGISTER_COMPILED_FN
3123 // arm) and lives in shfunctab + sigtrapped — fire
3124 // via dotrap(SIGEXIT) which dispatches the named
3125 // shfunc. Bug #157 in docs/BUGS.md.
3126 // Remove the trap from `traps_table` first to prevent
3127 // infinite recursion of `(a)`; `(b)`'s sigtrapped flag
3128 // is cleared by dotrap's own intrap guard.
3129 let exit_body = crate::ported::builtin::traps_table()
3130 .lock()
3131 .ok()
3132 .and_then(|mut t| t.remove("EXIT"));
3133 if let Some(action) = exit_body {
3134 tracing::debug!("firing EXIT trap (new pipeline)");
3135 // c:Src/signals.c — the EXIT trap body sees $? at the
3136 // value the script left off (so `trap 'echo $?' EXIT;
3137 // (exit 7)` prints 7), but the SHELL's final exit code
3138 // is still the pre-trap value (running `echo` inside
3139 // the trap doesn't reset the script's exit status).
3140 // Preserve `status` and re-apply it after the trap
3141 // body returns.
3142 //
3143 // c:Src/signals.c:1123/1236 — `intrap++` … `intrap--` bracket a
3144 // trap body, and while intrap the EXIT, DEBUG and ZERR traps are
3145 // suppressed (c:1112-1119, the guard in dotrap). This path runs
3146 // the EXIT body through the script pipeline rather than dotrap,
3147 // so nothing raised intrap and the body's own failure re-entered
3148 // the ERR trap: `trap 'print err' ERR; trap 'true; false' EXIT`
3149 // printed err where zsh prints nothing.
3150 //
3151 // A counter, not a flag, and paired with dotrap's SELECTIVE
3152 // guard: signals zsh does deliver from inside a trap body (e.g.
3153 // `trap 'kill -USR1 $$' EXIT`) must still dispatch.
3154 crate::ported::signals::intrap.fetch_add(1, Ordering::SeqCst); // c:1123
3155 let _ = self.execute_script_zsh_pipeline(&action);
3156 crate::ported::signals::intrap.fetch_sub(1, Ordering::SeqCst); // c:1236
3157 self.set_last_status(status);
3158 }
3159 // c:Src/signals.c::dotrap(SIGEXIT) — fire TRAPEXIT() shfunc
3160 // if installed via the function-name path. The TRAPEXIT()
3161 // form goes through settrap(SIGEXIT, None, ZSIG_FUNC) at
3162 // funcdef time (sets sigtrapped[SIGEXIT] |= ZSIG_FUNC).
3163 // Dispatching from here AFTER run_chunk returns means we're
3164 // outside the VM context — dotrap can't safely re-enter
3165 // via dispatch_function_call (which uses with_executor).
3166 // Route through execute_script_zsh_pipeline which sets up
3167 // a fresh VM context — invoke the function by name.
3168 let trapped = crate::ported::signals::sigtrapped
3169 .lock()
3170 .ok()
3171 .and_then(|g| g.get(crate::signals_h::SIGEXIT as usize).copied())
3172 .unwrap_or(0);
3173 // c:Src/signals.c:1112-1119 — `if (intrap) { switch (sig) { case
3174 // SIGEXIT: … return; } }`, and c:Src/signals.c:892 `if (!intrap &&
3175 // …)` in endtrapscope. An EXIT trap never fires from inside another
3176 // trap body. This site is a Rust-only end-of-pipeline hook (every
3177 // `eval` / `source` / trap body runs its own pipeline and reaches
3178 // here), and it dispatches TRAPEXIT by NAME without going through
3179 // dotrap — so nothing consulted `intrap` and nothing cleared
3180 // sigtrapped. Once `endtrapscope` started restoring a saved
3181 // ZSIG_FUNC EXIT trap (the c:929-931 arm), the TRAPEXIT body's own
3182 // nested pipeline re-entered this hook with the flag still set and
3183 // recursed without bound:
3184 // f() { eval 'TRAPEXIT() { echo T; }' }; f
3185 // The `intrap++ … intrap--` bracket is the same one the string-form
3186 // branch above already carries (c:1123 / c:1236).
3187 // c:Src/signals.c:744-752 — `sigtrapped[sig] |= (locallevel <<
3188 // ZSIG_SHIFT)`: a trap installed inside a function carries its
3189 // scope's locallevel, and `endtrapscope` (c:892-903/945-956) is what
3190 // fires THAT one, at the scope exit. Only an untagged (locallevel 0)
3191 // EXIT trap belongs to the shell-exit path this hook stands in for.
3192 // Without the test, `f() { TRAPEXIT() { echo T } }; f` fired twice —
3193 // once from f's endtrapscope and once more from the pipeline hook.
3194 let exit_trap_locallevel = trapped >> crate::ported::zsh_h::ZSIG_SHIFT;
3195 if (trapped & crate::ported::zsh_h::ZSIG_FUNC as i32) != 0
3196 && exit_trap_locallevel == 0
3197 && crate::ported::signals::intrap.load(Ordering::SeqCst) == 0
3198 {
3199 // The TRAP<SIG> function is stored in shfunctab as
3200 // "TRAPEXIT"; calling it by name re-enters
3201 // execute_script_zsh_pipeline with a fresh VM context.
3202 crate::ported::signals::intrap.fetch_add(1, Ordering::SeqCst); // c:1123
3203 let _ = self.execute_script_zsh_pipeline("TRAPEXIT");
3204 crate::ported::signals::intrap.fetch_sub(1, Ordering::SeqCst); // c:1236
3205 }
3206 // c:Src/init.c::zexit — `callhookfunc("zshexit", NULL, 1, NULL)`.
3207 // Fire the `zshexit` shfunc + walk `zshexit_functions` array.
3208 // Routed through execute_script_zsh_pipeline calls because
3209 // we're outside the VM context here (post-run_chunk). Iterate
3210 // the array directly + call zshexit by name. Bug #215 in
3211 // docs/BUGS.md.
3212 //
3213 // Re-entry guard: each call to execute_script_zsh_pipeline
3214 // (whether top-level script or the named-fn dispatch below)
3215 // hits this code at its tail. Without a guard, the zshexit
3216 // hook recurses infinitely (calls itself at end via this
3217 // path). Use a thread-local depth counter and skip the
3218 // dispatch when depth > 0.
3219 thread_local! {
3220 static ZSHEXIT_HOOK_DEPTH: std::cell::Cell<u32> = const {
3221 std::cell::Cell::new(0)
3222 };
3223 }
3224 let hook_depth = ZSHEXIT_HOOK_DEPTH.with(|c| c.get());
3225 if hook_depth == 0 {
3226 ZSHEXIT_HOOK_DEPTH.with(|c| c.set(hook_depth + 1));
3227 if crate::ported::hashtable::shfunctab_lock()
3228 .read()
3229 .ok()
3230 .map(|t| t.contains_key("zshexit"))
3231 .unwrap_or(false)
3232 {
3233 let _ = self.execute_script_zsh_pipeline("zshexit");
3234 }
3235 let exit_arr = crate::ported::params::paramtab()
3236 .read()
3237 .ok()
3238 .and_then(|t| t.get("zshexit_functions").and_then(|p| p.u_arr.clone()))
3239 .unwrap_or_default();
3240 for fn_name in exit_arr {
3241 let exists = crate::ported::hashtable::shfunctab_lock()
3242 .read()
3243 .ok()
3244 .map(|t| t.contains_key(&fn_name))
3245 .unwrap_or(false);
3246 if exists {
3247 let _ = self.execute_script_zsh_pipeline(&fn_name);
3248 }
3249 }
3250 ZSHEXIT_HOOK_DEPTH.with(|c| c.set(hook_depth));
3251 }
3252 // Preserve script status; trap body shouldn't override it.
3253 self.set_last_status(status);
3254
3255 let _ = status;
3256 Ok(self.last_status())
3257 }
3258 /// zshrs's script entry: lex + parse + compile + run, then the
3259 /// end-of-script hooks. `eval`, `source`, trap bodies and autoload
3260 /// registration all funnel through here.
3261 pub fn execute_script_zsh_pipeline(&mut self, script: &str) -> Result<i32, String> {
3262 let chunk = self.compile_script_isolated(script)?;
3263 self.run_chunk_with_exit_hooks(chunk, "execute_script_zsh_pipeline")
3264 }
3265
3266 /// Install an autoloaded function by running its definition program,
3267 /// reusing the rkyv-cached chunk when the cache can PROVE the chunk
3268 /// was compiled from this same definition text by this same binary.
3269 ///
3270 /// `registered` is what `autoload_register_source` produced: either
3271 /// `name() { <file body> }` or, for a file that already contains the
3272 /// definition, the body verbatim. Running it installs the function;
3273 /// the compiled chunk for it is exactly what the cache stores, so a hit
3274 /// skips lex+parse+compile of the whole file. For `_git` that is 424 KB
3275 /// of shell — the dominant cost of the first `git <tab>`.
3276 ///
3277 /// Two conditions gate caching, because outside them the chunk is not a
3278 /// function of the definition text alone:
3279 /// * ksh-style autoload (`KSHAUTOLOAD` / `PM_KSHSTORED`) runs the file
3280 /// at top level instead of wrapping it, so the same bytes produce a
3281 /// different program depending on a runtime option;
3282 /// * without `PM_UNALIASED` (`autoload` without `-U`) the body is
3283 /// parsed WITH alias expansion, so the chunk depends on the alias
3284 /// table too. Every compsys / plugin autoload uses `-Uz`.
3285 ///
3286 /// A hit that runs without defining `name` is treated as a corrupt
3287 /// entry, not as a failed load: the entry is dropped and the real
3288 /// source compiled. Installing a function is the one thing this
3289 /// function exists to do, so "it ran and the function is not there"
3290 /// is a fact the loader can check for itself rather than leaving the
3291 /// caller to report `function not defined by file` for what is
3292 /// actually a bad cache line.
3293 fn run_autoload_definition(
3294 &mut self,
3295 name: &str,
3296 registered: &str,
3297 ksh_style: bool,
3298 ) -> Result<i32, String> {
3299 let unaliased = crate::ported::utils::getshfunc(name)
3300 .map(|f| (f.node.flags as u32 & crate::ported::zsh_h::PM_UNALIASED) != 0)
3301 .unwrap_or(false);
3302 let key = if ksh_style || !unaliased {
3303 None
3304 } else {
3305 autoload_source_key(name, registered)
3306 };
3307 if let Some((dir, sha)) = key.as_ref() {
3308 if let Some(blob) = crate::autoload_cache::try_load_for_source(name, dir, sha) {
3309 match bincode::deserialize::<fusevm::Chunk>(&blob) {
3310 Ok(chunk) if !chunk.ops.is_empty() => {
3311 tracing::debug!(
3312 name,
3313 ops = chunk.ops.len(),
3314 "autoload: rkyv chunk hit, skipping parse+compile"
3315 );
3316 let status = self.run_chunk_with_exit_hooks(chunk, "autoload:cached");
3317 if self.functions_compiled.contains_key(name) {
3318 return status;
3319 }
3320 // The chunk ran and `name` is still undefined, so
3321 // it is not this function's definition program
3322 // whatever the key said. Drop it and fall through
3323 // to a real compile — a wrong answer here costs
3324 // every completion on the shell.
3325 tracing::warn!(
3326 name,
3327 "autoload: cached chunk did not define the function; \
3328 dropping the entry and recompiling"
3329 );
3330 crate::autoload_cache::try_remove(name);
3331 }
3332 _ => {}
3333 }
3334 }
3335 }
3336 let chunk = self.compile_script_isolated(registered)?;
3337 if let Some((dir, sha)) = key.as_ref() {
3338 match bincode::serialize(&chunk) {
3339 Ok(blob) => {
3340 if let Err(e) = crate::autoload_cache::try_save_one(name, &blob, dir, *sha) {
3341 tracing::warn!(name, error = %e, "autoload: rkyv chunk save failed");
3342 }
3343 }
3344 Err(e) => tracing::warn!(name, error = %e, "autoload: chunk serialize failed"),
3345 }
3346 }
3347 self.run_chunk_with_exit_hooks(chunk, "autoload:compiled")
3348 }
3349
3350 /// `execute_script` — see implementation.
3351 #[tracing::instrument(skip(self, script), fields(len = script.len()))]
3352 pub fn execute_script(&mut self, script: &str) -> Result<i32, String> {
3353 // lex+parse free ported + ZshCompiler is the only execution path.
3354 self.execute_script_zsh_pipeline(script)
3355 }
3356
3357 /// Run `script` with stdout AND stderr captured, returning `(exit status,
3358 /// output)` — the entry point for an embedder that owns the terminal (a
3359 /// TUI), where a stray `echo` corrupts the display.
3360 ///
3361 /// A shell cannot capture its output into an in-process buffer the way a
3362 /// single-runtime language can: a forked child writes fd 1 directly and
3363 /// knows nothing about the parent's buffers. The capture is therefore at fd
3364 /// level, and it differs from `$(…)` in the one way that matters to an
3365 /// embedder: [`Self::run_command_substitution`] runs on a sub-VM, as a
3366 /// subshell must, so a variable it sets is gone afterwards. This runs the
3367 /// script on THIS VM, so state persists across captured runs exactly as it
3368 /// does across ordinary [`Self::execute_script`] calls.
3369 ///
3370 /// The saved fds go through `movefd` to land at fd >= 10 and marked
3371 /// `FDT_INTERNAL`, per zsh's invariant that shell-internal fds never live
3372 /// below 10 — otherwise a script doing `exec 9>&-` closes the capture's own
3373 /// bookkeeping. A temp file, not a pipe, receives the output: with no
3374 /// concurrent reader, a pipe deadlocks the moment a script writes past the
3375 /// 64 KiB buffer.
3376 ///
3377 /// # Concurrency contract
3378 ///
3379 /// **While a capture is in flight, no other thread in the process may write
3380 /// fd 1 or fd 2.** POSIX has no per-thread fd table, so pointing fd 1 at the
3381 /// capture points it there for every thread at once; any byte another thread
3382 /// writes during the window lands in the returned `String` instead of on the
3383 /// terminal. The `CAPTURE_LOCK` below excludes a second *capture*, which is
3384 /// all a lock can do — a thread that never calls this function (a logger, a
3385 /// progress meter, a test harness's own reporter) is not excluded by
3386 /// anything, and its output is silently absorbed.
3387 ///
3388 /// This is not a gap that a different capture mechanism closes. C zsh dodges
3389 /// it for `$(…)` by forking: `getoutput` (`Src/exec.c:4816`) calls `zfork`
3390 /// and only the child does `redup(pipes[1], 1)` (`Src/exec.c:4837`), so the
3391 /// parent's fd 1 is never touched — but the child then runs `entersubsh`
3392 /// (`Src/exec.c:4838`) and a variable it sets is gone. Forking here would
3393 /// throw away the one property this call exists to provide (state persists
3394 /// on THIS VM across captured runs), so the cost is paid as a contract
3395 /// instead: **capture from one thread, and quiesce the rest.**
3396 pub fn execute_script_captured(&mut self, script: &str) -> (i32, String) {
3397 use std::io::{Read, Seek, SeekFrom};
3398 use std::os::unix::io::AsRawFd;
3399
3400 /// Serializes the redirect/restore window. fd 1 belongs to the process,
3401 /// not to a `ShellExecutor`, so two threads capturing at once would
3402 /// restore each other's fds mid-run and each would read back an empty
3403 /// file. An embedder that evaluates on one thread never contends here.
3404 /// It excludes another *capture* and nothing else: see the concurrency
3405 /// contract on this function for what remains the caller's problem.
3406 static CAPTURE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3407 let _guard = CAPTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3408
3409 let Ok(mut tmp) = tempfile::tempfile() else {
3410 // No temp file, no capture: run it anyway rather than silently
3411 // dropping the script, and report nothing captured.
3412 let status = self.execute_script(script).unwrap_or(1);
3413 return (status, String::new());
3414 };
3415
3416 // Flush Rust's buffered stdout against the REAL fd 1 before the swap,
3417 // or bytes written before this call drain into the capture instead
3418 // (the same ordering bug `run_command_substitution` documents).
3419 let _ = io::stdout().flush();
3420
3421 /// Puts fds 1 and 2 back on the way out, including when the run
3422 /// unwinds. A panic anywhere under `execute_script` would otherwise
3423 /// leave the whole PROCESS writing into a temp file that is already
3424 /// unlinked — every later write vanishes, starting with the one
3425 /// reporting the panic, which turns a localized bug into a silent one.
3426 struct RestoreFds {
3427 saved_out: i32,
3428 saved_err: i32,
3429 }
3430 impl Drop for RestoreFds {
3431 fn drop(&mut self) {
3432 let _ = io::stdout().flush();
3433 unsafe {
3434 libc::dup2(self.saved_out, libc::STDOUT_FILENO);
3435 libc::dup2(self.saved_err, libc::STDERR_FILENO);
3436 }
3437 crate::ported::utils::zclose(self.saved_out);
3438 crate::ported::utils::zclose(self.saved_err);
3439 }
3440 }
3441
3442 let saved_out = crate::ported::utils::movefd(unsafe { libc::dup(libc::STDOUT_FILENO) });
3443 let saved_err = crate::ported::utils::movefd(unsafe { libc::dup(libc::STDERR_FILENO) });
3444 unsafe {
3445 libc::dup2(tmp.as_raw_fd(), libc::STDOUT_FILENO);
3446 libc::dup2(tmp.as_raw_fd(), libc::STDERR_FILENO);
3447 }
3448 let restore = RestoreFds {
3449 saved_out,
3450 saved_err,
3451 };
3452
3453 let status = self.execute_script(script);
3454
3455 // Explicit, not end-of-scope: the temp file must be read back only
3456 // after the real fds are restored, or a diagnostic emitted while
3457 // reading would land in the very buffer being read.
3458 drop(restore);
3459
3460 let mut output = String::new();
3461 let _ = tmp.seek(SeekFrom::Start(0));
3462 let mut bytes = Vec::new();
3463 if tmp.read_to_end(&mut bytes).is_ok() {
3464 output = String::from_utf8_lossy(&bytes).into_owned();
3465 }
3466 // Match `$(…)`: one trailing newline is an artifact of the last `echo`,
3467 // not part of the output.
3468 while output.ends_with('\n') {
3469 output.pop();
3470 }
3471
3472 (status.unwrap_or_else(|_| self.last_status()), output)
3473 }
3474
3475 /// Run an ALREADY-PARSED program (the back half of
3476 /// `execute_script_zsh_pipeline`): compile the `ZshProgram` to a
3477 /// fusevm Chunk and run it. Used by the ported `loop()` REPL
3478 /// (Src/init.c:220 `execode`), which parses via `parse_event` and
3479 /// hands the program here through the `execute_program` exec hook.
3480 /// Returns the resulting `$?` (1 on a compile/run error).
3481 pub fn execute_program(&mut self, program: &crate::parse::ZshProgram) -> i32 {
3482 let chunk = crate::compile_zsh::ZshCompiler::new().compile(program);
3483 match self.run_chunk(chunk, "loop") {
3484 Ok(status) => status,
3485 Err(_) => 1,
3486 }
3487 }
3488
3489 /// Whether `name` is a known function. Checks the compiled-functions
3490 /// table and the autoload-pending registry — `autoload foo` should
3491 /// make `whence foo`/`type foo`/`functions foo` recognize `foo` as
3492 /// a function before it's actually loaded. Doesn't trigger autoload
3493 /// itself; use `maybe_autoload` first if you need to load before
3494 /// introspecting.
3495 pub fn function_exists(&self, name: &str) -> bool {
3496 // Either compiled (already loaded) or shfunctab has an
3497 // autoload stub with PM_UNDEFINED set (pending). Matches C's
3498 // `lookupshfunc(name)` semantics at `Src/exec.c:5215`.
3499 if self.functions_compiled.contains_key(name) {
3500 return true;
3501 }
3502 crate::ported::hashtable::shfunctab_lock()
3503 .read()
3504 .ok()
3505 .map(|t| t.get(name).is_some())
3506 .unwrap_or(false)
3507 }
3508
3509 /// Sorted list of every known function name (union of compiled + source).
3510 pub fn function_names(&self) -> Vec<String> {
3511 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3512 for k in self.functions_compiled.keys() {
3513 set.insert(k.clone());
3514 }
3515 for k in self.function_source.keys() {
3516 set.insert(k.clone());
3517 }
3518 set.into_iter().collect()
3519 }
3520
3521 /// Dispatch a function by name. Thin passthru — autoload-materialize
3522 /// the body if needed, build a synthetic `shfunc`, and hand off to
3523 /// the canonical `doshfunc` port (`Src/exec.c:5823` →
3524 /// `src/ported/exec.rs::doshfunc`). doshfunc owns ALL scope
3525 /// management (starttrapscope/endtrapscope, startparamscope/
3526 /// endparamscope, funcdepth bump, pipestats save/restore, scriptname
3527 /// snapshot, BREAKS/CONTFLAG/LOOPS/RETFLAG snapshot+restore, `$0`
3528 /// override via FUNCTIONARGZERO, etc.). The body run itself is the
3529 /// Rust-only adaptation passed via the `body_runner` closure because
3530 /// zshrs runs function bodies through fusevm bytecode (not C zsh's
3531 /// wordcode walker via `runshfunc`).
3532 ///
3533 /// Returns `None` when the name isn't a known function so the caller
3534 /// can fall through to external dispatch.
3535 /// Body-only counterpart to [`dispatch_function_call`] — runs
3536 /// the function body WITHOUT wrapping in `doshfunc`. Used as the
3537 /// `body_runner` closure target by `src/ported/` callers that
3538 /// already wrap their own `crate::ported::exec::doshfunc(...)`
3539 /// call (so going back through `dispatch_function_call` would
3540 /// double-wrap the scope). Mirrors C's `runshfunc(prog, wrappers,
3541 /// name)` at `exec.c:6042` from doshfunc's perspective.
3542 pub fn run_function_body_only(&mut self, name: &str, args: &[String]) -> Option<i32> {
3543 // Held for the WHOLE call, not just the load: an autoloaded function is
3544 // registered TWICE — once when its file's text defines it, and again
3545 // (unchanged) when its chunk is compiled at call time — and the second
3546 // stamp would otherwise relabel it with the caller's scriptfilename.
3547 // See the AUTOLOAD_DEF_FILE consumer in fusevm_bridge.
3548 let mut _autoload_file_guard: Option<AutoloadFileGuard> = None;
3549 // Same Rust-port short-circuit as dispatch_function_call,
3550 // sans the doshfunc wrap.
3551 if let Some(rc) = crate::compsys::router::dispatch_compsys(name, args) {
3552 // Plugin override (ABI v4) wins over the built-in Rust port.
3553 return Some(rc);
3554 }
3555 // Bug #657 gap #2 — `_regex_arguments`-generated completion functions
3556 // live in a runtime registry, not the static router table (a plain
3557 // `fn` ptr can't carry the dynamic name). Consult that registry here
3558 // so `compdef mycmd` → `_comps[cmd]=mycmd` → this call routes to the
3559 // compiled regex state machine.
3560 if let Some(rc) = crate::compsys::ported::_regex_arguments::dispatch_if_registered(name) {
3561 return Some(rc);
3562 }
3563 // c:Src/exec.c:5626 — see the twin site in
3564 // `dispatch_function_call`: a body loaded on THIS call runs one
3565 // `zsh_eval_context` frame deeper ("loadautofunc") than the
3566 // caller's "shfunc".
3567 let mut did_autoload = false;
3568 // Autoload prelude (same as dispatch_function_call's).
3569 if !self.functions_compiled.contains_key(name) {
3570 // On-demand $fpath autoload for `_`-prefixed compsys helpers that
3571 // compinit didn't register as autoload stubs — see the fuller
3572 // note in dispatch_function_call.
3573 if name.starts_with('_') && crate::ported::utils::getshfunc(name).is_none() {
3574 // c:6219 getfpfunc — gate the stub on the definition file
3575 // actually existing in $fpath, mirroring zsh's `compdef -na`
3576 // (only autoloads `_`-names present in fpath). Without this a
3577 // `_`-name with no file (e.g. a fasd completer trigger absent
3578 // from this fpath) got a phantom PM_UNDEFINED stub, and
3579 // loadautofn then leaked "function definition file not found"
3580 // to the terminal during completion. test_only=1 is a pure
3581 // probe; dump_out is preserved so .zwc-dump autoloads resolve.
3582 let mut _dir: Option<String> = None;
3583 let mut _dump = None;
3584 if crate::ported::exec::getfpfunc(name, &mut _dir, None, 1, &mut _dump).is_some() {
3585 let _ = self.execute_script_zsh_pipeline(&format!("autoload -rUz -- {name}"));
3586 }
3587 }
3588 if let Some(stub) = crate::ported::utils::getshfunc(name) {
3589 // c:Src/exec.c:5684-5704 (loadautofn) —
3590 // int noalias = noaliases;
3591 // noaliases = (shf->node.flags & PM_UNALIASED);
3592 // prog = getfpfunc(...); /* parses the file */
3593 // noaliases = noalias;
3594 // `autoload -U` records PM_UNALIASED (c:3354-3357), and its ONLY
3595 // effect is that the autoloaded body is PARSED with alias
3596 // expansion disabled. zshrs recorded the bit but never consulted
3597 // it, so a body calling `helper` picked up a caller-defined
3598 // `alias helper=...` — exactly what -U exists to prevent.
3599 let unaliased = (stub.node.flags as u32 & crate::ported::zsh_h::PM_UNALIASED) != 0;
3600 let noalias_save = crate::ported::lex::noaliases(); // c:5684
3601 crate::ported::lex::set_noaliases(unaliased); // c:5697
3602 let _restore_noaliases = NoAliasesRestore(noalias_save); // c:5704
3603 if (stub.node.flags as u32 & PM_UNDEFINED) != 0 {
3604 did_autoload = true; // c:5626 — body runs as "loadautofunc"
3605 let boxed = Box::new(stub.clone());
3606 let ptr = Box::into_raw(boxed);
3607 let load_rc = crate::ported::exec::loadautofn(ptr, 0, 0, 0);
3608 unsafe {
3609 let _ = Box::from_raw(ptr);
3610 }
3611 // c:Src/exec.c:5713-5719 — `if (prog == &dummy_eprog) {
3612 // zwarn("%s: function definition file not found",
3613 // shf->node.nam); … return NULL; }`, and
3614 // c:5635-5644 execautofn: `if (!loadautofn(...)) return 1;`
3615 // A failed load is TERMINAL: C has already replaced
3616 // shf->funcdef with the mkautofn trampoline (c:3180), so
3617 // nothing of the old stub body survives to be re-run.
3618 // zshrs keeps the stub's TEXT on the shfunc node, and the
3619 // `if let Some(body)` arm below would hand that text back
3620 // to run_autoload_definition — re-executing the very
3621 // `autoload -X` that triggered this load. `cod() {
3622 // autoload -XUz }; cod` recursed until FUNCNEST and
3623 // printed the diagnostic 500 times (C04funcdef:38,39,40).
3624 if load_rc != 0 {
3625 return Some(1); // c:5719 NULL → c:5644 `return 1`
3626 }
3627 // c:5657 — preserve the fpath dir + PM_LOADDIR across the
3628 // funcdef re-register (which stamps filename="zsh"), so
3629 // `whence -v` reports the source. See the twin site below.
3630 let loaded_dir = crate::ported::utils::getshfunc(name)
3631 .and_then(|f| f.filename)
3632 .filter(|d| d != "zsh");
3633 // c:Src/exec.c:5682-5760 — C loads the body IN PLACE on the
3634 // existing Shfunc, so every stub flag except PM_UNDEFINED
3635 // survives. See the twin site below for why PM_ABSPATH_USED
3636 // in particular has to come back.
3637 let abspath_used =
3638 (stub.node.flags as u32 & crate::ported::zsh_h::PM_ABSPATH_USED) != 0;
3639 let ksh_style = autoload_is_ksh_style(name); // c:5781 (pre-registration)
3640 if let Some(body) = crate::ported::utils::getshfunc(name).and_then(|f| f.body) {
3641 _autoload_file_guard = Some(AutoloadFileGuard::enter(name));
3642 let registered = autoload_register_source(name, &body);
3643 {
3644 // c:Src/exec.c:5735-5760 — C INSTALLS the parsed Eprog
3645 // as the function body; it executes nothing at load
3646 // time, so the global `lineno` still holds the line the
3647 // CALL was made on when doshfunc records
3648 // `funcsave->fstack.lineno = lineno` (c:6013). zshrs
3649 // installs the body by RUNNING `name() { … }` through
3650 // the pipeline, which walks the counter to the file's
3651 // last line — so the very first call of an autoloaded
3652 // function reported its caller's line as that instead:
3653 // `$functrace` read `script.zsh:1` where zsh reads
3654 // `script.zsh:4`, and inside completion `_subscript:0`
3655 // where zsh reads `_subscript:125`. Every LATER call
3656 // was already correct, because the load only happens
3657 // once.
3658 let caller_lineno = crate::ported::lex::lineno();
3659 // c:5384-5388 assigns `shf->lineno` only when a
3660 // `name() { … }` STATEMENT defines the function. An
3661 // autoload stub's Shfunc keeps the 0 it was created
3662 // with, and loadautofn replaces only `funcdef`, so zsh
3663 // reports `funcsourcetrace` as `<file>:0`. Running a
3664 // synthesized wrapper here stamps line 1 instead, so
3665 // put the stub's value back when the wrapper was ours.
3666 let synthesized = registered != body;
3667 let _ = self.run_autoload_definition(name, ®istered, ksh_style);
3668 crate::ported::lex::set_lineno(caller_lineno);
3669 if synthesized {
3670 // c:5384-5388 sets `shf->lineno` only where a
3671 // `name() { … }` STATEMENT defines the function; an
3672 // autoload stub keeps the 0 it was created with and
3673 // loadautofn replaces only `funcdef`, so
3674 // `funcsourcetrace` reads `<file>:0`. Executing our
3675 // synthesized wrapper records a line base of 1
3676 // instead. -1 marks "autoload-installed" so the
3677 // call-time clamp below can tell that apart from an
3678 // INLINE `f() { … }`, whose base underflows to 0 but
3679 // whose def line really is >= 1.
3680 self.function_line_base.insert(name.to_string(), -1);
3681 }
3682 }
3683 }
3684 if let Some(dir) = loaded_dir.as_deref() {
3685 restore_loaddir(name, dir, abspath_used, ksh_style);
3686 }
3687 } else if let Some(body) = stub.body.clone() {
3688 // c:Src/builtin.c:3180 (eval_autoload) — `autoload +X NAME`
3689 // loads the body EAGERLY through `loadautofn`, which sets
3690 // `body` + `filename`/PM_LOADDIR and clears PM_UNDEFINED
3691 // but leaves no compiled chunk behind. The first CALL of
3692 // such a function therefore lands in THIS arm, never the
3693 // PM_UNDEFINED arm above, so it needs the same
3694 // post-registration restore — otherwise `autoload +X
3695 // /abs/dir/NAME` lost the pair before the body ran and a
3696 // sibling `autoload -Uz SIB` inside it failed with
3697 // "function definition file not found" where zsh loads
3698 // /abs/dir/SIB. `functions[name]=body` (parameter.c
3699 // setpmfunction) reaches this arm too and simply has no
3700 // PM_LOADDIR, so the restore is skipped for it.
3701 let loaded_dir = ((stub.node.flags as u32 & crate::ported::zsh_h::PM_LOADDIR)
3702 != 0)
3703 .then(|| stub.filename.clone())
3704 .flatten();
3705 let abspath_used =
3706 (stub.node.flags as u32 & crate::ported::zsh_h::PM_ABSPATH_USED) != 0;
3707 let ksh_style = autoload_is_ksh_style(name); // c:5781 (pre-registration)
3708 _autoload_file_guard = Some(AutoloadFileGuard::enter(name));
3709 let registered = autoload_register_source(name, &body);
3710 {
3711 // c:Src/exec.c:5735-5760 — C INSTALLS the parsed Eprog
3712 // as the function body; it executes nothing at load
3713 // time, so the global `lineno` still holds the line the
3714 // CALL was made on when doshfunc records
3715 // `funcsave->fstack.lineno = lineno` (c:6013). zshrs
3716 // installs the body by RUNNING `name() { … }` through
3717 // the pipeline, which walks the counter to the file's
3718 // last line — so the very first call of an autoloaded
3719 // function reported its caller's line as that instead:
3720 // `$functrace` read `script.zsh:1` where zsh reads
3721 // `script.zsh:4`, and inside completion `_subscript:0`
3722 // where zsh reads `_subscript:125`. Every LATER call
3723 // was already correct, because the load only happens
3724 // once.
3725 let caller_lineno = crate::ported::lex::lineno();
3726 // c:5384-5388 assigns `shf->lineno` only when a
3727 // `name() { … }` STATEMENT defines the function. An
3728 // autoload stub's Shfunc keeps the 0 it was created
3729 // with, and loadautofn replaces only `funcdef`, so zsh
3730 // reports `funcsourcetrace` as `<file>:0`. Running a
3731 // synthesized wrapper here stamps line 1 instead, so
3732 // put the stub's value back when the wrapper was ours.
3733 let synthesized = registered != body;
3734 let _ = self.run_autoload_definition(name, ®istered, ksh_style);
3735 crate::ported::lex::set_lineno(caller_lineno);
3736 if synthesized {
3737 // c:5384-5388 sets `shf->lineno` only where a
3738 // `name() { … }` STATEMENT defines the function; an
3739 // autoload stub keeps the 0 it was created with and
3740 // loadautofn replaces only `funcdef`, so
3741 // `funcsourcetrace` reads `<file>:0`. Executing our
3742 // synthesized wrapper records a line base of 1
3743 // instead. -1 marks "autoload-installed" so the
3744 // call-time clamp below can tell that apart from an
3745 // INLINE `f() { … }`, whose base underflows to 0 but
3746 // whose def line really is >= 1.
3747 self.function_line_base.insert(name.to_string(), -1);
3748 }
3749 }
3750 if let Some(dir) = loaded_dir.as_deref() {
3751 restore_loaddir(name, dir, abspath_used, ksh_style);
3752 }
3753 }
3754 }
3755 }
3756 let chunk = self.functions_compiled.get(name).cloned()?;
3757 // c:5626 — `execode(shf->funcdef, 1, 0, "loadautofunc")`. Held
3758 // across the body run and dropped with the VM below.
3759 let _load_ctx =
3760 did_autoload.then(|| crate::ported::exec::EvalContextFrame::push("loadautofunc"));
3761 let seed_status = self.last_status();
3762 let _ = args; // fusevm body reads $1..$N from PPARAMS
3763 // Reuse a VM from the per-thread pool instead of building one from
3764 // scratch every call. `register_builtins` installs ~hundreds of
3765 // fn-pointer handlers into the VM's builtin_table; the table is
3766 // identical for every VM, so re-running it per function call was
3767 // pure waste (~130 profile samples in a tight call loop, the #2 hot
3768 // spot after option lookups). `VM::reset(chunk)` clears execution
3769 // state but PRESERVES builtin_table / host / JIT wiring, so a
3770 // recycled VM is call-ready without re-registration. Fresh VMs pay
3771 // the registration once. Nested calls simply check out additional
3772 // VMs; the pool grows to the max call depth. Re-entrant and
3773 // panic-safe: the VM is returned on the normal path below.
3774 // c:Src/exec.c:4364 — a `return` out of a redirected compound
3775 // command still runs `fixfds(save)`. See
3776 // `unwind_redirect_scopes_to`.
3777 let redir_depth = self.redirect_scope_stack.len();
3778 let mut vm = crate::vm_pool::acquire(chunk);
3779 vm.last_status = seed_status;
3780 let _ = vm.run();
3781 let status = vm.last_status;
3782 drop(vm);
3783 self.unwind_redirect_scopes_to(redir_depth);
3784 Some(status)
3785 }
3786
3787 pub fn dispatch_function_call(&mut self, name: &str, args: &[String]) -> Option<i32> {
3788 // Held for the WHOLE call, not just the load: an autoloaded function is
3789 // registered TWICE — once when its file's text defines it, and again
3790 // (unchanged) when its chunk is compiled at call time — and the second
3791 // stamp would otherwise relabel it with the caller's scriptfilename.
3792 // See the AUTOLOAD_DEF_FILE consumer in fusevm_bridge.
3793 let mut _autoload_file_guard: Option<AutoloadFileGuard> = None;
3794 // Nested scope for `>(cmd)` fd ownership — builtins running
3795 // inside the function body must not close the CALLER's
3796 // pending psub fds (`myfn >(cmd)` keeps /dev/fd/N alive for
3797 // the whole function, like C's per-job filelist). See
3798 // PSUB_SCOPE_DEPTH in fusevm_bridge.rs.
3799 let _psub_scope = crate::fusevm_bridge::PsubScope::enter();
3800 // c:Src/exec.c — `disable -f NAME` flips the DISABLED flag on
3801 // the shfunctab entry. `lookupshfunc` (which dispatch consults)
3802 // returns NULL for DISABLED entries, falling through to PATH
3803 // lookup → "command not found". zshrs keeps the compiled body
3804 // in functions_compiled independently of the flag, so check
3805 // shfunctab and short-circuit when DISABLED is set. Bug #221
3806 // in docs/BUGS.md.
3807 let is_disabled = crate::ported::hashtable::shfunctab_lock()
3808 .read()
3809 .ok()
3810 .and_then(|t| {
3811 let entry = t.get_including_disabled(name)?;
3812 Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
3813 })
3814 .unwrap_or(false);
3815 if is_disabled {
3816 return None;
3817 }
3818 // `_regex_arguments NAME …` (e.g. `_regex_arguments _sed_expressions …`
3819 // in `_sed`) eval-defines a real shell function NAME in zsh. This port
3820 // stores it in a runtime registry keyed by NAME (a static router fn-ptr
3821 // can't carry a dynamic name). `run_function_body_only` already consults
3822 // that registry, but `dispatch_function_call` — the path an `_arguments`
3823 // action (`:sed script:_sed_expressions`) or any by-name caller takes —
3824 // did not, so the call fell through to the autoload prelude and errored
3825 // "function definition file not found" (`sed -<TAB>`). Consult the
3826 // registry here too, before autoload. Returned directly (like
3827 // run_function_body_only) — the regex body drives compsys globals, not
3828 // function locals, so it needs no doshfunc scope wrap.
3829 if let Some(rc) = crate::compsys::ported::_regex_arguments::dispatch_if_registered(name) {
3830 return Some(rc);
3831 }
3832 // zshrs-original: `[compsys] backend = "rust"` short-circuit.
3833 // When a `_NAME` has a Rust port AND the user opted into the
3834 // rust backend, run the Rust fn directly here — but still
3835 // through the canonical doshfunc scope-management path below
3836 // (we synthesize a body_runner from the fn pointer). Router
3837 // returns None for names without a Rust port → graceful
3838 // fallback to the shfunc autoload path.
3839 //
3840 // Note: `compcore::callcompfunc` (the compsys entry hit by
3841 // Tab) wraps doshfunc itself per C `compcore.c:835`, so the
3842 // Rust _main_complete dispatch lands HERE only when called
3843 // from a non-compcore caller (e.g. a user shell script
3844 // directly invoking `_main_complete`). The doshfunc scope
3845 // wrap below applies uniformly to both.
3846 let direct_rust_fn: Option<fn(&[String]) -> i32> =
3847 crate::compsys::router::try_rust_dispatch(name);
3848 // A plugin-registered override (ABI v4, `zmodload -R`) also
3849 // intercepts natively: it supplies the body, so no shell autoload
3850 // or compiled chunk is needed — same as a built-in Rust port.
3851 let has_plugin_override = crate::extensions::plugin_host::compfn_override(name).is_some();
3852 // c:Src/exec.c:5626 — the body of a function loaded on THIS call
3853 // runs through `execode(shf->funcdef, 1, 0, "loadautofunc")`
3854 // (execautofn_basic), nested inside runshfunc's "shfunc" frame.
3855 // zshrs performs the load here, before `doshfunc`, so the flag
3856 // carries the fact into the body_runner that pushes the frame.
3857 let mut did_autoload = false;
3858 // Autoload prelude skipped when a Rust port OR plugin override wins
3859 // — no upstream shell function to load.
3860 if direct_rust_fn.is_none()
3861 && !has_plugin_override
3862 && !self.functions_compiled.contains_key(name)
3863 {
3864 // compinit bulk-loads $_comps from the dump/cache but (unlike
3865 // zsh's `compdef -na`, which `autoload -rUz`s every completer)
3866 // does NOT register the completer functions as autoload stubs.
3867 // So a shell completer WITHOUT a Rust port (e.g. `_cat`, or the
3868 // helpers it calls: `_pick_variant`, `_arguments`…) had no
3869 // shfunctab entry — getshfunc returned None, nothing compiled,
3870 // dispatch returned None, and the command's completion silently
3871 // produced nothing. Register `_`-prefixed helpers from $fpath on
3872 // demand (mirrors a fresh `autoload -Uz NAME`) so getshfunc finds
3873 // the stub below and loadautofn reads the file. Gated to `_`
3874 // names so ordinary commands still fall through to PATH.
3875 if name.starts_with('_') && crate::ported::utils::getshfunc(name).is_none() {
3876 // c:6219 getfpfunc — gate the stub on the definition file
3877 // actually existing in $fpath, mirroring zsh's `compdef -na`
3878 // (only autoloads `_`-names present in fpath). Without this a
3879 // `_`-name with no file (e.g. a fasd completer trigger absent
3880 // from this fpath) got a phantom PM_UNDEFINED stub, and
3881 // loadautofn then leaked "function definition file not found"
3882 // to the terminal during completion. test_only=1 is a pure
3883 // probe; dump_out is preserved so .zwc-dump autoloads resolve.
3884 let mut _dir: Option<String> = None;
3885 let mut _dump = None;
3886 if crate::ported::exec::getfpfunc(name, &mut _dir, None, 1, &mut _dump).is_some() {
3887 let _ = self.execute_script_zsh_pipeline(&format!("autoload -rUz -- {name}"));
3888 }
3889 }
3890 if let Some(stub) = crate::ported::utils::getshfunc(name) {
3891 // c:Src/exec.c:5684-5704 (loadautofn) — `autoload -U` records
3892 // PM_UNALIASED, whose ONLY effect is that the autoloaded body is
3893 // PARSED with alias expansion disabled:
3894 // int noalias = noaliases;
3895 // noaliases = (shf->node.flags & PM_UNALIASED);
3896 // prog = getfpfunc(...); /* parses the file */
3897 // noaliases = noalias;
3898 let unaliased = (stub.node.flags as u32 & crate::ported::zsh_h::PM_UNALIASED) != 0;
3899 let noalias_save = crate::ported::lex::noaliases(); // c:5684
3900 crate::ported::lex::set_noaliases(unaliased); // c:5697
3901 // c:5704 — restored on EVERY exit from this block, including the
3902 // early `return Some(1)` paths below.
3903 let _restore_noaliases = NoAliasesRestore(noalias_save);
3904 if (stub.node.flags as u32 & PM_UNDEFINED) != 0 {
3905 did_autoload = true; // c:5626 — body runs as "loadautofunc"
3906 let boxed = Box::new(stub.clone());
3907 let ptr = Box::into_raw(boxed);
3908 let load_rc = crate::ported::exec::loadautofn(ptr, 0, 0, 0);
3909 unsafe {
3910 let _ = Box::from_raw(ptr);
3911 }
3912 // c:Src/exec.c:5713-5719 / 5635-5644 — a failed load is
3913 // TERMINAL: `loadautofn` already emitted "function
3914 // definition file not found" and C's `execautofn` returns
3915 // 1 without ever touching a body (C replaced shf->funcdef
3916 // with the mkautofn trampoline at c:3180). zshrs still has
3917 // the stub's TEXT on the shfunc node, and the `if let
3918 // Some(body)` arm below would re-run it — for an
3919 // `autoload -X` stub that means re-entering the autoload
3920 // path, which recursed to FUNCNEST and printed the
3921 // diagnostic 500 times (C04funcdef:38,39,40). The
3922 // `else if load_rc != 0` arm below stays as the (now
3923 // unreachable) faithful mirror of the same C line.
3924 if load_rc != 0 {
3925 return Some(1); // c:5719 NULL → c:5644 `return 1`
3926 }
3927 // c:Src/exec.c:5657 loadautofnsetfile — capture the fpath
3928 // directory loadautofn wrote so it can be restored (as an
3929 // absolutized path with PM_LOADDIR) after the funcdef pipeline
3930 // below clobbers `filename` to scriptfilename ("zsh"). Without
3931 // this, `whence -v <autoloaded>` printed "from zsh".
3932 let loaded_dir = crate::ported::utils::getshfunc(name)
3933 .and_then(|f| f.filename)
3934 .filter(|d| d != "zsh");
3935 // c:Src/exec.c:5682-5760 — C loads the body IN PLACE on the
3936 // existing Shfunc: `shf->node.flags &= ~PM_UNDEFINED`
3937 // (c:5751) is the only flag C clears, so PM_ABSPATH_USED —
3938 // stamped by `autoload -Uz /abs/dir/NAME`
3939 // (`add_autoload_function`, Src/builtin.c:3290-3291) —
3940 // survives the load. zshrs re-registers the body through the
3941 // funcdef pipeline, which builds a FRESH node and drops the
3942 // whole flag word; `loadautofnsetfile` below puts filename +
3943 // PM_LOADDIR back, and PM_ABSPATH_USED has to come back with
3944 // them. It is read by `add_autoload_function`'s sibling arm
3945 // (Src/builtin.c:3310-3323): when a function loaded by
3946 // absolute path autoloads a sibling with a bare name, C
3947 // inherits the CALLER's load directory — `if ((shf2 = ...
3948 // getnode2(shfunctab, calling_f)) && (shf2->node.flags &
3949 // PM_LOADDIR) && (shf2->node.flags & PM_ABSPATH_USED) && ...`
3950 // Without the restore that test never fired, so
3951 // `autoload -Uz $D/wrapper; wrapper` → `autoload -Uz sibling`
3952 // failed with "function definition file not found" (zsh runs
3953 // $D/sibling), and compsys `_`-names fell through to the Rust
3954 // port instead of the user's file.
3955 let abspath_used =
3956 (stub.node.flags as u32 & crate::ported::zsh_h::PM_ABSPATH_USED) != 0;
3957 if let Some(body) = crate::ported::utils::getshfunc(name).and_then(|f| f.body) {
3958 let ksh_style = autoload_is_ksh_style(name); // c:5781 (pre-registration)
3959 _autoload_file_guard = Some(AutoloadFileGuard::enter(name));
3960 let registered = autoload_register_source(name, &body);
3961 // c:Src/exec.c:5739 — the ksh-autoload body runs via
3962 // `execode(prog, 1, 0, "evalautofunc")` at the function
3963 // invocation's locallevel, so a `return`/`break`/
3964 // `continue` inside the file body is CONTAINED to the
3965 // autoload call. add-zle-hook-widget's first line is
3966 // `zmodload -e zsh/zle || return 1`; when a plugin has
3967 // leaked `ksh_autoload` on (e.g. a bare `emulate sh`),
3968 // that `return` must NOT propagate out and abort the
3969 // caller's precmd/shell (zsh warns "not defined by file"
3970 // and CONTINUES). Save & restore the control-flow flags
3971 // around the body run to reinstate that boundary.
3972 {
3973 use crate::ported::builtin::{
3974 BREAKS, EXIT_PENDING, EXIT_VAL, RETFLAG, SHELL_EXITING,
3975 };
3976 use std::sync::atomic::Ordering::Relaxed;
3977 // c:Src/exec.c:5739 — `execode(prog, 1, 0,
3978 // "evalautofunc")` runs the file body as part of the
3979 // autoload invocation. add-zle-hook-widget's
3980 // `zmodload -e zsh/zle || return 1` sits at the
3981 // file's TOP LEVEL (above its anon-func wrapper); at
3982 // script scope a top-level `return` is a shell EXIT,
3983 // so running the body as a plain script aborted the
3984 // caller's precmd/shell. `return` is contained when
3985 // `locallevel || sourcelevel` (bin_return, c:5840) —
3986 // raise SOURCELEVEL (the file-source counter, which
3987 // unlike locallevel does NOT open a local scope, so
3988 // the body's global assignments still land globally)
3989 // so the top-level `return` returns from the load
3990 // instead of exiting. Save/restore the control-flow
3991 // flags so nothing leaks — matching zsh's
3992 // warn-and-continue.
3993 use crate::ported::init::sourcelevel;
3994 let saved_retflag = RETFLAG.swap(0, Relaxed);
3995 let saved_breaks = BREAKS.swap(0, Relaxed);
3996 let saved_exit_pending = EXIT_PENDING.swap(0, Relaxed);
3997 let saved_exit_val = EXIT_VAL.swap(0, Relaxed);
3998 let saved_shell_exiting = SHELL_EXITING.swap(0, Relaxed);
3999 sourcelevel.fetch_add(1, Relaxed);
4000 {
4001 // c:Src/exec.c:5739 — the ksh-autoload branch
4002 // runs the file through
4003 // `execode(prog, 1, 0, "evalautofunc")`, so
4004 // that label is on `zsh_eval_context` while
4005 // the file body executes.
4006 let _ctx =
4007 crate::ported::exec::EvalContextFrame::push("evalautofunc");
4008 {
4009 // c:Src/exec.c:5735-5760 — C INSTALLS the parsed Eprog
4010 // as the function body; it executes nothing at load
4011 // time, so the global `lineno` still holds the line the
4012 // CALL was made on when doshfunc records
4013 // `funcsave->fstack.lineno = lineno` (c:6013). zshrs
4014 // installs the body by RUNNING `name() { … }` through
4015 // the pipeline, which walks the counter to the file's
4016 // last line — so the very first call of an autoloaded
4017 // function reported its caller's line as that instead:
4018 // `$functrace` read `script.zsh:1` where zsh reads
4019 // `script.zsh:4`, and inside completion `_subscript:0`
4020 // where zsh reads `_subscript:125`. Every LATER call
4021 // was already correct, because the load only happens
4022 // once.
4023 let caller_lineno = crate::ported::lex::lineno();
4024 // c:5384-5388 assigns `shf->lineno` only when a
4025 // `name() { … }` STATEMENT defines the function. An
4026 // autoload stub's Shfunc keeps the 0 it was created
4027 // with, and loadautofn replaces only `funcdef`, so zsh
4028 // reports `funcsourcetrace` as `<file>:0`. Running a
4029 // synthesized wrapper here stamps line 1 instead, so
4030 // put the stub's value back when the wrapper was ours.
4031 let synthesized = registered != body;
4032 let _ =
4033 self.run_autoload_definition(name, ®istered, ksh_style);
4034 crate::ported::lex::set_lineno(caller_lineno);
4035 if synthesized {
4036 // c:5384-5388 sets `shf->lineno` only where a
4037 // `name() { … }` STATEMENT defines the function; an
4038 // autoload stub keeps the 0 it was created with and
4039 // loadautofn replaces only `funcdef`, so
4040 // `funcsourcetrace` reads `<file>:0`. Executing our
4041 // synthesized wrapper records a line base of 1
4042 // instead. -1 marks "autoload-installed" so the
4043 // call-time clamp below can tell that apart from an
4044 // INLINE `f() { … }`, whose base underflows to 0 but
4045 // whose def line really is >= 1.
4046 self.function_line_base.insert(name.to_string(), -1);
4047 }
4048 }
4049 }
4050 sourcelevel.fetch_sub(1, Relaxed);
4051 RETFLAG.store(saved_retflag, Relaxed);
4052 BREAKS.store(saved_breaks, Relaxed);
4053 EXIT_PENDING.store(saved_exit_pending, Relaxed);
4054 EXIT_VAL.store(saved_exit_val, Relaxed);
4055 SHELL_EXITING.store(saved_shell_exiting, Relaxed);
4056 }
4057 if let Some(dir) = loaded_dir.as_deref() {
4058 restore_loaddir(name, dir, abspath_used, ksh_style);
4059 }
4060 if !self.functions_compiled.contains_key(name) {
4061 // c:Src/exec.c:5742-5745 — ksh-style load ran
4062 // the file (`execode`, "evalautofunc") but it
4063 // didn't define NAME:
4064 // `zwarn("%s: function not defined by file", n);`
4065 // The wrap/strip zsh-style paths always define
4066 // NAME, so reaching here means the verbatim run
4067 // failed to — same condition as C.
4068 crate::ported::utils::zwarn(&format!(
4069 "{}: function not defined by file",
4070 name
4071 ));
4072 return Some(1);
4073 }
4074 } else if load_rc != 0 {
4075 // c:Src/exec.c:5713-5719 / 5635-5644 —
4076 // `execautofn`'s `if (!loadautofn(...)) return 1`
4077 // propagates the loadautofn failure as the
4078 // command's exit status. zshrs's previous
4079 // path returned None here, falling through to
4080 // execute_external which emitted a SECOND
4081 // diagnostic (`command not found: NAME`) on
4082 // top of loadautofn's `function definition
4083 // file not found`. Mirror C: when load failed
4084 // AND the stub still has no body, surface
4085 // status=1 so the caller does NOT fall back
4086 // to PATH search.
4087 return Some(1);
4088 }
4089 } else if let Some(body) = stub.body.clone() {
4090 // c:Src/Modules/parameter.c::setpmfunction — function
4091 // registered via `functions[name]=body` lives in
4092 // shfunctab with `body` set but `functions_compiled`
4093 // empty (the canonical port stores the parsed eprog,
4094 // not a fusevm Chunk). Lazy-compile here by feeding
4095 // the body through the standard funcdef pipeline so
4096 // the next CallFunction op finds the chunk.
4097 //
4098 // c:Src/builtin.c:3180 (eval_autoload) — `autoload +X NAME`
4099 // reaches this arm as well: it loads the body EAGERLY via
4100 // `loadautofn`, which sets `body` + `filename`/PM_LOADDIR
4101 // and clears PM_UNDEFINED but leaves no compiled chunk, so
4102 // the first CALL never sees the PM_UNDEFINED arm above.
4103 // Restore the load directory after re-registration exactly
4104 // as that arm does — otherwise `autoload +X /abs/dir/NAME`
4105 // dropped PM_LOADDIR|PM_ABSPATH_USED before the body ran
4106 // and a sibling `autoload -Uz SIB` inside it failed with
4107 // "function definition file not found" where zsh loads
4108 // /abs/dir/SIB. The `functions[name]=body` case has no
4109 // PM_LOADDIR, so the restore is skipped for it.
4110 let loaded_dir = ((stub.node.flags as u32 & crate::ported::zsh_h::PM_LOADDIR)
4111 != 0)
4112 .then(|| stub.filename.clone())
4113 .flatten();
4114 let abspath_used =
4115 (stub.node.flags as u32 & crate::ported::zsh_h::PM_ABSPATH_USED) != 0;
4116 let ksh_style = autoload_is_ksh_style(name); // c:5781 (pre-registration)
4117 _autoload_file_guard = Some(AutoloadFileGuard::enter(name));
4118 let registered = autoload_register_source(name, &body);
4119 {
4120 // c:Src/exec.c:5735-5760 — C INSTALLS the parsed Eprog
4121 // as the function body; it executes nothing at load
4122 // time, so the global `lineno` still holds the line the
4123 // CALL was made on when doshfunc records
4124 // `funcsave->fstack.lineno = lineno` (c:6013). zshrs
4125 // installs the body by RUNNING `name() { … }` through
4126 // the pipeline, which walks the counter to the file's
4127 // last line — so the very first call of an autoloaded
4128 // function reported its caller's line as that instead:
4129 // `$functrace` read `script.zsh:1` where zsh reads
4130 // `script.zsh:4`, and inside completion `_subscript:0`
4131 // where zsh reads `_subscript:125`. Every LATER call
4132 // was already correct, because the load only happens
4133 // once.
4134 let caller_lineno = crate::ported::lex::lineno();
4135 // c:5384-5388 assigns `shf->lineno` only when a
4136 // `name() { … }` STATEMENT defines the function. An
4137 // autoload stub's Shfunc keeps the 0 it was created
4138 // with, and loadautofn replaces only `funcdef`, so zsh
4139 // reports `funcsourcetrace` as `<file>:0`. Running a
4140 // synthesized wrapper here stamps line 1 instead, so
4141 // put the stub's value back when the wrapper was ours.
4142 let synthesized = registered != body;
4143 let _ = self.run_autoload_definition(name, ®istered, ksh_style);
4144 crate::ported::lex::set_lineno(caller_lineno);
4145 if synthesized {
4146 // c:5384-5388 sets `shf->lineno` only where a
4147 // `name() { … }` STATEMENT defines the function; an
4148 // autoload stub keeps the 0 it was created with and
4149 // loadautofn replaces only `funcdef`, so
4150 // `funcsourcetrace` reads `<file>:0`. Executing our
4151 // synthesized wrapper records a line base of 1
4152 // instead. -1 marks "autoload-installed" so the
4153 // call-time clamp below can tell that apart from an
4154 // INLINE `f() { … }`, whose base underflows to 0 but
4155 // whose def line really is >= 1.
4156 self.function_line_base.insert(name.to_string(), -1);
4157 }
4158 }
4159 if let Some(dir) = loaded_dir.as_deref() {
4160 restore_loaddir(name, dir, abspath_used, ksh_style);
4161 }
4162 }
4163 }
4164 }
4165 // When a Rust port is registered, skip the fusevm Chunk
4166 // lookup entirely — the body_runner closure below will run
4167 // the Rust fn pointer directly. Otherwise require a compiled
4168 // chunk for the autoloaded body.
4169 let chunk_opt = if direct_rust_fn.is_some() || has_plugin_override {
4170 None
4171 } else {
4172 Some(self.functions_compiled.get(name).cloned()?)
4173 };
4174
4175 // zshrs-specific bookkeeping that doshfunc doesn't own:
4176 // - prompt_funcstack (PS4 trace) push/pop
4177 // - local_scope_depth FUNCNEST guard
4178 //
4179 // c:Src/exec.c::funcnest_check — C zsh allows FUNCNEST=500 by
4180 // default. zshrs's per-call stack usage is heavier (vm_helper
4181 // state, fusevm closures, parse buffers), so on the default 8MB
4182 // stack a deep recursion overflowed around depth ~80-120 and
4183 // crashed. That is now fixed at the source: the shell runs on a
4184 // 512MB-stack thread (see bins/zshrs.rs::main), which comfortably
4185 // fits FUNCNEST (500) nested heavy frames. So the effective limit
4186 // is the user's FUNCNEST (default 500), matching zsh — no premature
4187 // clamp — with a generous hard ceiling as a last-resort backstop
4188 // that stays well under the big stack's capacity. Bug #519 (the
4189 // crash) / #643 (the false-positive clamp at 80 that broke
4190 // legitimately deep recursion). The authoritative FUNCNEST error
4191 // is also enforced in doshfunc (exec.rs) on the FS_FUNC depth.
4192 const FUNCNEST_RUST_CEILING: usize = 6000;
4193 let funcnest_user: usize = self
4194 .scalar("FUNCNEST")
4195 .and_then(|s| s.parse().ok())
4196 .unwrap_or(500);
4197 let funcnest_limit = funcnest_user.min(FUNCNEST_RUST_CEILING);
4198 if self.local_scope_depth >= funcnest_limit {
4199 // c:Src/exec.c:6060-6063 —
4200 // zerr("maximum nested function level reached; increase FUNCNEST?");
4201 // lastval = 1;
4202 // goto undoshfunc;
4203 // `zerr` is what makes this FATAL: it raises errflag, so the
4204 // enclosing list stops and a non-interactive shell exits.
4205 // zsh 5.9:
4206 // zsh -fc 'FUNCNEST=2; f() { f; }; f; printf after'
4207 // prints only the diagnostic and exits 1 — no `after`. bash
4208 // agrees ("Function invocations that exceed this nesting level
4209 // cause the current command to abort", bash(1) FUNCNEST), and
4210 // its own message is likewise followed by exit 1.
4211 //
4212 // This guard printed with a bare `eprintln!` and returned 1
4213 // WITHOUT raising errflag, so the runaway recursion stopped but
4214 // the script kept running — `printf after` ran and the shell
4215 // exited 0. The ported check in exec.rs::doshfunc already does
4216 // the C trio, but this one fires first (it is the zshrs-only
4217 // stack backstop, evaluated before dispatch reaches doshfunc),
4218 // so it has to carry the same side effects.
4219 //
4220 // The message is written here rather than through `zerr`
4221 // because C's prefix is the *function* name — `scriptname` is
4222 // the running function inside doshfunc — and this guard runs
4223 // before that switch; going through zerr would print the outer
4224 // script name instead. Byte-compared against zsh 5.9.
4225 // c:Src/utils.c zerr → zerrmsg prints `scriptname:lineno: msg`
4226 // whenever `scriptname` is set (which it is here: c:5963
4227 // `scriptname = dupstring(name)` runs BEFORE the c:6060 check).
4228 // The `:lineno` half was missing, so
4229 // ( FUNCNEST=0; fn() { true; }; fn )
4230 // printed `fn: maximum …` where zsh prints `fn:4: maximum …`
4231 // (C04funcdef:46).
4232 eprintln!(
4233 "{}:{}: maximum nested function level reached; increase FUNCNEST?",
4234 name,
4235 crate::ported::lex::lineno()
4236 );
4237 errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed); // c:6061 (zerr)
4238 crate::ported::builtin::LASTVAL.store(1, Ordering::Relaxed); // c:6062
4239 return Some(1);
4240 }
4241 let display_name = if name.starts_with("_zshrs_anon_") {
4242 "(anon)".to_string()
4243 } else {
4244 name.to_string()
4245 };
4246 let line_base = self.function_line_base.get(name).copied().unwrap_or(0);
4247 let def_file = self.function_def_file.get(name).cloned().flatten();
4248 self.prompt_funcstack
4249 .push((name.to_string(), line_base, def_file));
4250 self.local_scope_depth += 1;
4251
4252 // Synthetic shfunc for doshfunc — carries the name + def-file
4253 // info so funcstack push gets a proper filename. funcdef/body
4254 // stay None because the wordcode body is irrelevant on this
4255 // path (body_runner runs the fusevm Chunk directly).
4256 // c:Src/exec.c:5390-5410 — execfuncdef records the
4257 // current `scriptfilename` on the shfunc at definition
4258 // time so funcsourcetrace can show file:line of the
4259 // function's source. The function_def_file map stores
4260 // this; fall back to the live scriptfilename so dynamic
4261 // / non-`compile_funcdef`-routed definitions still get a
4262 // sensible filename. Without the fallback, the synth_shf
4263 // saw None and the funcstack push at exec.rs:5719
4264 // defaulted to an empty string, which the funcsourcetrace
4265 // getfn rendered as `:N` (or worse, picked up the
4266 // function name from a parallel field). Bug #515.
4267 // c:Src/exec.c:5620/5625 — the source file is `getshfuncfile(shf)`,
4268 // which reads the shfunc's own `filename` (authoritative: set by
4269 // execfuncdef for a normally-defined function, and by loadautofn — as
4270 // the fpath dir with PM_LOADDIR — for an AUTOLOADED one). Prefer it.
4271 // `function_def_file` is a zshrs-only side map that, for an autoloaded
4272 // function, was stamped with the OUTER `scriptfilename` ("zsh") at
4273 // compile time, not the fpath file — so it must NOT override
4274 // getshfuncfile. Consulting it second still covers functions whose
4275 // shfunc `filename` wasn't recorded (compile_funcdef-routed defs);
4276 // scriptfilename is the final fallback. Without getshfuncfile winning,
4277 // funcsourcetrace reported "zsh" for every autoloaded completer, which
4278 // broke `_git`: its first git-completion.bash search path is
4279 // `"$(dirname ${funcsourcetrace[1]%:*})"/git-completion.bash` — "zsh"
4280 // resolved to `./git-completion.bash`, found nothing, and
4281 // `. "$script"` errored (`_git:.:48: no such file or directory`).
4282 let synth_filename = crate::ported::hashtable::getshfuncfile(name)
4283 .or_else(|| self.function_def_file.get(name).cloned().flatten())
4284 .or_else(|| self.scriptfilename.clone());
4285 // c:Src/exec.c:5409 — `shf->lineno = lineno;` (def line).
4286 // `function_line_base[name]` carries compile_funcdef's
4287 // `lineno_offset = first_body_line - 1` — equals the def line
4288 // for multi-line `f() {\n body }` but underflows to 0 for
4289 // INLINE `f() { body }` (def and body share a line). zsh's
4290 // funcsourcetrace reports the def line as 1-based, so clamp
4291 // to >= 1 to handle the inline case without rebuilding
4292 // line tracking through the parser. Bug #396.
4293 let synth_lineno = {
4294 let base = self.function_line_base.get(name).copied().unwrap_or(0);
4295 if base < 0 {
4296 // Autoload-installed (see the -1 marker at the install
4297 // site): c:5384 never runs for it, so the def line is 0.
4298 0
4299 } else {
4300 std::cmp::max(1i64, base)
4301 }
4302 };
4303 // Carry the REAL function's attribute flags over from shfunctab.
4304 // `functions -t/-T/-W` store PM_TAGGED / PM_TAGGED_LOCAL /
4305 // PM_WARNNESTED on the shfunctab node (builtin.rs c:3719), and
4306 // doshfunc turns PM_TAGGED* into XTRACE for the duration of the call
4307 // (exec.c:5954-5960). Hardcoding 0 here severed that link: the flags
4308 // were parsed and stored correctly, but the synthesized shfunc handed
4309 // to doshfunc always claimed "no attributes", so `functions -t f; f`
4310 // ran silently while `setopt xtrace` (a global option, not routed
4311 // through this struct) traced normally. Bug #1058.
4312 // The shfunctab key is the REGISTRATION name, which for an
4313 // anonymous function is the generated `_zshrs_anon_*` (only the
4314 // DISPLAY name is `(anon)` — c:Src/exec.c:5492 sets
4315 // `shf->node.nam = ANONYMOUS_FUNCTION_NAME` on the same struct
4316 // that already carries `tracing_flags` from c:5437). Looking the
4317 // flags up under the display name missed every anonymous
4318 // function, so `function -T { … }` ran untraced (E02xtrace:7,9).
4319 let synth_flags = crate::ported::hashtable::shfunctab_lock()
4320 .read()
4321 .ok()
4322 .and_then(|t| {
4323 t.get(name)
4324 .or_else(|| t.get(display_name.as_str()))
4325 .map(|s| s.node.flags)
4326 })
4327 .unwrap_or(0);
4328 // c:Src/exec.c:5978 — `if (sticky_emulation_differs(shfunc->sticky))`
4329 // reads the STORED per-function sticky snapshot that
4330 // `shfunc_set_sticky` (c:5402) stamped at definition time. The
4331 // synthesized shfunc hardcoded `sticky: None`, so a function
4332 // defined under `emulate sh -c '...'` never re-entered its
4333 // emulation when called (B07emulate.ztst:6,7,8,12,13,14).
4334 // Carry it over from shfunctab like `synth_flags` above.
4335 let synth_sticky = crate::ported::hashtable::shfunctab_lock()
4336 .read()
4337 .ok()
4338 .and_then(|t| {
4339 t.get(name)
4340 .or_else(|| t.get(display_name.as_str()))
4341 .and_then(|s| {
4342 s.sticky
4343 .as_deref()
4344 .map(|b| crate::ported::exec::sticky_emulation_dup(b, 0))
4345 })
4346 });
4347 let mut synth_shf = crate::ported::zsh_h::shfunc {
4348 node: crate::ported::zsh_h::hashnode {
4349 next: None,
4350 nam: display_name.clone(),
4351 flags: synth_flags,
4352 },
4353 filename: synth_filename,
4354 lineno: synth_lineno,
4355 funcdef: None,
4356 redir: None,
4357 sticky: synth_sticky,
4358 body: None,
4359 redir_text: None,
4360 };
4361 // doshargs: C convention — argv[0] = function name (for
4362 // FUNCTIONARGZERO `$0`), argv[1..] = real positional args.
4363 let mut doshargs: Vec<String> = vec![display_name.clone()];
4364 doshargs.extend(args.iter().cloned());
4365
4366 // Seed `$?` with the parent's last status — C zsh's
4367 // doshfunc inherits lastval automatically because it's a
4368 // process-global; the fusevm VM creates a fresh
4369 // `vm.last_status = 0` per call, so we mirror the inherit
4370 // explicitly. Without this, a function reading `$?` BEFORE
4371 // running any command sees 0 instead of the caller's status.
4372 let seed_status = self.last_status();
4373 let body_args: Vec<String> = args.to_vec();
4374 let name_owned = name.to_string();
4375 let body_runner = move || -> i32 {
4376 // c:Src/exec.c:5626 — `execode(shf->funcdef, 1, 0,
4377 // "loadautofunc")`. On the call that autoloaded the function,
4378 // C runs its body one `zsh_eval_context` frame deeper than
4379 // runshfunc's "shfunc", which is why zsh reports
4380 // `shfunc:loadautofunc:…` down a chain of freshly autoloaded
4381 // completers where zshrs reported a flat `shfunc:shfunc:…`.
4382 let _load_ctx =
4383 did_autoload.then(|| crate::ported::exec::EvalContextFrame::push("loadautofunc"));
4384 // Branch: plugin override (ABI v4) → built-in Rust port →
4385 // fusevm Chunk (autoloaded shell body). All run INSIDE
4386 // doshfunc's scope so prologue/epilogue applies identically.
4387 if let Some(rc) =
4388 crate::extensions::plugin_host::dispatch_compfn(&name_owned, &body_args)
4389 {
4390 return rc;
4391 }
4392 if let Some(f) = direct_rust_fn {
4393 return f(&body_args);
4394 }
4395 let chunk = chunk_opt
4396 .as_ref()
4397 .expect("chunk_opt must be Some when direct_rust_fn is None");
4398 crate::fusevm_disasm::maybe_print_stdout(
4399 &format!(
4400 "function:{}",
4401 body_args.first().map(|s| s.as_str()).unwrap_or("")
4402 ),
4403 chunk,
4404 );
4405 let mut vm = crate::vm_pool::acquire(chunk.clone());
4406 vm.last_status = seed_status;
4407 let _ = vm.run();
4408 vm.last_status
4409 };
4410
4411 // Enter executor context BEFORE doshfunc so the body_runner's
4412 // VM builtins can `with_executor(...)` to reach this state.
4413 // c:Src/exec.c:5572-5585 — execshfunc swaps in a FRESH, EMPTY cmdstack
4414 // for the duration of a shell-function call and restores the caller's
4415 // afterwards:
4416 // ocs = cmdstack; ocsp = cmdsp;
4417 // cmdstack = zalloc(CMDSTACKSZ); cmdsp = 0;
4418 // doshfunc(shf, args, 0);
4419 // free(cmdstack); cmdstack = ocs; cmdsp = ocsp;
4420 // The cmdstack is what `%_` renders, so without the swap a function
4421 // body inherits the CALLER's parser context: `f(){ print -rP "[%_]" }`
4422 // printed `[cursh]` inside `{ f }`, `[then]` inside an `if`, `[for]`
4423 // inside a loop and `[case]` inside a case arm, where zsh prints `[]`
4424 // in every one. Most visible under xtrace, whose default PS4 ends in
4425 // `%_`, so every traced line inside a called function carried a stale
4426 // field. `( f )` was already correct only because the subshell forks.
4427 // Bug #1059.
4428 let saved_cmdstack: Vec<u8> =
4429 crate::ported::prompt::CMDSTACK.with(|s| std::mem::take(&mut *s.borrow_mut()));
4430 // c:Src/exec.c:4364 — a `return` out of a redirected compound
4431 // command still runs `fixfds(save)`. See
4432 // `unwind_redirect_scopes_to`.
4433 let redir_depth = self.redirect_scope_stack.len();
4434 let _ctx = ExecutorContext::enter(self);
4435 let status = crate::ported::exec::doshfunc(&mut synth_shf, doshargs, false, body_runner);
4436 drop(_ctx);
4437 self.unwind_redirect_scopes_to(redir_depth);
4438 crate::ported::prompt::CMDSTACK.with(|s| *s.borrow_mut() = saved_cmdstack);
4439
4440 self.prompt_funcstack.pop();
4441 self.local_scope_depth -= 1;
4442
4443 // Honor explicit `return N` from inside the function body.
4444 if let Some(ret) = self.returning.take() {
4445 self.set_last_status(ret);
4446 Some(ret)
4447 } else {
4448 self.set_last_status(status);
4449 Some(status)
4450 }
4451 }
4452
4453 pub(crate) fn execute_external(
4454 &mut self,
4455 cmd: &str,
4456 args: &[String],
4457 redirects: &[Redirect],
4458 ) -> Result<i32, String> {
4459 // FORK_EVENTS is bumped at the real spawn site inside
4460 // execute_external_bg — this entry is only ONE of several
4461 // callers of that spawn (the common static-head command path
4462 // calls execute_external_bg directly), so counting here would
4463 // miss `time sleep 0` while double-counting this path.
4464 self.execute_external_bg(cmd, args, redirects, false)
4465 }
4466
4467 fn execute_external_bg(
4468 &mut self,
4469 cmd: &str,
4470 args: &[String],
4471 _redirects: &[Redirect],
4472 background: bool,
4473 ) -> Result<i32, String> {
4474 tracing::trace!(cmd, bg = background, "exec external");
4475 // c:Src/exec.c:3545-3547 — `setunderscore((args && nonempty(args)) ?
4476 // ((char *) getdata(lastnode(args))) : "")`. execcmd_exec sets `$_`
4477 // to the last word of the command it is about to run, in the PARENT,
4478 // before any builtin/plugin resolution or fork — so `cat /dev/null;
4479 // print $_` reports `/dev/null` and a pipeline's stages each leave
4480 // their own last word behind. This is the single funnel every
4481 // external spawn reaches (the static-head command path calls it
4482 // directly and bypasses ZshrsHost::exec / host_exec_external), so
4483 // the write belongs here. C's `args` list carries argv[0], hence the
4484 // fallback to `cmd` for a bare command.
4485 {
4486 let last = args.last().cloned().unwrap_or_else(|| cmd.to_string());
4487 crate::ported::params::set_zunderscore(std::slice::from_ref(&last));
4488 // c:3546
4489 }
4490 // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
4491 // Native (Rust) plugin builtins registered via `zmodload -R`
4492 // (src/extensions/plugin_host.rs). fusevm compiles unknown
4493 // names into external execution, so a plugin command arrives
4494 // here as an "external". Resolve it BEFORE the PATH-unset guard
4495 // and the process spawn — plugin builtins are in-process and
4496 // need no PATH. This is the analog of C's `resolvebuiltin`
4497 // slot (Src/exec.c:2700), which likewise runs before the fork.
4498 // Bare names only: a `/`-qualified token is always a filesystem
4499 // path, never a plugin command name. Runs synchronously even
4500 // when backgrounded — zshrs is non-forking and an in-process
4501 // builtin has nothing to background.
4502 if !cmd.contains('/') {
4503 if let Some(status) = crate::plugin_host::dispatch(cmd, args) {
4504 return Ok(status);
4505 }
4506 }
4507 // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
4508 // Host-registered native commands (`extensions/native_cmds.rs`) — the
4509 // sibling runtimes a fat binary links into the shell's address space:
4510 // `git` (zvcs), `arb` (arblang) and `stryke` (strykelang) in the
4511 // zshrs-native build. Same slot and same reason as the plugin-builtin
4512 // dispatch directly above: the compiler has never heard of these names,
4513 // so it lowered them to external execution and they arrive here — this
4514 // is where they must be caught, BEFORE the PATH guard and before the
4515 // spawn, because an in-process builtin needs no PATH and no process.
4516 //
4517 // Two escape hatches to the binary on disk stay open, and both are
4518 // checked here. A `/`-qualified token (`/usr/bin/git`) is a filesystem
4519 // path the user named, never a registry key. And `command git`
4520 // explicitly asks past the in-process one — the `command` handler
4521 // raises `native_cmds::force_external` around this call, exactly as
4522 // `command cat` already escapes the coreutils shadow.
4523 //
4524 // The registry's contract is full argv (argv[0] = the name as
4525 // invoked), which zvcs reads for its `git-<verb>` dashed form.
4526 //
4527 // Empty in the thin shell: one map lookup that always misses.
4528 if !cmd.contains('/')
4529 && !crate::native_cmds::is_forced_external()
4530 && crate::native_cmds::is_enabled(cmd)
4531 {
4532 let full: Vec<String> = std::iter::once(cmd.to_string())
4533 .chain(args.iter().cloned())
4534 .collect();
4535 if let Some(status) = crate::native_cmds::dispatch(cmd, &full) {
4536 return Ok(status);
4537 }
4538 }
4539 // c:Src/exec.c:824-876 — when arg0 has no `/`, C zsh requires
4540 // a PATH search. With PATH unset, the search yields no hit
4541 // and C emits `command not found: <cmd>`. Rust's
4542 // `Command::new(name)` delegates to libc `execvp`, which on
4543 // many platforms falls back to a built-in default PATH when
4544 // the env entry is missing — so `unset PATH; ls` still finds
4545 // `/bin/ls` and runs it, breaking the security boundary the
4546 // unset is supposed to establish (#416). Gate explicitly:
4547 // when cmd is a bare name (no `/`) and zshrs's own PATH
4548 // param is unset OR empty, emit the canonical
4549 // "command not found" diagnostic and return 127 BEFORE
4550 // touching libc.
4551 if !cmd.contains('/') {
4552 let path_set_and_nonempty = crate::ported::params::getsparam("PATH")
4553 .map(|p| !p.is_empty())
4554 .unwrap_or(false);
4555 if !path_set_and_nonempty {
4556 let sn =
4557 crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
4558 // c:Src/exec.c:811 `zerr("command not found: %s", arg0)`
4559 // — the diagnostic carries the CURRENT line, not a
4560 // hardcoded 1. `lineno()` is the same counter zwarning
4561 // (utils.rs:179) uses and is live during VM execution
4562 // (verified: read-only / div-by-zero errors already
4563 // report the right line). Emitted directly (not via
4564 // zerr) to avoid setting errflag — command-not-found is
4565 // non-fatal and the script must continue.
4566 // Inline Rust FFI export: needs no PATH, so run it here rather
4567 // than reporting not-found when PATH is unset/empty.
4568 if let Some(rc) = self.try_registered_ffi_command(cmd, args) {
4569 return Ok(rc);
4570 }
4571 eprintln!("{}: command not found: {}", zerr_prefix(&sn), cmd);
4572 return Ok(127);
4573 }
4574 }
4575 // c:Src/exec.c:2700-2724 resolvebuiltin — names registered via
4576 // `zmodload -ab MOD NAME` resolve through builtintab BEFORE
4577 // PATH search in C (execcmd's builtin lookup precedes the
4578 // external fork). Names the compiler didn't know as builtins
4579 // land here; consult the autoload ledger, load the module,
4580 // and re-dispatch through the builtin chokepoint. Without
4581 // this, `zmodload -ab zsh/bogus mybltn; mybltn` skipped the
4582 // C autoload-fire entirely (PATH miss → 127 instead of the
4583 // load_module diagnostic → 1).
4584 if !cmd.contains('/') {
4585 if let Some(rc) = crate::ported::module::resolvebuiltin(cmd) {
4586 if rc != 0 {
4587 return Ok(1);
4588 }
4589 return Ok(crate::fusevm_bridge::dispatch_builtin_raw(
4590 cmd,
4591 args.to_vec(),
4592 ));
4593 }
4594 }
4595 // c:Src/exec.c:531-534 — `execve(pth, argv, newenvp); if ((eno =
4596 // errno) == ENOEXEC || eno == ENOENT) { … }`. The kernel is the only
4597 // thing that understands `#!`, and when it REFUSES the file — ENOEXEC
4598 // (no valid magic and no shebang) or ENOENT (a `#!` line naming an
4599 // interpreter that does not exist as spelled, e.g. `#!sh`) — zsh reads
4600 // the shebang itself and re-execs with the interpreter it names,
4601 // falling back to `/bin/sh` for a shebang-less script. These three
4602 // hold what C's second `execve` would receive: `spawn_prog` is c:566's
4603 // `pprog` (the RESOLVED program), `spawn_arg0` is c:564's `ptr2` (the
4604 // interpreter NAME as written on the `#!` line), `spawn_args` the
4605 // rest. `Command::new` conflates program and argv[0], hence the
4606 // explicit `arg0`. `cmd`/`args` stay untouched: every diagnostic and
4607 // hook below reports the command the user actually typed, exactly as
4608 // C reports `arg0` (c:797/811).
4609 let mut spawn_prog: String = cmd.to_string();
4610 let mut spawn_arg0: String = cmd.to_string();
4611 let mut spawn_args: Vec<String> = args.to_vec();
4612 // C recurses through zexecve for each rewrite; the loop is that
4613 // recursion, re-driving the spawn with the rewritten argv.
4614 loop {
4615 let mut command = Command::new(&spawn_prog);
4616 {
4617 use std::os::unix::process::CommandExt as _;
4618 command.arg0(&spawn_arg0);
4619 }
4620 // c:Src/exec.c execute — C unmetafies every arg before the
4621 // execve (the child must see raw bytes, not the shell's
4622 // internal Meta encoding). Args carrying Meta-char pairs
4623 // (from `$'\xff'` etc., vm_helper::meta_encode_byte) are
4624 // decoded to raw bytes via OsStr; plain args pass through
4625 // unchanged. Bug #127.
4626 for a in &spawn_args {
4627 if a.contains('\u{83}') {
4628 use std::os::unix::ffi::OsStrExt as _;
4629 command.arg(std::ffi::OsStr::from_bytes(&unmetafy_str(a)));
4630 } else {
4631 command.arg(a);
4632 }
4633 }
4634
4635 // Redirect handling lives in fusevm's WithRedirectsBegin/End
4636 // ops at compile time; `_redirects` arrives empty here.
4637
4638 // c:Src/jobs.c — `time` reports only on JOBS (forked work). This
4639 // is the single chokepoint where an external process is actually
4640 // spawned (both fg and bg, all callers), AFTER the
4641 // command-not-found and resolvebuiltin early-returns above — so
4642 // counting here makes BUILTIN_TIME_SUBLIST report `time sleep 0`
4643 // / `time /usr/bin/true` (external → fork) while staying silent
4644 // for `time true` (builtin, never reaches this point). The
4645 // subshell entry counts separately (fusevm_bridge.rs:9573).
4646 FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4647
4648 return if background {
4649 match command.spawn() {
4650 Ok(child) => {
4651 let pid = child.id();
4652 let cmd_str = format!("{} {}", cmd, args.join(" "));
4653 let job_id = self.jobs.add_job(child, cmd_str, JobState::Running);
4654 println!("[{}] {}", job_id, pid);
4655 Ok(0)
4656 }
4657 Err(e) => {
4658 // c:534-627 — the kernel refused the file; retry with
4659 // the interpreter the `#!` line names (or `/bin/sh` for a
4660 // shebang-less script). See zexecve_recover.
4661 let eno = e.raw_os_error().unwrap_or(0);
4662 if eno == libc::ENOEXEC || eno == libc::ENOENT {
4663 // c:Src/exec.c:815 — C hands `zexecve` the RESOLVED
4664 // candidate `pbuf` from its own `$path` walk, never the
4665 // bare word; and c:544 `*argv = pth;` then puts that
4666 // resolved path into argv[0] for the interpreter. Here
4667 // libc did the PATH search inside the spawn, so redo it
4668 // with `pathprog` (utils.rs:798) before probing —
4669 // otherwise a `#!` script found on `$path` was handed to
4670 // its interpreter as the bare name and `#!echo foo`
4671 // printed `foo tstcmd-arg` instead of
4672 // `foo <dir>/tstcmd-arg`.
4673 let probe_pth = if spawn_prog.contains('/') {
4674 spawn_prog.clone()
4675 } else {
4676 match crate::ported::utils::pathprog(&spawn_prog) {
4677 Some(p) => p.display().to_string(), // c:815
4678 None => spawn_prog.clone(),
4679 }
4680 };
4681 let mut cargv: Vec<String> = Vec::with_capacity(spawn_args.len() + 1);
4682 cargv.push(spawn_arg0.clone());
4683 cargv.extend_from_slice(&spawn_args);
4684 if let Ok((prog, newargv)) = zexecve_recover(&probe_pth, &cargv, eno) {
4685 spawn_arg0 =
4686 newargv.first().cloned().unwrap_or_else(|| prog.clone());
4687 spawn_args =
4688 newargv.get(1..).map(|v| v.to_vec()).unwrap_or_default();
4689 spawn_prog = prog;
4690 continue;
4691 }
4692 }
4693 let sn = crate::ported::utils::scriptname_get()
4694 .unwrap_or_else(|| "zshrs".to_string());
4695 if e.kind() == io::ErrorKind::NotFound {
4696 // Inline Rust FFI export run in the background: an
4697 // in-process FFI call has nothing to background, so run
4698 // it synchronously (mirrors the plugin-builtin path).
4699 if let Some(rc) = self.try_registered_ffi_command(cmd, args) {
4700 return Ok(rc);
4701 }
4702 // zsh: absolute paths emit "no such file or
4703 // directory" (the OS error, since the path was
4704 // tried directly), not "command not found"
4705 // (which implies PATH search).
4706 // c:Src/exec.c:871-876 — `if (eno) zerr("%e: %s", eno, arg0);
4707 // else … zerr("command not found: %s", arg0);`. `eno` is set
4708 // by an execve that actually ran, and zsh runs execve directly
4709 // for ANY arg0 containing a slash (no PATH search), so
4710 // `./foo` and `dir/foo` report the errno, not "command not
4711 // found". Testing only for a LEADING slash mis-reported the
4712 // relative forms:
4713 // ./nonexistent_script
4714 // zsh : zsh:1: no such file or directory: ./nonexistent_script
4715 // zshrs: zsh:1: command not found: ./nonexistent_script
4716 if cmd.contains('/') {
4717 eprintln!(
4718 "{}: no such file or directory: {}",
4719 zerr_prefix(&sn),
4720 cmd
4721 );
4722 } else {
4723 eprintln!("{}: command not found: {}", zerr_prefix(&sn), cmd);
4724 }
4725 Ok(127)
4726 } else {
4727 Err(format!("{}: {}: {}", sn, cmd, e))
4728 }
4729 }
4730 }
4731 } else {
4732 // Queue signals across the wait so zshrs's SIGCHLD reaper
4733 // (waitpid(-1) in wait_for_processes, delivered on any
4734 // thread) can't reap this child before Command::status()
4735 // does — otherwise status() fails with ECHILD ("No child
4736 // processes"). See ForegroundWaitGuard in fusevm_bridge.
4737 let status_result = {
4738 let _wait_guard = crate::fusevm_bridge::ForegroundWaitGuard::enter();
4739 command.status()
4740 };
4741 match status_result {
4742 Ok(status) => Ok(status.code().unwrap_or(1)),
4743 Err(e) => {
4744 // c:534-627 — the kernel refused the file; retry with
4745 // the interpreter the `#!` line names (or `/bin/sh` for a
4746 // shebang-less script). See zexecve_recover.
4747 let eno = e.raw_os_error().unwrap_or(0);
4748 if eno == libc::ENOEXEC || eno == libc::ENOENT {
4749 // c:Src/exec.c:815 — C hands `zexecve` the RESOLVED
4750 // candidate `pbuf` from its own `$path` walk, never the
4751 // bare word; and c:544 `*argv = pth;` then puts that
4752 // resolved path into argv[0] for the interpreter. Here
4753 // libc did the PATH search inside the spawn, so redo it
4754 // with `pathprog` (utils.rs:798) before probing —
4755 // otherwise a `#!` script found on `$path` was handed to
4756 // its interpreter as the bare name and `#!echo foo`
4757 // printed `foo tstcmd-arg` instead of
4758 // `foo <dir>/tstcmd-arg`.
4759 let probe_pth = if spawn_prog.contains('/') {
4760 spawn_prog.clone()
4761 } else {
4762 match crate::ported::utils::pathprog(&spawn_prog) {
4763 Some(p) => p.display().to_string(), // c:815
4764 None => spawn_prog.clone(),
4765 }
4766 };
4767 let mut cargv: Vec<String> = Vec::with_capacity(spawn_args.len() + 1);
4768 cargv.push(spawn_arg0.clone());
4769 cargv.extend_from_slice(&spawn_args);
4770 if let Ok((prog, newargv)) = zexecve_recover(&probe_pth, &cargv, eno) {
4771 spawn_arg0 =
4772 newargv.first().cloned().unwrap_or_else(|| prog.clone());
4773 spawn_args =
4774 newargv.get(1..).map(|v| v.to_vec()).unwrap_or_default();
4775 spawn_prog = prog;
4776 continue;
4777 }
4778 }
4779 // Use scriptname (the user-visible shell identifier
4780 // — "zsh" in --zsh mode, "zshrs" otherwise) instead
4781 // of a hardcoded "zshrs:" prefix so --zsh-mode
4782 // diagnostics byte-match C zsh's stderr format.
4783 let sn = crate::ported::utils::scriptname_get()
4784 .unwrap_or_else(|| "zshrs".to_string());
4785 if e.kind() == io::ErrorKind::NotFound {
4786 // c:Src/exec.c — `command_not_found_handler` user
4787 // hook: when a command lookup fails AND a function
4788 // by that name is defined, call it with the cmd
4789 // name + original args and return its rc instead
4790 // of the default 127 + "command not found" error.
4791 // Documented in zshmisc(1) under "Special
4792 // Functions". Bug #426.
4793 //
4794 // The hook only fires for bare names (PATH search
4795 // failed); absolute paths skip it and emit the
4796 // OS-error path below — matches zsh behavior.
4797 if !cmd.contains('/') {
4798 let mut hook_args = Vec::with_capacity(args.len() + 1);
4799 hook_args.push(cmd.to_string());
4800 hook_args.extend_from_slice(args);
4801 if let Some(rc) = self
4802 .dispatch_function_call("command_not_found_handler", &hook_args)
4803 {
4804 return Ok(rc);
4805 }
4806 }
4807 // Inline Rust FFI export: consulted after builtins,
4808 // functions, PATH search, and command_not_found_handler
4809 // have all missed — real commands keep priority.
4810 if let Some(rc) = self.try_registered_ffi_command(cmd, args) {
4811 return Ok(rc);
4812 }
4813 // zsh: absolute paths emit "no such file or
4814 // directory" (the OS error, since the path was
4815 // tried directly), not "command not found"
4816 // (which implies PATH search).
4817 // c:Src/exec.c:871-876 — `if (eno) zerr("%e: %s", eno, arg0);
4818 // else … zerr("command not found: %s", arg0);`. `eno` is set
4819 // by an execve that actually ran, and zsh runs execve directly
4820 // for ANY arg0 containing a slash (no PATH search), so
4821 // `./foo` and `dir/foo` report the errno, not "command not
4822 // found". Testing only for a LEADING slash mis-reported the
4823 // relative forms:
4824 // ./nonexistent_script
4825 // zsh : zsh:1: no such file or directory: ./nonexistent_script
4826 // zshrs: zsh:1: command not found: ./nonexistent_script
4827 if cmd.contains('/') {
4828 eprintln!(
4829 "{}: no such file or directory: {}",
4830 zerr_prefix(&sn),
4831 cmd
4832 );
4833 } else {
4834 eprintln!("{}: command not found: {}", zerr_prefix(&sn), cmd);
4835 }
4836 Ok(127)
4837 } else if e.kind() == io::ErrorKind::PermissionDenied {
4838 // zsh: non-executable file → "permission denied"
4839 // on stderr and exit 126 (POSIX "command found
4840 // but not executable").
4841 eprintln!("{}: permission denied: {}", zerr_prefix(&sn), cmd);
4842 Ok(126)
4843 } else {
4844 Err(format!("{}: {}: {}", sn, cmd, e))
4845 }
4846 }
4847 }
4848 };
4849 }
4850 }
4851 /// !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
4852 /// Inline Rust FFI fallback: when `cmd` names a function exported by a
4853 /// `rust { ... }` block (registered by the `__rust_compile` builtin) run it
4854 /// as a command. Consulted only when `cmd` resolved to nothing else — not a
4855 /// builtin, function, plugin, external on `$PATH`, or
4856 /// `command_not_found_handler` — so real commands keep priority. Positional
4857 /// args are marshalled as strings; fusevm coerces each to the export's
4858 /// signature (`i64` / `f64` / `*const c_char`). The return value is printed
4859 /// to stdout (the redirect-aware process fd 1) and the command exits 0.
4860 /// Bare names only — a `/`-qualified token is a filesystem path, never an
4861 /// FFI export. Returns `None` when `cmd` is not a registered export, so the
4862 /// caller emits its normal "command not found".
4863 fn try_registered_ffi_command(&self, cmd: &str, args: &[String]) -> Option<i32> {
4864 if cmd.contains('/') || !fusevm::ffi::is_registered(cmd) {
4865 return None;
4866 }
4867 let vals: Vec<fusevm::Value> = args.iter().map(|a| fusevm::Value::str(a.clone())).collect();
4868 match fusevm::ffi::try_call(cmd, &vals) {
4869 Some(Ok(v)) => {
4870 use std::io::Write as _;
4871 let mut out = io::stdout().lock();
4872 let _ = writeln!(out, "{}", v.to_str());
4873 let _ = out.flush();
4874 Some(0)
4875 }
4876 Some(Err(e)) => {
4877 eprintln!("zshrs: {e}");
4878 Some(1)
4879 }
4880 // Registered a moment ago but the entry vanished (registry race) —
4881 // treat as unresolved and let the caller report command-not-found.
4882 None => None,
4883 }
4884 }
4885
4886 /// Parse `cmd_str` via parse_init+parse and pull out the first Simple
4887 /// command's words, untokenized + variable-expanded, ready to spawn
4888 /// as argv. Used by process-substitution where we need raw argv to
4889 /// hand to `Command::new`. Returns empty vec if the cmd isn't a
4890 /// simple shape — pipelines / compound forms aren't process-sub
4891 /// friendly anyway.
4892 fn simple_cmd_words(&mut self, cmd_str: &str) -> Vec<String> {
4893 // Mirror Src/init.c-style errflag save/clear/check around the
4894 // parse. Process-sub argv extraction silently bails on syntax
4895 // errors (matches zsh's behavior when the inner command can't
4896 // be parsed).
4897 let saved_errflag = errflag.load(Ordering::Relaxed);
4898 errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
4899 // Context-isolated nested parse (c:Src/exec.c:283 parse_string) —
4900 // same rationale as run_command_substitution: process-sub argv
4901 // extraction runs during execution and must not clobber the outer
4902 // single-event reader's lexer/input position.
4903 let prog = parse_isolated(cmd_str);
4904 let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
4905 errflag.store(saved_errflag, Ordering::Relaxed);
4906 if parse_failed {
4907 return Vec::new();
4908 }
4909 let first = match prog.lists.first() {
4910 Some(l) => l,
4911 None => return Vec::new(),
4912 };
4913 let pipe = &first.sublist.pipe;
4914 if let crate::parse::ZshCommand::Simple(simple) = &pipe.cmd {
4915 simple
4916 .words
4917 .iter()
4918 .map(|w| {
4919 // Untokenize then variable-expand — text-based
4920 // word expansion for the spawned argv.
4921 let untoked = crate::lex::untokenize(w);
4922 singsub(&untoked)
4923 })
4924 .collect()
4925 } else {
4926 Vec::new()
4927 }
4928 }
4929 /// `run_command_substitution` — see implementation.
4930 /// The SQLite mirror, opened the first time anything asks for it.
4931 ///
4932 /// Returns `None` when no cache file exists yet (or it failed to open),
4933 /// which is the same answer the eager constructor produced.
4934 pub fn compsys_cache(&self) -> Option<&CompsysCache> {
4935 self.compsys_cache
4936 .get_or_init(|| {
4937 let cache_path = crate::compsys::cache::default_cache_path();
4938 if !cache_path.exists() {
4939 tracing::debug!("compsys: no cache at {}", cache_path.display());
4940 return None;
4941 }
4942 let db_size = fs::metadata(&cache_path).map(|m| m.len()).unwrap_or(0);
4943 match CompsysCache::open(&cache_path) {
4944 Ok(c) => {
4945 tracing::info!(
4946 db_bytes = db_size,
4947 path = %cache_path.display(),
4948 "compsys: sqlite mirror opened (dbview/SQL inspection only; rkyv shards are the authoritative cache)"
4949 );
4950 Some(c)
4951 }
4952 Err(e) => {
4953 tracing::warn!(error = %e, "compsys: failed to open cache");
4954 None
4955 }
4956 }
4957 })
4958 .as_ref()
4959 }
4960
4961 pub fn run_command_substitution(&mut self, cmd_str: &str) -> String {
4962 // c:Src/subst.c / Src/lex.c — the text inside `$(…)` is a FRESH
4963 // command line. The double quotes that may surround the substitution
4964 // apply to its RESULT, not to the words inside it: in `"$(f $x)"` the
4965 // `$x` is unquoted. `in_dq_context` is the runtime signal the
4966 // `${(flags)…}` bridges read for paramsubst's `qt` (c:1625), and it
4967 // stayed set for the whole body, so every flag-expansion inside a
4968 // DQ command substitution ran as if quoted.
4969 //
4970 // What that broke: `qt` suppresses RC_EXPAND_PARAM's word removal, so
4971 // under `setopt rcexpandparam` an EMPTY array kept a word instead of
4972 // deleting it (c:4327's `while ((x = *aval++))` emits nothing for an
4973 // empty array; the `!plan9` single-empty-word path at c:4261 is the
4974 // one that must NOT run):
4975 // setopt rcexpandparam
4976 // f() { declare -a x; print "n=$(set -- H ${(q)x}; print $#)" }
4977 // f # zsh: n=1, zshrs was n=2
4978 // Only the `"$(…)"` spelling was affected — unquoted `$(…)`,
4979 // backticks, and `v=$(…)` were all already correct, which is what
4980 // made it look like a quoting bug rather than an option bug.
4981 //
4982 // Bit through compsys: completion runs with rcexpandparam ON, and
4983 // `_git`'s __git_recent_commits passes `${(q)commit_opts}` to
4984 // `_call_program` inside `"$(…)"`. The stray empty word became a
4985 // bogus `''` argument to `git rev-list`, the command failed, and
4986 // `git checkout <TAB>` lost its whole recent-commits group.
4987 //
4988 // `SUBEXP_SCALAR_CTX` carries the same thing one level down — it is
4989 // what a NESTED expansion reads as `subexp_dq` (subst.rs:18873) to
4990 // learn that its OUTER `${…}` was quoted. A `$(…)` inside a quoted
4991 // outer expansion is still a fresh command line, so it has to be
4992 // cleared too:
4993 // setopt rcexpandparam
4994 // f() { declare -a co; local -a c
4995 // c=("${(f)"$(cmd HEAD ${(q)co})"}") }
4996 // is `_git`'s exact shape, and the leaked context flipped c:4354's
4997 // `mark_empty`, keeping the empty element that plan9 must delete.
4998 let saved_dq = std::mem::replace(&mut self.in_dq_context, 0);
4999 let saved_subexp = crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.replace(0));
5000 let out = self.run_command_substitution_inner(cmd_str, false);
5001 crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.set(saved_subexp));
5002 self.in_dq_context = saved_dq;
5003 out
5004 }
5005
5006 /// ksh93 funsub `${ list; }` / mksh valsub `${| list; }` — capture the
5007 /// output of `cmd_str` WITHOUT the subshell isolation `$( … )` applies.
5008 ///
5009 /// ksh(1), Command Substitution: "${ command;} … the command is
5010 /// executed in the current shell environment", so an assignment or a
5011 /// `cd` inside survives:
5012 /// `ksh -c 'x=0; y=${ x=5; print -n out; }; print "x=$x y=$y"'`
5013 /// → `x=5 y=out`, where the same body in `$( … )` leaves `x` at 0.
5014 /// mksh behaves identically for both of its forms.
5015 ///
5016 /// Same capture machinery as `$( … )` — only the parent-state
5017 /// snapshot/restore is skipped, which is exactly the difference the
5018 /// two references document.
5019 ///
5020 /// !!! RUST-ONLY ENTRY POINT — zsh has no funsub/valsub !!!
5021 pub fn run_shared_state_substitution(&mut self, cmd_str: &str) -> String {
5022 let saved_dq = std::mem::replace(&mut self.in_dq_context, 0);
5023 let saved_subexp = crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.replace(0));
5024 let out = self.run_command_substitution_inner(cmd_str, true);
5025 crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.set(saved_subexp));
5026 self.in_dq_context = saved_dq;
5027 out
5028 }
5029
5030 /// `shared_state`: skip the parent-state snapshot/restore that makes
5031 /// `$( … )` a subshell. Only the ksh/mksh funsub-valsub entry point
5032 /// passes true.
5033 fn run_command_substitution_inner(&mut self, cmd_str: &str, shared_state: bool) -> String {
5034 // `$(< FILE)` — zsh shorthand for "read FILE contents". Faster
5035 // than spawning `cat`. The leading `<` (after stripping
5036 // whitespace) means "read this file". Trailing newline is
5037 // stripped (same as command-substitution).
5038 let trimmed = cmd_str.trim_start();
5039 // Only treat as `$(<file)` shorthand when the SINGLE leading `<`
5040 // is followed by a filename, not another `<`. `$(<<<"hi" cat)`
5041 // starts with `<<<` (here-string) and must go through the full
5042 // parse path, not the read-file shortcut.
5043 if let Some(rest) = trimmed.strip_prefix('<').filter(|s| !s.starts_with('<')) {
5044 let filename = rest.trim();
5045 // c:Src/lex.c — the `$(<file)` shortcut ONLY applies when
5046 // the body is exactly `<` + ONE word. Anything else (extra
5047 // args, redirects, semicolons, pipes) is a regular command
5048 // list and must go through the full parse path so `2>/dev/null`
5049 // / `>file` / `|cmd` / `; next` etc. work. Without this
5050 // gate, `$(< file 2>/dev/null)` treated `file 2>/dev/null`
5051 // as the literal filename and errored on the missing file.
5052 // Bug #615.
5053 let is_single_word = !filename.is_empty()
5054 && !filename.chars().any(|c| {
5055 matches!(
5056 c,
5057 ' ' | '\t'
5058 | '\n'
5059 | ';'
5060 | '&'
5061 | '|'
5062 | '<'
5063 | '>'
5064 | '('
5065 | ')'
5066 | '`'
5067 | '"'
5068 | '\''
5069 )
5070 });
5071 if is_single_word {
5072 // Expand any leading $ / tilde in the filename so
5073 // `$(< $f)` and `$(< ~/x)` work.
5074 let resolved = if filename.contains('$') || filename.starts_with('~') {
5075 singsub(filename)
5076 } else {
5077 filename.to_string()
5078 };
5079 let resolved = resolved.to_string();
5080 match fs::read_to_string(&resolved) {
5081 Ok(contents) => {
5082 return contents.trim_end_matches('\n').to_string();
5083 }
5084 Err(_) => {
5085 eprintln!("zshrs:1: no such file or directory: {}", resolved);
5086 return String::new();
5087 }
5088 }
5089 }
5090 // Multi-word / has-redirects → fall through to full parse.
5091 }
5092
5093 // Port of getoutput(char *cmd, int qt) from Src/exec.c. Parse and compile via
5094 // the lex+parse free ported + ZshCompiler pipeline, run on a
5095 // sub-VM with the host wired up. Stdout is captured through
5096 // an in-process pipe via dup2 — no fork. The sub-VM emits
5097 // Op::Exec for unknown command names, which forks/execs
5098 // through the host.
5099
5100 // Set up the stdout-capture pipe. We dup the original stdout
5101 // so post-run we can restore it; the write end is dup2'd onto
5102 // STDOUT_FILENO so all output the sub-VM emits (including from
5103 // forked children, which inherit fd 1) lands in the pipe.
5104 //
5105 // c:Src/exec.c:4753 — `if (mpipe(pipes) < 0)`. mpipe (c:5160)
5106 // moves BOTH pipe ends to fd >= 10 via movefd and marks them
5107 // FDT_INTERNAL. This is load-bearing: zsh's invariant is that
5108 // shell-internal fds never live below 10, so user redirections
5109 // like `exec 9>&-` (which close fd<10 unconditionally, no
5110 // FDT_INTERNAL guard — c:Src/exec.c:3856-3868) can never hit
5111 // them. A raw pipe() here landed the read end on fd 9 when
5112 // fresh-HOME init held fds 3-7, and A04redirect's %prep
5113 // `exec 9>&-` closed our own capture pipe → SIGPIPE killed
5114 // the whole shell.
5115 let (read_fd, write_fd) = {
5116 let mut fds = [0i32; 2];
5117 if crate::ported::exec::mpipe(&mut fds) < 0 {
5118 return String::new();
5119 }
5120 (fds[0], fds[1])
5121 };
5122 // c:Src/utils.c:1996 — `movefd(dup(fd))`: saved copies of the
5123 // user-visible fds are shell-internal, so they too must live
5124 // at fd >= 10 / FDT_INTERNAL.
5125 let saved_stdout = crate::ported::utils::movefd(unsafe { libc::dup(libc::STDOUT_FILENO) });
5126 if saved_stdout < 0 {
5127 crate::ported::utils::zclose(read_fd);
5128 crate::ported::utils::zclose(write_fd);
5129 return String::new();
5130 }
5131 // Flush Rust's stdout BufWriter against the ORIGINAL fd before
5132 // dup2 swaps fd 1 to the capture pipe. Without this, bytes left
5133 // buffered by a prior `print -n` get drained to fd 1 AFTER the
5134 // dup2, which routes them into the cmd-subst's pipe — they end
5135 // up in the captured result and disappear from terminal output.
5136 //
5137 // Bug #10 in docs/BUGS.md — `print -n "A"; v=$(true); print -n
5138 // "B"; v=$(true); print -n "C"; echo` printed only `C` because
5139 // `A` and `B` were redirected into the empty cmd-subst's pipe
5140 // and discarded as its "output". C zsh's getoutput() forks, so
5141 // the child inherits the buffer COPY and the parent's buffer
5142 // stays untouched; zshrs runs cmd-subst in-process so the
5143 // parent buffer is the only one — must flush before the swap.
5144 let _ = io::stdout().flush();
5145 // c:Bug #56 — publish the saved outer stdout so a trap firing
5146 // during the nested run routes body output to the parent's
5147 // real stdout instead of the cmdsub's pipe-bound fd 1.
5148 // c:Src/utils.c:1996 — movefd(dup(fd)): internal fd, keep >= 10.
5149 let saved_stderr_for_trap =
5150 crate::ported::utils::movefd(unsafe { libc::dup(libc::STDERR_FILENO) });
5151 crate::fusevm_bridge::CMDSUBST_OUTER_FDS
5152 .with(|s| s.borrow_mut().push((saved_stdout, saved_stderr_for_trap)));
5153 unsafe {
5154 libc::dup2(write_fd, libc::STDOUT_FILENO);
5155 }
5156 // zclose (not raw close) so the FDT_INTERNAL mark set by mpipe
5157 // is cleared from fdtable — c:Src/utils.c:2137.
5158 crate::ported::utils::zclose(write_fd);
5159
5160 // Drain the capture pipe CONCURRENTLY on a background reader
5161 // thread. The sub-VM (and any children it forks, which inherit
5162 // fd 1) writes to the pipe; reading it only AFTER vm.run()
5163 // returns deadlocks the moment the output exceeds the OS pipe
5164 // buffer (~64KB): the writer blocks on a full pipe that nothing
5165 // is draining, so vm.run() never returns. `$(alias)` over
5166 // zpwr's 2000+ aliases (~177KB) hung the whole shell a few
5167 // prompts in (thefuck's `fuck()` init runs `TF_SHELL_ALIASES=
5168 // $(alias)`). C's getoutput (Src/exec.c) forks the writer child
5169 // so the parent reads concurrently; this reader thread is the
5170 // in-process analog. It does only raw fd reads (no shell state /
5171 // thread-locals). EOF arrives once every write end closes — fd 1
5172 // restored below plus any forked child exiting.
5173 let reader_handle = std::thread::spawn(move || {
5174 let mut buf: Vec<u8> = Vec::new();
5175 let mut chunk = [0u8; 65536];
5176 loop {
5177 let n = unsafe {
5178 libc::read(
5179 read_fd,
5180 chunk.as_mut_ptr() as *mut libc::c_void,
5181 chunk.len(),
5182 )
5183 };
5184 if n < 0 {
5185 // Retry on EINTR (a signal interrupted the read);
5186 // any other error ends the drain.
5187 let e = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
5188 if e == libc::EINTR {
5189 continue;
5190 }
5191 break;
5192 }
5193 if n == 0 {
5194 break; // EOF — all write ends closed.
5195 }
5196 buf.extend_from_slice(&chunk[..n as usize]);
5197 }
5198 buf
5199 });
5200
5201 // c:Src/exec.c:1161 — forked cmdsub child runs entersubsh()
5202 // which does `zsh_subshell++`; in-process equivalent (RAII,
5203 // restored on every return path below).
5204 // A funsub/valsub is NOT a subshell — ksh(1) says the command runs
5205 // "in the current shell environment" — so it must not bump the
5206 // nesting counter `$ZSH_SUBSHELL` / `$BASH_SUBSHELL` reads.
5207 let _subshell_bump = if shared_state {
5208 None
5209 } else {
5210 Some(crate::fusevm_bridge::CmdSubstSubshellBump::enter())
5211 };
5212
5213 // c:Src/exec.c:1208-1209 — the same forked child clears
5214 // `opts[USEZLE]` and `zleactive`. Without it a substitution run
5215 // from inside a widget still looks "in ZLE", so `fc` refuses with
5216 // "no interactive history within ZLE" (c:Src/builtin.c:1523-1527)
5217 // and history-based completers come back empty. Placed here rather
5218 // than in exec::getoutput so the bridge's own cmdsubst paths
5219 // (BUILTIN_CMD_SUBST_TEXT, backtick) are covered too.
5220 let _subsh_state = crate::ported::exec::SubshStateGuard::enter();
5221
5222 // Parse + compile + run.
5223 // Push CS_CMDSUBST for `%_` xtrace prefix — direct port of
5224 // Src/exec.c:4783 `cmdpush(CS_CMDSUBST);` around execode().
5225 // Trace lines emitted by the inner program inherit this token
5226 // so their PS4 prefix shows "cmdsubst" matching zsh -x.
5227 cmdpush(crate::ported::zsh_h::CS_CMDSUBST as u8); // c:zsh.h:2799
5228 // Save LINENO so the inner cmdsubst's line counter doesn't
5229 // leak into the outer trace — direct port of Src/exec.c:1407
5230 // `oldlineno = lineno;` followed by `lineno = oldlineno;`
5231 // restore at line 1640. Inner program parses fresh as line 1
5232 // and increments from there; once it returns, the outer
5233 // line at the `$(…)` site must read the original outer
5234 // lineno (so xtrace renders `+:5:> echo …` not `+:1:> …`).
5235 let saved_lineno = getsparam("LINENO");
5236 // Anchor the inner program's lineno to the outer's current
5237 // $LINENO so xtrace inside the cmdsubst renders the outer
5238 // line. zsh's execlist preserves lineno across the inner
5239 // exec — for our sub-VM (fresh compile) we use lineno_addend
5240 // to shift inner's line N → outer_lineno + (N - 1).
5241 let outer_lineno: u64 = self
5242 .scalar("LINENO")
5243 .and_then(|s| s.parse::<u64>().ok())
5244 .unwrap_or(0);
5245 // Mirror Src/init.c errflag save/clear/check pattern around
5246 // the nested parse so an inner syntax error doesn't bleed into
5247 // the outer execution.
5248 let saved_errflag = errflag.load(Ordering::Relaxed);
5249 errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
5250 // Context-isolated nested parse (c:Src/exec.c:283 parse_string).
5251 // The outer loop()/parse_event reader may be mid-stream when this
5252 // cmd-subst executes (single-event mode), so a destructive
5253 // parse_init/lex_init would clobber its next read. parse_isolated
5254 // brackets the parse with zcontext_save/restore + inpush/inpop.
5255 let parsed = parse_isolated(cmd_str);
5256 let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
5257 errflag.store(saved_errflag, Ordering::Relaxed);
5258 let prog = if parse_failed { None } else { Some(parsed) };
5259 let mut cmd_status: Option<i32> = None;
5260 if let Some(prog) = prog {
5261 let mut compiler = crate::compile_zsh::ZshCompiler::new();
5262 compiler.lineno_addend = outer_lineno.saturating_sub(1);
5263 let chunk = compiler.compile(&prog);
5264 if !chunk.ops.is_empty() {
5265 crate::fusevm_disasm::maybe_print_stdout("run_command_substitution", &chunk);
5266 // c:Src/exec.c:4783 — `$(...)` runs in a subshell, so
5267 // assignments / setopt / cd / trap changes inside
5268 // mustn't leak to the parent. zsh forks; we run
5269 // in-process and snapshot/restore manually. Same
5270 // snapshot shape used by host_subshell_begin/end for
5271 // the `(...)` subshell form.
5272 let paramtab_snap = crate::ported::params::paramtab()
5273 .read()
5274 .ok()
5275 .map(|t| t.clone())
5276 .unwrap_or_default();
5277 let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
5278 .lock()
5279 .ok()
5280 .map(|m| m.clone())
5281 .unwrap_or_default();
5282 let pparams_snap = self.pparams();
5283 let opts_snap = crate::ported::options::opt_state_snapshot();
5284 // c:Src/exec.c:1161 — a command substitution runs in a
5285 // subshell, so IFS changes inside it must NOT leak to the
5286 // parent. IFS lives in the external `ifs_lock` global (not
5287 // paramtab), so the paramtab snapshot above doesn't cover
5288 // it: `echo $(IFS=:; set -- a b c; echo "$*")` set IFS=":"
5289 // which both produced "a:b:c" AND then word-split the
5290 // UNQUOTED result on the leaked ":" → "a b c". Snapshot the
5291 // global IFS here and restore it (with inittyptab) below.
5292 let ifs_snap = crate::ported::params::ifs_lock()
5293 .lock()
5294 .map(|g| g.clone())
5295 .unwrap_or_default();
5296 let traps_snap = crate::ported::builtin::traps_table()
5297 .lock()
5298 .map(|t| t.clone())
5299 .unwrap_or_default();
5300 // c:Src/exec.c:4783 — function definitions / unfunction
5301 // inside `$(...)` must also be isolated from the parent.
5302 // C zsh's getoutput() forks, so the child's shfunctab
5303 // mutations die with the child. zshrs's in-process
5304 // cmd-subst needs to snapshot/restore the function
5305 // tables manually alongside the param/opts/trap snaps
5306 // already in this block. Bug #455.
5307 let shfunctab_snap = crate::ported::hashtable::shfunctab_lock()
5308 .read()
5309 .ok()
5310 .map(|t| t.snapshot())
5311 .unwrap_or_default();
5312 let functions_compiled_snap = self.functions_compiled.clone();
5313 let function_source_snap = self.function_source.clone();
5314 // c:Src/exec.c:4782 — getoutput's child runs
5315 // `entersubsh(ESUB_PGRP|ESUB_NOMONITOR)`, and c:1219
5316 // `if (flags & ESUB_PGRP) clearjobtab(monitor)` hands
5317 // that child an EMPTY job table. The oldjobtab snapshot
5318 // (c:Src/jobs.c:1800) is monitor-only, so a
5319 // non-interactive shell keeps nothing at all — which is
5320 // why zsh prints nothing for `sleep 5 & print $(jobs)`.
5321 // The `(...)` and pipeline-stage paths already call
5322 // clearjobtab in their forked children; cmd-subst runs
5323 // in-process, so snapshot the globals clearjobtab
5324 // mutates and restore them below. freejob (c:1457) is
5325 // struct-local — no waitpid/kill — so the restore is
5326 // exact.
5327 // c:Src/exec.c:4782 — same fork, one more thing it copies:
5328 // the completion-match arena (c:Src/Zle/compcore.c:124-259).
5329 // A `compadd` run inside `$(…)` lands in the CHILD's
5330 // `matches`/`amatches`/`mgroup`, which die with it, so the
5331 // completing parent never sees those matches. zshrs's
5332 // in-process cmd-subst shares the arena, so `_tmux`'s
5333 // `desc="$(_tmux-backup)"` description probe leaked five
5334 // whole completion groups into `tmux <TAB>` (551 matches vs
5335 // zsh's 450). Snapshot/restore it by hand, exactly as the
5336 // param/opts/trap/job snaps above do.
5337 let comp_arena_snap = crate::comp_match_handles::comp_arena_save();
5338 let jobtab_snap = crate::ported::jobs::JOBTAB
5339 .get()
5340 .and_then(|t| t.lock().ok().map(|g| g.clone()));
5341 let maxjob_snap = crate::ported::jobs::MAXJOB
5342 .get()
5343 .and_then(|m| m.lock().ok().map(|g| *g));
5344 let thisjob_snap = crate::ported::jobs::THISJOB
5345 .get()
5346 .and_then(|t| t.lock().ok().map(|g| *g));
5347 // curjob/prevjob (c:Src/jobs.c) are plain globals in C,
5348 // so the forked child's setcurjob calls never reach the
5349 // parent — restore them alongside the table, else the
5350 // `+`/`-` markers are lost after a `$(jobs)`.
5351 let curjob_snap = crate::ported::jobs::CURJOB
5352 .get()
5353 .and_then(|t| t.lock().ok().map(|g| *g));
5354 let prevjob_snap = crate::ported::jobs::PREVJOB
5355 .get()
5356 .and_then(|t| t.lock().ok().map(|g| *g));
5357 {
5358 let monitor = crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
5359 crate::ported::jobs::clearjobtab(&mut self.jobs, monitor);
5360 }
5361 let mut vm = fusevm::VM::new(chunk);
5362 register_builtins(&mut vm);
5363 vm.set_shell_host(Box::new(ZshrsHost));
5364 // Seed inner $? with the outer's last_status so the
5365 // sub-shell inherits the parent's exit code. Direct
5366 // port of Src/exec.c:4783 around execcmd_exec — the
5367 // child inherits `lastval` at fork time, so `false;
5368 // echo $(echo $?)` reads 1, not the freshly-zeroed
5369 // sub-VM default. Without this, every cmd-subst
5370 // started with $?==0 regardless of the parent's
5371 // last command.
5372 vm.last_status = self.last_status();
5373 // `exit N` inside a cmd-subst should terminate ONLY
5374 // the sub-shell (C zsh: cmd-subst forks, the child
5375 // `_exit(N)`s; status reaches the parent as
5376 // cmd-subst exit). zshrs runs in-process, so we
5377 // route through the SUBSHELL_DEPTH-gated deferred
5378 // path inside zexit (builtin.rs:7713): bump
5379 // SUBSHELL_DEPTH so `exit` sets EXIT_PENDING/
5380 // EXIT_VAL instead of calling realexit (which would
5381 // process::exit and kill the parent shell). After
5382 // the sub-VM returns, harvest EXIT_PENDING/EXIT_VAL
5383 // as the cmd-subst's status, then restore the
5384 // parent's flags so the outer VM continues normally.
5385 use crate::ported::builtin::{
5386 BREAKS, EXIT_PENDING, EXIT_VAL, RETFLAG, SHELL_EXITING, SUBSHELL_DEPTH,
5387 };
5388 use std::sync::atomic::Ordering::Relaxed;
5389 let saved_exit_pending = EXIT_PENDING.swap(0, Relaxed);
5390 let saved_exit_val = EXIT_VAL.swap(0, Relaxed);
5391 let saved_shell_exiting = SHELL_EXITING.swap(0, Relaxed);
5392 let saved_retflag = RETFLAG.swap(0, Relaxed);
5393 let saved_breaks = BREAKS.swap(0, Relaxed);
5394 // c:Src/exec.c:4784 — `execode(prog, 0, 1, "cmdsubst");`.
5395 // execode (c:1245-1266) APPENDS its `context` argument to
5396 // `zsh_eval_context` for the duration of the body, so code
5397 // inside `$(…)` / backticks sees `cmdarg:cmdsubst` where the
5398 // top level sees just `cmdarg`. zshrs pushed "shfunc" at the
5399 // function-call site but never pushed "cmdsubst", so
5400 // `$(print $ZSH_EVAL_CONTEXT)` reported `cmdarg` and
5401 // `$(f)` reported `cmdarg:shfunc` instead of
5402 // `cmdarg:cmdsubst:shfunc`. Popped on every return path by the
5403 // guard below, mirroring execode's stack discipline.
5404 // Bug #1065.
5405 let sync_eval_ctx = |stack: &[String]| {
5406 let joined = stack.join(":");
5407 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
5408 if let Some(pm) = tab.get_mut("zsh_eval_context") {
5409 pm.u_arr = Some(stack.to_vec());
5410 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
5411 }
5412 if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
5413 pm.u_str = Some(joined);
5414 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
5415 }
5416 }
5417 };
5418 if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
5419 ctx.push("cmdsubst".to_string());
5420 sync_eval_ctx(&ctx);
5421 }
5422 struct CmdsubstEvalCtxGuard<F: Fn(&[String])>(F);
5423 impl<F: Fn(&[String])> Drop for CmdsubstEvalCtxGuard<F> {
5424 fn drop(&mut self) {
5425 if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
5426 ctx.pop();
5427 (self.0)(&ctx);
5428 }
5429 }
5430 }
5431 let _cs_eval_ctx_guard = CmdsubstEvalCtxGuard(sync_eval_ctx);
5432 SUBSHELL_DEPTH.fetch_add(1, Relaxed);
5433 let _ctx = ExecutorContext::enter(self);
5434 let _ = vm.run();
5435 let inner_exit_pending = EXIT_PENDING.load(Relaxed);
5436 let inner_exit_val = EXIT_VAL.load(Relaxed);
5437 let inner_status = if inner_exit_pending != 0 {
5438 inner_exit_val & 0xFF
5439 } else {
5440 vm.last_status
5441 };
5442 cmd_status = Some(inner_status);
5443 SUBSHELL_DEPTH.fetch_sub(1, Relaxed);
5444 // c:Src/exec.c — `$(…)` is a FORK in C: an errflag
5445 // abort inside the child ends the child (its lastval
5446 // becomes the cmd-subst status) and the flag dies
5447 // with the child process — the parent's lists keep
5448 // running. zsh 5.9: `v=$(typeset -A q; q=(odd));
5449 // echo "after $?"` prints `after 1`. Mirror the fork
5450 // isolation by clearing ERRFLAG_ERROR at the
5451 // cmd-subst boundary.
5452 //
5453 // ERRFLAG_HARD dies here too: `${u:?msg}` inside the
5454 // child sets it (c:Src/subst.c:3344) then `_exit(1)`s
5455 // (c:3353) — C's parent never sees the bit. A leaked
5456 // HARD bit makes every later zerr() silent
5457 // (c:Src/utils.c:175-177) and silently fails every
5458 // later parse. Same fix as subshell_end.
5459 errflag.fetch_and(
5460 !(ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
5461 Relaxed,
5462 );
5463 // c:Src/exec.c:4783 execcmdoutsubst — `$(...)` is a
5464 // subshell, and zsh fires the EXIT trap when the
5465 // subshell ends BUT only if the trap was installed
5466 // INSIDE the subshell. An EXIT trap inherited from
5467 // the parent fires when the parent shell exits, not
5468 // again at cmdsub end. Detect "installed inside" by
5469 // comparing the current traps_table["EXIT"] entry
5470 // against the pre-cmdsub snapshot — fire only when
5471 // the body differs (newly set, removed, or replaced).
5472 // Pop the body before execute_script to avoid the
5473 // re-fire inside execute_script_zsh_pipeline's own
5474 // EXIT-handler tail at vm_helper.rs:1490. Bug #354.
5475 let snap_exit = traps_snap.get("EXIT").cloned();
5476 let live_exit = crate::ported::builtin::traps_table()
5477 .lock()
5478 .ok()
5479 .and_then(|t| t.get("EXIT").cloned());
5480 if live_exit != snap_exit {
5481 if let Some(body) = live_exit {
5482 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
5483 t.remove("EXIT");
5484 }
5485 let _ = crate::ported::exec::execute_script(&body);
5486 }
5487 }
5488 // c:Src/signals.c::dotrap(SIGEXIT) — also fire the
5489 // TRAPEXIT() function-named form (ZSIG_FUNC) — but
5490 // only if it was defined INSIDE the subshell (the
5491 // parent's TRAPEXIT fires at parent exit, not here).
5492 // ZSIG_FUNC bit on sigtrapped[SIGEXIT] tells us
5493 // whether a TRAPEXIT function is registered; check
5494 // BEFORE the snapshot restore.
5495 // Skip for now — function-form detection mirrors the
5496 // raw-body check above; deferred until a clean
5497 // sigtrapped snapshot/restore pair exists.
5498 // Restore parent's exit / loop / function-return
5499 // state so the outer VM continues normally.
5500 EXIT_PENDING.store(saved_exit_pending, Relaxed);
5501 EXIT_VAL.store(saved_exit_val, Relaxed);
5502 SHELL_EXITING.store(saved_shell_exiting, Relaxed);
5503 RETFLAG.store(saved_retflag, Relaxed);
5504 BREAKS.store(saved_breaks, Relaxed);
5505 // Restore parent state. The inner cmd-subst's stdout
5506 // (the captured pipe contents) is the only thing
5507 // that leaks out.
5508 //
5509 // A funsub/valsub skips ALL of it: that is the entire
5510 // difference between `${ list; }` and `$(list)`.
5511 if !shared_state {
5512 if let Ok(mut t) = crate::ported::params::paramtab().write() {
5513 *t = paramtab_snap;
5514 }
5515 if let Ok(mut m) = crate::ported::params::paramtab_hashed_storage().lock() {
5516 *m = paramtab_hashed_snap;
5517 }
5518 self.set_pparams(pparams_snap);
5519 crate::ported::options::opt_state_restore(opts_snap);
5520 // Restore the parent's IFS (subshell isolation): the body's
5521 // `IFS=` must not leak out and word-split the parent's use
5522 // of the cmdsub result. Runtime word-splitting reads the
5523 // IFS *string* (this `ifs_lock` global), so restoring it is
5524 // sufficient. Deliberately do NOT call inittyptab() here —
5525 // that rewrites the process-global typtab the LEXER reads
5526 // on every character, and firing it per-cmdsub races
5527 // concurrent lexing in zshrs's worker threads, producing
5528 // spurious "parse error" flakes (HEAD ran clean 3/3; the
5529 // per-cmdsub inittyptab flaked ~50%). The typtab only
5530 // affects re-lexing — the parent is already compiled — and
5531 // leaving it at the body's value is strictly less divergent
5532 // than the prior behavior, which leaked the whole IFS.
5533 if let Ok(mut g) = crate::ported::params::ifs_lock().lock() {
5534 *g = ifs_snap;
5535 }
5536 if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
5537 *t = traps_snap;
5538 }
5539 // Restore function tables (parallel to the trap/param
5540 // restore above). Bug #455.
5541 if let Ok(mut t) = crate::ported::hashtable::shfunctab_lock().write() {
5542 t.restore(shfunctab_snap);
5543 }
5544 self.functions_compiled = functions_compiled_snap;
5545 self.function_source = function_source_snap;
5546 // Discard anything the substitution added to the completion
5547 // arena — the in-process stand-in for the forked child's
5548 // address space going away (see comp_arena_save above).
5549 crate::comp_match_handles::comp_arena_restore(comp_arena_snap);
5550 // Undo the clearjobtab above — in C the cleared table
5551 // belongs to the forked child and dies with it, so the
5552 // parent's table must come back untouched.
5553 if let (Some(js), Some(t)) = (jobtab_snap, crate::ported::jobs::JOBTAB.get()) {
5554 if let Ok(mut g) = t.lock() {
5555 *g = js;
5556 }
5557 }
5558 if let (Some(mj), Some(m)) = (maxjob_snap, crate::ported::jobs::MAXJOB.get()) {
5559 if let Ok(mut g) = m.lock() {
5560 *g = mj;
5561 }
5562 }
5563 if let (Some(tj), Some(t)) = (thisjob_snap, crate::ported::jobs::THISJOB.get())
5564 {
5565 if let Ok(mut g) = t.lock() {
5566 *g = tj;
5567 }
5568 }
5569 if let (Some(cj), Some(t)) = (curjob_snap, crate::ported::jobs::CURJOB.get()) {
5570 if let Ok(mut g) = t.lock() {
5571 *g = cj;
5572 }
5573 }
5574 if let (Some(pj), Some(t)) = (prevjob_snap, crate::ported::jobs::PREVJOB.get())
5575 {
5576 if let Ok(mut g) = t.lock() {
5577 *g = pj;
5578 }
5579 }
5580 } // if !shared_state
5581 }
5582 }
5583 // Restore LINENO so outer xtrace sees the outer line. LINENO
5584 // carries PM_READONLY (matching zsh's `integer-readonly-special`
5585 // GSU), so the restore must bypass the generic readonly guard
5586 // exactly like BUILTIN_SET_LINENO (fusevm_bridge.rs:5156) — write
5587 // the param's `u_val` directly and mirror the file-static /
5588 // lexer line counters. The previous `set_scalar` went through the
5589 // readonly-checked path: harmless on the `-c` route (LINENO not
5590 // yet flagged readonly there) but fatal on the faithful
5591 // loop()/zsh_main route, where every `$(...)` in piped/redirected
5592 // input died with `read-only variable: LINENO`.
5593 if let Some(ln) = saved_lineno {
5594 let n: crate::ported::zsh_h::zlong = ln.parse().unwrap_or(0);
5595 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
5596 if let Some(pm) = tab.get_mut("LINENO") {
5597 // c:Src/utils.c:121 `zlong lineno` — the value lives in the C
5598 // GLOBAL, reached through LINENO's GSU. A `typeset -h +g LINENO`
5599 // local shadow has no PM_SPECIAL and no GSU, so C's `lineno = N`
5600 // never touches it; skip the paramtab mirror for the same reason.
5601 if (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) != 0 {
5602 pm.u_val = n;
5603 pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
5604 }
5605 }
5606 }
5607 crate::ported::utils::set_lineno(n as i32);
5608 crate::ported::lex::set_lineno(n as u64);
5609 }
5610 cmdpop();
5611 // Propagate the inner cmd's status to the parent shell. zsh:
5612 // `a=$(false); echo $?` → 1 because cmd-subst status leaks to
5613 // $?. Set last_status on the executor so $? reads the right
5614 // value for callers that don't have a SetStatus(0) overwrite
5615 // (echo, test, etc.). Bare assignment paths still get the
5616 // SetStatus(0) from compile_simple — that's a separate gap.
5617 // Empty cmd-subst (`\`\``, `$()`) resets status to 0 per
5618 // Src/exec.c — the inner ran no command so the "last
5619 // command's exit" is the implicit success of "did nothing".
5620 // Without this branch, a prior command's non-zero status
5621 // leaked through the empty cmd-subst.
5622 let final_status = cmd_status.unwrap_or(0);
5623 self.set_last_status(final_status);
5624 // c:Src/exec.c:4775 — `getoutput` (the C cmd-subst path used by
5625 // both `$(…)` and `` `…` ``) propagates the inner exit through
5626 // `cmdoutval`, then the caller does `LASTVAL = cmdoutval`. Mirror
5627 // by writing the cmd-subst's exit into the ported `cmdoutval`
5628 // global so `getoutput()`'s post-call `LASTVAL = cmdoutval` (at
5629 // exec.rs:559-562) and the C-equivalent `cmdoutval = lastval`
5630 // bookkeeping in execcmd_exec's assignment paths both see the
5631 // real exit. Without this, backtick assignments (`a=\`false\`;
5632 // echo $?`) reported 0 because getoutput's caller path read a
5633 // cmdoutval that was never updated by the in-process hook.
5634 crate::ported::exec::cmdoutval.store(final_status, std::sync::atomic::Ordering::Relaxed);
5635
5636 // Flush any buffered Rust-side stdout so it reaches the pipe
5637 // before we restore.
5638 let _ = io::stdout().flush();
5639
5640 // Pop the trap-routing stack BEFORE restoring stdout so any
5641 // trap that fires during the restore goes to the cmdsub's
5642 // pipe (matching what zsh's forked cmdsub would do — the
5643 // child's fd 1 is the pipe right up until the child exits).
5644 crate::fusevm_bridge::CMDSUBST_OUTER_FDS.with(|s| {
5645 s.borrow_mut().pop();
5646 });
5647 // c:Bug #353 — restore fd 2 from the saved outer stderr. A
5648 // body that ran `exec 2>&1` (no command, just redirects)
5649 // would have committed fd 2 → the cmdsub's pipe write end.
5650 // In zsh's forked cmdsub the committed redirect dies with
5651 // the child; zshrs's in-process cmdsub would leak the dup
5652 // back to the parent and keep the pipe write-end alive,
5653 // blocking the parent's read on the read_end forever.
5654 // Always restoring fd 2 here rolls back any commit so the
5655 // pipe write-end count drops to zero when we drop the
5656 // local write_fd reference (which already happened above).
5657 if saved_stderr_for_trap >= 0 {
5658 unsafe {
5659 libc::dup2(saved_stderr_for_trap, libc::STDERR_FILENO);
5660 }
5661 crate::ported::utils::zclose(saved_stderr_for_trap);
5662 }
5663 // Restore stdout and read what was captured.
5664 unsafe {
5665 libc::dup2(saved_stdout, libc::STDOUT_FILENO);
5666 }
5667 crate::ported::utils::zclose(saved_stdout);
5668 // Collect the concurrently-drained output. With fd 1 restored
5669 // above, the last shell-side write end is closed, so the reader
5670 // hits EOF and join() returns the full buffer regardless of
5671 // size — no pipe-full deadlock. The reader only read (never
5672 // closed) read_fd, so zclose still clears its FDT_INTERNAL mark
5673 // (c:Src/utils.c:2137).
5674 let bytes = reader_handle.join().unwrap_or_default();
5675 crate::ported::utils::zclose(read_fd);
5676 let mut output = String::from_utf8_lossy(&bytes).into_owned();
5677
5678 // POSIX: trailing newlines stripped from cmd-sub result.
5679 while output.ends_with('\n') {
5680 output.pop();
5681 }
5682 // !!! RUST-ONLY: provenance tap. `$(…)` is a lineage ORIGIN —
5683 // these bytes did not exist in the shell before the inner list
5684 // ran. This is the in-process cmd-subst funnel (the host
5685 // `ShellHost::cmd_subst` path taps the same event for chunks
5686 // that reach the VM as sub-chunks instead of source text).
5687 if crate::provenance::active() {
5688 crate::provenance::on_cmd_subst(cmd_str, &output);
5689 }
5690 output
5691 }
5692}
5693
5694#[cfg(test)]
5695mod tests {
5696 use super::*;
5697
5698 #[test]
5699 fn test_simple_echo() {
5700 let _g = crate::test_util::global_state_lock();
5701 let mut exec = ShellExecutor::new();
5702 let status = exec.execute_script("true").unwrap();
5703 assert_eq!(status, 0);
5704 }
5705
5706 /// A `zsh/parameter` param is an autoload stub until it is READ; the read
5707 /// materializes it. `$parameters` enumerations type a stub as "undefined"
5708 /// (Src/Modules/parameter.c:49-50) and never resolve it, which is what
5709 /// puts those names in the right `_parameters -g` bucket.
5710 ///
5711 /// Reference behavior (`zsh -f`):
5712 /// `m=( ${(kv)parameters} ); print $m[aliases]` → undefined
5713 /// `${parameters[aliases]}` first, then the same scan → association-…
5714 #[test]
5715 fn module_params_are_autoload_stubs_until_read() {
5716 let _g = crate::test_util::global_state_lock();
5717 // `jobstates` is never touched by shell startup, unlike `aliases`.
5718 assert!(
5719 module_param_is_autoload_stub("jobstates"),
5720 "untouched module param must read as a stub"
5721 );
5722 mark_module_param_used("jobstates");
5723 assert!(
5724 !module_param_is_autoload_stub("jobstates"),
5725 "a read must materialize it"
5726 );
5727 // A core special (not module-provided) is never a stub.
5728 assert!(!module_param_is_autoload_stub("path"));
5729 assert!(!module_param_is_autoload_stub("PATH"));
5730 }
5731
5732 /// Phase 3 diagnostic: a worker must be able to run a USER-DEFINED function
5733 /// (defined on the main executor) — the function source lives in the shared
5734 /// shfunctab, and the worker lazy-compiles it from there. Its `typeset -g`
5735 /// must reach the global param table. This is what `async_precmd` needs.
5736 #[test]
5737 fn phase3_worker_runs_user_defined_function() {
5738 let _g = crate::test_util::global_state_lock();
5739 let mut main = ShellExecutor::new();
5740 main.execute_script("phase3fn() { typeset -g PHASE3_FN_RESULT=fn_ran }")
5741 .unwrap();
5742 // sanity: it ran on main? (define only — not called yet)
5743 assert_eq!(getsparam("PHASE3_FN_RESULT"), None);
5744
5745 let pool = std::sync::Arc::new(crate::worker::WorkerPool::new(2));
5746 let pool2 = std::sync::Arc::clone(&pool);
5747 let (tx, rx) = std::sync::mpsc::channel::<()>();
5748 pool.submit(move || {
5749 let mut wex = ShellExecutor::new_worker(pool2);
5750 let _ = wex.execute_script_zsh_pipeline("phase3fn");
5751 let _ = tx.send(());
5752 });
5753 rx.recv().expect("worker completed");
5754 assert_eq!(
5755 getsparam("PHASE3_FN_RESULT"),
5756 Some("fn_ran".to_string()),
5757 "worker could not run the user-defined function from shared shfunctab"
5758 );
5759 }
5760
5761 /// Phase 1 of the in-process thread-execution model: prove a shell body
5762 /// runs on a POOL WORKER THREAD via `new_worker` and that its `typeset -g`
5763 /// lands in the GLOBAL (RwLock-synchronized) param table. Each worker writes
5764 /// a DISTINCT key, so a green run shows: (a) `ExecutorContext::enter` +
5765 /// `execute_script_zsh_pipeline` work off the main thread, and (b) N
5766 /// concurrent writers don't corrupt the shared table. This is the linchpin
5767 /// for converting the subprocess-forking parallel builtins to threads.
5768 #[test]
5769 fn phase1_worker_shell_writes_reach_global_paramtab() {
5770 let _g = crate::test_util::global_state_lock();
5771 // Seed the globals (options, default params) exactly as a live session
5772 // would — workers SHARE these; new_worker() never re-seeds them.
5773 let _main = ShellExecutor::new();
5774
5775 let pool = std::sync::Arc::new(crate::worker::WorkerPool::new(4));
5776 const N: usize = 16;
5777 let (tx, rx) = std::sync::mpsc::channel::<usize>();
5778 for i in 0..N {
5779 let tx = tx.clone();
5780 let pool_for_worker = std::sync::Arc::clone(&pool);
5781 pool.submit(move || {
5782 // Lightweight per-worker executor; shares the global tables.
5783 let mut wex = ShellExecutor::new_worker(pool_for_worker);
5784 let _ =
5785 wex.execute_script_zsh_pipeline(&format!("typeset -g PHASE1_WK_{i}=val_{i}"));
5786 let _ = tx.send(i);
5787 });
5788 }
5789 drop(tx);
5790 // Barrier: wait for all N workers.
5791 let mut done = 0usize;
5792 while rx.recv().is_ok() {
5793 done += 1;
5794 }
5795 assert_eq!(done, N, "all {N} workers completed");
5796
5797 // Every worker's write must be visible in the global param table.
5798 for i in 0..N {
5799 assert_eq!(
5800 getsparam(&format!("PHASE1_WK_{i}")),
5801 Some(format!("val_{i}")),
5802 "worker {i} typeset -g did not reach the global paramtab"
5803 );
5804 }
5805 }
5806
5807 #[test]
5808 fn test_if_true() {
5809 let _g = crate::test_util::global_state_lock();
5810 let mut exec = ShellExecutor::new();
5811 let status = exec.execute_script("if true; then true; fi").unwrap();
5812 assert_eq!(status, 0);
5813 }
5814
5815 #[test]
5816 fn test_if_false() {
5817 let _g = crate::test_util::global_state_lock();
5818 let mut exec = ShellExecutor::new();
5819 let status = exec
5820 .execute_script("if false; then true; else false; fi")
5821 .unwrap();
5822 assert_eq!(status, 1);
5823 }
5824
5825 #[test]
5826 fn test_for_loop() {
5827 let _g = crate::test_util::global_state_lock();
5828 let mut exec = ShellExecutor::new();
5829 exec.execute_script("for i in a b c; do true; done")
5830 .unwrap();
5831 assert_eq!(exec.last_status(), 0);
5832 }
5833
5834 #[test]
5835 fn test_and_list() {
5836 let _g = crate::test_util::global_state_lock();
5837 let mut exec = ShellExecutor::new();
5838 let status = exec.execute_script("true && true").unwrap();
5839 assert_eq!(status, 0);
5840
5841 let status = exec.execute_script("true && false").unwrap();
5842 assert_eq!(status, 1);
5843 }
5844
5845 #[test]
5846 fn test_or_list() {
5847 let _g = crate::test_util::global_state_lock();
5848 let mut exec = ShellExecutor::new();
5849 let status = exec.execute_script("false || true").unwrap();
5850 assert_eq!(status, 0);
5851 }
5852
5853 /// Pin: `forklevel` matches the C global declared at
5854 /// `Src/exec.c:1052` (`int forklevel;`). Like `int` in C, the
5855 /// Rust port is an AtomicI32 starting at 0 (no fork has occurred
5856 /// at process start). Per `Src/exec.c:1221` (`forklevel =
5857 /// locallevel;`), every subshell entry copies `locallevel` into
5858 /// the global; the SIGPIPE handler at `Src/signals.c:808` reads
5859 /// it back to distinguish the top-level shell from a subshell.
5860 #[test]
5861 fn test_forklevel_default_zero_and_roundtrip() {
5862 let _g = crate::test_util::global_state_lock();
5863 use std::sync::atomic::Ordering;
5864 let prev = crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed);
5865 // Default state at process start: zero (matches C's BSS init
5866 // of `int forklevel;` to 0).
5867 crate::ported::exec::FORKLEVEL.store(0, Ordering::Relaxed);
5868 assert_eq!(crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed), 0);
5869 // Simulate the c:1221 store: `forklevel = locallevel;`.
5870 crate::ported::exec::FORKLEVEL.store(3, Ordering::Relaxed);
5871 assert_eq!(crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed), 3);
5872 crate::ported::exec::FORKLEVEL.store(prev, Ordering::Relaxed);
5873 }
5874}
5875
5876// Plugin-Framework-Agnostic State-Modification Recorder hook helpers.
5877/// Recorder helper: emit one record for an array/scalar mutation
5878/// targeting a path-family parameter (path/fpath/manpath/module_path/
5879/// cdpath, lower- or upper-cased), or one `assign` record for any
5880/// other name. Centralises the path-family list so `BUILTIN_SET_ARRAY`,
5881/// `BUILTIN_APPEND_ARRAY`, and `BUILTIN_APPEND_SCALAR_OR_PUSH` share
5882/// the same routing.
5883///
5884/// `is_append` distinguishes `arr=(...)` from `arr+=(...)` so the
5885/// emitted event carries the APPEND attr bit and replay can choose
5886/// between fresh-set and extend semantics.
5887///
5888/// `attrs` carries any pre-existing type info from
5889/// `recorder_attrs_for(name)` (readonly/export/global) — array shape
5890/// and APPEND get OR'd in by emit_array_assign.
5891#[cfg(feature = "recorder")]
5892pub(crate) fn emit_path_or_assign(
5893 name: &str,
5894 values: &[String],
5895 attrs: crate::recorder::ParamAttrs,
5896 is_append: bool,
5897 ctx: &crate::recorder::RecordCtx,
5898) {
5899 let lower = name.to_ascii_lowercase();
5900 let kind_name: Option<&'static str> = match lower.as_str() {
5901 "path" => Some("path"),
5902 "fpath" => Some("fpath"),
5903 "manpath" => Some("manpath"),
5904 "module_path" => Some("module_path"),
5905 "cdpath" => Some("cdpath"),
5906 _ => None,
5907 };
5908 match kind_name {
5909 Some(k) => {
5910 for v in values {
5911 crate::recorder::emit_path_mod(v, k, ctx.clone());
5912 // Each fpath addition also surfaces every `_completion`
5913 // file inside the directory — matches zinit-report's
5914 // per-plugin "Completions:" listing. Only fpath dirs
5915 // get this treatment; PATH dirs hold executables, not
5916 // completion functions.
5917 if k == "fpath" {
5918 crate::recorder::discover_completions_in_fpath_dir(v, ctx);
5919 }
5920 }
5921 }
5922 None => {
5923 // Non-path arrays: emit ONE `assign` event with the
5924 // ordered element list preserved in value_array. Replay
5925 // reconstructs `name=(elem1 elem2 ...)` exactly without
5926 // having to re-split a joined string.
5927 crate::recorder::emit_array_assign(
5928 name,
5929 values.to_vec(),
5930 attrs,
5931 is_append,
5932 ctx.clone(),
5933 );
5934 }
5935 }
5936}
5937
5938use std::os::unix::fs::MetadataExt;
5939
5940bitflags::bitflags! {
5941 /// Flags for zfork()
5942 #[derive(Debug, Clone, Copy, Default)]
5943 pub struct ForkFlags: u32 {
5944 const NOJOB = 1 << 0; // Don't add to job table
5945 const NEWGRP = 1 << 1; // Create new process group
5946 const FGTTY = 1 << 2; // Take foreground terminal
5947 const KEEPSIGS = 1 << 3; // Keep signal handlers
5948 }
5949}
5950
5951bitflags::bitflags! {
5952 /// Flags for entersubsh()
5953 #[derive(Debug, Clone, Copy, Default)]
5954 pub struct SubshellFlags: u32 {
5955 const NOMONITOR = 1 << 0; // Disable job control
5956 const KEEPFDS = 1 << 1; // Keep file descriptors
5957 const KEEPTRAPS = 1 << 2; // Keep trap handlers
5958 }
5959}
5960
5961/// Result of fork operation
5962#[derive(Debug)]
5963/// `fork()` outcome (parent / child / error).
5964/// Mirrors the integer return of `zfork()` from Src/exec.c:349.
5965pub enum ForkResult {
5966 /// `Parent` variant.
5967 Parent(i32), // Contains child PID
5968 /// `Child` variant.
5969 Child,
5970}
5971
5972/// Redirection mode
5973#[derive(Debug, Clone, Copy)]
5974/// File-redirection mode (`>` / `>>` / `<` / etc.).
5975/// Mirrors the `REDIR_*` enum from Src/zsh.h.
5976pub enum RedirMode {
5977 /// `Dup` variant.
5978 Dup,
5979 /// `Close` variant.
5980 Close,
5981}
5982
5983/// Builtin command type
5984#[derive(Debug, Clone, Copy)]
5985/// Builtin classification.
5986/// Mirrors the `BINF_*` flag set Src/builtin.c uses to
5987/// classify special vs regular builtins.
5988pub enum BuiltinType {
5989 /// `Normal` variant.
5990 Normal,
5991 /// `Disabled` variant.
5992 Disabled,
5993}
5994
5995use crate::fusevm_bridge::with_executor;
5996use crate::ported::glob::*;
5997use crate::ported::hist::*;
5998use crate::ported::jobs::*;
5999use crate::ported::math::*;
6000use crate::ported::module::*;
6001use crate::ported::modules::cap::*;
6002use crate::ported::modules::terminfo::*;
6003use crate::ported::options::*;
6004use crate::ported::params::*;
6005use crate::ported::pattern::*;
6006use crate::ported::prompt::*;
6007use crate::ported::signals::*;
6008use crate::ported::subst::*;
6009use crate::ported::utils::{zerr, zerrnam, zwarn, zwarnnam};
6010use ::regex::{Error as RegexError, Regex, RegexBuilder};
6011
6012pub use crate::ported::modules::regex::posix_ere_bracket_escape;
6013
6014impl ShellExecutor {
6015 /// Every option name in `ZSH_OPTIONS_SET` (port of `optns[]` at
6016 /// `Src/options.c:79+`).
6017 pub(crate) fn all_zsh_options() -> Vec<&'static str> {
6018 ZSH_OPTIONS_SET.iter().copied().collect()
6019 }
6020
6021 /// `name → default-on` map via canonical `default_on_options`
6022 /// (port of `defset()` macro at `Src/options.c:73`).
6023 pub(crate) fn default_options() -> HashMap<String, bool> {
6024 let on = default_on_options();
6025 Self::all_zsh_options()
6026 .into_iter()
6027 .map(|n| (n.to_string(), on.contains(n)))
6028 .collect()
6029 }
6030}
6031impl ShellExecutor {
6032 /// PURE PASSTHRU to the canonical `params::getsparam` (C port of
6033 /// `Src/params.c::getsparam`). Every special-name case the old
6034 /// 316-line body handled lives in `params::lookup_special_var` +
6035 /// `getsparam`'s paramtab/env walk. Returns an empty string for
6036 /// unset names (matching the old fn's signature; callers that
6037 /// need the set/unset distinction call `scalar` / `has_scalar`
6038 /// directly).
6039 pub(crate) fn get_variable(&self, name: &str) -> String {
6040 getsparam(name).unwrap_or_default()
6041 }
6042}
6043
6044// Source-form registration step used by the autoload-load path
6045// (`dispatch_function_call` / `run_function_body_only`). Decides
6046// whether to feed the file body to the funcdef pipeline VERBATIM
6047// (zsh-style: the body's own `function NAME() {...}` definition
6048// registers the function on execution) or to WRAP it in
6049// `NAME() {...}` (ksh-style or multi-statement: the body is just
6050// commands or includes additional statements past the def).
6051//
6052// The classification mirrors c:Src/exec.c:5725 + 5750 — KSHAUTOLOAD-
6053// equivalent vs zsh-style autoload. The structural check is the
6054// canonical `stripkshdef` (Src/exec.c:6291, ported at exec.rs:10548):
6055// parse the body to Eprog, run stripkshdef, and check whether it
6056// returned a stripped (different-length wordcode) Eprog. When it
6057// did, the file is the single-funcdef shape `[function] NAME [()] {
6058// INNER }`; running the file source directly through the funcdef
6059// pipeline registers NAME via the WC_FUNCDEF opcode at
6060// fusevm_bridge.rs:6330, matching C's `shf->funcdef = stripkshdef(
6061// prog, name)` semantics (the inner body becomes the function's
6062// body). When it didn't strip — single statement that isn't a
6063// funcdef, or multiple list nodes (e.g. `function ztm() {...}` +
6064// trailing `ztm "$@"` self-call) — we fall back to wrap-and-run so
6065// the canonical funcdef opcode still fires and any extra
6066// statements run inside the registered body, matching C's
6067// behavior of using the whole prog as funcdef in that case.
6068/// Restore `noaliases` on scope exit.
6069///
6070/// C's `loadautofn` (Src/exec.c:5684-5704) saves `noaliases`, sets it from the
6071/// function's PM_UNALIASED bit for the duration of the body parse, and restores
6072/// it unconditionally. The zshrs autoload block has early `return` paths, so the
6073/// restore has to ride on Drop rather than a trailing statement — otherwise a
6074/// `-U` autoload that failed to load would leave alias expansion disabled for
6075/// the rest of the shell.
6076struct NoAliasesRestore(bool);
6077
6078impl Drop for NoAliasesRestore {
6079 fn drop(&mut self) {
6080 crate::ported::lex::set_noaliases(self.0); // c:5704
6081 }
6082}
6083
6084/// c:Src/exec.c:5735 `loadautofnsetfile(shf, fdir)` + c:5751 — put the load
6085/// directory (and the absolute-path marker) back on a function whose body
6086/// zshrs just re-registered through the funcdef pipeline.
6087///
6088/// C loads an autoloaded body IN PLACE on the existing `Shfunc`, so
6089/// `filename`, PM_LOADDIR and PM_ABSPATH_USED all survive the load:
6090/// `shf->node.flags &= ~PM_UNDEFINED` (c:5751) is the only flag C clears.
6091/// zshrs re-registers the body as SOURCE through the WC_FUNCDEF pipeline,
6092/// which builds a FRESH node whose `filename` is the enclosing script and
6093/// whose flag word starts at zero. Both have to be reinstated, or `whence -v`
6094/// names the calling script instead of the definition file, and the
6095/// PM_LOADDIR|PM_ABSPATH_USED pair that `add_autoload_function`
6096/// (Src/builtin.c:3310-3323) tests — to hand a sibling `autoload -Uz NAME`
6097/// the caller's directory — is gone.
6098///
6099/// One helper rather than four inline copies: all four
6100/// `autoload_register_source` call sites need the identical restore.
6101///
6102/// `ksh_style` must be sampled BEFORE the re-registration (the fresh node's
6103/// flag word no longer carries PM_KSHSTORED / PM_ZSHSTORED, so re-deriving it
6104/// here would read the wrong answer): c:Src/exec.c:5792-5806 is the one arm
6105/// where C does NOT reload the body in place. It runs the whole FILE at top
6106/// level (`execode(prog, 1, 0, "evalautofunc")`, c:5795) and then REFETCHES
6107/// the node (`shf = shfunctab->getnode(shfunctab, n)`, c:5797) because the
6108/// file's own `NAME() { … }` created a brand-new Shfunc. There is no
6109/// `loadautofnsetfile` call on that arm, so zsh itself loses `filename`,
6110/// PM_LOADDIR and PM_ABSPATH_USED there. Verified against the reference shell:
6111///
6112/// ```text
6113/// $ zsh -f -c 'autoload -k /D/kw; kw'
6114/// ksh-kw ran
6115/// kw: ksib: function definition file not found
6116/// $ zsh -f -c 'autoload -k /D/kw; kw >/dev/null; whence -v kw'
6117/// kw is a shell function from zsh
6118/// ```
6119///
6120/// Restoring on that arm would make a ksh-autoloaded function inherit its
6121/// directory to siblings where zsh does not.
6122fn restore_loaddir(name: &str, dir: &str, abspath_used: bool, ksh_style: bool) {
6123 if ksh_style {
6124 return; // c:5792-5806 — no loadautofnsetfile on the ksh arm
6125 }
6126 if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
6127 if let Some(shf) = tab.get_mut(name) {
6128 crate::ported::exec::loadautofnsetfile(shf, Some(dir)); // c:5735
6129 if abspath_used {
6130 shf.node.flags |= crate::ported::zsh_h::PM_ABSPATH_USED as i32; // c:5751
6131 }
6132 }
6133 }
6134}
6135
6136/// c:Src/exec.c:5781 — `if (ksh == 2 || (ksh == 1 && isset(KSHAUTOLOAD)))`,
6137/// the ksh-style load branch. `ksh` derives from the stub's stored-style bits
6138/// per c:5762-5766 (`PM_KSHSTORED ? 2 : PM_ZSHSTORED ? 0 : 1`; a decisive
6139/// `.zwc` header flag was already folded into these bits by `loadautofn`).
6140///
6141/// Two zshrs steps need the same answer — `autoload_register_source` (wrap vs
6142/// verbatim) and `restore_loaddir` (whether the load kept the original node)
6143/// — so the decision lives in one place.
6144fn autoload_is_ksh_style(name: &str) -> bool {
6145 let flags = crate::ported::utils::getshfunc(name)
6146 .map(|f| f.node.flags as u32)
6147 .unwrap_or(0);
6148 let ksh = if flags & crate::ported::zsh_h::PM_KSHSTORED != 0 {
6149 2 // c:5765
6150 } else if flags & crate::ported::zsh_h::PM_ZSHSTORED != 0 {
6151 0 // c:5766
6152 } else {
6153 1 // c:5766
6154 };
6155 ksh == 2 || (ksh == 1 && crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHAUTOLOAD))
6156 // c:5781
6157}
6158
6159/// The cache key for an autoloaded function: `(resolved fpath dir,
6160/// SHA-256 of the definition text)`, or `None` when the directory
6161/// cannot be pinned down.
6162///
6163/// `loadautofn` records the resolved fpath directory on the shfunc
6164/// (`filename` + `PM_LOADDIR`, c:Src/exec.c:5657); a function whose
6165/// `filename` is still the placeholder `"zsh"` was not resolved through
6166/// `$fpath` and is not cached.
6167///
6168/// The hash is over `registered` — the exact string about to be
6169/// compiled — and NOT over a `stat` of `<dir>/<name>`. Those are not the
6170/// same thing: `getfpfunc` prefers a `<dir>.zwc` digest over the plain
6171/// file whenever the digest is newer (c:Src/parse.c:3771-3777), so the
6172/// body being installed may have no relationship to the bytes of the
6173/// file that path names. Stamping the path let a chunk built from one
6174/// text be served for another.
6175fn autoload_source_key(name: &str, registered: &str) -> Option<(String, [u8; 32])> {
6176 let dir = crate::ported::utils::getshfunc(name)
6177 .and_then(|f| f.filename)
6178 .filter(|d| d != "zsh")?;
6179 Some((dir, crate::autoload_cache::source_digest(registered)))
6180}
6181
6182fn autoload_register_source(name: &str, body: &str) -> String {
6183 autoload_definition_source(name, body, autoload_is_ksh_style(name))
6184}
6185
6186/// The exact source text an autoload of `name` installs — either the
6187/// file body verbatim (ksh style, or a file that already defines the
6188/// function) or `name() { <body> }`.
6189///
6190/// Split out of [`autoload_register_source`] with the ksh decision
6191/// passed IN so the prewarm (`autoload_prewarm`, which has no shfunc
6192/// flags to consult because nothing is registered yet) compiles
6193/// byte-identical text to what the loader will run. The two drifting
6194/// apart is precisely what made the pre-v2 shard unusable: it cached a
6195/// different program than the one the loader installs.
6196pub(crate) fn autoload_definition_source(name: &str, body: &str, ksh_style: bool) -> String {
6197 // c:Src/exec.c:5781 — a ksh-style load executes the file contents at top
6198 // level (c:5795 `execode(prog, 1, 0, "evalautofunc")`) and expects the
6199 // file itself to define the function — so the body goes through the
6200 // pipeline VERBATIM, never wrapped.
6201 if ksh_style {
6202 return body.to_string(); // c:5795 execode(prog, ..., "evalautofunc")
6203 }
6204 let stripped = crate::ported::exec::parse_string(body, 0)
6205 .map(|prog| {
6206 let original_len = prog.prog.len();
6207 // stripkshdef returns the input untouched when the prog
6208 // doesn't match the single-`function NAME` shape, and a
6209 // shorter (body-only) prog when it does. Compare the
6210 // wordcode length to detect the strip without owning the
6211 // post-strip Eprog (we only need the yes/no answer here).
6212 let prog_box = Box::new(prog);
6213 crate::ported::exec::stripkshdef(Some(prog_box), name)
6214 .map(|p| p.prog.len() != original_len)
6215 .unwrap_or(false)
6216 })
6217 .unwrap_or(false);
6218 if stripped {
6219 body.to_string()
6220 } else {
6221 format!("{name}() {{\n{body}\n}}")
6222 }
6223}
6224
6225// zsh_eval_context push/pop/sync relocated 2026-06-12 INTO doshfunc
6226// (src/ported/exec.rs) — its sole caller, and `zsh_eval_context` is
6227// that module's own static. The shell-visible mirror writes inline
6228// at the push site + the guard's Drop. No bridge indirection.
6229
6230impl ShellExecutor {
6231 /// Execute the trap body for a signal name from the REPL signal
6232 /// loop (bins/zshrs.rs CtrlC/CtrlD dispatch). Thin passthru to
6233 /// `traps_table` lookup + `execute_script` — kept as a method
6234 /// because the REPL loop owns `&mut ShellExecutor` and needs a
6235 /// single call point. The async signal-handler dispatch path
6236 /// goes through `crate::ported::signals::dotrap` instead.
6237 pub fn run_trap(&mut self, signal: &str) {
6238 let action = crate::ported::builtin::traps_table()
6239 .lock()
6240 .ok()
6241 .and_then(|t| t.get(signal).cloned());
6242 if let Some(body) = action {
6243 if !body.is_empty() {
6244 let _ = self.execute_script(&body);
6245 }
6246 }
6247 }
6248}
6249
6250impl ShellExecutor {
6251 pub(crate) fn apply_prompt_theme(&mut self, theme: &str, preview: bool) {
6252 let (ps1, rps1) = match theme {
6253 "minimal" => ("%# ", ""),
6254 "off" => ("$ ", ""),
6255 "adam1" => (
6256 "%B%F{cyan}%n@%m %F{blue}%~%f%b %# ",
6257 "%F{yellow}%D{%H:%M}%f",
6258 ),
6259 "redhat" => ("[%n@%m %~]$ ", ""),
6260 _ => ("%n@%m %~ %# ", ""),
6261 };
6262 if preview {
6263 println!("PS1={:?}", ps1);
6264 println!("RPS1={:?}", rps1);
6265 } else {
6266 self.set_scalar("PS1".to_string(), ps1.to_string());
6267 self.set_scalar("RPS1".to_string(), rps1.to_string());
6268 self.set_scalar("prompt_theme".to_string(), theme.to_string());
6269 }
6270 }
6271}
6272impl ShellExecutor {
6273 /// Expand glob pattern via canonical `glob_path` (port of
6274 /// `Src/glob.c::zglob`). Adds executor-side `current_command_glob_failed`
6275 /// cell so the dispatch layer skips the current command on NOMATCH +
6276 /// looks_like_glob instead of exiting the shell.
6277 pub fn expand_glob(&self, pattern: &str) -> Vec<String> {
6278 let expanded = glob_path(pattern);
6279 if !expanded.is_empty() {
6280 // c:Src/glob.c:1871-1872 — `if (matchct) badcshglob |= 2;`
6281 // (at least one expansion on this command line worked).
6282 // Only real glob patterns count — C's zglob early-returns
6283 // before the matchct accounting for non-wild words, so
6284 // gate on haswilds like the failure path below. Consumed
6285 // per command by fusevm_bridge::consume_badcshglob.
6286 if crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLGLOB) {
6287 let mut pattern_tok = pattern.to_string();
6288 crate::ported::glob::tokenize(&mut pattern_tok);
6289 if crate::ported::pattern::haswilds(&pattern_tok) {
6290 crate::ported::glob::BADCSHGLOB
6291 .fetch_or(2, std::sync::atomic::Ordering::Relaxed);
6292 }
6293 }
6294 return expanded;
6295 }
6296 // c:Src/glob.c:1786-1788 — `if (errflag) { restore_globstate(saved);
6297 // return; }`. A qualifier-parse error returns from `zglob` outright,
6298 // so C never reaches the c:1873-1886 nullglob/nomatch dispatch below.
6299 // The port has to re-derive `gf_nullglob` from the pattern because
6300 // `glob_path` hands back only a `Vec` — and that SECOND qualifier
6301 // parse re-runs every diagnostic the first one already emitted. It is
6302 // normally invisible because `zerr` suppresses itself while
6303 // ERRFLAG_ERROR is set (c:Src/utils.c:175), but a subscript qualifier
6304 // runs the lexer (`getindex` → `parse_subscript` → `strinbeg` →
6305 // `hbegin`, c:Src/hist.c:1115 `errflag &= ~ERRFLAG_ERROR`), which
6306 // clears exactly that bit — so `*(N[1,])` printed `bad math
6307 // expression: empty string` twice. Bail out where C's `return` lands.
6308 if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
6309 return Vec::new();
6310 }
6311 // No matches. Mirror zsh's `setopt nullglob` / `nomatch`
6312 // dispatch (Src/glob.c:1873-1886) here because glob_path
6313 // returns an empty Vec without knowing executor state.
6314 // c:Src/glob.c:1567-1569 `gf_nullglob` per-glob — the `(N)`
6315 // qualifier acts like `setopt nullglob` for this expression
6316 // alone. parse_qualifiers detects the suffix `(...)` block;
6317 // the resulting `qualifiers.nullglob` mirrors C's gf_nullglob
6318 // carrier.
6319 let per_glob_nullglob = crate::ported::glob::parse_qualifiers(pattern)
6320 .1
6321 .map(|q| q.nullglob)
6322 .unwrap_or(false);
6323 let nullglob = opt_state_get("nullglob").unwrap_or(false) || per_glob_nullglob;
6324 if nullglob {
6325 // c:Src/glob.c:1888-1894 —
6326 // `else if (in_expandredir) {`
6327 // `/* if completing for redirection, we can't remove the`
6328 // ` pattern even if NULL_GLOB is in effect */`
6329 // `zerr("redirection failed (no match): %s", ostr);`
6330 // `zfree(matchbuf, 0);`
6331 // `restore_globstate(saved);`
6332 // `return;`
6333 // `}`
6334 // Reached ONLY when gf_nullglob is set (the `else if` chain at
6335 // c:1873 owns every other no-match case), which is exactly the
6336 // `> file(N)` shape: dropping the word would leave the
6337 // redirection with no target at all, so `echo > nope(N)` failed
6338 // with an empty filename in `no such file or directory:`.
6339 if crate::ported::glob::IN_EXPANDREDIR.load(std::sync::atomic::Ordering::SeqCst) != 0 {
6340 zerr(&format!(
6341 "redirection failed (no match): {}",
6342 crate::ported::lex::untokenize(pattern)
6343 )); // c:1891
6344 self.current_command_glob_failed.set(true);
6345 return Vec::new(); // c:1894
6346 }
6347 return Vec::new();
6348 }
6349 let nomatch = opt_state_get("nomatch").unwrap_or(true);
6350 // Use canonical `haswilds` (port of Src/pattern.c:4306-4376)
6351 // instead of the Rust-only `looks_like_glob`. C zsh's
6352 // `Src/glob.c:1876` NOMATCH branch fires whenever the input
6353 // tripped haswilds during the `zglob` entry check —
6354 // including patterns whose internal `(` / `)` form a group
6355 // or alternation but don't end with `)` (e.g. `abc(a)def`,
6356 // `(abc`). The previous `looks_like_glob` only caught
6357 // trailing-`(...)` qualifiers, leaving mid-word groups and
6358 // unclosed parens to fall through to the literal-passthrough
6359 // branch. #170 in docs/BUGS.md.
6360 //
6361 // haswilds scans TOKENIZED strings (C's zglob gets the
6362 // lexer-tokenized word at Src/glob.c:1230); this entry point
6363 // receives untokenized fast-path patterns, so tokenize a
6364 // local copy first — the same preparation C applies to
6365 // runtime-built strings (compcore.c:2231 tokenizes fignore
6366 // entries before its haswilds call). tokenize Bnull's
6367 // backslash-escaped metachars, so `\*` stays literal here
6368 // exactly as in C. Bug #627: plain multibyte text (`↔`)
6369 // passes through tokenize unchanged and matches no token.
6370 let mut pattern_tok = pattern.to_string();
6371 crate::ported::glob::tokenize(&mut pattern_tok); // c:Src/glob.c:3548
6372 let is_glob = crate::ported::pattern::haswilds(&pattern_tok);
6373 // c:Src/glob.c:1874-1875 — `if (isset(CSHNULLGLOB)) {
6374 // badcshglob |= 1; }` — the else-if chain means neither the
6375 // NOMATCH error nor the literal passthrough runs: the failed
6376 // word is silently DROPPED here, and the per-command boundary
6377 // (fusevm_bridge::consume_badcshglob, Src/subst.c:505-507)
6378 // emits the csh-style `no match` iff NO glob on the line
6379 // matched.
6380 if is_glob && crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLGLOB) {
6381 crate::ported::glob::BADCSHGLOB.fetch_or(1, std::sync::atomic::Ordering::Relaxed);
6382 return Vec::new();
6383 }
6384 if nomatch && is_glob {
6385 // c:Src/glob.c:1876-1880 — `else if (isset(NOMATCH)) {`
6386 // `zerr("no matches found: %s", ostr);`
6387 // `zfree(matchbuf, 0);`
6388 // `restore_globstate(saved);`
6389 // `return;`
6390 // `}`
6391 // C aborts via ERRFLAG_ERROR set by zerr() at c:Src/utils.c
6392 // and the matchbuf/state cleanup. The Rust port mirrors
6393 // both: zerr() in utils.rs sets ERRFLAG_ERROR via
6394 // `errflag.fetch_or(ERRFLAG_ERROR, ...)` already; we then
6395 // re-set explicitly (defensive — historically this line
6396 // had `fetch_and(!ERRFLAG_ERROR)` which CLEARED the flag
6397 // immediately after zerr, making `echo /never/*` print
6398 // the literal and exit 0 instead of erroring like zsh —
6399 // parity bug #13).
6400 // c:1877 `zerr("no matches found: %s", ostr);` — `ostr` is
6401 // the TOKENIZED word, and zerrmsg's `%s` arm renders it
6402 // through `nicezputs` → `sb_niceformat`, which calls
6403 // `untokenize(ums)` (Src/utils.c). Without that step the
6404 // token bytes are dropped by the terminal and the message
6405 // reads `no matches found: /tmp/nope_.txt`.
6406 zerr(&format!(
6407 "no matches found: {}",
6408 crate::ported::lex::untokenize(pattern)
6409 )); // c:1877
6410 self.current_command_glob_failed.set(true);
6411 // c:Src/glob.c:1876-1880 — zerr sets ERRFLAG_ERROR and
6412 // glob_failed cell carries the signal. The ERRFLAG_ERROR
6413 // clear (so subsequent sublists run) now lives at the
6414 // dispatcher's post-command-boundary at
6415 // fusevm_bridge.rs:299 where current_command_glob_failed
6416 // is consumed — matches C's execlist behavior of clearing
6417 // command-error errflag between sublists.
6418 return Vec::new(); // c:1880 return
6419 }
6420 // Pattern has no glob meta — pass through literally.
6421 // c:Src/glob.c:1882-1886 — `/* treat as an ordinary string */
6422 // untokenize(matchptr->name = dupstring(ostr));`. The word
6423 // arrives here in LEXER-TOKENIZED form (c:1221 `ostr =
6424 // getdata(np)`), so the literal fallback MUST untokenize or the
6425 // raw token bytes reach stdout: `unsetopt nomatch; echo
6426 // /tmp/nope_*.txt` printed `/tmp/nope_\u{87}.txt`.
6427 vec![crate::ported::lex::untokenize(pattern)]
6428 }
6429 /// True iff the literal `pattern` actually contains a glob metachar
6430 /// in a position that would have triggered globbing. Used to avoid
6431 /// spurious "no matches" errors when expand_glob is called on a
6432 /// plain path that happened to route through this code (e.g. some
6433 /// fast paths bridge unconditionally).
6434 pub(crate) fn looks_like_glob(pattern: &str) -> bool {
6435 // A trailing `(qualifier)` is itself a glob trigger — e.g.
6436 // `path(L+10)` should be treated as a glob even when the
6437 // body has no `*`/`?`/`[...]`.
6438 let has_qual_suffix = if let Some(open) = pattern.rfind('(') {
6439 pattern.ends_with(')') && open + 1 < pattern.len() - 1
6440 } else {
6441 false
6442 };
6443 // Strip trailing `(...)` qualifier so we test the pattern body.
6444 let body = if let Some(open) = pattern.rfind('(') {
6445 if pattern.ends_with(')') {
6446 &pattern[..open]
6447 } else {
6448 pattern
6449 }
6450 } else {
6451 pattern
6452 };
6453 // Walk character-by-character so escaped metachars (`\*`, `\?`,
6454 // `\[`) are NOT counted as glob triggers. zsh: `echo \*` prints
6455 // a literal `*`; without the unescaped check, looks_like_glob
6456 // returned true on the bare `*` and the runtime glob expansion
6457 // aborted with NOMATCH.
6458 let chars: Vec<char> = body.chars().collect();
6459 let mut i = 0;
6460 let mut has_unescaped_star = false;
6461 let mut has_unescaped_question = false;
6462 let mut has_unescaped_bracket_open: Option<usize> = None;
6463 while i < chars.len() {
6464 let c = chars[i];
6465 if c == '\\' && i + 1 < chars.len() {
6466 // Escaped char — skip both.
6467 i += 2;
6468 continue;
6469 }
6470 match c {
6471 '*' => has_unescaped_star = true,
6472 '?' => has_unescaped_question = true,
6473 '[' if has_unescaped_bracket_open.is_none() => {
6474 has_unescaped_bracket_open = Some(i);
6475 }
6476 _ => {}
6477 }
6478 i += 1;
6479 }
6480 // `[` only counts when there's a matching `]` after it.
6481 let has_bracket_class = has_unescaped_bracket_open
6482 .map(|i| body[i + 1..].contains(']'))
6483 .unwrap_or(false);
6484 // `<N-M>` numeric range glob is also a trigger — match shape
6485 // `<` + optional digits + `-` + optional digits + `>` outside
6486 // any bracket expression.
6487 let has_numeric_range =
6488 body.contains('<') && body.contains('>') && !extract_numeric_ranges(body).is_empty();
6489 has_unescaped_star
6490 || has_unescaped_question
6491 || has_bracket_class
6492 || has_qual_suffix
6493 || has_numeric_range
6494 }
6495}
6496
6497impl ShellExecutor {
6498 pub(crate) fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
6499 if !dest.exists() {
6500 fs::create_dir_all(dest)?;
6501 }
6502 for entry in fs::read_dir(src)? {
6503 let entry = entry?;
6504 let file_type = entry.file_type()?;
6505 let src_path = entry.path();
6506 let dest_path = dest.join(entry.file_name());
6507
6508 if file_type.is_dir() {
6509 Self::copy_dir_recursive(&src_path, &dest_path)?;
6510 } else {
6511 fs::copy(&src_path, &dest_path)?;
6512 }
6513 }
6514 Ok(())
6515 }
6516}
6517
6518// Magic-assoc scan-by-name aggregator. C's per-table getfn/scanfn
6519// pointers in paramdef[] (Src/Modules/parameter.c:825+) handle this
6520// indirectly via paramtab dispatch; this Rust-only helper exposes a
6521// single `partab_get` / `partab_scan_keys` entry that the bridge
6522// uses for name → keys lookup.
6523use std::cell::RefCell;
6524thread_local! {
6525 static SCAN_KEYS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
6526}
6527
6528/// Lookup helper for `${name[key]}` magic-assoc reads — dispatches
6529/// through canonical `PARTAB` (Src/Modules/parameter.c:2235 ports).
6530/// Returns `None` if name isn't a known magic-assoc.
6531/// Module parameters that have actually been touched this session.
6532///
6533/// zshrs-original bookkeeping for a C behavior that falls out of the module
6534/// system there. zsh registers `zsh/parameter`'s params (`aliases`,
6535/// `commands`, `functions`, …) as PM_AUTOLOAD stubs in `realparamtab`;
6536/// touching ONE of them materializes only that name — its siblings stay
6537/// stubs even though the module is now loaded. `paramtypestr`
6538/// (Src/Modules/parameter.c:49-50) reports a PM_AUTOLOAD node as
6539/// "undefined", which is what an enumeration of `$parameters` shows.
6540/// zshrs seeds all of them eagerly (init_partab_params), so without this
6541/// set every one reported its real type and `${(@k)parameters[(R)a*]}`
6542/// matched 56 names against zsh's 18 — putting them in the wrong
6543/// `_parameters -g` bucket (`unset <TAB>`: 418 entries vs zsh's 496).
6544static MATERIALIZED_MODULE_PARAMS: std::sync::OnceLock<Mutex<HashSet<String>>> =
6545 std::sync::OnceLock::new();
6546
6547/// Record that `name` was read/written, so `$parameters` stops reporting it
6548/// as an unmaterialized autoload stub. See [`MATERIALIZED_MODULE_PARAMS`].
6549pub fn mark_module_param_used(name: &str) {
6550 let set = MATERIALIZED_MODULE_PARAMS.get_or_init(|| Mutex::new(HashSet::new()));
6551 let first_touch = {
6552 let mut g = set.lock();
6553 // Drop the guard before the module load below — `boot_` runs shell
6554 // code (setsparam/setiparam) that can re-enter this function.
6555 g.insert(name.to_string())
6556 };
6557 if first_touch {
6558 materialize_module_param(name);
6559 }
6560}
6561
6562/// The side effect C's `loadparamnode` (`Src/params.c:563-585`) has beyond
6563/// clearing PM_AUTOLOAD: `(void)ensurefeature(mn, "p:", nam)`
6564/// (`Src/module.c:3419-3432`) actually LOADS the owning module, running its
6565/// `setup_`/`boot_`. `zsh/watch`'s `boot_` (`Src/Modules/watch.c:750-753`)
6566/// seeds `WATCHFMT`/`LOGCHECK` when absent, so in zsh
6567/// `${parameters[watch]}` leaves `${parameters[LOGCHECK]}` == "integer".
6568///
6569/// !!! WARNING: RUST-ONLY HELPER !!!
6570/// C has no separate function: `loadparamnode` calls `ensurefeature`
6571/// inline. zshrs models PM_AUTOLOAD as a side-set rather than a node flag
6572/// (see [`MATERIALIZED_MODULE_PARAMS`]), so the load side effect needs its
6573/// own hook off the marking point. The `try_lock` and the re-entrancy guard
6574/// are also Rust-only: C serialises on `queue_signals`, whereas zshrs's
6575/// `MODULESTAB` is a real mutex that several callers of
6576/// `mark_module_param_used` already hold.
6577fn materialize_module_param(name: &str) {
6578 use std::cell::Cell;
6579 thread_local! {
6580 static LOADING: Cell<bool> = const { Cell::new(false) };
6581 }
6582 // c:Src/params.c:566 — only PM_AUTOLOAD stubs carry `pm->u.str` (the
6583 // owning module name); anything else falls straight through.
6584 let Some((_, modname)) = AUTOLOAD_PARAMS.iter().find(|(p, _)| *p == name) else {
6585 return;
6586 };
6587 if LOADING.with(|f| f.get()) {
6588 return;
6589 }
6590 // The whole load chain wants `&mut modulestab`. Every other caller takes
6591 // the same lock for a moment; if one of them is mid-flight (or is our own
6592 // caller), skip rather than deadlock — the mark itself already landed.
6593 let Ok(mut tab) = crate::ported::module::MODULESTAB.try_lock() else {
6594 return;
6595 };
6596 // c:Src/module.c:2352 — require_module short-circuits on an already
6597 // booted module, but check first so the common case never pays for the
6598 // find_module/alias walk.
6599 if tab
6600 .modules
6601 .get(*modname)
6602 .is_some_and(|m| (m.node.flags & crate::ported::zsh_h::MOD_INIT_B) != 0)
6603 {
6604 return;
6605 }
6606 LOADING.with(|f| f.set(true));
6607 // c:3419-3432 ensurefeature(mn, "p:", nam) — `silent` is 0 in C, but a
6608 // failure here is not user-visible in the autoload path (c:571-580 only
6609 // errors when the parameter is still undefined afterwards), and zshrs's
6610 // require_module warns on any module it cannot static-link.
6611 let _ = crate::ported::module::ensurefeature(&mut tab, modname, "p:", Some(name)); // c:3419
6612 LOADING.with(|f| f.set(false));
6613}
6614
6615/// True when `name` is still an untouched module-parameter stub — i.e. zsh
6616/// would report it as PM_AUTOLOAD ("undefined") when enumerating
6617/// `$parameters`. See [`MATERIALIZED_MODULE_PARAMS`].
6618pub fn module_param_is_autoload_stub(name: &str) -> bool {
6619 if !AUTOLOAD_PARAMS.iter().any(|(p, _)| *p == name) {
6620 return false;
6621 }
6622 MATERIALIZED_MODULE_PARAMS
6623 .get_or_init(|| Mutex::new(HashSet::new()))
6624 .lock()
6625 .contains(name)
6626 .eq(&false)
6627}
6628
6629/// True when the magic special parameter `name` (a `partab[]` row from
6630/// `Src/Modules/parameter.c:2235-2298` — `options`, `functions`,
6631/// `commands`, `parameters`, `dirstack`, …) is currently SHADOWED by a
6632/// plain user parameter of the same name.
6633///
6634/// Rust-only helper with no C counterpart BY CONSTRUCTION: C zsh keeps
6635/// specials and user parameters in the ONE `paramtab` hash, so ordinary
6636/// hash lookup already implements this. `createparam`
6637/// (c:Src/params.c:1090-1115) finds the existing special node, stashes
6638/// it in `pm->old`, and inserts a fresh plain node under the same key;
6639/// every later `getvalue` / `fetchvalue` / `gethashparam` therefore hits
6640/// the plain node and the special's `gsu` callbacks are unreachable
6641/// until `endparamscope` restores `pm->old`. zshrs instead keeps the
6642/// magic rows in SEPARATE static tables (`PARTAB`, `PARTAB_ARRAY`) and
6643/// matches them BY NAME, so a name-only match resurrected the special
6644/// even while a local shadowed it. This predicate re-imposes C's
6645/// shadowing on the split-table layout; it is architecture glue, not a
6646/// port.
6647///
6648/// `init_partab_params` (below) seeds every magic row into `paramtab`
6649/// with `PM_SPECIAL` (C's `SPECIALPMDEF` macro), and `local`/`typeset`
6650/// replaces that node with one carrying no `PM_SPECIAL`, so the live
6651/// node's `PM_SPECIAL` bit is exactly C's "is the special still the
6652/// visible binding" test.
6653///
6654/// Returns false when a MODULE-GATED row (`sysparams`, `errnos`,
6655/// `mapfile`, `langinfo`) has no `paramtab` node: those are seeded on
6656/// demand by `seed_partab_param`, and the PARTAB walk must still answer
6657/// for them (their own `module` gate decides).
6658///
6659/// For every other magic row an ABSENT node means the binding is gone —
6660/// `unset` removed it. C reaches the same answer through one gate:
6661/// `Src/params.c:2264-2266` `if (!pm || ((pm->node.flags & PM_UNSET) &&
6662/// !(pm->node.flags & PM_DECLARED))) return NULL;` in `fetchvalue`, the
6663/// single choke point every `${X}` / `${#X}` / `${(k)X}` / `${(t)X}` /
6664/// `${X[k]}` read passes through. Both of its arms show up here:
6665/// * `!pm` — `unset functions` at a point where the name is still the
6666/// `PM_AUTOLOAD` stub (`Src/module.c:1218-1223`) finds a PLAIN
6667/// `PM_SCALAR` node with neither `PM_SPECIAL` nor `PM_READONLY`, so
6668/// `unsetparam_pm`'s c:3851-3852 keep-the-node test
6669/// (`(flags & (PM_SPECIAL|PM_REMOVABLE)) == PM_SPECIAL`) is false and
6670/// c:3874 `paramtab->removenode` drops it outright.
6671/// * `PM_UNSET && !PM_DECLARED` — once the special HAS been
6672/// materialized, c:3851-3852 keeps the node and `stdunsetfn`
6673/// (c:3939) marking `PM_UNSET` is the entire record of the unset;
6674/// `setpmfunctions(pm, NULL)` returns immediately on `if (!ht)
6675/// return` (`Src/Modules/parameter.c:361-362`), so `shfunctab` — the
6676/// real table behind the row — survives untouched and `ff` still runs.
6677///
6678/// Both arms mean the same thing for a split-table port: the magic row
6679/// is no longer the visible binding for this name, exactly as when a
6680/// `local` shadows it. Answering that one question here is what lets
6681/// every PARTAB dispatch site keep a single guard.
6682///
6683/// Symptom this fixes: git's `git-completion.bash`
6684/// `__git_resolve_builtins` does `local options; eval
6685/// "options=\${$var-}"` (git 2.55.0
6686/// share/zsh/site-functions/git-completion.bash:500-501). `$options`
6687/// read back the zsh/parameter option table (`off on off …`) instead of
6688/// the local, so `git checkout --<TAB>` completed nothing.
6689pub fn magic_special_shadowed(name: &str) -> bool {
6690 // Only `partab[]` names can be shadowed in this sense — an ordinary
6691 // user assoc / array has no special behind it, and its own paramtab
6692 // node legitimately carries no PM_SPECIAL.
6693 if !PARTAB.iter().any(|e| e.name == name) && !PARTAB_ARRAY.iter().any(|e| e.name == name) {
6694 return false;
6695 }
6696 crate::ported::params::paramtab()
6697 .read()
6698 .map_or(false, |tab| {
6699 let Some(pm) = tab.get(name) else {
6700 // c:Src/params.c:2264 `if (!pm ...) return NULL` —
6701 // `unset` removed the node (see the doc comment). Only
6702 // a valid reading once the rows have been seeded, and
6703 // never for the seeded-on-demand module rows.
6704 return PARTAB_SEEDED.load(std::sync::atomic::Ordering::Acquire)
6705 && module_gated_partab_module(name).is_none();
6706 };
6707 {
6708 // c:Src/params.c:2264-2266 — `(pm->node.flags & PM_UNSET)
6709 // && !(pm->node.flags & PM_DECLARED)`: the materialized
6710 // special was unset and the node kept (c:3851-3852).
6711 let f = pm.node.flags as u32;
6712 if (f & crate::ported::zsh_h::PM_UNSET) != 0
6713 && (f & crate::ported::zsh_h::PM_DECLARED) == 0
6714 {
6715 return true;
6716 }
6717 }
6718 {
6719 // c:Src/module.c:1029-1052 checkaddparam — `if (pm->level ||
6720 // !(pm->node.flags & PM_AUTOLOAD))` is C's OWN test for "is
6721 // this node a blocker or the module's own placeholder": a
6722 // GLOBAL PM_AUTOLOAD node is the autoload STUB
6723 // `add_autoparam` planted (c:1222-1223 `setsparam(pnam,
6724 // module); pm->node.flags |= PM_AUTOLOAD` — its VALUE is the
6725 // owning module's name), and C replaces it with the real
6726 // special via `unsetparam_pm` (c:1051) + `createspecialhash`
6727 // (c:1068) the moment the module loads. Reading the name is
6728 // what triggers that: c:Src/params.c:563-585 loadparamnode
6729 // runs `ensurefeature(mn, "p:", nam)` and re-fetches the node.
6730 // So a stub NEVER makes the special unreachable — treating it
6731 // as a shadow left `${options}` reading the stub's own scalar
6732 // value ("zsh/parameter") and killed every magic-assoc read
6733 // for that name for the rest of the session. A LOCAL stub
6734 // (pm->level != 0) still blocks, exactly as c:1032 says.
6735 let f = pm.node.flags as u32;
6736 if pm.level == 0 && (f & crate::ported::zsh_h::PM_AUTOLOAD) != 0 {
6737 return false;
6738 }
6739 (f & crate::ported::zsh_h::PM_SPECIAL) == 0
6740 }
6741 })
6742}
6743
6744pub fn partab_get(name: &str, key: &str) -> Option<String> {
6745 // C's paramtab lookup would already have found the shadowing local;
6746 // the split-table port needs the explicit check.
6747 if magic_special_shadowed(name) {
6748 return None;
6749 }
6750 mark_module_param_used(name);
6751 // c:Src/Modules/system.c:902,904 — `sysparams` and `errnos` are
6752 // bound by zsh/system's boot_/setup_ chain. Same for `mapfile`
6753 // from zsh/mapfile. Without explicit `zmodload`, these names
6754 // are unset in zsh; gate the PARTAB dispatch here so they
6755 // resolve via the empty-fallback path (matching ${sysparams[k]:-x}
6756 // taking the default). Bug #69 in docs/BUGS.md.
6757 if let Some(modname) = module_gated_partab_module(name) {
6758 if !crate::ported::module::MODULESTAB
6759 .lock()
6760 .unwrap()
6761 .is_loaded(modname)
6762 {
6763 return None;
6764 }
6765 }
6766 for entry in PARTAB.iter() {
6767 if entry.name == name {
6768 return (entry.getfn)(std::ptr::null_mut(), key).and_then(|p| p.u_str);
6769 }
6770 }
6771 None
6772}
6773
6774/// Returns the owning module name for partab entries that are
6775/// bound by an explicit zmodload — `sysparams`/`errnos` from
6776/// zsh/system, `mapfile` from zsh/mapfile. Other partab entries
6777/// (aliases/commands/functions/...) are part of zsh/main and
6778/// always available.
6779fn module_gated_partab_module(name: &str) -> Option<&'static str> {
6780 match name {
6781 "sysparams" | "errnos" => Some("zsh/system"),
6782 "mapfile" => Some("zsh/mapfile"),
6783 "langinfo" => Some("zsh/langinfo"),
6784 _ => None,
6785 }
6786}
6787
6788/// Publish a value into a read-only special from shell-INTERNAL code.
6789///
6790/// C binds these params to C variables through a gsu vtable —
6791/// `compvarscalar_gsu` for `$QIPREFIX`/`$QISUFFIX`
6792/// (Src/Zle/complete.c:1308-1324), `keymap_gsu` for `$KEYMAP`
6793/// (Src/Zle/zle_params.c:151) — and the shell's own writes go straight
6794/// to that variable. PM_READONLY is only consulted on the ASSIGNMENT
6795/// path (`assignsparam`, Src/params.c), so the bit stops a user's
6796/// `QIPREFIX=x` without ever standing in the way of the completion
6797/// machinery's own publish.
6798///
6799/// zshrs keeps the value in the param itself, so the internal publish
6800/// has to step around the same gate explicitly: drop PM_READONLY,
6801/// assign through the canonical path, put the bit back.
6802pub fn set_readonly_special(name: &str, value: &str) {
6803 use crate::ported::zsh_h::PM_READONLY;
6804 let was_readonly = crate::ported::params::paramtab()
6805 .write()
6806 .ok()
6807 .and_then(|mut tab| {
6808 tab.get_mut(name).map(|pm| {
6809 let ro = (pm.node.flags & PM_READONLY as i32) != 0;
6810 pm.node.flags &= !(PM_READONLY as i32);
6811 ro
6812 })
6813 })
6814 .unwrap_or(false);
6815 let _ = crate::ported::params::setsparam(name, value);
6816 if was_readonly {
6817 if let Ok(mut tab) = crate::ported::params::paramtab().write() {
6818 if let Some(pm) = tab.get_mut(name) {
6819 pm.node.flags |= PM_READONLY as i32;
6820 }
6821 }
6822 }
6823}
6824
6825/// PM_ARRAY lookup for `${name}` / `${name[N]}` — walks
6826/// PARTAB_ARRAY and dispatches the whole-array getfn (Src/Modules/
6827/// parameter.c:2239-2291 ports). Returns `None` if name isn't a
6828/// known PM_ARRAY magic-assoc.
6829pub fn partab_array_get(name: &str) -> Option<Vec<String>> {
6830 // c:Src/params.c:1090-1115 createparam — see `partab_get`.
6831 if magic_special_shadowed(name) {
6832 return None;
6833 }
6834 mark_module_param_used(name);
6835 // Bug #69 — gate module-bound PARTAB names on the owning
6836 // module's MOD_LINKED && !MOD_UNLOAD state.
6837 if let Some(modname) = module_gated_partab_module(name) {
6838 if !crate::ported::module::MODULESTAB
6839 .lock()
6840 .unwrap()
6841 .is_loaded(modname)
6842 {
6843 return None;
6844 }
6845 }
6846 for entry in PARTAB_ARRAY.iter() {
6847 if entry.name == name {
6848 return Some((entry.getfn)(std::ptr::null_mut()));
6849 }
6850 }
6851 None
6852}
6853
6854/// Scan helper for `${(k)name}` — enumerates keys via canonical
6855/// scanfn, collected into Vec via SCAN_KEYS thread-local.
6856pub fn partab_scan_keys(name: &str) -> Option<Vec<String>> {
6857 // c:Src/params.c:1090-1115 createparam — see `partab_get`.
6858 if magic_special_shadowed(name) {
6859 return None;
6860 }
6861 mark_module_param_used(name);
6862 // Bug #69 — gate module-bound PARTAB names on the owning
6863 // module's MOD_LINKED && !MOD_UNLOAD state.
6864 if let Some(modname) = module_gated_partab_module(name) {
6865 if !crate::ported::module::MODULESTAB
6866 .lock()
6867 .unwrap()
6868 .is_loaded(modname)
6869 {
6870 return None;
6871 }
6872 }
6873 for entry in PARTAB.iter() {
6874 if entry.name == name {
6875 SCAN_KEYS.with(|k| k.borrow_mut().clear());
6876 // c:Src/Modules/parameter.c — a param-table ScanFunc receives
6877 // `&pm.node` of a fully populated `struct param`; the Rust side
6878 // models that as `ParamScanFunc = fn(¶m, i32)`.
6879 fn cb(pm: &crate::ported::zsh_h::param, _flags: i32) {
6880 SCAN_KEYS.with(|k| k.borrow_mut().push(pm.node.nam.clone()));
6881 }
6882 // c:Src/params.c:3138 — `paramvalarr(…, SCANPM_WANTKEYS)`: keys
6883 // only, so a scanfn need not materialize the value side.
6884 (entry.scanfn)(
6885 std::ptr::null_mut(),
6886 Some(cb),
6887 crate::ported::zsh_h::SCANPM_WANTKEYS as i32,
6888 );
6889 return Some(SCAN_KEYS.with(|k| k.borrow().clone()));
6890 }
6891 }
6892 None
6893}
6894/// Populate paramtab with PM_SPECIAL placeholder Params for every
6895/// PARTAB / PARTAB_ARRAY entry — Rust-only init helper, no direct
6896/// C counterpart (closest is `handlefeatures` walking `partab[]`
6897/// in `Src/Modules/parameter.c:2341` boot/enables chain).
6898///
6899/// Each magic-assoc name gets a Param with `entry.flags | PM_SPECIAL`.
6900/// Value reads still route through `partab_get` / `partab_array_get`;
6901/// having the Param in paramtab makes `paramtab.get(name)` return
6902/// Some(Param) so `${+name}` / `${(t)name}` / `typeset -p name` see
6903/// the entry. Without this, those reads returned empty for every
6904/// magic-assoc (aliases, commands, functions, etc.).
6905///
6906/// Called from ShellExecutor::new() since zshrs's bin entry skips
6907/// the canonical module-bootstrap chain.
6908pub fn init_partab_params() {
6909 use crate::ported::modules::parameter::{PARTAB, PARTAB_ARRAY};
6910 use crate::ported::zsh_h::{
6911 hashnode, param, Param, PM_HIDE, PM_HIDEVAL, PM_READONLY, PM_SPECIAL,
6912 };
6913 let mut tab = match paramtab().write() {
6914 Ok(t) => t,
6915 Err(_) => return,
6916 };
6917 // c:Src/zsh.h SPECIALPMDEF macro: `flags | PM_SPECIAL | PM_HIDE |
6918 // PM_HIDEVAL`. All magic-assoc/array params get HIDE+HIDEVAL added
6919 // by the macro itself.
6920 //
6921 // PM_READONLY is preserved on the stub for params that legitimately
6922 // need user-write protection (reswords, dis_reswords, patchars,
6923 // dis_patchars — all compute via getfn and have no legitimate
6924 // internal-write path). Other specials that DO have internal-write
6925 // paths (e.g. funcstack from function-call tracking) get the bit
6926 // stripped so the runtime can mutate their u_arr. Bug #374.
6927 // `parameters` is computed entirely by getpmparameter and has no
6928 // internal-write path either (zsh: PM_READONLY_SPECIAL, c:2287), so it
6929 // keeps the bit — `${parameters[parameters]}` reads
6930 // `association-readonly-hide-hideval-special` in zsh.
6931 // The remaining names below are the rest of C's PM_READONLY_SPECIAL
6932 // rows whose `partab[]` entry has a NULL gsu (c:2237/2243/2255/2265/
6933 // :2272/2276-2280/2284/2296-2298 + Src/Zle/zleparameter.c:133): they
6934 // are computed purely by their getfn/scanfn, so there is nothing for
6935 // the runtime to write and the bit is safe to keep. Without them
6936 // `${(t)builtins}` and friends reported
6937 // `association-hide-hideval-special` where zsh reports
6938 // `association-readonly-hide-hideval-special`.
6939 let user_protected: &[&str] = &[
6940 "parameters",
6941 "reswords",
6942 "dis_reswords",
6943 "patchars",
6944 "dis_patchars",
6945 "historywords",
6946 "errnos",
6947 "keymaps",
6948 "builtins", // c:2237
6949 "dis_builtins", // c:2243
6950 "functions_source", // c:2265
6951 "dis_functions_source", // c:2247
6952 "history", // c:2272
6953 "jobdirs", // c:2276
6954 "jobstates", // c:2278
6955 "jobtexts", // c:2280
6956 "modules", // c:2284
6957 "userdirs", // c:2296
6958 "usergroups", // c:2297
6959 "widgets", // c:Src/Zle/zleparameter.c:133
6960 // c:2279-2280 — `SPECIALPMDEF("funcstack", PM_ARRAY|
6961 // PM_READONLY_SPECIAL, &funcstack_gsu, NULL, NULL)`. The bit was
6962 // being stripped here on the theory that the runtime writes
6963 // `funcstack`'s `u_arr`; it does not — `funcstackgetfn`
6964 // (PARTAB_ARRAY, parameter.rs:4726-4732) computes the value from
6965 // the `FUNCSTACK` global on every read and the row's `setfn` is
6966 // `None`, so there is nothing to protect against. Without the bit
6967 // `${(t)funcstack}` read `array-hide-hideval-special` where zsh
6968 // reads `array-readonly-hide-hideval-special`, which put it in the
6969 // wrong `_parameters -g '^*(readonly|association)*'` bucket and
6970 // added one candidate zsh does not offer.
6971 "funcstack", // c:2279
6972 // Same argument as `funcstack` above, for the three sibling trace
6973 // arrays: `SPECIALPMDEF(..., PM_ARRAY|PM_READONLY_SPECIAL, ...)` in C
6974 // and `setfn: None` in PARTAB_ARRAY (parameter.rs:4717/4725/4741), so
6975 // they are getfn-computed with no internal-write path to protect.
6976 "funcfiletrace", // c:2275
6977 "funcsourcetrace", // c:2277
6978 "functrace", // c:2285
6979 // c:Src/Modules/termcap.c:312 / Src/Modules/terminfo.c:305 —
6980 // `SPECIALPMDEF("termcap", PM_READONLY, NULL, gettermcap, scantermcap)`
6981 // and the terminfo twin: NULL gsu, value produced entirely by the
6982 // getnode/scan fns, so the readonly bit has nothing to fight.
6983 "termcap", // c:Src/Modules/termcap.c:312
6984 "terminfo", // c:Src/Modules/terminfo.c:305
6985 // c:Src/Builtins/sched.c:382 — `SPECIALPMDEF(
6986 // "zsh_scheduled_events", PM_ARRAY|PM_READONLY, &sched_gsu,
6987 // NULL, NULL)`. Same shape as `funcstack` above: `schedgetfn`
6988 // (sched.rs:582) walks the schedcmds list on every read and the
6989 // PARTAB_ARRAY row's `setfn` is `None`, so no internal write
6990 // needs the bit cleared. `sched`/`sched -N` mutate the list,
6991 // never the param.
6992 "zsh_scheduled_events", // c:Src/Builtins/sched.c:382
6993 ];
6994 let mk_pm = |name: &str, flags: i32| -> Param {
6995 let keep_readonly = user_protected.contains(&name);
6996 let pre_readonly_mask = if keep_readonly {
6997 !0i32
6998 } else {
6999 !(PM_READONLY as i32)
7000 };
7001 Box::new(param {
7002 node: hashnode {
7003 next: None,
7004 nam: name.to_string(),
7005 flags: (flags & pre_readonly_mask)
7006 | PM_SPECIAL as i32
7007 | PM_HIDE as i32
7008 | PM_HIDEVAL as i32,
7009 },
7010 u_data: 0,
7011 u_tied: None,
7012 u_arr: None,
7013 u_str: None,
7014 u_val: 0,
7015 u_dval: 0.0,
7016 u_hash: None,
7017 gsu_s: None,
7018 gsu_i: None,
7019 gsu_f: None,
7020 gsu_a: None,
7021 gsu_h: None,
7022 base: 0,
7023 width: 0,
7024 env: None,
7025 ename: None,
7026 old: None,
7027 level: 0,
7028 })
7029 };
7030 // c:Src/Modules/system.c:902,904 + Src/Modules/mapfile.c — these
7031 // params are provided by modules that real zsh requires explicit
7032 // `zmodload` for. Seeding them unconditionally makes
7033 // `${+sysparams}` return 1 by default (bug #69 in docs/BUGS.md),
7034 // diverging from zsh which returns 0 until the user runs
7035 // `zmodload zsh/system`. Skip here; `seed_partab_param` below adds
7036 // them on demand from the module's load path.
7037 let module_gated: &[&str] = &[
7038 "sysparams", // zsh/system
7039 "errnos", // zsh/system
7040 "mapfile", // zsh/mapfile
7041 "langinfo", // zsh/langinfo
7042 ];
7043 // c:Src/module.c:1065 `addparamdef` — `checkaddparam` (c:1026) finds
7044 // the PM_AUTOLOAD stub `init_bltinmods` planted, calls
7045 // `unsetparam_pm(pm, 0, 1)` which UNLINKS the node (c:1052), and only
7046 // then does `createparam` re-add it — so the real param takes a FRESH
7047 // chain slot, it does not inherit the stub's. `hashtable_nodes::
7048 // insert` is `addhashnode2` (Src/hashtable.c:168), which replaces an
7049 // existing key IN PLACE (c:187-203) and would pin the special to the
7050 // stub's slot; remove first to reproduce C. Visible in
7051 // `${(k)parameters}`: without the remove, `dis_reswords` and
7052 // `usergroups` came out one position off from `zsh -f`.
7053 for entry in PARTAB.iter() {
7054 if module_gated.contains(&entry.name) {
7055 continue;
7056 }
7057 tab.remove(entry.name); // c:1052 unsetparam_pm
7058 tab.insert(entry.name.to_string(), mk_pm(entry.name, entry.flags));
7059 }
7060 for entry in PARTAB_ARRAY.iter() {
7061 if module_gated.contains(&entry.name) {
7062 continue;
7063 }
7064 tab.remove(entry.name); // c:1052 unsetparam_pm
7065 tab.insert(entry.name.to_string(), mk_pm(entry.name, entry.flags));
7066 }
7067 // See `PARTAB_SEEDED` — every magic row now has its paramtab node,
7068 // so from here on a MISSING node is a real "no binding" answer.
7069 PARTAB_SEEDED.store(true, std::sync::atomic::Ordering::Release);
7070}
7071
7072/// !!! WARNING: RUST-ONLY HELPER !!!
7073///
7074/// True once [`init_partab_params`] has finished planting a paramtab
7075/// node for every `PARTAB` / `PARTAB_ARRAY` row.
7076///
7077/// No C counterpart BY CONSTRUCTION. In C the magic rows only ENTER
7078/// `paramtab` when `zsh/parameter` boots (`handlefeatures` →
7079/// `addparamdef` → `createspecialhash`, `Src/module.c:1065`), and before
7080/// that the name still resolves — to the `PM_AUTOLOAD` stub
7081/// `init_bltinmods` planted (`Src/module.c:1218-1223`). Either way C's
7082/// `paramtab->getnode(name)` answers, so C never has to distinguish
7083/// "not seeded yet" from "unset". zshrs seeds the rows from
7084/// `ShellExecutor::new` (vm_helper.rs:2566) and matches `PARTAB` BY NAME
7085/// out of a separate static table, so a magic read that runs BEFORE that
7086/// seeding would see an absent node. `magic_special_shadowed` reads
7087/// absence as "unset" (C: `getnode` → NULL → `fetchvalue` NULL,
7088/// `Src/params.c:2264-2266`), which is only a valid inference once the
7089/// seeding has run; this flag is that precondition.
7090static PARTAB_SEEDED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
7091
7092/// Insert a single PARTAB / PARTAB_ARRAY entry into paramtab. Called
7093/// from `zmodload <module>` once the module's boot completes, so that
7094/// `${+sysparams}` (etc.) flip from 0 → 1 only after explicit load.
7095/// No direct C counterpart — the C path runs through the module's
7096/// `setup_/boot_` chain which adds the SPECIALPMDEF entry via the
7097/// general hashtable machinery. Bug #69 in docs/BUGS.md.
7098pub fn seed_partab_param(name: &str) {
7099 use crate::ported::modules::parameter::{PARTAB, PARTAB_ARRAY};
7100 use crate::ported::zsh_h::{hashnode, param, PM_HIDE, PM_HIDEVAL, PM_READONLY, PM_SPECIAL};
7101 let mut tab = match crate::ported::params::paramtab().write() {
7102 Ok(t) => t,
7103 Err(_) => return,
7104 };
7105 if tab.contains_key(name) {
7106 return; // already seeded
7107 }
7108 let flags = PARTAB
7109 .iter()
7110 .find(|e| e.name == name)
7111 .map(|e| e.flags)
7112 .or_else(|| {
7113 PARTAB_ARRAY
7114 .iter()
7115 .find(|e| e.name == name)
7116 .map(|e| e.flags)
7117 });
7118 let Some(flags) = flags else {
7119 return;
7120 };
7121 let pm = Box::new(param {
7122 node: hashnode {
7123 next: None,
7124 nam: name.to_string(),
7125 // Keep C's PM_READONLY. `init_partab_params` strips it from
7126 // rows the RUNTIME writes internally (funcstack pushes and
7127 // friends) and keeps it on the getfn/scanfn-computed rows —
7128 // see its `user_protected` list. Every name reaching THIS
7129 // seeder is a zmodload-gated row
7130 // (`module_gated_params_for`), and all of them are the
7131 // computed kind: `SPECIALPMDEF("sysparams", PM_READONLY,
7132 // NULL, getpmsysparams, scanpmsysparams)`
7133 // (Src/Modules/system.c:906), `errnos` (c:904), `langinfo`
7134 // (Src/Modules/langinfo.c:455); `mapfile` carries flags 0 in
7135 // C so it is unaffected either way. Stripping the bit made
7136 // `${(t)sysparams}` read `association-hide-hideval-special`
7137 // against zsh's `association-readonly-hide-hideval-special`,
7138 // and let `unset sysparams` succeed where zsh rejects with
7139 // `read-only variable: sysparams`.
7140 flags: flags | PM_SPECIAL as i32 | PM_HIDE as i32 | PM_HIDEVAL as i32,
7141 },
7142 u_data: 0,
7143 u_tied: None,
7144 u_arr: None,
7145 u_str: None,
7146 u_val: 0,
7147 u_dval: 0.0,
7148 u_hash: None,
7149 gsu_s: None,
7150 gsu_i: None,
7151 gsu_f: None,
7152 gsu_a: None,
7153 gsu_h: None,
7154 base: 0,
7155 width: 0,
7156 env: None,
7157 ename: None,
7158 old: None,
7159 level: 0,
7160 });
7161 tab.insert(name.to_string(), pm);
7162}
7163
7164/// Default autoloadable parameters: name → owning module. Port of the
7165/// `autofeatures` `p:` rows in Src/Modules/parameter.mdd, watch.mdd,
7166/// termcap.mdd, terminfo.mdd, Src/Zle/zleparameter.mdd and
7167/// Src/Builtins/sched.mdd, registered at startup through
7168/// `setautofeatures` → `add_autoparam` (Src/module.c:1198-1229): each
7169/// name becomes a scalar paramtab stub whose VALUE is the module name,
7170/// flagged PM_AUTOLOAD (module.c:1218-1219). Matches `zmodload -ap`
7171/// output of the reference zsh build.
7172pub const AUTOLOAD_PARAMS: &[(&str, &str)] = &[
7173 // Src/Modules/watch.mdd:5 autofeatures
7174 ("WATCH", "zsh/watch"),
7175 ("watch", "zsh/watch"),
7176 // Src/Modules/parameter.mdd:5 autofeatures
7177 ("aliases", "zsh/parameter"),
7178 ("builtins", "zsh/parameter"),
7179 ("commands", "zsh/parameter"),
7180 ("dirstack", "zsh/parameter"),
7181 ("dis_aliases", "zsh/parameter"),
7182 ("dis_builtins", "zsh/parameter"),
7183 ("dis_functions", "zsh/parameter"),
7184 ("dis_functions_source", "zsh/parameter"),
7185 ("dis_galiases", "zsh/parameter"),
7186 ("dis_patchars", "zsh/parameter"),
7187 ("dis_reswords", "zsh/parameter"),
7188 ("dis_saliases", "zsh/parameter"),
7189 ("funcfiletrace", "zsh/parameter"),
7190 ("funcsourcetrace", "zsh/parameter"),
7191 ("funcstack", "zsh/parameter"),
7192 ("functions", "zsh/parameter"),
7193 ("functions_source", "zsh/parameter"),
7194 ("functrace", "zsh/parameter"),
7195 ("galiases", "zsh/parameter"),
7196 ("history", "zsh/parameter"),
7197 ("historywords", "zsh/parameter"),
7198 ("jobdirs", "zsh/parameter"),
7199 ("jobstates", "zsh/parameter"),
7200 ("jobtexts", "zsh/parameter"),
7201 ("modules", "zsh/parameter"),
7202 ("nameddirs", "zsh/parameter"),
7203 ("options", "zsh/parameter"),
7204 ("parameters", "zsh/parameter"),
7205 ("patchars", "zsh/parameter"),
7206 ("reswords", "zsh/parameter"),
7207 ("saliases", "zsh/parameter"),
7208 ("userdirs", "zsh/parameter"),
7209 ("usergroups", "zsh/parameter"),
7210 // Src/Zle/zleparameter.mdd:5 autofeatures
7211 ("keymaps", "zsh/zleparameter"),
7212 ("widgets", "zsh/zleparameter"),
7213 // Src/Modules/termcap.mdd:5 / terminfo.mdd:5 autofeatures
7214 ("termcap", "zsh/termcap"),
7215 ("terminfo", "zsh/terminfo"),
7216 // Src/Builtins/sched.mdd:5 autofeatures
7217 ("zsh_scheduled_events", "zsh/sched"),
7218];
7219
7220/// Autoload stubs whose owning module is NOT loaded — the rows zsh's
7221/// `typeset` listings print as `undefined NAME` (printparamnode's
7222/// PM_AUTOLOAD pmtypes row, Src/params.c:6011 + the PM_AUTOLOAD
7223/// NAMEONLY arm at Src/params.c:6146-6155). Once a module loads, its
7224/// stubs drop out and the real params list instead.
7225pub fn autoload_param_stubs() -> Vec<(&'static str, &'static str)> {
7226 use crate::ported::zsh_h::{MOD_INIT_B, MOD_UNLOAD};
7227 let tab = crate::ported::module::MODULESTAB.lock().unwrap();
7228 AUTOLOAD_PARAMS
7229 .iter()
7230 .copied()
7231 .filter(|(_, m)| {
7232 // "Boot ran" is MOD_INIT_B && !MOD_UNLOAD (the criterion
7233 // printmodulenode uses, src/ported/module.rs:246 — C's
7234 // `m->u.handle` union check at Src/module.c:218-241).
7235 // modulestab::is_loaded checks MOD_LINKED which
7236 // register_builtin_modules pre-seeds for EVERY compiled-in
7237 // module, so it would report zsh/parameter "loaded" in a
7238 // fresh `zsh -f` where real zsh still shows the stubs.
7239 !tab.modules.get(*m).is_some_and(|md| {
7240 (md.node.flags & MOD_INIT_B) != 0 && (md.node.flags & MOD_UNLOAD) == 0
7241 })
7242 })
7243 .collect()
7244}
7245
7246/// Names provided by `zsh/system` / `zsh/mapfile` etc. that are
7247/// gated on explicit `zmodload`. Used by the bin_zmodload path to
7248/// re-seed paramtab after the module's boot completes.
7249pub fn module_gated_params_for(module: &str) -> &'static [&'static str] {
7250 match module {
7251 "zsh/system" => &["sysparams", "errnos"],
7252 "zsh/mapfile" => &["mapfile"],
7253 "zsh/langinfo" => &["langinfo"],
7254 _ => &[],
7255 }
7256}
7257impl ShellExecutor {
7258 /// `enter_posix_mode` — see implementation.
7259 pub fn enter_posix_mode(&mut self) {
7260 self.posix_mode = true;
7261 self.plugin_cache = None;
7262 self.compsys_cache = std::cell::OnceCell::new();
7263 self.compinit_pending = None;
7264 self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
7265 // Direct call to the canonical `emulate()` port
7266 // (Src/options.c:533) — `-R` semantics = fully=true.
7267 // bin_emulate goes through dispatch_builtin which needs an
7268 // ExecutorContext that isn't set up yet at apply_cli_flags
7269 // time; the underlying emulate() doesn't need one.
7270 crate::ported::options::emulate("sh", true);
7271 }
7272 /// `enter_ksh_mode` — see implementation.
7273 pub fn enter_ksh_mode(&mut self) {
7274 self.plugin_cache = None;
7275 self.compsys_cache = std::cell::OnceCell::new();
7276 self.compinit_pending = None;
7277 self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
7278 crate::ported::options::emulate("ksh", true);
7279 }
7280 /// `enter_dash_mode` — strict-dash (Debian Almquist Shell) runtime.
7281 /// Same executor setup as [`enter_posix_mode`] (dash IS `sh` for every
7282 /// option), but calls `emulate("dash")` so the Rust-only DASH_STRICT
7283 /// flag is raised (and NOT cleared, as `emulate("sh")` would). See
7284 /// `src/extensions/dash_mode.rs`.
7285 pub fn enter_dash_mode(&mut self) {
7286 self.posix_mode = true;
7287 self.plugin_cache = None;
7288 self.compsys_cache = std::cell::OnceCell::new();
7289 self.compinit_pending = None;
7290 self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
7291 crate::ported::options::emulate("dash", true);
7292 }
7293}
7294
7295/// Thin (text, pattern) → bool wrapper over the canonical
7296/// `patcompile()` + `pattry()` pair from `Src/pattern.c`. Argument
7297/// order is flipped so callers read naturally. Lives in vm_helper.rs
7298/// (non-port file) as the public convenience entry for extensions
7299/// and the VM bridge; `src/ported/*` files inline the compile+match
7300/// idiom directly to preserve PORT.md Rule 1 faithfulness.
7301pub fn glob_match_static(s: &str, pattern: &str) -> bool {
7302 let Some(prog) = patcompile(
7303 &{
7304 let mut __pat_tok = (pattern).to_string();
7305 crate::ported::glob::tokenize(&mut __pat_tok);
7306 __pat_tok
7307 },
7308 PAT_HEAPDUP as i32,
7309 None,
7310 ) else {
7311 return false;
7312 };
7313 // c:Src/pattern.c:2570-2621 — `else if (prog->patnpar && !(patflags &
7314 // PAT_FILE))`: when the caller passes NO nump/begp/endp, `pattryrefs`
7315 // ITSELF publishes $match / $mbegin / $mend. That arm is ported in
7316 // pattern.rs, so this layer must not re-derive them from begp/endp.
7317 //
7318 // Re-deriving required compensating for the EXCLUSIVE end index
7319 // `pattryrefs` used to return, and the compensation was wrong twice over:
7320 // * it would double-correct the c:2562-2564 `- 1` now applied there, and
7321 // collide with the `endp[i] < 0` unset-group sentinel — an empty
7322 // capture at offset 0 reports (0, -1) and was misread as an UNMATCHED
7323 // alternation branch;
7324 // * `saturating_sub(1)` clamped at 0, so under KSHARRAYS an empty capture
7325 // at offset 0 gave `mend=0` where C computes `0 + 0 + 0 - 1` = -1.
7326 // Measured: `setopt ksharrays; [[ abc = (#b)(x#)abc ]]` → zsh mend=-1,
7327 // zshrs mend=0, while the substitution path (which already uses the
7328 // c:2570-2621 arm) printed -1 correctly.
7329 let matched = pattry(&prog, s);
7330 // $MATCH / $MBEGIN / $MEND are set by the MATCHER (pattern.rs, c:2526),
7331 // which is where C decides it — gated on the GLOBAL patglobflags so a later
7332 // `(#M)` can turn GF_MATCHREF back off (c:1099-1100).
7333 //
7334 // This layer used to re-do it with `pattern.contains("(#m)")`, which can
7335 // only ever answer "on": it re-set $MATCH after the matcher had correctly
7336 // declined to, so `(#m)(#M)a*` reported a match string where zsh leaves it
7337 // unset. Wrong mechanism (a substring test cannot see which flag came last)
7338 // and wrong layer (the matcher already has the compiled flags).
7339 matched
7340}
7341
7342pub use crate::ported::lex::untokenize_ztokens;
7343
7344pub use crate::ported::utils::unmetafy_str;
7345
7346pub use crate::ported::utils::zsh_errno_msg;
7347
7348// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7349// PM_NAMEREF bridge helpers (typeset -n / named references).
7350//
7351// Rust-only adapters around the canonical C nameref machinery in
7352// Src/params.c (resolve_nameref_rec c:6332, setscope c:6382,
7353// upscope c:6455) and Src/builtin.c (bin_typeset nameref arm
7354// c:3117-3150). zshrs's paramtab is a name-keyed HashMap handing
7355// out clones, so the chain walk operates by NAME against the live
7356// table instead of by Param pointer — same hop rule, same loop
7357// detection, same upscope old-chain walk. The ported fns in
7358// params.rs/builtin.rs call into these at the exact C deref points
7359// (getparamnode c:570-575, getvalue/fetchvalue c:2247-2270,
7360// assignsparam c:3252/3258, assignaparam c:3392-3398, bin_unset
7361// c:3939-3951, typeset_single c:2032-2050).
7362// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7363
7364pub use crate::ported::params::*;