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