zsh/exec.rs
1//! Shell executor state for zshrs.
2//!
3//! **Not a port of Src/exec.c.** C zsh runs compiled programs on the native
4//! **wordcode VM** in `Src/exec.c` (`execlist` / `execpline` / `execcmd`).
5//! zshrs uses fusevm bytecode instead; the bridge lives in `src/fusevm_bridge.rs`.
6//! This file holds:
7//! - `ShellExecutor` — the runtime state struct that the VM and
8//! every ported builtin/utility threads through
9//! - VM-adjacent helpers that read/write that state
10//! - drift extension scaffolding still being moved out
11//!
12//! Path-wise this file lives at the crate root (`src/exec.rs`) rather
13//! than in `src/ported/` because nothing here corresponds 1:1 to a
14//! `Src/*.c` source file. `crate::ported::exec` is kept as a
15//! re-export alias so existing call-sites continue to compile.
16
17use crate::history::HistoryEngine;
18// MathState is private to math.rs (per math.c — no public state struct);
19// math API surface is matheval/mathevali/mnumber.
20use crate::options::ZSH_OPTIONS_SET;
21// TcpSessions struct deleted — see modules/tcp.rs ZTCP_SESSIONS thread_local.
22// `Profiler`/`ProfileEntry` deleted in the zprof.rs strict-rules
23// rewrite — zprof state now lives in module-level statics
24// (`CALLS`/`NCALLS`/`ARCS`/`NARCS`/`STACK`/`ZPROF_MODULE`) matching
25// the C file-statics at zprof.c:66-71.
26use compsys::cache::CompsysCache;
27use compsys::CompInitResult;
28use parking_lot::Mutex;
29use std::collections::HashSet;
30use crate::ported::utils::{errflag, ERRFLAG_ERROR};
31use std::sync::atomic::Ordering;
32use crate::ported::parse::ECBUF;
33use crate::ported::zsh_h::{wc_code, wc_data, WC_END, WC_LIST};
34use crate::ported::zsh_h::WC_SUBLIST;
35use crate::ported::zsh_h::WC_PIPE;
36use crate::ported::zsh_h::{WC_ARITH, WC_CASE, WC_COND, WC_CURSH, WC_FOR, WC_FUNCDEF, WC_IF, WC_REPEAT, WC_SELECT, WC_SIMPLE, WC_SUBSH, WC_TIMED, WC_TRY, WC_WHILE, };
37use crate::ported::zsh_h::{WC_FOR_LIST, WC_FOR_SKIP, WC_FOR_TYPE};
38use crate::ported::zsh_h::{WC_CASE_SKIP, WC_CASE_TYPE};
39use crate::ported::builtin::RETFLAG;
40use crate::ported::zsh_h::{WC_IF_SKIP, WC_IF_TYPE};
41use crate::ported::builtin::{BREAKS, CONTFLAG, LOOPS};
42use crate::ported::zsh_h::{WC_WHILE_SKIP, WC_WHILE_TYPE};
43use crate::ported::math::mathevali;
44use crate::ported::parse::ecgetstr_wordcode;
45use crate::ported::subst::singsub;
46use crate::ported::zsh_h::WC_REPEAT_SKIP;
47use crate::ported::parse::ecgetstr_wordcode as ecgetstr;
48use std::fs;
49use std::os::unix::io::FromRawFd;
50use std::io::Read;
51use std::ffi::CString;
52use std::os::unix::ffi::OsStrExt;
53use crate::ported::zsh_h::{options, MAX_OPS};
54use crate::ported::zsh_h::{PM_INTEGER, PM_EFLOAT, PM_FFLOAT, PM_ARRAY, PM_HASHED, PM_LOWER, PM_UPPER, PM_READONLY, PM_EXPORTED, PM_LEFT, PM_RIGHT_B, PM_RIGHT_Z};
55use std::ffi::CStr;
56use crate::ported::zle::zle_thingy::{listwidgets, getwidgettarget};
57use std::time::{SystemTime, UNIX_EPOCH};
58use walkdir::WalkDir;
59use std::os::unix::fs::FileTypeExt;
60use std::os::unix::fs::PermissionsExt;
61use std::sync::atomic::AtomicI32;
62use crate::ported::modules::parameter::*;
63use crate::ported::zsh_h::PM_UNDEFINED;
64
65// Backward-compat re-exports for free fns recently relocated to their
66// canonical-C-file Rust modules. Existing call-sites in this file (and
67// elsewhere) still reference these unqualified.
68#[allow(unused_imports)]
69#[allow(unused_imports)]
70pub(crate) use crate::ported::glob::{expand_glob_alternation, find_top_level_tilde};
71#[allow(unused_imports)]
72pub use crate::ported::params::convbase as format_int_in_base;
73pub use crate::ported::params::convbase_underscore;
74#[allow(unused_imports)]
75pub(crate) use crate::ported::math::{parse_assign, parse_compound, parse_pre_inc};
76#[allow(unused_imports)]
77pub(crate) use crate::ported::params::getarrvalue;
78#[allow(unused_imports)]
79#[allow(unused_imports)]
80// drift imports removed: apply_subst_modifier, slice_scalar, strip_match_op
81#[allow(unused_imports)]
82pub(crate) use crate::func_body_fmt::FuncBodyFmt;
83#[allow(unused_imports)]
84pub(crate) use crate::ported::utils::base64_decode;
85#[allow(unused_imports)]
86pub(crate) use crate::ported::utils::{ispwd, printprompt4, quotedzputs};
87#[allow(unused_imports)]
88pub(crate) use crate::ported::hist::bufferwords as bufferwords_z_tuple;
89
90pub(crate) use crate::intercepts::intercept_matches;
91/// AOP advice type — before, after, or around.
92pub use crate::intercepts::{AdviceKind, Intercept};
93
94/// Result from background compinit thread.
95pub use crate::compinit_bg::CompInitBgResult;
96use std::io::Write;
97use std::sync::LazyLock;
98
99
100/// State snapshot for plugin delta computation.
101pub(crate) use crate::plugin_cache::PluginSnapshot;
102
103
104/// Cached compiled regexes for hot paths
105pub(crate) static REGEX_CACHE: LazyLock<Mutex<std::collections::HashMap<String, regex::Regex>>> =
106 LazyLock::new(|| Mutex::new(std::collections::HashMap::with_capacity(64)));
107
108/// Port of `int trap_state;` from `Src/exec.c:134`. Tracks whether
109/// a trap handler is currently being processed and, paired with
110/// `TRAP_RETURN` below, whether a `return` inside the trap should
111/// promote to `TRAP_STATE_FORCE_RETURN` to unwind the trap caller.
112///
113/// Values: `TRAP_STATE_INACTIVE = 0`, `TRAP_STATE_PRIMED = 1`,
114/// `TRAP_STATE_FORCE_RETURN = 2` (see `Src/zsh.h`).
115pub static TRAP_STATE: std::sync::atomic::AtomicI32 = // c:134 (Src/exec.c)
116 std::sync::atomic::AtomicI32::new(0);
117
118/// Port of `int trap_return;` from `Src/exec.c:155`. Carries the
119/// pending exit status from inside a trap; sentinel `-2` means
120/// "running an EXIT/DEBUG-style trap at the current level"
121/// (signals.c:1166). Promoted to the user's `return N` value by
122/// `bin_return` when POSIX-trap semantics apply (builtin.c:5852).
123pub static TRAP_RETURN: std::sync::atomic::AtomicI32 = // c:155 (Src/exec.c)
124 std::sync::atomic::AtomicI32::new(0);
125
126/// Port of `int forklevel;` from `Src/exec.c:1052`. Records the
127/// `locallevel` at the most recent fork point (set at c:1221:
128/// `forklevel = locallevel;` inside `entersubsh()`). Used by:
129/// - `signals.c:808` SIGPIPE handler — `!forklevel` distinguishes
130/// the top-level shell from a forked subshell.
131/// - `exec.c:6146` — `if (locallevel > forklevel)` decides whether
132/// a function-defined trap should fire on this subshell exit.
133/// - `params.c:3724` — WARNCREATEGLOBAL nest-depth check.
134///
135/// Initialised to 0 (no fork has occurred yet). Set to `locallevel`
136/// at every `entersubsh()` entry per c:1221.
137pub static FORKLEVEL: std::sync::atomic::AtomicI32 = // c:1052 (Src/exec.c)
138 std::sync::atomic::AtomicI32::new(0);
139
140// ───────────────────────────────────────────────────────────────────────────
141// fusevm VM bridge (extension; not a port of Src/exec.c) lives in
142// src/fusevm_bridge.rs. The bridge re-exports the symbols that the
143// rest of the codebase imports as `crate::ported::exec::X`.
144// ───────────────────────────────────────────────────────────────────────────
145pub use crate::fusevm_bridge::*;
146pub(crate) use crate::fusevm_bridge::{ExecutorContext};
147
148/// `ZSH_VERSION` / `ZSH_PATCHLEVEL` / `ZSH_VERSION_DATE` consts
149/// generated by `build.rs` from `src/zsh/Config/version.mk`. Use
150/// `zsh_version::ZSH_VERSION` etc. at call sites so version bumps
151/// pick up automatically.
152pub mod zsh_version {
153 include!(concat!(env!("OUT_DIR"), "/zsh_version.rs"));
154}
155
156/// Convert a here-document into a here-string. Line-by-line port of
157/// `gethere()` from `Src/exec.c:4569-4652`. Reads the body from the
158/// input stream via `hgetc()` until the terminator line is matched,
159/// returning the collected body as a string. `strp` is in/out: on
160/// entry the raw terminator (possibly with token markers + leading
161/// tabs); on return the munged terminator (after `quotesubst` +
162/// `untokenize` and, for `REDIR_HEREDOCDASH`, leading-tab strip).
163///
164/// Returns `None` on out-of-memory (C `zalloc`/`realloc` failure).
165/// Rust's `String` auto-grows so the OOM branch is effectively
166/// unreachable, but the return type stays `Option<String>` to mirror
167/// the C signature which can return NULL.
168///
169/// Port of `gethere(char **strp, int typ)` from `Src/exec.c:4573`.
170pub fn gethere(strp: &mut String, typ: i32) -> Option<String> { // c:4573 (Src/exec.c)
171 let mut buf: String; // c:4575 char *buf
172 let mut bsiz: usize; // c:4576 int bsiz
173 let mut qt: i32 = 0; // c:4576 int qt = 0
174 let mut strip: i32 = 0; // c:4576 int strip = 0
175 // c:4577 — char *s, *t, *bptr, c. zshrs uses byte-offsets into
176 // `buf` for `t` and tracks `bptr` implicitly as `buf.len()` (the
177 // C `bptr++` increment is `buf.push(c)`; `bptr--` is `buf.pop()`).
178 // `s` (the loop iterator for the inull-scan) stays local to its
179 // for-loop. `c` mirrors the C `char c`.
180 let mut t: usize; // c:4577 char *t
181 let mut c: Option<char>; // c:4577 char c
182 let mut str: String = strp.clone(); // c:4578 char *str = *strp
183
184 // c:4580-4584 — for (s = str; *s; s++) if (inull(*s)) { qt = 1; break; }
185 for s in str.bytes() {
186 if crate::ported::ztype_h::inull(s) { // c:4581
187 qt = 1; // c:4582
188 break; // c:4583
189 }
190 }
191 str = crate::ported::subst::quotesubst(&str); // c:4585
192 str = crate::ported::lex::untokenize(&str); // c:4586
193 if typ == crate::ported::zsh_h::REDIR_HEREDOCDASH { // c:4587
194 strip = 1; // c:4588
195 // c:4589-4590 — while (*str == '\t') str++;
196 while str.starts_with('\t') {
197 str.remove(0);
198 }
199 }
200 *strp = str.clone(); // c:4592 *strp = str
201
202 // c:4593 — bptr = buf = zalloc(bsiz = 256);
203 bsiz = 256;
204 buf = String::with_capacity(bsiz);
205 let _ = bsiz; // bsiz is tracked by C for zfree; Rust drops automatically
206
207 // c:4594 — for (;;)
208 loop {
209 t = buf.len(); // c:4595 t = bptr
210
211 // c:4597-4598 — while ((c = hgetc()) == '\t' && strip) ;
212 loop {
213 c = crate::ported::lex::hgetc();
214 if !(c == Some('\t') && strip != 0) {
215 break;
216 }
217 }
218
219 // c:4599 — for (;;) — inner body-read loop
220 loop {
221 // c:4600-4613 — buffer-growth realloc dance. Rust's
222 // String auto-grows; nothing to do.
223 // c:4614 — if (lexstop || c == '\n') break;
224 if crate::ported::lex::LEX_LEXSTOP.with(|f| f.get()) || c == Some('\n') || c.is_none() {
225 break;
226 }
227 // c:4616 — if (!qt && c == '\\')
228 if qt == 0 && c == Some('\\') {
229 buf.push('\\'); // c:4617 *bptr++ = c
230 c = crate::ported::lex::hgetc(); // c:4618
231 if c == Some('\n') { // c:4619
232 buf.pop(); // c:4620 bptr--
233 c = crate::ported::lex::hgetc(); // c:4621
234 continue; // c:4622
235 }
236 }
237 if let Some(ch) = c { // c:4625 *bptr++ = c
238 buf.push(ch);
239 }
240 c = crate::ported::lex::hgetc(); // c:4626
241 }
242 // c:4628 — *bptr = '\0'; (implicit — Rust String tracks len)
243
244 // c:4629-4630 — if (!strcmp(t, str)) break;
245 if &buf[t..] == str.as_str() {
246 break;
247 }
248 // c:4631-4634 — if (lexstop) { t = bptr; break; }
249 if crate::ported::lex::LEX_LEXSTOP.with(|f| f.get()) {
250 t = buf.len();
251 break;
252 }
253 // c:4635 — *bptr++ = '\n';
254 buf.push('\n');
255 }
256 // c:4637 — *t = '\0';
257 buf.truncate(t);
258
259 // c:4638-4640 — s = buf; buf = dupstring(buf); zfree(s, bsiz);
260 // The C dance frees the realloc'd block and re-allocates via the
261 // string-heap allocator. Rust drops the old String when reassigned.
262 buf = crate::ported::mem::dupstring(&buf);
263
264 if qt == 0 { // c:4641
265 // c:4642 — int ef = errflag;
266 let ef = errflag.load(Ordering::Relaxed);
267 // c:4644 — parsestr(&buf);
268 if let Ok(parsed) = crate::ported::lex::parsestr(&buf) {
269 buf = parsed;
270 }
271 // c:4646-4649 — if (!(errflag & ERRFLAG_ERROR)) errflag = ef | (errflag & ERRFLAG_INT);
272 if (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) == 0 {
273 let cur = errflag.load(Ordering::Relaxed);
274 errflag.store(ef | (cur & crate::ported::zsh_h::ERRFLAG_INT), Ordering::Relaxed);
275 }
276 }
277 Some(buf) // c:4651 return buf
278}
279
280/// Free-function wrapper for `getoutput()` from `Src/exec.c:4712`.
281/// Runs a command-substitution body in the active executor and
282/// returns its captured stdout. The C signature is `LinkList
283/// getoutput(char *cmd, int qt)` but every caller in subst.rs
284/// joins the list back into a string, so the Rust port collapses
285/// the intermediate.
286///
287/// Uses `with_executor` (panics on missing VM context), not
288/// `try_with_executor + unwrap_or_default()`. C `getoutput` calls
289/// `execpline` directly — there's no "no shell" code path. The
290/// silent-no-op pattern (return empty string when no executor) would
291/// mask catastrophic state corruption as "command produced no output",
292/// which is the failure mode the `subst.rs:496` warning block flags.
293pub fn getoutput(cmd: &str) -> String { // c:4712 (Src/exec.c)
294 with_executor(|exec| exec.run_command_substitution(cmd))
295}
296
297/// Match an intercept pattern against a command name or full command string.
298/// Supports: exact match, glob ("git *", "_*", "*"), or "all".
299
300/// Get or compile a regex, caching the result
301pub(crate) fn cached_regex(pattern: &str) -> Option<regex::Regex> {
302 let mut cache = REGEX_CACHE.lock();
303 if let Some(re) = cache.get(pattern) {
304 return Some(re.clone());
305 }
306 match regex::Regex::new(pattern) {
307 Ok(re) => {
308 cache.insert(pattern.to_string(), re.clone());
309 Some(re)
310 }
311 Err(_) => None,
312 }
313}
314
315/// O(1) builtin-name lookup set derived from the canonical
316/// `BUILTINS` table (`src/ported/builtin.rs:122`, the 1:1 port of
317/// `static struct builtin builtins[]` at `Src/builtin.c:40-137`).
318/// Earlier incarnation hardcoded a separate 130-entry list which
319/// drifted whenever new builtins landed in the canonical table — and
320/// shadowed the `fusevm::shell_builtins::BUILTIN_SET` u16 opcode
321/// constant. Renaming to `BUILTIN_NAMES` removes the shadow; the
322/// initialiser walks `BUILTINS` so the set stays in sync.
323///
324/// The hardcoded entries inside `LazyLock::new` below are kept as
325/// the union of: (1) names from `BUILTINS` (walked at first access),
326/// (2) zshrs daemon-side builtins from `ZSHRS_BUILTIN_NAMES`. Both
327/// arms run once at static init.
328pub(crate) static BUILTIN_NAMES: LazyLock<HashSet<String>> = LazyLock::new(|| {
329 let mut s: HashSet<String> = HashSet::new();
330 // Walk the canonical `BUILTINS` table — the 1:1 port of
331 // `static struct builtin builtins[]` at `Src/builtin.c:40-137`
332 // (ported at `src/ported/builtin.rs:122`). Every name in there is
333 // a real zsh builtin; the set stays in sync as new ports land.
334 for b in crate::ported::builtin::BUILTINS.iter() {
335 s.insert(b.node.nam.clone());
336 }
337 // Daemon-side (zshrs-specific extensions).
338 for &n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.iter() {
339 s.insert(n.to_string());
340 }
341 s
342});
343
344/// Slice an array per zsh `${arr:offset[:length]}` semantics: the
345/// offset is 0-based "skip N elements" (so `${arr:1:2}` returns
346/// elements at indices 1,2). Negative offset counts from the end.
347/// `length < 0` means "to the end".
348pub(crate) fn slice_array_zero_based(arr: &[String], offset: i64, length: i64) -> Vec<String> {
349 let n = arr.len() as i64;
350 if n == 0 {
351 return Vec::new();
352 }
353 let start = if offset < 0 {
354 (n + offset).max(0) as usize
355 } else {
356 (offset as usize).min(arr.len())
357 };
358 let take = if length < 0 {
359 arr.len().saturating_sub(start)
360 } else {
361 (length as usize).min(arr.len().saturating_sub(start))
362 };
363 arr.iter().skip(start).take(take).cloned().collect()
364}
365
366/// Same shape but for positional params (`@`/`*`). zsh treats
367/// position 0 as `$0` (the script/shell name). For `${@:0}` it
368/// includes `$0`; for `${@:1}` it skips `$0` and starts at `$1`.
369/// Internally `positional_params[0]` is `$1`, so we prepend `$0`
370/// then slice 0-based.
371pub(crate) fn slice_positionals(exec: &ShellExecutor, offset: i64, length: i64) -> Vec<String> {
372 let pp = exec.pparams();
373 let mut all: Vec<String> = Vec::with_capacity(pp.len() + 1);
374 all.push(
375 exec.scalar("0")
376 .unwrap_or_else(|| std::env::args().next().unwrap_or_default()),
377 );
378 for p in pp {
379 all.push(p);
380 }
381 slice_array_zero_based(&all, offset, length)
382}
383
384use crate::exec_jobs::{JobState, JobTable};
385use crate::parse::{Redirect, RedirectOp, ShellCommand, ShellWord, VarModifier, ZshParamFlag};
386use crate::zwc::ZwcFile;
387use indexmap::IndexMap;
388use std::collections::HashMap;
389use std::env;
390use std::fs::{File, OpenOptions};
391use std::io;
392use std::path::{Path, PathBuf};
393use std::process::{Child, Command, Stdio};
394
395
396// Drift structs moved to their canonical-C-file modules
397// (src/ported/zle/computil.rs, modules/{zutil,zpty,zprof,socket}.rs,
398// builtins/sched.rs). Re-exported here so existing call-sites that
399// reference `crate::ported::exec::<Name>` keep compiling.
400pub use crate::bash_complete::{CompSpec, CompMatch, CompGroup, CompState};
401pub use crate::ported::modules::zutil::zstyle_entry;
402// `ProfileEntry` re-export deleted — was unused outside
403// `ShellExecutor::profile_data` (which itself is now removed).
404// `ScheduledCommand` (Rust-only) deleted; use `crate::builtins::sched::schedcmd`
405// (port of `struct schedcmd` from Src/Builtins/sched.c:43) for live state.
406pub use crate::ported::builtin::AutoloadFlags;
407
408
409/// Cross-VM loop-control signal. When `break`/`continue` is hit inside a body
410/// that runs on a sub-VM (e.g. select's body), the inline patches mechanism
411/// can't reach the outer loop — set this flag and the outer-loop builtin
412/// drains it after each iteration.
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414/// Loop control signal from a command body.
415/// Mirrors the `LF_*` set Src/loop.c uses to thread
416/// `break`/`continue`/`return` flags up through the executor.
417pub enum LoopSignal {
418 Break,
419 Continue,
420}
421
422/// Snapshot of subshell-isolated state. Captured at `(` entry, restored at
423/// `)` exit. zsh subshell semantics: assignments inside `(…)` don't leak to
424/// the outer scope — and that includes `export`. zsh forks a child for the
425/// subshell so the child's env::set_var dies with the child; without a fork
426/// (zshrs runs subshells in-process for perf), we snapshot+restore the OS
427/// env table around the subshell. Otherwise `(export y=v)` would leak `y`
428/// to the parent shell, breaking every script that uses a subshell to
429/// scope an env override.
430/// Snapshot of mutable executor state across a subshell
431/// boundary.
432/// Port of the `entersubsh()` save/restore Src/exec.c does at
433/// line 1084 — captures everything that must be replaced when a
434/// `(...)` group fires.
435pub struct SubshellSnapshot {
436 /// Snapshot of `paramtab` (the C-canonical parameter store) at
437 /// subshell entry. Step 1 of the unification mirrors writes to
438 /// paramtab, so subshell-scoped assignments now show up there
439 /// too — without this snapshot, restoring only `variables` /
440 /// `arrays` / `assoc_arrays` leaks the subshell's writes to the
441 /// parent via paramtab (e.g. `x=outer; (x=inner); echo $x` returned
442 /// `inner` because paramsubst reads through paramtab).
443 pub paramtab: HashMap<String, crate::ported::zsh_h::Param>,
444 pub paramtab_hashed_storage: HashMap<String, indexmap::IndexMap<String, String>>,
445 pub positional_params: Vec<String>,
446 pub env_vars: HashMap<String, String>,
447 /// Process working directory at subshell entry. `cd` inside the
448 /// subshell shouldn't leak to the parent; we restore on End.
449 pub cwd: Option<std::path::PathBuf>,
450 /// File-creation mask at subshell entry. zsh forks for `(...)` so
451 /// `umask` set inside dies with the child; we run subshells in
452 /// process so we must restore the mask on End. Otherwise
453 /// `umask 022; (umask 077); umask` shows 077 in the parent.
454 pub umask: u32,
455 /// Parent's traps at subshell entry. zsh's `(trap "echo X" EXIT;
456 /// true)` runs the trap when the subshell exits — BEFORE the parent
457 /// continues. Without this snapshot, the trap inherited from parent
458 /// would fire, OR a trap set inside the subshell would leak to the
459 /// parent's process exit. Restored on subshell_end after the
460 /// subshell's own EXIT trap (if any) has fired.
461 pub traps: HashMap<String, String>,
462}
463
464/// Variable attribute record + kind enum — moved to params.rs.
465
466
467// Pattern helpers moved to src/ported/pattern.rs.
468#[allow(unused_imports)]
469pub(crate) use crate::ported::pattern::{
470 extract_numeric_ranges, numeric_range_contains, numeric_ranges_to_star,
471};
472
473// `impl VarAttr` moved to src/ported/params.rs.
474
475/// Top-level shell executor state.
476/// Port of the file-static globals + `Estate` chain Src/exec.c
477/// uses — `execlist()` (line 1349) drives every list, with
478/// `execpline()` (line 1668), `execpline2()` (line 1991),
479/// `execsimple()` (line 1290), and the per-`WC_*` `execfuncs[]`
480/// table (line 268) feeding off it. The Rust port collapses
481/// everything into one `ShellExecutor` so we don't need
482/// thread-local globals.
483pub struct ShellExecutor {
484 /// Mirrors C zsh's file-static `scriptname` (Src/init.c). Used by
485 /// PS4's `%N` and the `scriptname:line: …` prefix on error
486 /// messages. Inside a function, MUTATES to the function name
487 /// (Src/exec.c:5903 `scriptname = dupstring(name)`). Init sets
488 /// this in `-c` mode to the binary basename per init.c:479; when
489 /// sourcing a file via `source`/`bin_dot`, it becomes the
490 /// resolved file path; otherwise it falls back through `$0` →
491 /// `$ZSH_ARGZERO`.
492 pub scriptname: Option<String>,
493 /// Mirrors C zsh's `scriptfilename` global (Src/init.c). Tracks
494 /// the FILE BEING READ (vs scriptname which tracks the active
495 /// function name during a call). Used by PS4's `%x` and certain
496 /// error-message prefixes that want the file location, NOT the
497 /// function name.
498 ///
499 /// At -c-mode init, scriptname == scriptfilename == "zsh"
500 /// (Src/init.c:479). When entering a function, ONLY scriptname
501 /// updates (exec.c:5903); scriptfilename stays at the outer
502 /// file path, so `%x` inside a function still shows the file
503 /// the function was called from.
504 pub scriptfilename: Option<String>,
505 // `expanding_aliases` deleted — was a Rust-only HashSet recursion
506 // guard duplicating C's `alias.inuse` field (`Src/zsh.h:1256`).
507 // Callers now bump/clear `inuse` on the canonical alias node in
508 // `aliastab` (`hashtable.rs:1804`), matching C's lexer behavior.
509 /// Set by `break`/`continue` keywords when no enclosing loop in the
510 /// current chunk's patch lists. Outer-loop builtins (BUILTIN_RUN_SELECT)
511 /// observe + clear this after each body run.
512 pub loop_signal: Option<LoopSignal>,
513 /// Stack of subshell-state snapshots. Each `(…)` subshell pushes a copy
514 /// of variables/arrays/assoc_arrays at entry and pops/restores at exit.
515 /// Without this, `(x=inner; …); echo $x` shows `inner` instead of the
516 /// outer-scope value.
517 pub subshell_snapshots: Vec<SubshellSnapshot>,
518 /// Stack of inline-assignment scopes — `X=foo Y=bar cmd` pushes
519 /// a frame at the start, the assigns run inside it, and `cmd`
520 /// returns into END_INLINE_ENV which restores both shell-vars
521 /// and process-env to the pre-frame state. Each frame holds
522 /// `(name, prev_var, prev_env)` per assigned name. zsh's
523 /// equivalent is the parser-level "addvar" list executed under
524 /// `addvars()` (Src/exec.c) right before the command exec.
525 pub inline_env_stack: Vec<Vec<(String, Option<String>, Option<String>)>>,
526 /// Set by `expand_glob`'s no-match arm when `nomatch` is on (zsh
527 /// default) — instructs the simple-command dispatcher to skip
528 /// executing the current command, set last_status=1, and continue
529 /// to the next command in the script. zsh's bin_simple uses the
530 /// errflag global for the same role: error printed, command
531 /// suppressed, script continues. Without this we were calling
532 /// `process::exit(1)` deep inside expand_glob, killing the whole
533 /// shell on any unmatched glob even with multi-statement input.
534 /// `Cell` because the no-match site only has a `&self` borrow.
535 pub current_command_glob_failed: std::cell::Cell<bool>,
536 pub jobs: JobTable,
537 pub fpath: Vec<PathBuf>,
538 pub zwc_cache: HashMap<PathBuf, ZwcFile>,
539 pub history: Option<HistoryEngine>,
540 /// Session-relative history line counter. Starts at 0; incremented
541 /// when an interactive command is recorded. Used by `%h`/`%!` in
542 /// prompt expansion (zsh's "current history line number"), distinct
543 /// from the persistent disk history total.
544 pub session_histnum: i64,
545 pub(crate) process_sub_counter: u32,
546 pub traps: HashMap<String, String>,
547 // `options` field deleted — dup of canonical `OPTS_LIVE` in
548 // `src/ported/options.rs:1112`. Callers route through
549 // `opt_state_get`/`opt_state_set`/`opt_state_unset`/`opt_state_snapshot`.
550 pub completions: HashMap<String, CompSpec>, // command -> completion spec
551 // `dir_stack` field deleted — canonical `DIRSTACK` lives in
552 // `modules/parameter.rs:398` (mirror of C `dirstack` global at
553 // `Src/builtin.c:1456`). Callers go through that Mutex directly.
554 // zsh completion system state
555 pub comp_matches: Vec<CompMatch>, // Current completion matches
556 pub comp_groups: Vec<CompGroup>, // Completion groups
557 pub comp_state: CompState, // compstate associative array
558 pub zstyles: Vec<zstyle_entry>, // zstyle configurations
559 pub comp_words: Vec<String>, // words on command line
560 pub comp_current: i32, // current word index (1-based)
561 pub comp_prefix: String, // PREFIX parameter
562 pub comp_suffix: String, // SUFFIX parameter
563 pub comp_iprefix: String, // IPREFIX parameter
564 pub comp_isuffix: String, // ISUFFIX parameter
565 // `readonly_vars` deleted — was a never-populated HashSet
566 // duplicating the canonical `PM_READONLY` flag check on Param
567 // (`zsh_h::PM_READONLY` bit on `Param.node.flags`). Callers go
568 // through `is_readonly_param(name)`.
569 // `last_subst` deleted — 0 callers. Canonical `hsubl`/`hsubr`
570 // globals live in `Src/hist.c` and are ported on demand when
571 // `:&` history-modifier replay arrives in zshrs.
572 // `sub_flags` deleted — zero real callers; canonical lives in
573 // `SUB_FLAGS` thread_local at `src/ported/subst.rs:498` (`sub_flags`
574 // global in `Src/subst.c:2169`), accessed via `sub_flags_get` /
575 // `sub_flags_set`.
576 /// Current function scope depth for `local` tracking.
577 pub local_scope_depth: usize,
578 /// Last arg of the currently-running command, deferred into `$_`
579 /// when the next command dispatches. zsh: `$_` reflects the LAST
580 /// command's last arg, so `echo hi; echo $_` prints `hi` (not the
581 /// `_` arg of `echo $_` itself). Promoted in `pop_args` and
582 /// `host.exec` before the command's args are read.
583 pub pending_underscore: Option<String>,
584 /// True while expanding inside a double-quoted context. Set by
585 /// `BUILTIN_EXPAND_TEXT` mode 1 around `expand_string` calls.
586 /// Used by parameter-flag application to suppress array-only flags
587 /// (`(o)`/`(O)`/`(n)`/`(i)`/`(M)`/`(u)`) — zsh's behaviour: those
588 /// flags only fire in array context.
589 pub in_dq_context: u32,
590 // `in_paramsubst_nest` deleted — canonical lives in
591 // `IN_PARAMSUBST_NEST` thread_local at `subst.rs:464` (mirrors
592 // `paramsub_nest` global in `Src/subst.c`). Callers read it
593 // directly via `crate::ported::subst::IN_PARAMSUBST_NEST.with(...)`.
594 /// True (>0) while expanding the RHS of a scalar assignment.
595 /// Direct port of zsh's `PREFORK_SINGLE` bit set by
596 /// Src/exec.c::addvars line 2546 (`prefork(vl, isstr ?
597 /// (PREFORK_SINGLE|PREFORK_ASSIGN) : PREFORK_ASSIGN, ...)`).
598 /// Subst_port's paramsubst reads this via `ssub` and suppresses
599 /// `(f)` / `(s:STR:)` / `(0)` / `(z)` split flags per
600 /// Src/subst.c:1759 + 3902, so `y="${(f)x}"` preserves x's
601 /// original separator (newlines) instead of re-joining with
602 /// IFS-first-char (space).
603 pub in_scalar_assign: u32,
604 // `cmd_stack` deleted — duplicated the canonical `prompt::CMDSTACK`
605 // thread_local (`Src/prompt.c:56 unsigned char *cmdstack`).
606 // `BUILTIN_CMD_PUSH`/`BUILTIN_CMD_POP` now call `cmdpush`/`cmdpop`
607 // on the canonical TLS only; prompt expansion reads it directly.
608 /// IDs of history entries explicitly added during this session
609 /// via `print -s`. `fc -l` uses this to scope listings to just
610 /// the script-added entries (matches zsh's `-c` semantics where
611 /// session history is the only thing visible to the script).
612 pub session_history_ids: Vec<i64>,
613 // `autoload_pending` deleted — dup of canonical shfunctab entries
614 // with PM_UNDEFINED flag bit (port of C autoload_func stub at
615 // `Src/exec.c:5215`). The -U/-z/-k/-t/-d AutoloadFlags details
616 // were never consumed beyond serialization, dropped along with
617 // the field.
618 // `hook_functions` deleted — Rust-only side-store duplicating zsh's
619 // canonical `<hook>_functions` paramtab arrays (the add-zsh-hook
620 // idiom). `add_hook` / `delete_hook` now mutate those arrays
621 // directly via `setaparam`.
622 // `named_dirs` deleted — canonical `nameddirtab` lives in
623 // `src/ported/hashnameddir.rs:36` (port of C `nameddirtab` in
624 // `Src/hashnameddir.c`). Callers route through that Mutex.
625 // bin_sysopen - file descriptor management
626 pub open_fds: HashMap<i32, std::fs::File>,
627 pub next_fd: i32,
628 // sched (Src/Builtins/sched.c) — schedcmds list lives in module
629 // statics in the canonical port; nothing to carry on ShellExecutor.
630 // zprof — profiling data lives in `crate::zprof` module statics
631 // (CALLS/NCALLS/ARCS/NARCS/STACK), matching the C file-statics
632 // at zprof.c:66-71. Only the user's "is profiling on?" toggle
633 // stays here, set by the `profile` extension builtin.
634 pub profiling_enabled: bool,
635 // compsys - completion system cache
636 pub compsys_cache: Option<CompsysCache>,
637 // Background compinit — receiver for async fpath scan result
638 pub compinit_pending: Option<(
639 std::sync::mpsc::Receiver<CompInitBgResult>,
640 std::time::Instant,
641 )>,
642 // Plugin source cache — stores side effects of source/. in SQLite
643 pub plugin_cache: Option<crate::plugin_cache::PluginCache>,
644 // cdreplay - deferred compdef calls for zinit turbo mode
645 pub deferred_compdefs: Vec<Vec<String>>,
646 // `command_hash` deleted — never-populated dup of canonical
647 // `cmdnamtab` (`hashtable.rs:1780`, port of `Src/exec.c:5260`
648 // findcmd's hash table). Callers route through cmdnamtab.
649 // Control flow signals
650 pub returning: Option<i32>, // Set by return builtin, cleared after function returns
651 pub breaking: i32, // break level (0 = not breaking, N = break N levels)
652 pub continuing: i32, // continue level
653 // New module state — TcpSessions struct dissolved into the
654 // thread_local ZTCP_SESSIONS in modules/tcp.rs (matches C's
655 // file-static `ztcp_sessions` linked list).
656 // `zftp` field deleted — 0 callers. Module-level state lives in
657 // `ZFTP_STATE_INNER` (Src/Modules/zftp.c file-statics analogue).
658 // `profiler: Profiler` deleted — see comment above.
659 // `style_table` field deleted — 0 callers. Canonical `zstyletab`
660 // lives in `src/ported/modules/zutil.rs::zstyletab` (LazyLock
661 // Mutex matching C's `static HashTable zstyletab` at zutil.c:209).
662 // termcap state dissolved per strict-rules audit — no Rust-only
663 // Termcap struct; capability_lookup is stateless on $TERM.
664 // Watch state — dissolved per PORT_PLAN Phase 2. C
665 // (Src/Modules/watch.c:150-156) keeps `wtab`/`lastwatch`/
666 // `lastutmpcheck`/`watch` as file-statics; zshrs mirrors them
667 // as `thread_local!`s in src/ported/modules/watch.rs.
668 // curses (Src/Modules/curses.c) — windows/colour-pairs/init flag
669 // now live in module-static OnceLock<Mutex<…>>'s in
670 // src/ported/modules/curses.rs (matching C's file-statics
671 // `zcurses_windows`, `colorpairs`, `next_pair`).
672 // pty_cmds moved to PTYCMDS global static in src/ported/modules/
673 // zpty.rs (port of C `static struct ptycmd *ptycmds` file-static).
674 // sched: scheduled commands now live in `SCHEDCMDS` static in
675 // `src/ported/builtins/sched.rs` (port of `static struct schedcmd
676 // *schedcmds` from Src/Builtins/sched.c:52). No state on
677 // ShellExecutor.
678 /// zsh compatibility mode - use .zcompdump, fpath scanning, etc.
679 /// Also serves as the `--zsh` parity-test flag: caches off, daemon
680 /// off, plugin_cache replay off so every `source` re-runs the file
681 /// fresh per Src/builtin.c:6080-6123 bin_dot semantics.
682 pub zsh_compat: bool,
683 /// bash compatibility mode (`--bash`). Same parity-mode semantics
684 /// as `zsh_compat` (caches/daemon/replay off) plus bash-specific
685 /// behavior tweaks where bash 5.x diverges from zsh — e.g.
686 /// `BASH_VERSION` / `BASH_REMATCH` exposed, `[[ =~ ]]` populates
687 /// match indices the bash way, mapfile/readarray as builtins.
688 pub bash_compat: bool,
689 /// POSIX sh strict mode — no SQLite, no worker pool, no zsh extensions
690 pub posix_mode: bool,
691 /// Worker thread pool for background tasks (compinit, process subs, etc.)
692 pub worker_pool: std::sync::Arc<crate::worker::WorkerPool>,
693 /// AOP intercept table: command/function name → advice chain.
694 /// Glob patterns supported (e.g. "git *", "*").
695 pub intercepts: Vec<Intercept>,
696 /// Async job handles: id → receiver for (status, stdout)
697 pub async_jobs: HashMap<u32, crossbeam_channel::Receiver<(i32, String)>>,
698 /// Next async job ID
699 pub next_async_id: u32,
700 /// Defer stack: commands to run on scope exit (LIFO).
701 pub defer_stack: Vec<Vec<String>>,
702 /// Per-scope saved-fd stacks for `Op::WithRedirectsBegin/End`. Each entry
703 /// is a Vec of (fd, saved_dup_fd) pairs taken from `dup(fd)` before the
704 /// redirect was applied; `with_redirects_end` `dup2`s them back and closes.
705 pub redirect_scope_stack: Vec<Vec<(i32, i32)>>,
706 /// Set by `host_apply_redirect` when a redirect target couldn't be
707 /// opened (permission denied, no such directory, etc). The next
708 /// builtin/command checks this at entry and short-circuits with
709 /// status 1 instead of running. Mirrors zsh's "command skip" on
710 /// redirect failure.
711 pub redirect_failed: bool,
712 /// Stdin content set by `Op::HereDoc(idx)` / `Op::HereString` for the next
713 /// command/builtin in this VM. Consumed (and cleared) by the next command.
714 pub pending_stdin: Option<String>,
715 /// Compiled function bodies — name → fusevm::Chunk. Populated by
716 /// `BUILTIN_REGISTER_FUNCTION` (from `FunctionDef` lowering) and lazily by
717 /// `ZshrsHost::call_function` when only an AST exists in `self.functions`
718 /// (autoloaded, sourced, etc.). `Op::CallFunction` dispatches through here.
719 pub functions_compiled: HashMap<String, fusevm::Chunk>,
720 /// Canonical source text for functions. Populated by autoload paths (the
721 /// raw file/cache body), runtime FuncDef compile (the parsed source span),
722 /// and `unfunction` removal. Used by introspection (`whence`, `which`,
723 /// `typeset -f`) instead of reconstructing from a ShellCommand AST. When a
724 /// function is in `functions_compiled` but not here, introspection falls
725 /// back to `text::getpermtext(self.functions[name])`.
726 pub function_source: HashMap<String, String>,
727 /// `first_body_line - 1` per compiled function — matches inner
728 /// `ZshCompiler::lineno_offset` / zsh `funcstack->flineno` combined with
729 /// relative `$LINENO` for Src/prompt.c:909 `%I`.
730 pub function_line_base: HashMap<String, i64>,
731 /// `scriptfilename` when `BUILTIN_REGISTER_COMPILED_FN` ran — `%x` inside
732 /// a function (prompt.c:931-934) reads `funcstack->filename`.
733 pub function_def_file: HashMap<String, Option<String>>,
734 /// Innermost-last stack of active compiled-call frames for prompt `%I` / `%x`.
735 pub prompt_funcstack: Vec<(String, i64, Option<String>)>,
736 /// Scalar→(array, sep) tie table set up by `typeset -T VAR var [SEP]`.
737 /// Used by BUILTIN_SET_VAR to split the assigned scalar on `sep` and
738 /// mirror it into `array`.
739 pub tied_scalar_to_array: HashMap<String, (String, String)>,
740 /// Array→(scalar, sep) reverse-tie table. Used by BUILTIN_SET_ARRAY to
741 /// join the array elements with `sep` and mirror to the scalar side.
742 pub tied_array_to_scalar: HashMap<String, (String, String)>,
743 /// ZLE buffer stack — port of `bufstack` (zsh/Src/builtin.c:4567,
744 /// `LinkList bufstack`). `print -z` (builtin.c:5039-5045) pushes
745 /// joined args onto it; `read -z` and `getln` (builtin.c:6769-6770)
746 /// pop the top entry as the input source. zsh treats this as a stack
747 /// shared between the buffer/zle subsystem and the read path.
748 pub buffer_stack: Vec<String>,
749}
750
751impl ShellExecutor {
752 /// Set a scalar parameter via the canonical `paramtab`
753 /// (`Src/params.c:3350 setsparam`). The single store.
754 pub fn set_scalar(&mut self, name: String, value: String) {
755 crate::ported::params::setsparam(&name, &value); // c:params.c:3350
756 }
757
758 /// Read positional parameters from canonical `PPARAMS`
759 /// `Mutex<Vec<String>>` (Src/init.c:pparams). The single store.
760 pub fn pparams(&self) -> Vec<String> {
761 crate::ported::builtin::PPARAMS
762 .lock()
763 .map(|p| p.clone())
764 .unwrap_or_default()
765 }
766
767 /// Write positional parameters to canonical `PPARAMS`.
768 pub fn set_pparams(&mut self, params: Vec<String>) {
769 if let Ok(mut p) = crate::ported::builtin::PPARAMS.lock() {
770 *p = params;
771 }
772 }
773
774 /// Read PM_* type flags from the paramtab Param entry. Used by
775 /// SET_VAR / `+=` arms (case-fold, integer-add, readonly guard)
776 /// instead of the legacy `exec.var_attrs` HashMap. Returns 0 when
777 /// the name isn't in paramtab. Mirrors the C source's direct
778 /// `pm->node.flags & PM_INTEGER` checks.
779 pub fn param_flags(&self, name: &str) -> i32 {
780 crate::ported::params::paramtab().read()
781 .ok()
782 .and_then(|t| t.get(name).map(|p| p.node.flags))
783 .unwrap_or(0)
784 }
785
786 /// `typeset -i name` — Param has PM_INTEGER. Reads via
787 /// `param_flags`.
788 pub fn is_integer_param(&self, name: &str) -> bool {
789 (self.param_flags(name) as u32 & crate::ported::zsh_h::PM_INTEGER) != 0
790 }
791
792 /// `typeset -F` / `-E` — float.
793 pub fn is_float_param(&self, name: &str) -> bool {
794 let f = self.param_flags(name) as u32;
795 (f & (crate::ported::zsh_h::PM_EFLOAT | crate::ported::zsh_h::PM_FFLOAT)) != 0
796 }
797
798 /// `typeset -l` — Param has PM_LOWER.
799 pub fn is_lowercase_param(&self, name: &str) -> bool {
800 (self.param_flags(name) as u32 & crate::ported::zsh_h::PM_LOWER) != 0
801 }
802
803 /// `typeset -u` — Param has PM_UPPER.
804 pub fn is_uppercase_param(&self, name: &str) -> bool {
805 (self.param_flags(name) as u32 & crate::ported::zsh_h::PM_UPPER) != 0
806 }
807
808 /// `readonly` / `typeset -r` — Param has PM_READONLY.
809 pub fn is_readonly_param(&self, name: &str) -> bool {
810 (self.param_flags(name) as u32 & crate::ported::zsh_h::PM_READONLY) != 0
811 }
812
813 /// Most-recent-command exit status. Reads canonical
814 /// `builtin::LASTVAL` AtomicI32 (`Src/builtin.c:6443`).
815 pub fn last_status(&self) -> i32 {
816 crate::ported::builtin::LASTVAL
817 .load(std::sync::atomic::Ordering::Relaxed)
818 }
819
820 /// Write the most-recent-command exit status. The canonical
821 /// store is `builtin::LASTVAL`; this is the single setter.
822 /// Used everywhere `$?` / `%?` / errexit / ZERR trap read.
823 pub fn set_last_status(&mut self, status: i32) {
824 crate::ported::builtin::LASTVAL
825 .store(status, std::sync::atomic::Ordering::Relaxed);
826 }
827
828 /// Set an indexed array parameter via canonical paramtab
829 /// (`setaparam`, `Src/params.c:3595`). The single store.
830 pub fn set_array(&mut self, name: String, value: Vec<String>) {
831 crate::ported::params::setaparam(&name, value); // c:params.c:3595
832 }
833
834 /// Set an associative array parameter via canonical
835 /// `sethparam` (`Src/params.c:3602`). The single store.
836 pub fn set_assoc(&mut self, name: String, value: indexmap::IndexMap<String, String>) {
837 let mut flat: Vec<String> = Vec::with_capacity(value.len() * 2);
838 for (k, v) in &value {
839 flat.push(k.clone());
840 flat.push(v.clone());
841 }
842 crate::ported::params::sethparam(&name, flat); // c:params.c:3602
843 }
844
845 /// Read a scalar parameter. Mirrors C `getsparam` at
846 /// `Src/params.c:3076` — reads through paramtab, falls back to
847 /// special-var hooks and env.
848 pub fn scalar(&self, name: &str) -> Option<String> {
849 crate::ported::params::getsparam(name)
850 }
851
852 /// Read an array parameter from canonical paramtab. Mirrors C
853 /// `getaparam` at `Src/params.c:3100` — `paramtab->getnode(s)`
854 /// then `pm->u.arr.clone()`. Returns an owned `Vec<String>`.
855 pub fn array(&self, name: &str) -> Option<Vec<String>> {
856 crate::ported::params::paramtab().read()
857 .ok()
858 .and_then(|t| t.get(name).and_then(|pm| pm.u_arr.clone()))
859 }
860
861 /// Read an associative array parameter from canonical
862 /// `paramtab_hashed_storage`. Mirrors C `gethparam` at
863 /// `Src/params.c:3115` — returns the typed `IndexMap`.
864 pub fn assoc(&self, name: &str) -> Option<indexmap::IndexMap<String, String>> {
865 crate::ported::params::paramtab_hashed_storage()
866 .lock().ok()
867 .and_then(|m| m.get(name).cloned())
868 }
869
870 /// Test whether a scalar parameter exists in paramtab.
871 /// Mirrors the C `paramtab->getnode(name) != NULL` check.
872 pub fn has_scalar(&self, name: &str) -> bool {
873 crate::ported::params::getsparam(name).is_some()
874 }
875
876 /// Test whether an array parameter exists in paramtab.
877 pub fn has_array(&self, name: &str) -> bool {
878 crate::ported::params::paramtab().read()
879 .ok()
880 .and_then(|t| t.get(name).map(|pm| pm.u_arr.is_some()))
881 .unwrap_or(false)
882 }
883
884 /// Test whether an associative array parameter exists. Reads
885 /// canonical `paramtab_hashed_storage` (Src/params.c hashed
886 /// PM_HASHED slot).
887 pub fn has_assoc(&self, name: &str) -> bool {
888 crate::ported::params::paramtab_hashed_storage()
889 .lock()
890 .ok()
891 .map(|m| m.contains_key(name))
892 .unwrap_or(false)
893 }
894
895 /// Unset an associative array parameter from canonical paramtab
896 /// + paramtab_hashed_storage. Direct port of `unsetparam_pm`
897 /// for a PM_HASHED Param.
898 pub fn unset_assoc(&mut self, name: &str) {
899 if let Some(tab) = crate::ported::params::paramtab().write().ok().as_deref_mut() {
900 tab.remove(name);
901 }
902 let _ = crate::ported::params::paramtab_hashed_storage()
903 .lock().ok().as_deref_mut()
904 .map(|m| m.remove(name));
905 }
906
907 /// Read a regular (non-global) alias value. Reads canonical
908 /// `aliastab` (Src/hashtable.c:1186). Filters out aliases that
909 /// have the ALIAS_GLOBAL flag set so the regular-alias slot is
910 /// distinct from the global-alias slot, mirroring C's two
911 /// separate dispatch paths via `aliasflags` checks.
912 pub fn alias(&self, name: &str) -> Option<String> {
913 let tab = crate::ported::hashtable::aliastab_lock().read().ok()?;
914 let a = tab.get(name)?;
915 if (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0 {
916 None
917 } else {
918 Some(a.text.clone())
919 }
920 }
921
922 /// Read a global alias value (`alias -g`). Reads canonical
923 /// `aliastab` and filters to entries with the ALIAS_GLOBAL flag.
924 pub fn global_alias(&self, name: &str) -> Option<String> {
925 let tab = crate::ported::hashtable::aliastab_lock().read().ok()?;
926 let a = tab.get(name)?;
927 if (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0 {
928 Some(a.text.clone())
929 } else {
930 None
931 }
932 }
933
934 /// Read a suffix alias value (`alias -s`). Reads canonical
935 /// `sufaliastab` (Src/hashtable.c:1187).
936 pub fn suffix_alias(&self, name: &str) -> Option<String> {
937 let tab = crate::ported::hashtable::sufaliastab_lock().read().ok()?;
938 Some(tab.get(name)?.text.clone())
939 }
940
941 /// Set a regular alias. Writes canonical aliastab with
942 /// ALIAS_GLOBAL bit cleared.
943 pub fn set_alias(&mut self, name: String, value: String) {
944 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
945 tab.add(crate::ported::hashtable::createaliasnode(&name, &value, 0));
946 }
947 }
948
949 /// Set a global alias (`alias -g`). Writes canonical aliastab
950 /// with ALIAS_GLOBAL bit set.
951 pub fn set_global_alias(&mut self, name: String, value: String) {
952 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
953 tab.add(crate::ported::hashtable::createaliasnode(
954 &name, &value, crate::ported::zsh_h::ALIAS_GLOBAL as u32,
955 ));
956 }
957 }
958
959 /// Set a suffix alias (`alias -s ext=cmd`). Writes canonical
960 /// sufaliastab.
961 pub fn set_suffix_alias(&mut self, name: String, value: String) {
962 if let Ok(mut tab) = crate::ported::hashtable::sufaliastab_lock().write() {
963 tab.add(crate::ported::hashtable::createaliasnode(&name, &value, 0));
964 }
965 }
966
967 /// Unset an alias from canonical aliastab (any flag). Mirrors
968 /// C's `unalias` lookup.
969 pub fn unset_alias(&mut self, name: &str) {
970 if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
971 tab.remove(name);
972 }
973 }
974
975 /// Unset a suffix alias.
976 pub fn unset_suffix_alias(&mut self, name: &str) {
977 if let Ok(mut tab) = crate::ported::hashtable::sufaliastab_lock().write() {
978 tab.remove(name);
979 }
980 }
981
982 /// Snapshot the alias map as a sorted `Vec<(name, value)>`,
983 /// only entries WITHOUT the ALIAS_GLOBAL flag (regular aliases).
984 pub fn alias_entries(&self) -> Vec<(String, String)> {
985 if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
986 tab.iter_sorted()
987 .into_iter()
988 .filter(|(_, a)| (a.node.flags
989 & crate::ported::zsh_h::ALIAS_GLOBAL as i32) == 0)
990 .map(|(k, a)| (k.clone(), a.text.clone()))
991 .collect()
992 } else {
993 Vec::new()
994 }
995 }
996
997 /// Snapshot the global-alias entries (ALIAS_GLOBAL flag set).
998 pub fn global_alias_entries(&self) -> Vec<(String, String)> {
999 if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
1000 tab.iter_sorted()
1001 .into_iter()
1002 .filter(|(_, a)| (a.node.flags
1003 & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0)
1004 .map(|(k, a)| (k.clone(), a.text.clone()))
1005 .collect()
1006 } else {
1007 Vec::new()
1008 }
1009 }
1010
1011 /// Snapshot the suffix-alias entries.
1012 pub fn suffix_alias_entries(&self) -> Vec<(String, String)> {
1013 if let Ok(tab) = crate::ported::hashtable::sufaliastab_lock().read() {
1014 tab.iter_sorted()
1015 .into_iter()
1016 .map(|(k, a)| (k.clone(), a.text.clone()))
1017 .collect()
1018 } else {
1019 Vec::new()
1020 }
1021 }
1022
1023 /// Unset an array parameter. Direct port of `unsetparam_pm` for
1024 /// a PM_ARRAY Param. Mirrors are kept for now while the field
1025 /// transitions.
1026 pub fn unset_array(&mut self, name: &str) {
1027 if let Some(tab) = crate::ported::params::paramtab().write().ok().as_deref_mut() {
1028 tab.remove(name);
1029 }
1030 }
1031
1032 /// Unset a scalar parameter from canonical paramtab. Narrower
1033 /// than `unset_var` which clears arrays + assocs too. Direct
1034 /// port of `Src/params.c:unsetparam_pm` for a scalar PM_TYPE.
1035 pub fn unset_scalar(&mut self, name: &str) {
1036 if let Some(tab) = crate::ported::params::paramtab().write().ok().as_deref_mut() {
1037 tab.remove(name);
1038 }
1039 }
1040
1041 /// Unset a parameter from every store. Mirrors the C
1042 /// `unsetparam_pm` semantics at `Src/params.c:3905`: clear the
1043 /// paramtab entry + the legacy HashMap caches + (for exported
1044 /// vars) the env entry. Callers that need to scope the unset to
1045 /// just one type pass through this single entry so paramtab and
1046 /// the HashMaps don't drift apart.
1047 pub(crate) fn unset_var(&mut self, name: &str) {
1048 if let Some(tab) = crate::ported::params::paramtab().write().ok().as_deref_mut() {
1049 tab.remove(name); // c:params.c:3900 paramtab removenode
1050 }
1051 let _ = crate::ported::params::paramtab_hashed_storage()
1052 .lock().ok().as_deref_mut()
1053 .map(|m| m.remove(name));
1054 }
1055
1056 /// Single-string substitution via the canonical pipeline. Snapshots
1057 /// the executor state into a `SubstState`, runs `singsub` from
1058 /// `Src/subst.c:514`, commits any side-effects (assigns inside
1059 /// `${var:=default}`, etc.) back to the executor.
1060 ///
1061 /// Replaces the bot-invented `expand_string` method that was deleted
1062 /// in the citation purge (180463e1e7). All call sites that previously
1063 /// did `exec.singsub(s)` now do `exec.singsub(s)` and route
1064
1065
1066 pub fn new() -> Self {
1067 tracing::debug!("ShellExecutor::new() initializing");
1068
1069 // Validate the inherited $PWD against the real cwd before any
1070 // builtin reads it as a logical-path base. Direct port of zsh's
1071 // ispwd() at src/zsh/Src/utils.c:809-829: $PWD is honored only
1072 // when it (a) is absolute, (b) stat's to the same dev+inode as
1073 // ".", and (c) contains no `.`/`..` components. Otherwise zsh
1074 // resets it to getcwd() (init.c:1247-1253).
1075 //
1076 // Without this check, a child process that inherits $PWD from
1077 // a parent run in a different directory (cargo test setting
1078 // current_dir(/tmp) but leaking PWD=/project/root) sees the
1079 // stale PWD and `cd .` later snaps the real cwd to wherever
1080 // PWD points, escaping the parent's sandbox. ztst harnesses
1081 // hit this and polluted the project root with test artifacts.
1082 if let Ok(pwd_env) = env::var("PWD") {
1083 let valid = ispwd(&pwd_env);
1084 if !valid {
1085 if let Ok(real) = env::current_dir() {
1086 env::set_var("PWD", &real);
1087 }
1088 }
1089 } else if let Ok(real) = env::current_dir() {
1090 env::set_var("PWD", &real);
1091 }
1092
1093 // Initialize fpath from FPATH env var or use defaults
1094 let fpath = env::var("FPATH")
1095 .unwrap_or_default()
1096 .split(':')
1097 .filter(|s| !s.is_empty())
1098 .map(PathBuf::from)
1099 .collect();
1100
1101 let history = HistoryEngine::new().ok();
1102
1103 // Initialize standard zsh variables.
1104 //
1105 // `ZSH_VERSION` / `ZSH_PATCHLEVEL` come from the vendored zsh
1106 // source — build.rs parses `src/zsh/Config/version.mk` and
1107 // emits the constants below. Previously hardcoded `"5.9"` /
1108 // `"zsh-5.9-0-g73d3173"`; the latter was an invented git-hash
1109 // literal that didn't correspond to any real commit. The C
1110 // source sets these at `Src/params.c:972-973` via
1111 // `setsparam("ZSH_VERSION", ztrdup_metafy(ZSH_VERSION))`.
1112 let mut variables = HashMap::new();
1113 variables.insert("ZSH_VERSION".to_string(), zsh_version::ZSH_VERSION.to_string()); // c:params.c:972
1114 variables.insert("ZSH_PATCHLEVEL".to_string(),
1115 zsh_version::ZSH_PATCHLEVEL.to_string()); // c:params.c:973
1116 variables.insert("ZSH_NAME".to_string(), "zsh".to_string());
1117 // $ZSH_ARGZERO mirrors `posixzero` from Src/init.c:271
1118 // (`argv0 = argzero = posixzero = *argv++`). Src/params.c:971
1119 // does the actual `setsparam("ZSH_ARGZERO", ztrdup(posixzero))`
1120 // at the same setup phase Rust handles here. For -c / runscript
1121 // invocations the bin entrypoint overrides this with the
1122 // script path (Src/init.c:297).
1123 variables.insert(
1124 "ZSH_ARGZERO".to_string(),
1125 std::env::args().next().unwrap_or_else(|| "zsh".to_string()),
1126 );
1127 // ZLE word boundary chars — matches mainline zsh's default.
1128 variables.insert(
1129 "WORDCHARS".to_string(),
1130 "*?_-.[]~=/&;!#$%^(){}<>".to_string(),
1131 );
1132 variables.insert(
1133 "SHLVL".to_string(),
1134 env::var("SHLVL")
1135 .map(|v| {
1136 v.parse::<i32>()
1137 .map(|n| (n + 1).to_string())
1138 .unwrap_or_else(|_| "1".to_string())
1139 })
1140 .unwrap_or_else(|_| "1".to_string()),
1141 );
1142 // POSIX/zsh default IFS is space, tab, newline, NUL. Splitters
1143 // throughout the codebase fall back to ` \t\n` when IFS is
1144 // missing; expose the actual default value so user code that
1145 // inspects $IFS sees what zsh exposes.
1146 variables.insert("IFS".to_string(), " \t\n\0".to_string());
1147
1148 // POSIX `getopts` initial state: OPTIND starts at 1, OPTERR
1149 // at 1 (errors enabled). Without these, scripts that read
1150 // `$OPTIND` before the first `getopts` call see empty strings
1151 // (zsh: `1`).
1152 variables.insert("OPTIND".to_string(), "1".to_string());
1153 variables.insert("OPTERR".to_string(), "1".to_string());
1154
1155 // zsh starts with `$_` empty (unlike bash which inherits the
1156 // OS-env value). The parent process sets `_=/path/to/binary`
1157 // before exec; zsh wipes that. Initialize to empty so script
1158 // reads of `$_` before any command runs return empty.
1159 variables.insert("_".to_string(), String::new());
1160 // `$histchars` — `Src/params.c:5064 histcharsgetfn` composes
1161 // bangchar+hatchar+hashchar (defaults `!`, `^`, `#` per
1162 // `Src/init.c:1100-1102`). Route through the C-port `histcharsgetfn`
1163 // so the value follows any runtime updates to the trio.
1164 variables.insert("histchars".to_string(),
1165 crate::ported::params::histcharsgetfn()); // c:params.c:5064
1166
1167 // c:Src/params.c:858-860 standard non-special param defaults.
1168 // The full createparamtable() body installs special_paramdef
1169 // entries (LINENO/PPID/EUID/etc) as PM_READONLY which would
1170 // block subsequent BUILTIN_SET_LINENO writes; the readonly-
1171 // special bypass at setsparam isn't ported yet. Inline these
1172 // three setiparam-equivalent values in the meantime.
1173 variables.insert("MAILCHECK".to_string(), "60".to_string()); // c:858
1174 variables.insert("KEYTIMEOUT".to_string(), "40".to_string()); // c:859
1175 variables.insert("LISTMAX".to_string(), "100".to_string()); // c:860
1176 // `$WATCHFMT` — `Src/Modules/watch.c:137 DEFAULT_WATCHFMT`.
1177 // zsh's watch boot_ seeds WATCHFMT to the default when the
1178 // module loads. zshrs's modules are statically linked but
1179 // boot_ isn't wired into require_module yet, so seed the
1180 // default here. `print "$WATCHFMT"` prints the default
1181 // (diverges from `/bin/zsh -fc` which leaves it unset until
1182 // an explicit `zmodload zsh/watch`, but matches the
1183 // post-zmodload state that most plugin code expects).
1184 variables.insert("WATCHFMT".to_string(),
1185 crate::ported::modules::watch::DEFAULT_WATCHFMT.to_string());
1186
1187 // `$FUNCNEST` default. Real zsh defaults to 500 (Src/zsh.h
1188 // MAXNEST), but zshrs's bytecode-VM recursion eats ~40KB of
1189 // Rust stack per frame and tops out around 150 on the
1190 // default 8MB stack. We seed `100` here so plugin probes
1191 // (`${FUNCNEST:-default}`) get a realistic cap that
1192 // matches what `call_function` actually enforces. Users
1193 // who need deeper need to raise FUNCNEST explicitly AND
1194 // run with a larger stack (RUST_MIN_STACK).
1195 variables.insert("FUNCNEST".to_string(), "100".to_string());
1196
1197 // Run setlocale(LC_ALL, "") so nl_langinfo() (used by the
1198 // `langinfo` module) returns the host's actual locale instead
1199 // of the C/POSIX default ("US-ASCII"). Direct port of zsh's
1200 // Src/init.c:1208 setlocale call. unsafe { } around libc is
1201 // standard for this exact use-case — setlocale is process-
1202 // global and must run once at startup.
1203 unsafe {
1204 libc::setlocale(libc::LC_ALL, c"".as_ptr());
1205 }
1206
1207 // c:hashtable.c:1206 createaliastables() — seeds aliastab with
1208 // the `run-help` / `which-command` defaults. Run once at shell
1209 // init so the canonical port owns the default-alias set; the
1210 // Executor's `aliases` HashMap then mirrors aliastab.
1211 crate::ported::hashtable::createaliastables();
1212 // Build the initial $path tied array as a local — fans out
1213 // to paramtab below; no ShellExecutor mirror anymore.
1214 let mut arrays: HashMap<String, Vec<String>> = HashMap::new();
1215 let path_dirs: Vec<String> = env::var("PATH")
1216 .unwrap_or_default()
1217 .split(':')
1218 .map(|s| s.to_string())
1219 .collect();
1220 arrays.insert("path".to_string(), path_dirs);
1221 // Seed canonical OPTS_LIVE with defaults if not already
1222 // populated. `default_options` builds the same name→bool map
1223 // we previously cloned into `exec.options`.
1224 if crate::ported::options::opt_state_len() == 0 {
1225 for (k, v) in Self::default_options() {
1226 crate::ported::options::opt_state_set(&k, v);
1227 }
1228 }
1229 let mut exec = Self {
1230 scriptname: None,
1231 scriptfilename: None,
1232 loop_signal: None,
1233 subshell_snapshots: Vec::new(),
1234 inline_env_stack: Vec::new(),
1235 current_command_glob_failed: std::cell::Cell::new(false),
1236 jobs: JobTable::new(),
1237 fpath,
1238 zwc_cache: HashMap::new(),
1239 history,
1240 session_histnum: 0,
1241 completions: HashMap::new(),
1242 process_sub_counter: 0,
1243 traps: HashMap::new(),
1244 // zsh completion system
1245 comp_matches: Vec::new(),
1246 comp_groups: Vec::new(),
1247 comp_state: CompState::default(),
1248 zstyles: Vec::new(),
1249 comp_words: Vec::new(),
1250 comp_current: 0,
1251 comp_prefix: String::new(),
1252 comp_suffix: String::new(),
1253 comp_iprefix: String::new(),
1254 comp_isuffix: String::new(),
1255 local_scope_depth: 0,
1256 pending_underscore: None,
1257 in_dq_context: 0,
1258 in_scalar_assign: 0,
1259 session_history_ids: Vec::new(),
1260 open_fds: HashMap::new(),
1261 next_fd: 10,
1262 profiling_enabled: false,
1263 compsys_cache: {
1264 let cache_path = compsys::cache::default_cache_path();
1265 if cache_path.exists() {
1266 let db_size = std::fs::metadata(&cache_path).map(|m| m.len()).unwrap_or(0);
1267 match CompsysCache::open(&cache_path) {
1268 Ok(c) => {
1269 tracing::info!(
1270 db_bytes = db_size,
1271 path = %cache_path.display(),
1272 "compsys: sqlite cache opened"
1273 );
1274 Some(c)
1275 }
1276 Err(e) => {
1277 tracing::warn!(error = %e, "compsys: failed to open cache");
1278 None
1279 }
1280 }
1281 } else {
1282 tracing::debug!("compsys: no cache at {}", cache_path.display());
1283 None
1284 }
1285 },
1286 compinit_pending: None, // (receiver, start_time)
1287 plugin_cache: {
1288 let pc_path = crate::plugin_cache::default_cache_path();
1289 if let Some(parent) = pc_path.parent() {
1290 let _ = std::fs::create_dir_all(parent);
1291 }
1292 match crate::plugin_cache::PluginCache::open(&pc_path) {
1293 Ok(pc) => {
1294 let (plugins, functions) = pc.stats();
1295 tracing::info!(
1296 plugins,
1297 cached_functions = functions,
1298 path = %pc_path.display(),
1299 "plugin_cache: sqlite opened"
1300 );
1301 Some(pc)
1302 }
1303 Err(e) => {
1304 tracing::warn!(error = %e, "plugin_cache: failed to open");
1305 None
1306 }
1307 }
1308 },
1309 deferred_compdefs: Vec::new(),
1310 returning: None,
1311 breaking: 0,
1312 continuing: 0,
1313 zsh_compat: false,
1314 bash_compat: false,
1315 posix_mode: false,
1316 worker_pool: {
1317 let config = crate::config::load();
1318 let pool_size = crate::config::resolve_pool_size(&config.worker_pool);
1319 std::sync::Arc::new(crate::worker::WorkerPool::new(pool_size))
1320 },
1321 intercepts: Vec::new(),
1322 async_jobs: HashMap::new(),
1323 next_async_id: 1,
1324 defer_stack: Vec::new(),
1325 redirect_scope_stack: Vec::new(),
1326 redirect_failed: false,
1327 pending_stdin: None,
1328 functions_compiled: HashMap::new(),
1329 function_source: HashMap::new(),
1330 function_line_base: HashMap::new(),
1331 function_def_file: HashMap::new(),
1332 prompt_funcstack: Vec::new(),
1333 tied_scalar_to_array: HashMap::new(),
1334 tied_array_to_scalar: HashMap::new(),
1335 buffer_stack: Vec::new(),
1336 };
1337 // Mirror env-derived path arrays into the `arrays` table so
1338 // user-level `fpath` / `path` array reads see the inherited
1339 // entries. zsh: `fpath+=…` should append to the inherited
1340 // 43-entry array, not replace it. Same for `path` (PATH).
1341 let fpath_arr: Vec<String> = exec
1342 .fpath
1343 .iter()
1344 .map(|p| p.to_string_lossy().to_string())
1345 .collect();
1346 if !fpath_arr.is_empty() {
1347 exec.set_array("fpath".to_string(), fpath_arr);
1348 }
1349 if let Ok(path) = env::var("PATH") {
1350 let path_arr: Vec<String> = path
1351 .split(':')
1352 .filter(|s| !s.is_empty())
1353 .map(String::from)
1354 .collect();
1355 if !path_arr.is_empty() {
1356 exec.set_array("path".to_string(), path_arr);
1357 }
1358 }
1359 // Register the standard tied path-family pairs so `path+=` /
1360 // `fpath+=` / etc. mirror through the array→scalar sync hook
1361 // in BUILTIN_APPEND_ARRAY (and the SET_ARRAY tied path).
1362 // Direct port of the implicit ties that zsh wires up at
1363 // startup for PATH/path, FPATH/fpath, etc. Source-of-truth
1364 // for the pairs is Src/init.c's `setupvals()` PM_TIED entries.
1365 for (scalar, arr) in [
1366 ("PATH", "path"),
1367 ("FPATH", "fpath"),
1368 ("MANPATH", "manpath"),
1369 ("CDPATH", "cdpath"),
1370 ("MODULE_PATH", "module_path"),
1371 ] {
1372 exec.tied_array_to_scalar
1373 .insert(arr.to_string(), (scalar.to_string(), ":".to_string()));
1374 exec.tied_scalar_to_array
1375 .insert(scalar.to_string(), (arr.to_string(), ":".to_string()));
1376 }
1377
1378 // Mirror every constructor-time `variables` / `arrays` /
1379 // `assoc_arrays` seed into paramtab so the C-port readers see
1380 // the same initial state. C does this implicitly because its
1381 // single `paramtab` is populated by `setupvals()` /
1382 // `createparam()` calls at init (Src/init.c:1014-1300). The
1383 // Rust port builds local HashMaps first and then constructs
1384 // self; this loop fans the contents out to paramtab in one
1385 // pass at the end of new().
1386 for (k, v) in &variables {
1387 crate::ported::params::setsparam(k, v); // c:params.c:3350
1388 }
1389 for (k, v) in &arrays {
1390 crate::ported::params::setaparam(k, v.clone()); // c:params.c:3595
1391 }
1392 // Assocs: there are no pre-seeded entries (terminfo / termcap
1393 // resolve lazily via magic_assoc_lookup) so no mirror loop.
1394 exec
1395 }
1396
1397 // enter_posix_mode / enter_ksh_mode moved to src/ported/options.rs
1398 // (canonical C source: Src/options.c:533 emulate()).
1399
1400 // host_apply_redirect / host_redirect_scope_begin / host_redirect_scope_end /
1401 // host_set_pending_stdin / host_exec_external moved to src/fusevm_bridge.rs
1402 // (extension; not a port of Src/exec.c).
1403
1404 /// Add a directory to fpath
1405 pub fn add_fpath(&mut self, path: PathBuf) {
1406 if !self.fpath.contains(&path) {
1407 self.fpath.insert(0, path);
1408 }
1409 }
1410
1411 /// Tab expansion — direct port of `zexpandtabs(const char *s, int len, int width, int startpos, FILE *fout, int all)` in zsh/Src/utils.c:5973.
1412 /// Moved to `crate::ported::utils::zexpandtabs`; re-exported below.
1413
1414 /// Execute a script file with bytecode caching — skips lex+parse+compile on cache hit.
1415 /// Bytecode is stored in rkyv keyed by (path, mtime).
1416 pub fn execute_script_file(&mut self, file_path: &str) -> Result<i32, String> {
1417
1418 let path = Path::new(file_path);
1419 let abs_path = path
1420 .canonicalize()
1421 .unwrap_or_else(|_| path.to_path_buf())
1422 .to_string_lossy()
1423 .to_string();
1424
1425 // Try bytecode cache first — rkyv shard at ~/.zshrs/scripts.rkyv.
1426 // The cache validates path + mtime + zshrs binary mtime; on any miss
1427 // we fall through to lex/parse/compile.
1428 if let Some(bc_blob) = crate::script_cache::try_load_bytes(path) {
1429 if let Ok(chunk) = bincode::deserialize::<fusevm::Chunk>(&bc_blob) {
1430 if !chunk.ops.is_empty() {
1431 tracing::trace!(
1432 path = %abs_path,
1433 ops = chunk.ops.len(),
1434 "execute_script_file: bytecode cache hit"
1435 );
1436 crate::fusevm_disasm::maybe_print_stdout(
1437 &format!("execute_script_file:cache:{abs_path}"),
1438 &chunk,
1439 );
1440 let mut vm = fusevm::VM::new(chunk);
1441 register_builtins(&mut vm);
1442 let _ctx = ExecutorContext::enter(self);
1443 match vm.run() {
1444 fusevm::VMResult::Ok(_) | fusevm::VMResult::Halted => {
1445 self.set_last_status(vm.last_status);
1446 }
1447 fusevm::VMResult::Error(e) => {
1448 return Err(format!("VM error: {}", e));
1449 }
1450 }
1451 return Ok(self.last_status());
1452 }
1453 }
1454 }
1455
1456 // Cache miss — read, parse, compile, execute, then cache.
1457 // No history expansion: zsh fires `!` history sub only on
1458 // interactive input (the REPL line). Sourced files are
1459 // verbatim — `(( !${#ARR} ))` (logical-not) must NOT
1460 // become `(( <last-arg-of-prev-cmd>{#ARR} ))`. Direct port
1461 // of Src/init.c source() which calls `lex_init_buf` /
1462 // `loop()` without engaging the history layer.
1463 let content =
1464 std::fs::read_to_string(file_path).map_err(|e| format!("{}: {}", file_path, e))?;
1465 // Save & clear errflag around the parse so we can detect a
1466 // fresh syntax error vs an inherited one. Direct port of
1467 // Src/init.c source()'s `errflag &= ~ERRFLAG_ERROR;` before
1468 // `parse_event(ENDINPUT)` and the post-parse errflag check.
1469 let saved_errflag = errflag.load(Ordering::Relaxed);
1470 errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
1471 crate::ported::parse::parse_init(&content);
1472 let program = crate::ported::parse::parse();
1473 let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
1474 errflag.store(saved_errflag, Ordering::Relaxed);
1475 if parse_failed {
1476 return Err("parse error".to_string());
1477 }
1478
1479 let compiler = crate::compile_zsh::ZshCompiler::new();
1480 let chunk = compiler.compile(&program);
1481
1482 // Cache the bytecode for next time. Best-effort — failures don't
1483 // block execution since the chunk is already in hand.
1484 if let Ok(blob) = bincode::serialize(&chunk) {
1485 let _ = crate::script_cache::try_save_bytes(path, &blob);
1486 tracing::trace!(
1487 path = %abs_path,
1488 bytes = blob.len(),
1489 "execute_script_file: bytecode cached"
1490 );
1491 }
1492
1493 // Execute
1494 if !chunk.ops.is_empty() {
1495 crate::fusevm_disasm::maybe_print_stdout(
1496 &format!("execute_script_file:compile:{abs_path}"),
1497 &chunk,
1498 );
1499 let mut vm = fusevm::VM::new(chunk);
1500 register_builtins(&mut vm);
1501 let _ctx = ExecutorContext::enter(self);
1502 match vm.run() {
1503 fusevm::VMResult::Ok(_) | fusevm::VMResult::Halted => {
1504 self.set_last_status(vm.last_status);
1505 }
1506 fusevm::VMResult::Error(e) => {
1507 return Err(format!("VM error: {}", e));
1508 }
1509 }
1510 }
1511
1512 Ok(self.last_status())
1513 }
1514
1515 /// P9d: wordcode-consumer entry. Direct port of zsh's `execlist`
1516 /// from `Src/exec.c:1551-1671` — walks the wordcode buffer that
1517 /// P9c's `par_event_wordcode` emitted into `ECBUF`, dispatching on
1518 /// `WC_KIND` (wc_code) for each entry.
1519 ///
1520 /// Minimal implementation: walks ECBUF, dispatches WC_END to a
1521 /// no-op return-0 path. The full WC_LIST/WC_SUBLIST/WC_PIPE/WC_CMD/
1522 /// WC_REDIR/WC_SIMPLE/... dispatch tree (Src/exec.c ~30k lines
1523 /// total) is the multi-week rewrite called out in PORT_PLAN.md.
1524 /// This stub establishes the entry point and proves the consumer
1525 /// can walk a buffer P9c emitted into.
1526 pub fn exec_wordcode(&mut self) -> i32 {
1527 let buf = ECBUF.with_borrow(|b| b.clone());
1528 let (status, _next) = self.exec_list_wordcode(&buf, 0);
1529 self.set_last_status(status);
1530 status
1531 }
1532
1533 /// P9d stub: direct port of `execlist(Estate state, int dont_change_job,
1534 /// int exiting)` from `Src/exec.c:1551-1671`. Walks WC_LIST entries,
1535 /// dispatches each sublist payload to exec_pline_wordcode. Real
1536 /// implementation handles fork/wait + signal-trap dispatch.
1537 /// Returns (last_status, pc_after_walk).
1538 pub fn exec_list_wordcode(&mut self, buf: &[u32], mut pc: usize) -> (i32, usize) {
1539 let mut last_status: i32 = 0;
1540 while pc < buf.len() {
1541 let code = wc_code(buf[pc]);
1542 if code == WC_END {
1543 pc += 1;
1544 break;
1545 }
1546 if code != WC_LIST {
1547 pc += 1;
1548 continue;
1549 }
1550 let header = buf[pc];
1551 let skip = (wc_data(header)
1552 >> crate::ported::zsh_h::WC_LIST_FREE) as usize;
1553 pc += 1;
1554 let (s, _) = self.exec_sublist_wordcode(buf, pc);
1555 last_status = s;
1556 pc += skip;
1557 }
1558 (last_status, pc)
1559 }
1560
1561 /// P9d stub: direct port of `execsublist` from
1562 /// `Src/exec.c:1672-1810`. Walks WC_SUBLIST + pipeline payload.
1563 pub fn exec_sublist_wordcode(&mut self, buf: &[u32], mut pc: usize) -> (i32, usize) {
1564 let mut last_status: i32 = 0;
1565 if pc < buf.len() && wc_code(buf[pc]) == WC_SUBLIST {
1566 let header = buf[pc];
1567 let skip = (wc_data(header) >> 7) as usize;
1568 pc += 1;
1569 let (s, _) = self.exec_pline_wordcode(buf, pc);
1570 last_status = s;
1571 pc += skip;
1572 }
1573 (last_status, pc)
1574 }
1575
1576 /// P9d stub: direct port of `execpline` from
1577 /// `Src/exec.c:1812-1980`. Walks WC_PIPE chain + cmd payloads.
1578 pub fn exec_pline_wordcode(&mut self, buf: &[u32], mut pc: usize) -> (i32, usize) {
1579 let mut last_status: i32 = 0;
1580 if pc < buf.len() && wc_code(buf[pc]) == WC_PIPE {
1581 let header = buf[pc];
1582 let skip = ((wc_data(header) >> 1) & 0xffff) as usize;
1583 pc += 1;
1584 let (s, _) = self.exec_cmd_wordcode(buf, pc);
1585 last_status = s;
1586 pc += skip;
1587 }
1588 (last_status, pc)
1589 }
1590
1591 /// P9d: direct port of `execcmd_exec` / `execcmd_analyze` from
1592 /// `Src/exec.c:2700-3700`. Reads the cmd header (WC_SIMPLE /
1593 /// WC_SUBSH / WC_FOR / WC_CASE / ...) and dispatches accordingly.
1594 pub fn exec_cmd_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1595 if pc >= buf.len() {
1596 return (0, pc);
1597 }
1598 match wc_code(buf[pc]) {
1599 WC_SIMPLE => self.exec_simple_wordcode(buf, pc),
1600 WC_SUBSH => self.exec_subsh_wordcode(buf, pc),
1601 WC_CURSH => self.exec_cursh_wordcode(buf, pc),
1602 WC_FOR => self.exec_for_wordcode(buf, pc),
1603 WC_SELECT => self.exec_select_wordcode(buf, pc),
1604 WC_CASE => self.exec_case_wordcode(buf, pc),
1605 WC_IF => self.exec_if_wordcode(buf, pc),
1606 WC_WHILE => self.exec_while_wordcode(buf, pc),
1607 WC_REPEAT => self.exec_repeat_wordcode(buf, pc),
1608 WC_FUNCDEF => self.exec_funcdef_wordcode(buf, pc),
1609 WC_TIMED => self.exec_timed_wordcode(buf, pc),
1610 WC_COND => self.exec_cond_wordcode(buf, pc),
1611 WC_ARITH => self.exec_arith_wordcode(buf, pc),
1612 WC_TRY => self.exec_try_wordcode(buf, pc),
1613 _ => (0, pc + 1),
1614 }
1615 }
1616
1617 /// P9d: direct port of `execfor(Estate state, int do_exec)` from `Src/exec.c:1232-1350`.
1618 /// Reads WC_FOR header via WC_FOR_TYPE/WC_FOR_SKIP, dispatches on
1619 /// type (PPARAM / LIST / COND), iterates body via recursive
1620 /// exec_list_wordcode calls.
1621 pub fn exec_for_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1622 if pc >= buf.len() {
1623 return (0, pc);
1624 }
1625 let header = buf[pc];
1626 let _type_bits = WC_FOR_TYPE(header);
1627 let skip = WC_FOR_SKIP(header) as usize;
1628 let _ = WC_FOR_LIST;
1629 // exec.c:1245+ — read var name via ecgetstr, iterate words,
1630 // exec body. Full implementation needs the var-binding +
1631 // iteration loop; this stub advances past the form.
1632 let mut last_status: i32 = 0;
1633 let end_pc = pc + 1 + skip;
1634 // Walk inner body (after header + var-name slot) once as a
1635 // shape-correct placeholder.
1636 let body_pc = pc + 2;
1637 if body_pc < end_pc {
1638 let (s, _) = self.exec_list_wordcode(buf, body_pc);
1639 last_status = s;
1640 }
1641 (last_status, end_pc)
1642 }
1643 /// P9d: `execselect` shape — same as exec_for but with `select`
1644 /// REPL prompt at each iteration. Src/exec.c:1352-1490.
1645 pub fn exec_select_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1646 self.exec_for_wordcode(buf, pc)
1647 }
1648 /// P9d: direct port of `execcase(Estate state, int do_exec)` from `Src/exec.c:1492-1550`.
1649 /// Reads WC_CASE_TYPE + WC_CASE_SKIP, walks pattern arms.
1650 pub fn exec_case_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1651 if pc >= buf.len() {
1652 return (0, pc);
1653 }
1654 let header = buf[pc];
1655 let _type_bits = WC_CASE_TYPE(header);
1656 let skip = WC_CASE_SKIP(header) as usize;
1657 // Full implementation: pattern-match word against each arm's
1658 // patterns, exec the first matching arm's body. Stub walks the
1659 // body once as a placeholder.
1660 let mut last_status: i32 = 0;
1661 let end_pc = pc + 1 + skip;
1662 let body_pc = pc + 1;
1663 if body_pc < end_pc {
1664 let (s, _) = self.exec_list_wordcode(buf, body_pc);
1665 last_status = s;
1666 }
1667 (last_status, end_pc)
1668 }
1669 /// P9d: full port of `execif(Estate state, int do_exec)` from `Src/loop.c:299-340`.
1670 ///
1671 /// C body walks the if/elif/else chain. Each cond is an inner
1672 /// WC_IF header with WC_IF_TYPE distinguishing IF / ELIF / ELSE.
1673 /// Returns lastval = status of the run branch, or 0 if no branch
1674 /// matched.
1675 pub fn exec_if_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1676 if pc >= buf.len() {
1677 return (0, pc);
1678 }
1679 let header = buf[pc];
1680 let skip = WC_IF_SKIP(header) as usize;
1681 let end_pc = pc + 1 + skip;
1682 let mut cur = pc + 1;
1683 let mut run: i32 = 0; // 0=no branch, 1=if/elif body, 2=else body
1684 let mut s = 0; // 0=in if/elif chain, 1=elif seen at least once
1685 let mut last_status: i32 = 0;
1686 // loop.c:307-326 — walk the chain.
1687 while cur < end_pc {
1688 if cur >= buf.len() {
1689 break;
1690 }
1691 let code = buf[cur];
1692 cur += 1;
1693 if wc_code(code) != WC_IF {
1694 // Past the IF header chain — must be the body of a
1695 // previously-selected branch we should run.
1696 run = 1;
1697 cur -= 1;
1698 break;
1699 }
1700 // WC_IF_TYPE == ELSE (2) — unconditional else body.
1701 if WC_IF_TYPE(code) == 2 {
1702 run = 2;
1703 break;
1704 }
1705 let next = cur + WC_IF_SKIP(code) as usize;
1706 let (cond_status, after_cond) = self.exec_list_wordcode(buf, cur);
1707 last_status = cond_status;
1708 if cond_status == 0 {
1709 run = 1;
1710 cur = after_cond;
1711 break;
1712 }
1713 if RETFLAG.load(Ordering::SeqCst) != 0 {
1714 break;
1715 }
1716 s = 1;
1717 cur = next;
1718 }
1719 let _ = s;
1720 // loop.c:328-336 — run the selected branch body.
1721 if run != 0 && cur < end_pc {
1722 let (body_status, _) = self.exec_list_wordcode(buf, cur);
1723 last_status = body_status;
1724 } else if RETFLAG.load(Ordering::SeqCst) == 0
1725 && (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) == 0
1726 {
1727 last_status = 0;
1728 }
1729 (last_status, end_pc)
1730 }
1731 /// P9d: full port of `execwhile(Estate state, UNUSED(int do_exec))` from `Src/loop.c:432-498`.
1732 ///
1733 /// Loops {exec cond; check status XOR isuntil; exec body; check
1734 /// breaks/contflag/retflag/errflag} until termination.
1735 pub fn exec_while_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1736 if pc >= buf.len() {
1737 return (0, pc);
1738 }
1739 let header = buf[pc];
1740 // loop.c:438 — `isuntil = (WC_WHILE_TYPE(code) == WC_WHILE_UNTIL)`.
1741 // WC_WHILE_UNTIL = 2 per zsh.h:1015.
1742 let isuntil = WC_WHILE_TYPE(header) == 2;
1743 let skip = WC_WHILE_SKIP(header) as usize;
1744 let end_pc = pc + 1 + skip;
1745 let loop_pc = pc + 1;
1746 // loop.c:443-446 — pushheap; cmdpush; loops++.
1747 LOOPS.fetch_add(1, Ordering::SeqCst);
1748 let mut last_status: i32 = 0;
1749 let mut oldval: i32 = 0;
1750 // Safety cap to prevent runaway infinite loops in stubs — real
1751 // C loops forever if conditions hold.
1752 let mut iters = 0u64;
1753 const ITER_CAP: u64 = 1_000_000;
1754 loop {
1755 iters += 1;
1756 if iters > ITER_CAP {
1757 break;
1758 }
1759 // loop.c:467 — exec cond (first inner list).
1760 let (cond_status, after_cond) = self.exec_list_wordcode(buf, loop_pc);
1761 last_status = cond_status;
1762 // loop.c:473 — `if (!((lastval == 0) ^ isuntil)) break;`
1763 let cond_passed = (cond_status == 0) ^ isuntil;
1764 if !cond_passed {
1765 if BREAKS.load(Ordering::SeqCst) > 0 {
1766 BREAKS.fetch_sub(1, Ordering::SeqCst);
1767 }
1768 if RETFLAG.load(Ordering::SeqCst) == 0 {
1769 last_status = oldval;
1770 }
1771 break;
1772 }
1773 // loop.c:481 — retflag bail.
1774 if RETFLAG.load(Ordering::SeqCst) != 0 {
1775 if BREAKS.load(Ordering::SeqCst) > 0 {
1776 BREAKS.fetch_sub(1, Ordering::SeqCst);
1777 }
1778 break;
1779 }
1780 // loop.c:489 — exec body.
1781 let (body_status, _) = self.exec_list_wordcode(buf, after_cond);
1782 last_status = body_status;
1783 // loop.c:493-497 — breaks/continue handling.
1784 if BREAKS.load(Ordering::SeqCst) > 0 {
1785 let prev = BREAKS.fetch_sub(1, Ordering::SeqCst);
1786 if prev - 1 > 0 || CONTFLAG.load(Ordering::SeqCst) == 0 {
1787 break;
1788 }
1789 CONTFLAG.store(0, Ordering::SeqCst);
1790 }
1791 // loop.c:498-501 — errflag bail.
1792 if (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0 {
1793 last_status = 1;
1794 break;
1795 }
1796 // loop.c:502 — retflag bail.
1797 if RETFLAG.load(Ordering::SeqCst) != 0 {
1798 break;
1799 }
1800 oldval = last_status;
1801 }
1802 LOOPS.fetch_sub(1, Ordering::SeqCst);
1803 (last_status, end_pc)
1804 }
1805 /// P9d: full port of `execrepeat(Estate state, UNUSED(int do_exec))` from `Src/loop.c:499-552`.
1806 ///
1807 /// C body:
1808 /// end = state->pc + WC_REPEAT_SKIP(code);
1809 /// tmp = ecgetstr(state, EC_DUPTOK, &htok);
1810 /// if (htok) { singsub(&tmp); untokenize(tmp); }
1811 /// count = mathevali(tmp);
1812 /// loops++;
1813 /// loop = state->pc;
1814 /// while (count-- > 0) {
1815 /// state->pc = loop;
1816 /// execlist(state, 1, 0);
1817 /// if (breaks) { breaks--; if (breaks || !contflag) break; contflag = 0; }
1818 /// if (errflag) { lastval = 1; break; }
1819 /// if (retflag) break;
1820 /// }
1821 /// loops--;
1822 pub fn exec_repeat_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1823 if pc >= buf.len() {
1824 return (0, pc);
1825 }
1826 let header = buf[pc];
1827 let skip = WC_REPEAT_SKIP(header) as usize;
1828 let end_pc = pc + 1 + skip;
1829 // loop.c:511 — `tmp = ecgetstr(state, EC_DUPTOK, &htok);`
1830 let (count_expr_raw, after_count) = ecgetstr_wordcode(buf, pc + 1);
1831 // loop.c:512-515 — singsub + untokenize on tokenized count.
1832 let count_expr_sub = singsub(&count_expr_raw);
1833 let count_expr = crate::ported::lex::untokenize(&count_expr_sub);
1834 // loop.c:516 — `count = mathevali(tmp);`
1835 let count_val = mathevali(&count_expr).unwrap_or(0);
1836 if (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0 {
1837 return (1, end_pc);
1838 }
1839 let mut last_status: i32 = 0; // loop.c:519 — `lastval = 0` for zero count.
1840 // loop.c:520-522 — `pushheap(); cmdpush(CS_REPEAT); loops++;`
1841 LOOPS.fetch_add(1, Ordering::SeqCst);
1842 let loop_body_pc = after_count;
1843 // loop.c:523-545 — main iteration.
1844 let mut remaining = count_val;
1845 while remaining > 0 {
1846 remaining -= 1;
1847 let (s, _) = self.exec_list_wordcode(buf, loop_body_pc);
1848 last_status = s;
1849 // loop.c:528-533 — breaks/continue handling.
1850 if BREAKS.load(Ordering::SeqCst) > 0 {
1851 let prev = BREAKS.fetch_sub(1, Ordering::SeqCst);
1852 if prev - 1 > 0 || CONTFLAG.load(Ordering::SeqCst) == 0 {
1853 break;
1854 }
1855 CONTFLAG.store(0, Ordering::SeqCst);
1856 }
1857 // loop.c:534-537 — errflag bail.
1858 if (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0 {
1859 last_status = 1;
1860 break;
1861 }
1862 // loop.c:538 — retflag bail (function return).
1863 if RETFLAG.load(Ordering::SeqCst) != 0 {
1864 break;
1865 }
1866 }
1867 // loop.c:546-549 — `cmdpop(); popheap(); loops--;`
1868 LOOPS.fetch_sub(1, Ordering::SeqCst);
1869 (last_status, end_pc)
1870 }
1871 /// P9d stub: `execfuncdef`.
1872 pub fn exec_funcdef_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1873 self.skip_form(buf, pc)
1874 }
1875 /// P9d stub: `execsubsh` for `(...)` subshell.
1876 pub fn exec_subsh_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1877 self.skip_form(buf, pc)
1878 }
1879 /// P9d stub: `execcursh` for `{...}` brace group.
1880 pub fn exec_cursh_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1881 self.skip_form(buf, pc)
1882 }
1883 /// P9d stub: `exectimed` for `time pipeline`.
1884 pub fn exec_timed_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1885 self.skip_form(buf, pc)
1886 }
1887 /// P9d stub: `execcond` for `[[ ... ]]`.
1888 pub fn exec_cond_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1889 self.skip_form(buf, pc)
1890 }
1891 /// P9d stub: `execarith` for `(( ... ))`.
1892 pub fn exec_arith_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1893 self.skip_form(buf, pc)
1894 }
1895 /// P9d stub: `exectry` for `{ try } always { finally }`.
1896 pub fn exec_try_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1897 self.skip_form(buf, pc)
1898 }
1899
1900 /// Shared helper for WC_* form dispatch stubs: read the header's
1901 /// `skip` field (data >> WC_CODEBITS) and advance pc past the
1902 /// payload. Each real production-specific exec_* will replace its
1903 /// call to this with the form-specific logic.
1904 fn skip_form(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1905 if pc >= buf.len() {
1906 return (0, pc);
1907 }
1908 let skip = wc_data(buf[pc]) as usize;
1909 (0, pc + 1 + skip)
1910 }
1911
1912 /// P9d: direct port of `execsimple(Estate state)` from `Src/exec.c:3702-4100`.
1913 /// Walks WC_SIMPLE header + word slots, decodes the interned
1914 /// strings via `ecgetstr`, builds argv, invokes the command.
1915 /// Real implementation handles assignments + redirections inline
1916 /// from the same wordcode; this minimal version pulls just words.
1917 pub fn exec_simple_wordcode(&mut self, buf: &[u32], mut pc: usize) -> (i32, usize) {
1918 let mut last_status: i32 = 0;
1919 if pc < buf.len() && wc_code(buf[pc]) == WC_SIMPLE {
1920 let header = buf[pc];
1921 let nwords = wc_data(header) as usize;
1922 pc += 1;
1923 // Decode the interned strings into an argv vector.
1924 let mut argv: Vec<String> = Vec::with_capacity(nwords);
1925 for _ in 0..nwords {
1926 let (word, next) = ecgetstr(buf, pc);
1927 argv.push(word);
1928 pc = next;
1929 }
1930 // Invoke via the existing command-execution path. argv[0]
1931 // is the command name; remainder are arguments. Real exec
1932 // (Src/exec.c:3850 execcmd_analyze) would resolve builtin /
1933 // function / external + fork/exec; we delegate to the
1934 // existing AST-based simple-cmd executor's argv hook.
1935 if !argv.is_empty() {
1936 last_status = self.invoke_argv_wordcode(&argv);
1937 }
1938 }
1939 (last_status, pc)
1940 }
1941
1942 /// Minimal command invoker for wordcode-driven simple commands.
1943 /// Bridges the wordcode-side argv into the existing AST-side
1944 /// simple-cmd dispatch by constructing a single-Simple ZshProgram
1945 /// and running it through `execute_script_zsh_pipeline`. Real exec
1946 /// (P9d full) bypasses the AST and dispatches builtin/function/
1947 /// external directly from the wordcode — but the AST path
1948 /// already does this correctly today, so until the full
1949 /// builtin/function/external dispatch is ported into the wordcode
1950 /// consumer, this bridge keeps actual execution working.
1951 fn invoke_argv_wordcode(&mut self, argv: &[String]) -> i32 {
1952 let script = argv
1953 .iter()
1954 .map(|s| {
1955 // Minimal shell-escape: wrap in single quotes if
1956 // the arg contains whitespace or special chars.
1957 if s.chars().any(|c| c.is_whitespace() || "\"'`$\\|;&<>(){}[]*?~".contains(c)) {
1958 format!("'{}'", s.replace('\'', "'\\''"))
1959 } else {
1960 s.clone()
1961 }
1962 })
1963 .collect::<Vec<_>>()
1964 .join(" ");
1965 self.execute_script_zsh_pipeline(&script).unwrap_or(1)
1966 }
1967
1968 /// Execute via the lex+parse free fns + ZshCompiler pipeline.
1969 /// This is the only execution path; `execute_script` delegates here.
1970 pub fn execute_script_zsh_pipeline(&mut self, script: &str) -> Result<i32, String> {
1971 // Skip history expansion for non-interactive script execution
1972 // (`zsh -c '…'`, internal eval, sourced files). zsh's `!`
1973 // history sub only fires on the REPL command line, never on
1974 // a pre-parsed script body. The interactive REPL has its
1975 // own dedicated path that calls expand_history before
1976 // dispatching here.
1977 // Save & clear errflag around the parse so a fresh syntax
1978 // error is distinguishable from one already in flight. Mirrors
1979 // Src/init.c loop()'s pre-parse `errflag &= ~ERRFLAG_ERROR;`.
1980 let saved_errflag = errflag.load(Ordering::Relaxed);
1981 errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
1982 crate::ported::parse::parse_init(script);
1983 let program = crate::ported::parse::parse();
1984 let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
1985 errflag.store(saved_errflag, Ordering::Relaxed);
1986 if parse_failed {
1987 return Err("parse error".to_string());
1988 }
1989
1990 let compiler = crate::compile_zsh::ZshCompiler::new();
1991 let chunk = compiler.compile(&program);
1992
1993 if chunk.ops.is_empty() {
1994 return Ok(self.last_status());
1995 }
1996
1997 crate::fusevm_disasm::maybe_print_stdout("execute_script_zsh_pipeline", &chunk);
1998 let mut vm = fusevm::VM::new(chunk);
1999 register_builtins(&mut vm);
2000 {
2001 let _ctx = ExecutorContext::enter(self);
2002 match vm.run() {
2003 fusevm::VMResult::Ok(_) | fusevm::VMResult::Halted => {
2004 self.set_last_status(vm.last_status);
2005 }
2006 fusevm::VMResult::Error(e) => return Err(format!("VM error: {}", e)),
2007 }
2008 }
2009
2010 // Fire EXIT trap if set. Same logic as execute_script's old path:
2011 // remove first to prevent infinite recursion, then run.
2012 if let Some(action) = self.traps.remove("EXIT") {
2013 tracing::debug!("firing EXIT trap (new pipeline)");
2014 let _ = self.execute_script_zsh_pipeline(&action);
2015 }
2016
2017 Ok(self.last_status())
2018 }
2019
2020 #[tracing::instrument(skip(self, script), fields(len = script.len()))]
2021 pub fn execute_script(&mut self, script: &str) -> Result<i32, String> {
2022 // lex+parse free fns + ZshCompiler is the only execution path.
2023 self.execute_script_zsh_pipeline(script)
2024 }
2025
2026 /// Whether `name` is a known function. Checks the compiled-functions
2027 /// table and the autoload-pending registry — `autoload foo` should
2028 /// make `whence foo`/`type foo`/`functions foo` recognize `foo` as
2029 /// a function before it's actually loaded. Doesn't trigger autoload
2030 /// itself; use `maybe_autoload` first if you need to load before
2031 /// introspecting.
2032 pub fn function_exists(&self, name: &str) -> bool {
2033 // Either compiled (already loaded) or shfunctab has an
2034 // autoload stub with PM_UNDEFINED set (pending). Matches C's
2035 // `lookupshfunc(name)` semantics at `Src/exec.c:5215`.
2036 if self.functions_compiled.contains_key(name) {
2037 return true;
2038 }
2039 crate::ported::hashtable::shfunctab_lock().read().ok()
2040 .map(|t| t.get(name).is_some())
2041 .unwrap_or(false)
2042 }
2043
2044 /// Canonical source text for a function. Returns from `function_source`
2045 /// (populated by autoload paths and runtime FuncDef registration via
2046 /// BUILTIN_REGISTER_COMPILED_FN with body_source). Returns `None` if
2047 /// no canonical source is on file.
2048 pub fn function_definition_text(&self, name: &str) -> Option<String> {
2049 self.function_source.get(name).cloned()
2050 }
2051
2052 /// Remove a function from both tables (compiled chunk + canonical
2053 /// source). Returns true iff at least one table held it.
2054 pub fn remove_function(&mut self, name: &str) -> bool {
2055 let a = self.functions_compiled.remove(name).is_some();
2056 let c = self.function_source.remove(name).is_some();
2057 let _ = self.function_line_base.remove(name);
2058 let _ = self.function_def_file.remove(name);
2059 a || c
2060 }
2061
2062 /// Sorted list of every known function name (union of compiled + source).
2063 pub fn function_names(&self) -> Vec<String> {
2064 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2065 for k in self.functions_compiled.keys() {
2066 set.insert(k.clone());
2067 }
2068 for k in self.function_source.keys() {
2069 set.insert(k.clone());
2070 }
2071 set.into_iter().collect()
2072 }
2073
2074 /// Dispatch a function by name through the new (compiled) pipeline.
2075 /// Mirrors `ZshrsHost::call_function`'s resolution order — checks
2076 /// `functions_compiled` first, triggers autoload if needed, then falls
2077 /// back to the legacy AST recompile path. Returns `None` if the name
2078 /// isn't a function (caller falls back to external dispatch).
2079 ///
2080 /// This is the synchronous-side replacement for the legacy
2081 /// `call_function(&ShellCommand, args)`. It avoids the AST detour when
2082 /// the new pipeline already has a Chunk for the function.
2083 pub fn dispatch_function_call(&mut self, name: &str, args: &[String]) -> Option<i32> {
2084 // maybe_autoload / autoload_function were deleted with the
2085 // old exec.c stubs. Until canonical autoload is wired,
2086 // resolve from functions_compiled directly.
2087 let chunk = self.functions_compiled.get(name).cloned()?;
2088
2089 // FUNCNEST guard — see `call_function` for the lower-than-
2090 // zsh ceiling rationale. Cap at 100 by default (matches
2091 // call_function's ceiling).
2092 let funcnest_limit: usize = self
2093 .scalar("FUNCNEST")
2094 .and_then(|s| s.parse().ok())
2095 .unwrap_or(100);
2096 if self.local_scope_depth >= funcnest_limit {
2097 eprintln!(
2098 "{}: maximum nested function level reached; increase FUNCNEST?",
2099 name
2100 );
2101 return Some(1);
2102 }
2103 // Save and replace positional params + local-scope save/restore,
2104 // mirroring the legacy `call_function(&ShellCommand, args)` and
2105 // ZshrsHost::call_function.
2106 let saved_params = self.pparams();
2107 self.set_pparams(args.to_vec());
2108 // FUNCTION_ARGZERO: zsh sets `\$0` inside a function to the
2109 // function name (default-on option). The bytecode-level
2110 // call_function path already does this; the dispatch path
2111 // used by dynamic-command-name dispatch (`f=hook; \$f`)
2112 // didn't, so plugin code reading `\$0` saw the binary path
2113 // instead. Save and install the function name; restore on
2114 // exit. Anonymous functions get the cosmetic `(anon)` per
2115 // call_function above.
2116 let display_name = if name.starts_with("_zshrs_anon_") {
2117 "(anon)".to_string()
2118 } else {
2119 name.to_string()
2120 };
2121 let saved_zero = crate::ported::params::getsparam("0");
2122 self.set_scalar("0".to_string(), display_name);
2123 self.local_scope_depth += 1;
2124 // c:Src/exec.c doshfunc startparamscope(): bump canonical
2125 // `locallevel` so any `local`/`typeset` inside the body
2126 // installs Params at the correct scope. endparamscope at
2127 // exit decrements + restores Param.old chain.
2128 crate::ported::params::locallevel.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2129 let line_base = self
2130 .function_line_base
2131 .get(name)
2132 .copied()
2133 .unwrap_or(0);
2134 let def_file = self.function_def_file.get(name).cloned().flatten();
2135 self.prompt_funcstack
2136 .push((name.to_string(), line_base, def_file));
2137
2138 crate::fusevm_disasm::maybe_print_stdout(&format!("function:{name}"), &chunk);
2139 let mut vm = fusevm::VM::new(chunk);
2140 register_builtins(&mut vm);
2141 let _ctx = ExecutorContext::enter(self);
2142 let _ = vm.run();
2143 let status = vm.last_status;
2144 drop(_ctx);
2145
2146 self.set_pparams(saved_params);
2147 self.prompt_funcstack.pop();
2148 // c:Src/exec.c doshfunc → endparamscope(). Decrements
2149 // canonical locallevel and walks paramtab restoring the
2150 // Param.old chain for every entry installed at this depth.
2151 crate::ported::params::endparamscope();
2152 self.local_scope_depth -= 1;
2153 match saved_zero {
2154 Some(v) => {
2155 self.set_scalar("0".to_string(), v);
2156 }
2157 None => {
2158 self.unset_scalar("0");
2159 }
2160 }
2161
2162 // Honor explicit `return N` from inside the function body.
2163 if let Some(ret) = self.returning.take() {
2164 self.set_last_status(ret);
2165 Some(ret)
2166 } else {
2167 self.set_last_status(status);
2168 Some(status)
2169 }
2170 }
2171
2172 pub(crate) fn execute_external(
2173 &mut self,
2174 cmd: &str,
2175 args: &[String],
2176 redirects: &[Redirect],
2177 ) -> Result<i32, String> {
2178 self.execute_external_bg(cmd, args, redirects, false)
2179 }
2180
2181 fn execute_external_bg(
2182 &mut self,
2183 cmd: &str,
2184 args: &[String],
2185 _redirects: &[Redirect],
2186 background: bool,
2187 ) -> Result<i32, String> {
2188 tracing::trace!(cmd, bg = background, "exec external");
2189 let mut command = Command::new(cmd);
2190 command.args(args);
2191
2192 // Redirect handling moved entirely to fusevm's WithRedirectsBegin/End
2193 // ops at compile time; the `_redirects` slice arrives empty in every
2194 // production code path. The legacy `for redir in redirects { ... }`
2195 // block (~120 LOC of file/pipe/heredoc/herestring/fd_var handling)
2196 // is gone.
2197
2198 if background {
2199 match command.spawn() {
2200 Ok(child) => {
2201 let pid = child.id();
2202 let cmd_str = format!("{} {}", cmd, args.join(" "));
2203 let job_id = self.jobs.add_job(child, cmd_str, JobState::Running);
2204 println!("[{}] {}", job_id, pid);
2205 Ok(0)
2206 }
2207 Err(e) => {
2208 if e.kind() == io::ErrorKind::NotFound {
2209 // zsh: absolute paths emit "no such file or
2210 // directory" (the OS error, since the path was
2211 // tried directly), not "command not found"
2212 // (which implies PATH search).
2213 if cmd.starts_with('/') {
2214 eprintln!("zshrs:1: no such file or directory: {}", cmd);
2215 } else {
2216 eprintln!("zshrs:1: command not found: {}", cmd);
2217 }
2218 Ok(127)
2219 } else {
2220 Err(format!("zshrs: {}: {}", cmd, e))
2221 }
2222 }
2223 }
2224 } else {
2225 match command.status() {
2226 Ok(status) => Ok(status.code().unwrap_or(1)),
2227 Err(e) => {
2228 if e.kind() == io::ErrorKind::NotFound {
2229 // zsh: absolute paths emit "no such file or
2230 // directory" (the OS error, since the path was
2231 // tried directly), not "command not found"
2232 // (which implies PATH search).
2233 if cmd.starts_with('/') {
2234 eprintln!("zshrs:1: no such file or directory: {}", cmd);
2235 } else {
2236 eprintln!("zshrs:1: command not found: {}", cmd);
2237 }
2238 Ok(127)
2239 } else if e.kind() == io::ErrorKind::PermissionDenied {
2240 // zsh: non-executable file → "permission denied"
2241 // on stderr and exit 126 (POSIX convention for
2242 // "command found but not executable"). zshrs
2243 // previously bubbled the IO error up via Err
2244 // and the surrounding code converted to 127
2245 // with no diagnostic.
2246 eprintln!("zshrs:1: permission denied: {}", cmd);
2247 Ok(126)
2248 } else {
2249 Err(format!("zshrs: {}: {}", cmd, e))
2250 }
2251 }
2252 }
2253 }
2254 }
2255
2256 pub(crate) fn collect_until_paren(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
2257 let mut result = String::new();
2258 let mut depth = 1;
2259
2260 for c in chars.by_ref() {
2261 if c == '(' {
2262 depth += 1;
2263 result.push(c);
2264 } else if c == ')' {
2265 depth -= 1;
2266 if depth == 0 {
2267 break;
2268 }
2269 result.push(c);
2270 } else {
2271 result.push(c);
2272 }
2273 }
2274
2275 result
2276 }
2277
2278 pub(crate) fn collect_until_double_paren(
2279 chars: &mut std::iter::Peekable<std::str::Chars>,
2280 ) -> String {
2281 let mut result = String::new();
2282 let mut arith_depth = 1; // Tracks $(( ... )) nesting
2283 let mut paren_depth = 0; // Tracks ( ... ) nesting within expression
2284
2285 while let Some(c) = chars.next() {
2286 if c == '(' {
2287 if paren_depth == 0 && chars.peek() == Some(&'(') {
2288 // Nested $(( - but we need to see if it's really another arithmetic
2289 // For simplicity, track inner parens
2290 paren_depth += 1;
2291 result.push(c);
2292 } else {
2293 paren_depth += 1;
2294 result.push(c);
2295 }
2296 } else if c == ')' {
2297 if paren_depth > 0 {
2298 // Inside nested parens, just close one level
2299 paren_depth -= 1;
2300 result.push(c);
2301 } else if chars.peek() == Some(&')') {
2302 // At top level and seeing )) - this closes our arithmetic
2303 chars.next();
2304 arith_depth -= 1;
2305 if arith_depth == 0 {
2306 break;
2307 }
2308 result.push_str("))");
2309 } else {
2310 // Single ) at top level - shouldn't happen in valid expression
2311 result.push(c);
2312 }
2313 } else {
2314 result.push(c);
2315 }
2316 }
2317
2318 result
2319 }
2320
2321 /// Parse `cmd_str` via parse_init+parse and pull out the first Simple
2322 /// command's words, untokenized + variable-expanded, ready to spawn
2323 /// as argv. Used by process-substitution where we need raw argv to
2324 /// hand to `Command::new`. Returns empty vec if the cmd isn't a
2325 /// simple shape — pipelines / compound forms aren't process-sub
2326 /// friendly anyway.
2327 fn simple_cmd_words(&mut self, cmd_str: &str) -> Vec<String> {
2328 // Mirror Src/init.c-style errflag save/clear/check around the
2329 // parse. Process-sub argv extraction silently bails on syntax
2330 // errors (matches zsh's behavior when the inner command can't
2331 // be parsed).
2332 let saved_errflag = errflag.load(Ordering::Relaxed);
2333 errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
2334 crate::ported::parse::parse_init(cmd_str);
2335 let prog = crate::ported::parse::parse();
2336 let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
2337 errflag.store(saved_errflag, Ordering::Relaxed);
2338 if parse_failed {
2339 return Vec::new();
2340 }
2341 let first = match prog.lists.first() {
2342 Some(l) => l,
2343 None => return Vec::new(),
2344 };
2345 let pipe = &first.sublist.pipe;
2346 if let crate::parse::ZshCommand::Simple(simple) = &pipe.cmd {
2347 simple
2348 .words
2349 .iter()
2350 .map(|w| {
2351 // Untokenize then variable-expand — text-based
2352 // word expansion for the spawned argv.
2353 let untoked = crate::lex::untokenize(w);
2354 crate::ported::subst::singsub(&untoked)
2355 })
2356 .collect()
2357 } else {
2358 Vec::new()
2359 }
2360 }
2361
2362 pub(crate) fn run_process_sub_in(&mut self, cmd_str: &str) -> String {
2363
2364 // Phase 2: parse via parse_init+parse. Extract the first Simple cmd's
2365 // words (untokenized), pre-expand to argv strings, spawn.
2366 let words = self.simple_cmd_words(cmd_str);
2367
2368 // Create a unique FIFO in temp directory
2369 let fifo_path = format!("/tmp/zshrs_psub_{}", std::process::id());
2370 let fifo_counter = self.process_sub_counter;
2371 self.process_sub_counter += 1;
2372 let fifo_path = format!("{}_{}", fifo_path, fifo_counter);
2373
2374 // Remove if exists, then create FIFO
2375 let _ = fs::remove_file(&fifo_path);
2376 if nix::unistd::mkfifo(fifo_path.as_str(), nix::sys::stat::Mode::S_IRWXU).is_err() {
2377 return String::new();
2378 }
2379
2380 // Spawn command that writes to the FIFO
2381 let fifo_clone = fifo_path.clone();
2382 if !words.is_empty() {
2383 let cmd_name = words[0].clone();
2384 let args: Vec<String> = words[1..].to_vec();
2385
2386 self.worker_pool.submit(move || {
2387 // Open FIFO for writing (will block until reader connects)
2388 if let Ok(fifo) = fs::OpenOptions::new().write(true).open(&fifo_clone) {
2389 let _ = Command::new(&cmd_name)
2390 .args(&args)
2391 .stdout(fifo)
2392 .stderr(Stdio::inherit())
2393 .status();
2394 }
2395 // Clean up FIFO after command completes
2396 let _ = fs::remove_file(&fifo_clone);
2397 });
2398 }
2399
2400 fifo_path
2401 }
2402
2403 pub(crate) fn run_process_sub_out(&mut self, cmd_str: &str) -> String {
2404
2405 let words = self.simple_cmd_words(cmd_str);
2406
2407 // Create a unique FIFO in temp directory
2408 let fifo_path = format!("/tmp/zshrs_psub_{}", std::process::id());
2409 let fifo_counter = self.process_sub_counter;
2410 self.process_sub_counter += 1;
2411 let fifo_path = format!("{}_{}", fifo_path, fifo_counter);
2412
2413 // Remove if exists, then create FIFO
2414 let _ = fs::remove_file(&fifo_path);
2415 if nix::unistd::mkfifo(fifo_path.as_str(), nix::sys::stat::Mode::S_IRWXU).is_err() {
2416 return String::new();
2417 }
2418
2419 // Spawn command that reads from the FIFO
2420 let fifo_clone = fifo_path.clone();
2421 if !words.is_empty() {
2422 let cmd_name = words[0].clone();
2423 let args: Vec<String> = words[1..].to_vec();
2424
2425 self.worker_pool.submit(move || {
2426 // Open FIFO for reading (will block until writer connects)
2427 if let Ok(fifo) = fs::File::open(&fifo_clone) {
2428 let _ = Command::new(&cmd_name)
2429 .args(&args)
2430 .stdin(fifo)
2431 .stdout(Stdio::inherit())
2432 .stderr(Stdio::inherit())
2433 .status();
2434 }
2435 // Clean up FIFO after command completes
2436 let _ = fs::remove_file(&fifo_clone);
2437 });
2438 }
2439
2440 fifo_path
2441 }
2442
2443 pub fn run_command_substitution(&mut self, cmd_str: &str) -> String {
2444 // `$(< FILE)` — zsh shorthand for "read FILE contents". Faster
2445 // than spawning `cat`. The leading `<` (after stripping
2446 // whitespace) means "read this file". Trailing newline is
2447 // stripped (same as command-substitution).
2448 let trimmed = cmd_str.trim_start();
2449 // Only treat as `$(<file)` shorthand when the SINGLE leading `<`
2450 // is followed by a filename, not another `<`. `$(<<<"hi" cat)`
2451 // starts with `<<<` (here-string) and must go through the full
2452 // parse path, not the read-file shortcut.
2453 if let Some(rest) = trimmed.strip_prefix('<').filter(|s| !s.starts_with('<')) {
2454 let filename = rest.trim();
2455 // Expand any leading $ / tilde in the filename so
2456 // `$(< $f)` and `$(< ~/x)` work.
2457 let resolved = if filename.contains('$') || filename.starts_with('~') {
2458 crate::ported::subst::singsub(filename)
2459 } else {
2460 filename.to_string()
2461 };
2462 let resolved = resolved.to_string();
2463 match std::fs::read_to_string(&resolved) {
2464 Ok(contents) => {
2465 return contents.trim_end_matches('\n').to_string();
2466 }
2467 Err(_) => {
2468 eprintln!("zshrs:1: no such file or directory: {}", resolved);
2469 return String::new();
2470 }
2471 }
2472 }
2473
2474 // Port of getoutput(char *cmd, int qt) from Src/exec.c. Parse and compile via
2475 // the lex+parse free fns + ZshCompiler pipeline, run on a
2476 // sub-VM with the host wired up. Stdout is captured through
2477 // an in-process pipe via dup2 — no fork.
2478 //
2479 // This single path replaces the prior "internal vs external"
2480 // fast-path split: the sub-VM emits Op::Exec for unknown
2481 // command names, which forks/execs through the host.
2482
2483 // Set up the stdout-capture pipe. We dup the original stdout
2484 // so post-run we can restore it; the write end is dup2'd onto
2485 // STDOUT_FILENO so all output the sub-VM emits (including from
2486 // forked children, which inherit fd 1) lands in the pipe.
2487 let (read_fd, write_fd) = {
2488 let mut fds = [0i32; 2];
2489 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
2490 return String::new();
2491 }
2492 (fds[0], fds[1])
2493 };
2494 let saved_stdout = unsafe { libc::dup(libc::STDOUT_FILENO) };
2495 if saved_stdout < 0 {
2496 unsafe {
2497 libc::close(read_fd);
2498 libc::close(write_fd);
2499 }
2500 return String::new();
2501 }
2502 unsafe {
2503 libc::dup2(write_fd, libc::STDOUT_FILENO);
2504 libc::close(write_fd);
2505 }
2506
2507 // Parse + compile + run.
2508 // Push CS_CMDSUBST for `%_` xtrace prefix — direct port of
2509 // Src/exec.c:4783 `cmdpush(CS_CMDSUBST);` around execode().
2510 // Trace lines emitted by the inner program inherit this token
2511 // so their PS4 prefix shows "cmdsubst" matching zsh -x.
2512 crate::ported::prompt::cmdpush(crate::ported::zsh_h::CS_CMDSUBST as u8); // c:zsh.h:2799
2513 // Save LINENO so the inner cmdsubst's line counter doesn't
2514 // leak into the outer trace — direct port of Src/exec.c:1407
2515 // `oldlineno = lineno;` followed by `lineno = oldlineno;`
2516 // restore at line 1640. Inner program parses fresh as line 1
2517 // and increments from there; once it returns, the outer
2518 // line at the `$(…)` site must read the original outer
2519 // lineno (so xtrace renders `+:5:> echo …` not `+:1:> …`).
2520 let saved_lineno = crate::ported::params::getsparam("LINENO");
2521 // Anchor the inner program's lineno to the outer's current
2522 // $LINENO so xtrace inside the cmdsubst renders the outer
2523 // line. zsh's execlist preserves lineno across the inner
2524 // exec — for our sub-VM (fresh compile) we use lineno_addend
2525 // to shift inner's line N → outer_lineno + (N - 1).
2526 let outer_lineno: u64 = self
2527 .scalar("LINENO")
2528 .and_then(|s| s.parse::<u64>().ok())
2529 .unwrap_or(0);
2530 // Mirror Src/init.c errflag save/clear/check pattern around
2531 // the nested parse so an inner syntax error doesn't bleed into
2532 // the outer execution.
2533 let saved_errflag = errflag.load(Ordering::Relaxed);
2534 errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
2535 crate::ported::parse::parse_init(cmd_str);
2536 let parsed = crate::ported::parse::parse();
2537 let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
2538 errflag.store(saved_errflag, Ordering::Relaxed);
2539 let prog = if parse_failed { None } else { Some(parsed) };
2540 let mut cmd_status: Option<i32> = None;
2541 if let Some(prog) = prog {
2542 let mut compiler = crate::compile_zsh::ZshCompiler::new();
2543 compiler.lineno_addend = outer_lineno.saturating_sub(1);
2544 let chunk = compiler.compile(&prog);
2545 if !chunk.ops.is_empty() {
2546 crate::fusevm_disasm::maybe_print_stdout("run_command_substitution", &chunk);
2547 let mut vm = fusevm::VM::new(chunk);
2548 register_builtins(&mut vm);
2549 vm.set_shell_host(Box::new(ZshrsHost));
2550 // Seed inner $? with the outer's last_status so the
2551 // sub-shell inherits the parent's exit code. Direct
2552 // port of Src/exec.c:4783 around execcmd_exec — the
2553 // child inherits `lastval` at fork time, so `false;
2554 // echo $(echo $?)` reads 1, not the freshly-zeroed
2555 // sub-VM default. Without this, every cmd-subst
2556 // started with $?==0 regardless of the parent's
2557 // last command.
2558 vm.last_status = self.last_status();
2559 let _ctx = ExecutorContext::enter(self);
2560 let _ = vm.run();
2561 cmd_status = Some(vm.last_status);
2562 }
2563 }
2564 // Restore LINENO so outer xtrace sees the outer line.
2565 if let Some(ln) = saved_lineno {
2566 self.set_scalar("LINENO".to_string(), ln);
2567 }
2568 crate::ported::prompt::cmdpop();
2569 // Propagate the inner cmd's status to the parent shell. zsh:
2570 // `a=$(false); echo $?` → 1 because cmd-subst status leaks to
2571 // $?. Set last_status on the executor so $? reads the right
2572 // value for callers that don't have a SetStatus(0) overwrite
2573 // (echo, test, etc.). Bare assignment paths still get the
2574 // SetStatus(0) from compile_simple — that's a separate gap.
2575 // Empty cmd-subst (`\`\``, `$()`) resets status to 0 per
2576 // Src/exec.c — the inner ran no command so the "last
2577 // command's exit" is the implicit success of "did nothing".
2578 // Without this branch, a prior command's non-zero status
2579 // leaked through the empty cmd-subst.
2580 if let Some(status) = cmd_status {
2581 self.set_last_status(status);
2582 } else {
2583 self.set_last_status(0);
2584 }
2585
2586 // Flush any buffered Rust-side stdout so it reaches the pipe
2587 // before we restore.
2588 let _ = io::stdout().flush();
2589
2590 // Restore stdout and read what was captured.
2591 unsafe {
2592 libc::dup2(saved_stdout, libc::STDOUT_FILENO);
2593 libc::close(saved_stdout);
2594 }
2595 let read_file = unsafe { std::fs::File::from_raw_fd(read_fd) };
2596 let mut output = String::new();
2597 let _ = std::io::BufReader::new(read_file).read_to_string(&mut output);
2598
2599 // POSIX: trailing newlines stripped from cmd-sub result.
2600 while output.ends_with('\n') {
2601 output.pop();
2602 }
2603 output
2604 }
2605
2606 // ksh_autoload_body moved to src/ported/builtin.rs
2607}
2608
2609impl Default for ShellExecutor {
2610 fn default() -> Self {
2611 Self::new()
2612 }
2613}
2614
2615#[cfg(test)]
2616mod tests {
2617 use super::*;
2618
2619 #[test]
2620 fn test_simple_echo() {
2621 let mut exec = ShellExecutor::new();
2622 let status = exec.execute_script("true").unwrap();
2623 assert_eq!(status, 0);
2624 }
2625
2626 #[test]
2627 fn test_if_true() {
2628 let mut exec = ShellExecutor::new();
2629 let status = exec.execute_script("if true; then true; fi").unwrap();
2630 assert_eq!(status, 0);
2631 }
2632
2633 #[test]
2634 fn test_if_false() {
2635 let mut exec = ShellExecutor::new();
2636 let status = exec
2637 .execute_script("if false; then true; else false; fi")
2638 .unwrap();
2639 assert_eq!(status, 1);
2640 }
2641
2642 #[test]
2643 fn test_for_loop() {
2644 let mut exec = ShellExecutor::new();
2645 exec.execute_script("for i in a b c; do true; done")
2646 .unwrap();
2647 assert_eq!(exec.last_status(), 0);
2648 }
2649
2650 #[test]
2651 fn test_and_list() {
2652 let mut exec = ShellExecutor::new();
2653 let status = exec.execute_script("true && true").unwrap();
2654 assert_eq!(status, 0);
2655
2656 let status = exec.execute_script("true && false").unwrap();
2657 assert_eq!(status, 1);
2658 }
2659
2660 #[test]
2661 fn test_or_list() {
2662 let mut exec = ShellExecutor::new();
2663 let status = exec.execute_script("false || true").unwrap();
2664 assert_eq!(status, 0);
2665 }
2666
2667 /// Pin: `forklevel` matches the C global declared at
2668 /// `Src/exec.c:1052` (`int forklevel;`). Like `int` in C, the
2669 /// Rust port is an AtomicI32 starting at 0 (no fork has occurred
2670 /// at process start). Per `Src/exec.c:1221` (`forklevel =
2671 /// locallevel;`), every subshell entry copies `locallevel` into
2672 /// the global; the SIGPIPE handler at `Src/signals.c:808` reads
2673 /// it back to distinguish the top-level shell from a subshell.
2674 #[test]
2675 fn test_forklevel_default_zero_and_roundtrip() {
2676 use std::sync::atomic::Ordering;
2677 let prev = FORKLEVEL.load(Ordering::Relaxed);
2678 // Default state at process start: zero (matches C's BSS init
2679 // of `int forklevel;` to 0).
2680 FORKLEVEL.store(0, Ordering::Relaxed);
2681 assert_eq!(FORKLEVEL.load(Ordering::Relaxed), 0);
2682 // Simulate the c:1221 store: `forklevel = locallevel;`.
2683 FORKLEVEL.store(3, Ordering::Relaxed);
2684 assert_eq!(FORKLEVEL.load(Ordering::Relaxed), 3);
2685 FORKLEVEL.store(prev, Ordering::Relaxed);
2686 }
2687}
2688
2689// Plugin-Framework-Agnostic State-Modification Recorder hook helpers.
2690/// Recorder helper: emit one record for an array/scalar mutation
2691/// targeting a path-family parameter (path/fpath/manpath/module_path/
2692/// cdpath, lower- or upper-cased), or one `assign` record for any
2693/// other name. Centralises the path-family list so `BUILTIN_SET_ARRAY`,
2694/// `BUILTIN_APPEND_ARRAY`, and `BUILTIN_APPEND_SCALAR_OR_PUSH` share
2695/// the same routing.
2696///
2697/// `is_append` distinguishes `arr=(...)` from `arr+=(...)` so the
2698/// emitted event carries the APPEND attr bit and replay can choose
2699/// between fresh-set and extend semantics.
2700///
2701/// `attrs` carries any pre-existing type info from
2702/// `recorder_attrs_for(name)` (readonly/export/global) — array shape
2703/// and APPEND get OR'd in by emit_array_assign.
2704#[cfg(feature = "recorder")]
2705pub(crate) fn emit_path_or_assign(
2706 name: &str,
2707 values: &[String],
2708 attrs: crate::recorder::ParamAttrs,
2709 is_append: bool,
2710 ctx: &crate::recorder::RecordCtx,
2711) {
2712 let lower = name.to_ascii_lowercase();
2713 let kind_name: Option<&'static str> = match lower.as_str() {
2714 "path" => Some("path"),
2715 "fpath" => Some("fpath"),
2716 "manpath" => Some("manpath"),
2717 "module_path" => Some("module_path"),
2718 "cdpath" => Some("cdpath"),
2719 _ => None,
2720 };
2721 match kind_name {
2722 Some(k) => {
2723 for v in values {
2724 crate::recorder::emit_path_mod(v, k, ctx.clone());
2725 // Each fpath addition also surfaces every `_completion`
2726 // file inside the directory — matches zinit-report's
2727 // per-plugin "Completions:" listing. Only fpath dirs
2728 // get this treatment; PATH dirs hold executables, not
2729 // completion functions.
2730 if k == "fpath" {
2731 crate::recorder::discover_completions_in_fpath_dir(v, ctx);
2732 }
2733 }
2734 }
2735 None => {
2736 // Non-path arrays: emit ONE `assign` event with the
2737 // ordered element list preserved in value_array. Replay
2738 // reconstructs `name=(elem1 elem2 ...)` exactly without
2739 // having to re-split a joined string.
2740 crate::recorder::emit_array_assign(
2741 name,
2742 values.to_vec(),
2743 attrs,
2744 is_append,
2745 ctx.clone(),
2746 );
2747 }
2748 }
2749}
2750
2751// Whole impl block is `#[cfg(feature = "recorder")]` so the default
2752// build sees no recorder symbols on `ShellExecutor`.
2753#[cfg(feature = "recorder")]
2754impl ShellExecutor {}
2755
2756#[cfg(feature = "recorder")]
2757impl ShellExecutor {}
2758
2759impl ShellExecutor {
2760 /// `add-zsh-hook` registration stub. The hooks-machinery file was
2761 /// removed from extensions/; these no-ops preserve the call sites
2762 /// in `ext_builtins.rs::builtin_add_zsh_hook` and `plugin_cache.rs`.
2763 pub(crate) fn add_hook(&mut self, _hook: &str, _func: &str) {}
2764
2765 /// `add-zsh-hook -d` removal — same stub rationale as `add_hook`.
2766 pub(crate) fn delete_hook(&mut self, _hook: &str, _func: &str) {}
2767
2768 // ═══════════════════════════════════════════════════════════════════
2769 // AOP INTERCEPT — the killer builtin
2770 // ═══════════════════════════════════════════════════════════════════
2771
2772 // ═══════════════════════════════════════════════════════════════════
2773 // CONCURRENT PRIMITIVES — ship work to the worker pool from shell
2774 // No stryke dependency. Pure zshrs. Thin binary gets full parallelism.
2775 // ═══════════════════════════════════════════════════════════════════
2776}
2777
2778
2779impl ShellExecutor {
2780 // ═══════════════════════════════════════════════════════════════════════════
2781 // Additional zsh builtins
2782 // ═══════════════════════════════════════════════════════════════════════════
2783
2784 /// Helper to check if name is a builtin. Consults the canonical
2785 /// `BUILTINS` table (`src/ported/builtin.rs:122`, the 1:1 port of
2786 /// `static struct builtin builtins[]` at `Src/builtin.c:40-137`).
2787 /// Earlier implementation hardcoded a separate `BUILTIN_SET`
2788 /// HashSet of 130+ names — duplicated state that drifts when new
2789 /// builtins land in the canonical table. The cached lookup set
2790 /// below is built once from `BUILTINS` so the O(1) cost stays
2791 /// without a separate authoritative list.
2792 pub(crate) fn is_builtin(&self, name: &str) -> bool {
2793 BUILTIN_NAMES.contains(name) || name.starts_with('_')
2794 }
2795
2796 /// Helper to find command in PATH. The fast path consults the
2797 /// `command_hash` table (rebuilt by `rehash` per `Src/Modules/
2798 /// hashed.c`); the slow path delegates to the canonical port of
2799 /// `findcmd()` (`Src/exec.c:5260`, ported at
2800 /// `src/ported/builtin.rs:4047`). Earlier inline PATH walk
2801 /// duplicated findcmd's logic without honoring `name.contains('/')`
2802 /// (the C source returns the literal path for slashed names
2803 /// without walking $PATH).
2804 pub(crate) fn find_in_path(&self, name: &str) -> Option<String> {
2805 // Canonical command-hash lives in `cmdnamtab`. `get_full_path`
2806 // returns the resolved path for HASHED entries.
2807 if let Some(p) = crate::ported::hashtable::cmdnamtab_lock()
2808 .read().ok()
2809 .and_then(|t| t.get_full_path(name))
2810 {
2811 return Some(p.display().to_string());
2812 }
2813 crate::ported::builtin::findcmd(name, 0, 0) // c:exec.c:5260
2814 }
2815
2816 // ═══════════════════════════════════════════════════════════════════════════
2817 // zsh module builtins
2818 // ═══════════════════════════════════════════════════════════════════════════
2819
2820 // =========================================================================
2821 // Process control functions - Port from exec.c
2822 // =========================================================================
2823
2824 /// Fork a new process
2825 /// Port of zfork(struct timespec *ts) from exec.c
2826 pub fn zfork(&mut self, flags: ForkFlags) -> std::io::Result<ForkResult> {
2827 // Check for job control
2828 let can_background = crate::ported::options::opt_state_get("monitor").unwrap_or(false);
2829
2830 unsafe {
2831 match libc::fork() {
2832 -1 => Err(std::io::Error::last_os_error()),
2833 0 => {
2834 // Child process
2835 if !flags.contains(ForkFlags::NOJOB) && can_background {
2836 // Set up job control
2837 let pid = libc::getpid();
2838 if flags.contains(ForkFlags::NEWGRP) {
2839 libc::setpgid(0, 0);
2840 }
2841 if flags.contains(ForkFlags::FGTTY) {
2842 libc::tcsetpgrp(0, pid);
2843 }
2844 }
2845
2846 // Reset signal handlers
2847 if !flags.contains(ForkFlags::KEEPSIGS) {
2848 self.reset_signals();
2849 }
2850
2851 Ok(ForkResult::Child)
2852 }
2853 pid => {
2854 // Parent process
2855 if !flags.contains(ForkFlags::NOJOB) {
2856 // Add to job table
2857 self.add_child_process(pid);
2858 }
2859 Ok(ForkResult::Parent(pid))
2860 }
2861 }
2862 }
2863 }
2864
2865 /// Add a child process to tracking
2866 fn add_child_process(&mut self, pid: i32) {
2867 // Would track in job table
2868 self.set_scalar("!".to_string(), pid.to_string());
2869 }
2870
2871 /// Reset signal handlers to defaults
2872 fn reset_signals(&self) {
2873 unsafe {
2874 libc::signal(libc::SIGINT, libc::SIG_DFL);
2875 libc::signal(libc::SIGQUIT, libc::SIG_DFL);
2876 libc::signal(libc::SIGTERM, libc::SIG_DFL);
2877 libc::signal(libc::SIGTSTP, libc::SIG_DFL);
2878 libc::signal(libc::SIGTTIN, libc::SIG_DFL);
2879 libc::signal(libc::SIGTTOU, libc::SIG_DFL);
2880 libc::signal(libc::SIGCHLD, libc::SIG_DFL);
2881 }
2882 }
2883
2884 /// Execute a command in the current process (exec family)
2885 /// Port of zexecve(char *pth, char **argv, char **newenvp) from exec.c
2886 pub fn zexecve(&self, cmd: &str, args: &[String]) -> ! {
2887
2888 let c_cmd = CString::new(cmd).expect("CString::new failed");
2889
2890 // Build argv
2891 let c_args: Vec<CString> = std::iter::once(c_cmd.clone())
2892 .chain(args.iter().map(|s| CString::new(s.as_str()).unwrap()))
2893 .collect();
2894
2895 let c_argv: Vec<*const libc::c_char> = c_args
2896 .iter()
2897 .map(|s| s.as_ptr())
2898 .chain(std::iter::once(std::ptr::null()))
2899 .collect();
2900
2901 // Build envp from current environment
2902 let env_vars: Vec<CString> = std::env::vars()
2903 .map(|(k, v)| CString::new(format!("{}={}", k, v)).unwrap())
2904 .collect();
2905
2906 let c_envp: Vec<*const libc::c_char> = env_vars
2907 .iter()
2908 .map(|s| s.as_ptr())
2909 .chain(std::iter::once(std::ptr::null()))
2910 .collect();
2911
2912 unsafe {
2913 libc::execve(c_cmd.as_ptr(), c_argv.as_ptr(), c_envp.as_ptr());
2914 // If we get here, exec failed
2915 eprintln!(
2916 "zshrs: exec failed: {}: {}",
2917 cmd,
2918 std::io::Error::last_os_error()
2919 );
2920 std::process::exit(127);
2921 }
2922 }
2923
2924 /// Enter a subshell
2925 /// Port of entersubsh(int flags, struct entersubsh_ret *retp) from exec.c
2926 pub fn entersubsh(&mut self, flags: SubshellFlags) {
2927 // Increment subshell level
2928 let level = self
2929 .get_variable("ZSH_SUBSHELL")
2930 .parse::<i32>()
2931 .unwrap_or(0);
2932 self.set_scalar("ZSH_SUBSHELL".to_string(), (level + 1).to_string());
2933
2934 // Handle job control
2935 if flags.contains(SubshellFlags::NOMONITOR) {
2936 crate::ported::options::opt_state_set("monitor", false);
2937 }
2938
2939 // Close unneeded fds
2940 if !flags.contains(SubshellFlags::KEEPFDS) {
2941 self.close_extra_fds();
2942 }
2943
2944 // Reset traps
2945 if !flags.contains(SubshellFlags::KEEPTRAPS) {
2946 self.reset_traps();
2947 }
2948
2949 // c:1221 — `forklevel = locallevel;` at the tail of entersubsh.
2950 // Records the locallevel depth at this subshell entry so the
2951 // SIGPIPE handler (signals.c:808) and the WARNCREATEGLOBAL
2952 // nest-depth check (params.c:3724) can distinguish parent vs
2953 // child shell contexts.
2954 let cur_local = crate::ported::params::locallevel
2955 .load(std::sync::atomic::Ordering::Relaxed);
2956 FORKLEVEL.store(cur_local, std::sync::atomic::Ordering::Relaxed); // c:1221 (Src/exec.c)
2957 }
2958
2959 /// Close extra file descriptors
2960 fn close_extra_fds(&self) {
2961 // Close fds > 10 (common shell convention)
2962 for fd in 10..256 {
2963 unsafe {
2964 libc::close(fd);
2965 }
2966 }
2967 }
2968
2969 fn reset_traps(&mut self) {
2970 self.traps.clear();
2971 }
2972
2973
2974 // ═══════════════════════════════════════════════════════════════════════
2975 // Coreutils builtins (anti-fork) — only active when !posix_mode
2976 // ═══════════════════════════════════════════════════════════════════════
2977
2978 // nproc-equivalent already exists via builtin_nproc.
2979}
2980
2981use std::os::unix::fs::MetadataExt;
2982
2983bitflags::bitflags! {
2984 /// Flags for zfork()
2985 #[derive(Debug, Clone, Copy, Default)]
2986 pub struct ForkFlags: u32 {
2987 const NOJOB = 1 << 0; // Don't add to job table
2988 const NEWGRP = 1 << 1; // Create new process group
2989 const FGTTY = 1 << 2; // Take foreground terminal
2990 const KEEPSIGS = 1 << 3; // Keep signal handlers
2991 }
2992}
2993
2994bitflags::bitflags! {
2995 /// Flags for entersubsh()
2996 #[derive(Debug, Clone, Copy, Default)]
2997 pub struct SubshellFlags: u32 {
2998 const NOMONITOR = 1 << 0; // Disable job control
2999 const KEEPFDS = 1 << 1; // Keep file descriptors
3000 const KEEPTRAPS = 1 << 2; // Keep trap handlers
3001 }
3002}
3003
3004/// Result of fork operation
3005#[derive(Debug)]
3006/// `fork()` outcome (parent / child / error).
3007/// Mirrors the integer return of `zfork()` from Src/exec.c:349.
3008pub enum ForkResult {
3009 Parent(i32), // Contains child PID
3010 Child,
3011}
3012
3013/// Redirection mode
3014#[derive(Debug, Clone, Copy)]
3015/// File-redirection mode (`>` / `>>` / `<` / etc.).
3016/// Mirrors the `REDIR_*` enum from Src/zsh.h.
3017pub enum RedirMode {
3018 Dup,
3019 Close,
3020}
3021
3022/// Builtin command type
3023#[derive(Debug, Clone, Copy)]
3024/// Builtin classification.
3025/// Mirrors the `BINF_*` flag set Src/builtin.c uses to
3026/// classify special vs regular builtins.
3027pub enum BuiltinType {
3028 Normal,
3029 Disabled,
3030}
3031
3032// =====================================================================
3033// Builtin dispatch stubs.
3034//
3035// These methods used to live in `src/ported/builtin.rs` inside
3036// `impl ShellExecutor` blocks. Per user feedback ("each of those
3037// bin_* is fake anyways"), the impl blocks were deleted from the
3038// port tree. The methods are recreated here as stubs so existing
3039// callers (fusevm_bridge, ext_builtins, exec.rs's own dispatch loop)
3040// keep compiling. Each stub delegates to the canonical free-fn port
3041// at `crate::ported::builtin::bin_X` when one exists, or returns 0.
3042//
3043// The recorder hooks the original methods carried are preserved as
3044// commented snippets at the bottom of `src/ported/builtin.rs` —
3045// they will be re-wired here once the canonical bin_* ports are
3046// true to C.
3047// =====================================================================
3048#[allow(unused_variables, dead_code)]
3049impl ShellExecutor {
3050 fn _empty_ops() -> crate::ported::zsh_h::options {
3051 options { ind: [0u8; MAX_OPS], args: Vec::new(),
3052 argscount: 0, argsalloc: 0 }
3053 }
3054
3055 pub(crate) fn dispatch_pending_traps(&mut self) {}
3056 // Note: `recorder_ctx()` lives in src/extensions/recorder.rs
3057 // (gated behind --features recorder). Do not stub it here or
3058 // you'll get a duplicate-definition error when the recorder
3059 // feature is enabled.
3060
3061 pub(crate) fn builtin_pwd_with_args(&mut self, args: &[String]) -> i32 {
3062 let ops = Self::_empty_ops();
3063 crate::ported::builtin::bin_pwd("pwd", args, &ops, 0)
3064 }
3065 pub(crate) fn bin_zcompile(&mut self, args: &[String]) -> i32 {
3066 // C-faithful dispatch via the canonical port in
3067 // `src/ported/parse.rs::bin_zcompile` (port of `Src/parse.c:3180`).
3068 // The caller's argv is the full builtin command line, e.g.
3069 // `["zcompile", "-t", "/path/file.zwc"]`. Parse option chars
3070 // into an `options` ind[] bitmap and pass positionals.
3071 let mut ops = options {
3072 ind: [0u8; MAX_OPS],
3073 args: Vec::new(),
3074 argscount: 0,
3075 argsalloc: 0,
3076 };
3077 let mut positional: Vec<String> = Vec::new();
3078 let mut consume_opts = true;
3079 // `args` from pop_args holds positional + option words only —
3080 // no leading builtin name (matches what `bin_*` entry points
3081 // get from C's `bin_X(char *nam, char **args, ...)` arg vector).
3082 for a in args.iter() {
3083 if consume_opts && a == "--" { consume_opts = false; continue; }
3084 if consume_opts && a.starts_with('-') && a.len() > 1 {
3085 for c in a.bytes().skip(1) {
3086 let idx = c as usize;
3087 if idx < MAX_OPS {
3088 ops.ind[idx] |= 1; // bit 1 = OPT_MINUS (set as `-X`)
3089 }
3090 }
3091 continue;
3092 }
3093 consume_opts = false;
3094 positional.push(a.clone());
3095 }
3096 crate::ported::parse::bin_zcompile("zcompile", &positional, &ops, 0)
3097 }
3098
3099 // zsh implements echo/printf via funcid dispatch into shared
3100 // `bin_print` (Src/builtin.c:4587 — BIN_ECHO and BIN_PRINTF
3101 // funcids route through the same handler). Route through
3102 // `execbuiltin` (Src/builtin.c:250) so flag parsing matches C
3103 // — earlier impl was `println!("{}", args.join(" "))` which
3104 // ignored `-n` / `-e` flags and didn't interpret escape
3105 // sequences.
3106 pub(crate) fn builtin_echo(&mut self, args: &[String], _redir: &[crate::parse::Redirect]) -> i32 {
3107 let bn_idx = crate::ported::builtin::BUILTINS.iter()
3108 .position(|b| b.node.nam == "echo");
3109 match bn_idx {
3110 Some(idx) => {
3111 let bn_static: &'static crate::ported::zsh_h::builtin =
3112 &crate::ported::builtin::BUILTINS[idx];
3113 let bn_ptr = bn_static as *const _ as *mut _;
3114 crate::ported::builtin::execbuiltin(args.to_vec(), Vec::new(), bn_ptr)
3115 }
3116 None => {
3117 let ops = Self::_empty_ops();
3118 crate::ported::builtin::bin_print("echo", args, &ops,
3119 crate::ported::builtin::BIN_ECHO)
3120 }
3121 }
3122 }
3123 pub(crate) fn builtin_printf(&mut self, args: &[String]) -> i32 {
3124 let bn_idx = crate::ported::builtin::BUILTINS.iter()
3125 .position(|b| b.node.nam == "printf");
3126 match bn_idx {
3127 Some(idx) => {
3128 let bn_static: &'static crate::ported::zsh_h::builtin =
3129 &crate::ported::builtin::BUILTINS[idx];
3130 let bn_ptr = bn_static as *const _ as *mut _;
3131 crate::ported::builtin::execbuiltin(args.to_vec(), Vec::new(), bn_ptr)
3132 }
3133 None => {
3134 let ops = Self::_empty_ops();
3135 crate::ported::builtin::bin_print("printf", args, &ops,
3136 crate::ported::builtin::BIN_PRINTF)
3137 }
3138 }
3139 }
3140 pub(crate) fn builtin_local(&mut self, args: &[String]) -> i32 {
3141 self.dispatch_typeset_as("local", args)
3142 }
3143 pub(crate) fn builtin_export(&mut self, args: &[String]) -> i32 {
3144 self.dispatch_typeset_as("export", args)
3145 }
3146 pub(crate) fn builtin_readonly(&mut self, args: &[String]) -> i32 {
3147 self.dispatch_typeset_as("readonly", args)
3148 }
3149 pub(crate) fn builtin_declare(&mut self, args: &[String]) -> i32 {
3150 self.dispatch_typeset_as("declare", args)
3151 }
3152 pub(crate) fn builtin_typeset_named(&mut self, name: &str, args: &[String]) -> i32 {
3153 self.dispatch_typeset_as(name, args)
3154 }
3155 pub(crate) fn builtin_integer(&mut self, args: &[String]) -> i32 {
3156 self.dispatch_typeset_as("integer", args)
3157 }
3158 pub(crate) fn builtin_float(&mut self, args: &[String]) -> i32 {
3159 self.dispatch_typeset_as("float", args)
3160 }
3161 /// Route `local`/`export`/`readonly`/`declare`/`integer`/`float`
3162 /// through canonical `bin_typeset` via `execbuiltin` so the
3163 /// BUILTIN("…") entry's optstr is parsed and the `nm0` char in
3164 /// `bin_typeset` picks up the PM_LOCAL implicit-scope path
3165 /// (`l` → local, `r`+func → PM_READONLY etc.). The old stub
3166 /// only `env::set_var`-d which gave no local scoping or
3167 /// type-flag effect.
3168 fn dispatch_typeset_as(&mut self, name: &str, args: &[String]) -> i32 {
3169 let bn_idx = crate::ported::builtin::BUILTINS.iter()
3170 .position(|b| b.node.nam == name);
3171 if let Some(idx) = bn_idx {
3172 let bn_static: &'static crate::ported::zsh_h::builtin =
3173 &crate::ported::builtin::BUILTINS[idx];
3174 let bn_ptr = bn_static as *const _ as *mut _;
3175 return crate::ported::builtin::execbuiltin(args.to_vec(), Vec::new(), bn_ptr);
3176 }
3177 // Fallback: best-effort env mirror.
3178 for a in args {
3179 if let Some(eq) = a.find('=') {
3180 std::env::set_var(&a[..eq], &a[eq+1..]);
3181 }
3182 }
3183 0
3184 }
3185 // Stubs deleted — these were leftover from an old zshrs port aligned to
3186 // `Src/exec.c` before fusevm replaced that path. Each was a `{ 0 }` / `{ None }` / `{ false }` no-op
3187 // pretending to implement a builtin while silently succeeding.
3188 // Removed: builtin_pushd, builtin_popd, builtin_history,
3189 // builtin_unalias, builtin_unfunction, builtin_autoload,
3190 // builtin_command, builtin_builtin, builtin_exec, builtin_noglob,
3191 // builtin_type, builtin_which, builtin_where, builtin_r,
3192 // builtin_getln, builtin_pushln, builtin_source_named,
3193 // autoload_function, maybe_autoload, find_function_file,
3194 // ksh_autoload_body, findcmd (shadow), get_builtin_names.
3195 // Callers must route to canonical ports in `src/ported/builtin.rs`
3196 // or fail loudly so the gap is visible.
3197
3198 // zutil.rs orphan stubs — moved here after `impl ShellExecutor`
3199 // was ripped out of src/ported/modules/zutil.rs. The canonical
3200 // bin_zstyle/bin_zparseopts/bin_zformat/bin_zregexparse free-fn
3201 // ports (Src/Modules/zutil.c) will land in zutil.rs at module
3202 // level; until then these stubs unblock callers.
3203}
3204
3205// === DISSOLVED FROM src/exec_shims.rs ===
3206use crate::ported::utils::{zwarn, zwarnnam, zerr, zerrnam};
3207use crate::ported::params::*;
3208use crate::ported::options::*;
3209use crate::ported::hist::*;
3210use crate::ported::pattern::*;
3211use crate::ported::prompt::*;
3212use crate::ported::subst::*;
3213use crate::ported::math::*;
3214use crate::ported::jobs::*;
3215use crate::ported::glob::*;
3216use crate::ported::module::*;
3217use crate::ported::signals::*;
3218use crate::ported::modules::cap::*;
3219use crate::ported::modules::tcp::bin_ztcp;
3220use crate::ported::modules::termcap::bin_echotc;
3221use crate::ported::modules::terminfo::*;
3222use crate::fusevm_bridge::with_executor;
3223use ::regex::{Regex, RegexBuilder, Error as RegexError};
3224
3225// =====================================================================
3226// MOVED FROM: src/ported/pattern.rs
3227// =====================================================================
3228
3229impl crate::ported::exec::ShellExecutor {
3230 /// Check if pattern contains extended glob syntax
3231 pub(crate) fn has_extglob_pattern(&self, pattern: &str) -> bool {
3232 let chars: Vec<char> = pattern.chars().collect();
3233 for i in 0..chars.len().saturating_sub(1) {
3234 if (chars[i] == '?'
3235 || chars[i] == '*'
3236 || chars[i] == '+'
3237 || chars[i] == '@'
3238 || chars[i] == '!')
3239 && chars[i + 1] == '('
3240 {
3241 return true;
3242 }
3243 }
3244 false
3245 }
3246 /// Extract the inner part of an extglob pattern (until closing paren)
3247 pub(crate) fn extract_extglob_inner(&self, chars: &[char], start: usize) -> (String, usize) {
3248 let mut inner = String::new();
3249 let mut depth = 1;
3250 let mut i = start;
3251
3252 while i < chars.len() && depth > 0 {
3253 if chars[i] == '(' {
3254 depth += 1;
3255 } else if chars[i] == ')' {
3256 depth -= 1;
3257 if depth == 0 {
3258 return (inner, i);
3259 }
3260 }
3261 inner.push(chars[i]);
3262 i += 1;
3263 }
3264
3265 (inner, i)
3266 }
3267 /// Convert the inner part of extglob (handles | for alternation)
3268 pub(crate) fn extglob_inner_to_regex(&self, inner: &str) -> String {
3269 // Split by | and convert each alternative
3270 let alternatives: Vec<String> = inner
3271 .split('|')
3272 .map(|alt| {
3273 let mut result = String::new();
3274 for c in alt.chars() {
3275 match c {
3276 '*' => result.push_str(".*"),
3277 '?' => result.push('.'),
3278 '.' => result.push_str("\\."),
3279 '^' | '$' | '(' | ')' | '{' | '}' | '\\' => {
3280 result.push('\\');
3281 result.push(c);
3282 }
3283 _ => result.push(c),
3284 }
3285 }
3286 result
3287 })
3288 .collect();
3289
3290 alternatives.join("|")
3291 }
3292}
3293
3294
3295// =====================================================================
3296// MOVED FROM: src/ported/options.rs
3297// =====================================================================
3298
3299impl crate::ported::exec::ShellExecutor {
3300 /// Returns every option name in `ZSH_OPTIONS_SET` (canonical port
3301 /// of `optns[]` at `Src/options.c:79+`). Replaces a 200-line
3302 /// hardcoded `&[...]` duplicate that drifted from upstream.
3303 pub(crate) fn all_zsh_options() -> Vec<&'static str> {
3304 crate::ported::options::ZSH_OPTIONS_SET.iter().copied().collect()
3305 }
3306 pub(crate) fn default_options() -> HashMap<String, bool> {
3307 let mut opts = HashMap::new();
3308 // Initialize all options to false first
3309 for opt in Self::all_zsh_options() {
3310 opts.insert(opt.to_string(), false);
3311 }
3312 // Set zsh defaults (options marked with <D> or <Z> in zshoptions man page)
3313 let defaults_on = [
3314 "aliases",
3315 "alwayslastprompt",
3316 "appendhistory",
3317 "autolist",
3318 "automenu",
3319 "autoparamkeys",
3320 "autoparamslash",
3321 "autoremoveslash",
3322 "badpattern",
3323 "banghist",
3324 "bareglobqual",
3325 "beep",
3326 "bgnice",
3327 "caseglob",
3328 "casematch",
3329 "checkjobs",
3330 "checkrunningjobs",
3331 "clobber",
3332 "debugbeforecmd",
3333 "equals",
3334 "evallineno",
3335 "exec",
3336 "flowcontrol",
3337 "functionargzero",
3338 "glob",
3339 "globalexport",
3340 "globalrcs",
3341 "hashcmds",
3342 "hashdirs",
3343 "hashlistall",
3344 "histbeep",
3345 "histsavebycopy",
3346 "hup",
3347 // INTERACTIVE is NOT default-on in zsh — C init sets it
3348 // from isatty(0). Marking it default-on here makes the
3349 // lexer's `!interact() || unset(SHINSTDIN)` comment gate
3350 // (lex.c:678) false for non-tty script runs, so every
3351 // `#`-line was re-lexed as a command. Drop from defaults;
3352 // tty-driven init can flip it back on for real terminals.
3353 "listambiguous",
3354 "listbeep",
3355 "listtypes",
3356 "monitor",
3357 "multibyte",
3358 "multifuncdef",
3359 "multios",
3360 "nomatch",
3361 "notify",
3362 "promptcr",
3363 "promptpercent",
3364 "promptsp",
3365 "rcs",
3366 // SHINSTDIN — same story. Default off; init sets when
3367 // stdin is the real interactive source.
3368 "shortloops",
3369 "unset",
3370 "zle",
3371 ];
3372 for opt in defaults_on {
3373 opts.insert(opt.to_string(), true);
3374 }
3375 opts
3376 }
3377
3378 pub(crate) fn default_on_options() -> &'static [&'static str] {
3379 &[
3380 "aliases",
3381 "alwayslastprompt",
3382 "appendhistory",
3383 "autolist",
3384 "automenu",
3385 "autoparamkeys",
3386 "autoparamslash",
3387 "autoremoveslash",
3388 "badpattern",
3389 "banghist",
3390 "bareglobqual",
3391 "beep",
3392 "bgnice",
3393 "caseglob",
3394 "casematch",
3395 "checkjobs",
3396 "checkrunningjobs",
3397 "clobber",
3398 "debugbeforecmd",
3399 "equals",
3400 "evallineno",
3401 "exec",
3402 "flowcontrol",
3403 "functionargzero",
3404 "glob",
3405 "globalexport",
3406 "globalrcs",
3407 "hashcmds",
3408 "hashdirs",
3409 "hashlistall",
3410 "histbeep",
3411 "histsavebycopy",
3412 "hup",
3413 // INTERACTIVE / SHINSTDIN intentionally absent — see the
3414 // matching note in `default_options()` above. C init
3415 // computes these from isatty(0), not from the static
3416 // emulation-default table.
3417 "listambiguous",
3418 "listbeep",
3419 "listtypes",
3420 "monitor",
3421 "multibyte",
3422 "multifuncdef",
3423 "multios",
3424 "nomatch",
3425 "notify",
3426 "promptcr",
3427 "promptpercent",
3428 "promptsp",
3429 "rcs",
3430 "shortloops",
3431 "unset",
3432 "zle",
3433 ]
3434 }
3435}
3436
3437// =====================================================================
3438// MOVED FROM: src/ported/options.rs
3439// =====================================================================
3440
3441impl crate::ported::exec::ShellExecutor {
3442
3443}
3444
3445// =====================================================================
3446// MOVED FROM: src/ported/params.rs
3447// =====================================================================
3448
3449impl crate::ported::exec::ShellExecutor {
3450 /// Parse subscript range like "1" or "1,5" or "-1" or "1,-1"
3451 pub(crate) fn parse_subscript_range(&self, s: &str, len: usize) -> Option<(usize, usize)> {
3452 if s.is_empty() || len == 0 {
3453 return None;
3454 }
3455
3456 let parts: Vec<&str> = s.split(',').collect();
3457
3458 let parse_idx = |idx_str: &str| -> Option<usize> {
3459 let idx: i64 = idx_str.trim().parse().ok()?;
3460 if idx < 0 {
3461 // Negative index from end
3462 let abs = (-idx) as usize;
3463 if abs > len {
3464 None
3465 } else {
3466 Some(len - abs)
3467 }
3468 } else if idx == 0 {
3469 Some(0)
3470 } else {
3471 // 1-indexed
3472 Some((idx as usize).saturating_sub(1).min(len))
3473 }
3474 };
3475
3476 match parts.len() {
3477 1 => {
3478 // Single element [n]
3479 let idx = parse_idx(parts[0])?;
3480 Some((idx, idx + 1))
3481 }
3482 2 => {
3483 // Range [n,m]
3484 let start = parse_idx(parts[0])?;
3485 let end = parse_idx(parts[1])?.saturating_add(1);
3486 Some((start.min(end), start.max(end)))
3487 }
3488 _ => None,
3489 }
3490 }
3491 /// Get value from zsh/parameter special arrays (options, commands, functions, etc.)
3492 /// Returns Some(value) if this is a special array access, None otherwise
3493 pub fn get_special_array_value(&self, array_name: &str, key: &str) -> Option<String> {
3494 match array_name {
3495 // === ZSH/MAPFILE module ===
3496 // `${mapfile[/path]}` reads the file's contents. Direct
3497 // port of `getpmmapfile(UNUSED(HashTable ht), const char *name)` (Src/Modules/mapfile.c:217)
3498 // which calls `get_contents()` (line 167) on the path.
3499 // Splice (`@`/`*`) returns the CWD entry list per
3500 // `scanpmmapfile()` (line 240).
3501 "mapfile" => {
3502 if key == "@" || key == "*" {
3503 // Inline readdir loop — direct port of
3504 // scanpmmapfile (Src/Modules/mapfile.c:241).
3505 let mut files: Vec<String> = Vec::new();
3506 if let Ok(rd) = std::fs::read_dir(".") {
3507 for entry in rd.flatten() {
3508 let path = entry.path();
3509 if path.is_file() {
3510 if let Some(name) =
3511 path.file_name().and_then(|n| n.to_str())
3512 {
3513 files.push(name.to_string());
3514 }
3515 }
3516 }
3517 }
3518 return Some(files.join(" "));
3519 }
3520 Some(crate::modules::mapfile::get_contents(key).unwrap_or_default())
3521 }
3522 // === ZSH/SYSTEM — errnos / sysparams ===
3523 "errnos" => {
3524 let table = crate::modules::system::ERRNO_NAMES;
3525 if key == "@" || key == "*" {
3526 return Some(
3527 table
3528 .iter()
3529 .map(|(n, _)| (*n).to_string())
3530 .collect::<Vec<_>>()
3531 .join(" "),
3532 );
3533 }
3534 if let Ok(n) = key.parse::<i64>() {
3535 let len = table.len() as i64;
3536 let pos = if n > 0 {
3537 (n - 1) as usize
3538 } else if n < 0 {
3539 let p = len + n;
3540 if p < 0 {
3541 return Some(String::new());
3542 }
3543 p as usize
3544 } else {
3545 return Some(String::new());
3546 };
3547 if let Some((name, _)) = table.get(pos) {
3548 return Some((*name).to_string());
3549 }
3550 }
3551 Some(String::new())
3552 }
3553 "sysparams" => {
3554 let pid = std::process::id().to_string();
3555 let ppid = unsafe { libc::getppid() }.to_string();
3556 if key == "@" || key == "*" {
3557 return Some(format!("{} {}", pid, ppid));
3558 }
3559 Some(match key {
3560 "pid" => pid,
3561 "ppid" => ppid,
3562 "procsubstpid" => "0".to_string(),
3563 _ => String::new(),
3564 })
3565 }
3566 // === SHELL OPTIONS ===
3567 "options" => {
3568 if key == "@" || key == "*" {
3569 // Return all options as "name=on/off" pairs.
3570 let opts: Vec<String> = crate::ported::options::opt_state_snapshot()
3571 .iter()
3572 .map(|(k, v)| format!("{}={}", k, if *v { "on" } else { "off" }))
3573 .collect();
3574 return Some(opts.join(" "));
3575 }
3576 let opt_name = key.to_lowercase().replace('_', "");
3577 let is_on = crate::ported::options::opt_state_get(&opt_name).unwrap_or(false);
3578 Some(if is_on {
3579 "on".to_string()
3580 } else {
3581 "off".to_string()
3582 })
3583 }
3584
3585 // === ALIASES ===
3586 // ${aliases[@]} returns values in sorted-name order.
3587 // Iterating HashMap::values() gave random order; tests
3588 // and prompt code that snapshot ${(v)aliases} flickered.
3589 "aliases" => {
3590 // Read from canonical `aliastab` (`Src/hashtable.c:1210`,
3591 // ported at `src/ported/hashtable.rs::aliastab_lock`).
3592 // bin_alias writes through `aliastab.add()` — the local
3593 // `exec.aliases` HashMap is a stale init-time snapshot.
3594 if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
3595 if key == "@" || key == "*" {
3596 let mut names: Vec<&String> = tab.iter().map(|(n, _)| n).collect();
3597 names.sort();
3598 let vals: Vec<String> = names.iter()
3599 .filter_map(|n| tab.get(n).map(|a| a.text.clone()))
3600 .collect();
3601 return Some(vals.join(" "));
3602 }
3603 return Some(tab.get(key).map(|a| a.text.clone()).unwrap_or_default());
3604 }
3605 Some(self.alias(key).unwrap_or_default())
3606 }
3607 "galiases" => {
3608 if key == "@" || key == "*" {
3609 let entries = self.global_alias_entries();
3610 let vals: Vec<String> = entries.into_iter().map(|(_, v)| v).collect();
3611 return Some(vals.join(" "));
3612 }
3613 Some(self.global_alias(key).unwrap_or_default())
3614 }
3615 "saliases" => {
3616 if key == "@" || key == "*" {
3617 let entries = self.suffix_alias_entries();
3618 let vals: Vec<String> = entries.into_iter().map(|(_, v)| v).collect();
3619 return Some(vals.join(" "));
3620 }
3621 Some(self.suffix_alias(key).unwrap_or_default())
3622 }
3623
3624 // === TERMINFO (zsh/terminfo module) ===
3625 // `${terminfo[capname]}` returns the escape sequence for
3626 // capability `capname`. Direct port of zsh/Src/Modules/
3627 // terminfo.c — the C version calls `tigetstr(name)` from
3628 // ncurses; we map the common-subset capability names to
3629 // standard xterm/VT escape sequences inline. Covers the
3630 // function-keys / cursor-motion / clear / color set that
3631 // user keymaps query (`key[F1]=$terminfo[kf1]` etc.).
3632 "terminfo" => {
3633 // Lazy lookup via ncurses tigetstr/tigetnum/tigetflag
3634 // — the pre-populated assoc init seeds the common
3635 // subset, but a script may query any cap by name
3636 // (`$terminfo[acsc]`, `$terminfo[colors]`). Mirror
3637 // zsh's terminfo.c::getterminfo lazy-resolve path.
3638 Some(crate::modules::terminfo::getterminfo(key).unwrap_or_default())
3639 }
3640 // `termcap` is dispatched in the `magic_assoc_lookup`
3641 // function (the primary special-array path) so that
3642 // ${termcap[cl]} resolves before this fallback runs.
3643 // Keeping a no-op arm here avoids a spurious "unknown
3644 // assoc" diagnostic if a caller bypasses
3645 // magic_assoc_lookup.
3646 "termcap" => Some(crate::modules::termcap::gettermcap(key).unwrap_or_default()),
3647
3648 // === FUNCTIONS ===
3649 "functions" => {
3650 if key == "@" || key == "*" {
3651 return Some(self.function_names().join(" "));
3652 }
3653 // Apply zsh's getfn_functions formatter — leading-tab
3654 // body, no trailing `;`. Direct port of Src/exec.c
3655 // shipped via compile_zsh's fast path; this branch
3656 // is the slow-path/subst_port entry that previously
3657 // returned the raw user-typed source. Keeps
3658 // `${functions[foo]:0:20}` (substring extraction)
3659 // consistent with the fast-path `\$functions[foo]`.
3660 let text = self.function_definition_text(key)?;
3661 let formatted = FuncBodyFmt::render(text.trim());
3662 Some(format!("\t{}", formatted))
3663 }
3664 "functions_source" => {
3665 // ${functions_source[name]} → file path where the
3666 // function was defined. zsh/Src/Modules/parameter.c
3667 // exposes this as an assoc keyed by function name.
3668 // For autoload functions we recover the source path
3669 // via the same fpath walk that loads them; for inline
3670 // functions we don't yet track the defining file, so
3671 // emit empty in that case.
3672 // find_function_file deleted with old exec.c stubs.
3673 if key == "@" || key == "*" {
3674 let _ = self.function_names();
3675 return Some(String::new());
3676 }
3677 { let _ = key; Some(String::new()) }
3678 }
3679
3680 // === COMMANDS (command hash table) ===
3681 // ${commands[name]} → full path (or empty), per
3682 // zsh/Modules/parameter.c. The @/* expansion enumerates
3683 // every command on PATH (deduplicated, first-wins).
3684 "commands" => {
3685 if key == "@" || key == "*" {
3686 let path_var = env::var("PATH").unwrap_or_default();
3687 let mut seen: std::collections::HashSet<String> =
3688 std::collections::HashSet::new();
3689 let mut names: Vec<String> = Vec::new();
3690 // Hashed entries first (rehash population) — read
3691 // from canonical `cmdnamtab` (Src/exec.c:5260).
3692 if let Ok(tab) = crate::ported::hashtable::cmdnamtab_lock().read() {
3693 for (k, _) in tab.iter() {
3694 if seen.insert(k.clone()) {
3695 names.push(k.clone());
3696 }
3697 }
3698 }
3699 for dir in path_var.split(':') {
3700 if dir.is_empty() {
3701 continue;
3702 }
3703 if let Ok(entries) = std::fs::read_dir(dir) {
3704 for entry in entries.flatten() {
3705 if let Ok(name) = entry.file_name().into_string() {
3706 if seen.insert(name.clone()) {
3707 names.push(name);
3708 }
3709 }
3710 }
3711 }
3712 }
3713 names.sort();
3714 return Some(names.join(" "));
3715 }
3716 if let Some(path) = self.find_in_path(key) {
3717 Some(path)
3718 } else {
3719 Some(String::new())
3720 }
3721 }
3722
3723 // === BUILTINS ===
3724 "builtins" => {
3725 let builtins: Vec<&str> = crate::exec::BUILTIN_NAMES
3726 .iter().map(|s| s.as_str()).collect();
3727 if key == "@" || key == "*" {
3728 return Some(builtins.join(" "));
3729 }
3730 if builtins.iter().any(|b| *b == key) {
3731 Some("defined".to_string())
3732 } else {
3733 Some(String::new())
3734 }
3735 }
3736
3737 // === PARAMETERS ===
3738 // ${parameters[name]} → full attribute string per
3739 // VarAttr::format_zsh (e.g. 'integer-readonly-export').
3740 // @/* enumerates every parameter name, sorted+deduped.
3741 "parameters" => {
3742 if key == "@" || key == "*" {
3743 let mut names: std::collections::BTreeSet<String> =
3744 if let Ok(tab) = crate::ported::params::paramtab().read() {
3745 tab.keys().cloned().collect()
3746 } else {
3747 std::collections::BTreeSet::new()
3748 };
3749 if let Ok(m) = crate::ported::params::paramtab_hashed_storage().lock() {
3750 names.extend(m.keys().cloned());
3751 }
3752 let v: Vec<String> = names.into_iter().collect();
3753 return Some(v.join(" "));
3754 }
3755 // Read PM_TYPE from paramtab Param flags first
3756 // (canonical). PM_INTEGER → "integer", PM_FFLOAT|
3757 // PM_EFLOAT → "float", PM_HASHED → "association",
3758 // PM_ARRAY → "array", scalar default. Append PM_LOWER
3759 // / PM_UPPER / PM_READONLY / PM_EXPORTED suffixes.
3760 let flags = self.param_flags(key) as u32;
3761 if flags != 0 || self.array(key).is_some() || self.assoc(key).is_some() {
3762 let base = if flags & PM_INTEGER != 0 { "integer" }
3763 else if flags & (PM_EFLOAT | PM_FFLOAT) != 0 { "float" }
3764 else if flags & PM_HASHED != 0 || self.assoc(key).is_some() { "association" }
3765 else if flags & PM_ARRAY != 0 || self.array(key).is_some() { "array" }
3766 else { "scalar" };
3767 let mut out = String::from(base);
3768 if flags & PM_LEFT != 0 { out.push_str("-left"); }
3769 if flags & PM_RIGHT_B != 0 { out.push_str("-right_blanks"); }
3770 if flags & PM_RIGHT_Z != 0 { out.push_str("-right_zeros"); }
3771 if flags & PM_LOWER != 0 { out.push_str("-lower"); }
3772 if flags & PM_UPPER != 0 { out.push_str("-upper"); }
3773 if flags & PM_READONLY != 0 { out.push_str("-readonly"); }
3774 if flags & PM_EXPORTED != 0 { out.push_str("-export"); }
3775 return Some(out);
3776 }
3777 if self.has_assoc(key) {
3778 Some("association".to_string())
3779 } else if self.has_array(key) {
3780 Some("array".to_string())
3781 } else if self.has_scalar(key) || std::env::var(key).is_ok() {
3782 Some("scalar".to_string())
3783 } else {
3784 Some(String::new())
3785 }
3786 }
3787
3788 // === NAMED DIRECTORIES ===
3789 // ${nameddirs[@]} returns paths in sorted-name order (was
3790 // HashMap::values() with random iteration).
3791 "nameddirs" => {
3792 // Canonical `nameddirtab` lives in
3793 // `src/ported/hashnameddir.rs` (port of `Src/hashnameddir.c`).
3794 let tab = crate::ported::hashnameddir::nameddirtab();
3795 if key == "@" || key == "*" {
3796 let snapshot: Vec<(String, String)> = tab.lock().ok()
3797 .map(|g| g.iter().map(|(k, v)| (k.clone(), v.dir.clone())).collect())
3798 .unwrap_or_default();
3799 let mut keys: Vec<&(String, String)> = snapshot.iter().collect();
3800 keys.sort_by(|a, b| a.0.cmp(&b.0));
3801 let vals: Vec<String> = keys.iter().map(|(_, v)| v.clone()).collect();
3802 return Some(vals.join(" "));
3803 }
3804 Some(
3805 tab.lock().ok()
3806 .and_then(|g| g.get(key).map(|nd| nd.dir.clone()))
3807 .unwrap_or_default(),
3808 )
3809 }
3810
3811 // === USER DIRECTORIES ===
3812 // ${userdirs[name]} → home directory of user `name` per
3813 // zsh/Modules/parameter.c userdirs_*. With @/* expansion,
3814 // walk getpwent(3) to enumerate every passwd entry's
3815 // home directory.
3816 "userdirs" => {
3817 #[cfg(unix)]
3818 {
3819 if key == "@" || key == "*" {
3820 let mut homes: Vec<String> = Vec::new();
3821 unsafe {
3822 libc::setpwent();
3823 loop {
3824 let pwd = libc::getpwent();
3825 if pwd.is_null() {
3826 break;
3827 }
3828 let dir = CStr::from_ptr((*pwd).pw_dir);
3829 homes.push(dir.to_string_lossy().to_string());
3830 }
3831 libc::endpwent();
3832 }
3833 homes.sort();
3834 homes.dedup();
3835 return Some(homes.join(" "));
3836 }
3837 if let Ok(name) = CString::new(key) {
3838 unsafe {
3839 let pwd = libc::getpwnam(name.as_ptr());
3840 if !pwd.is_null() {
3841 let dir = CStr::from_ptr((*pwd).pw_dir);
3842 return Some(dir.to_string_lossy().to_string());
3843 }
3844 }
3845 }
3846 }
3847 Some(String::new())
3848 }
3849
3850 // === USER GROUPS ===
3851 // ${usergroups[name]} → GID of group `name`. With @/*
3852 // expansion, walk getgrent(3) to enumerate every group's
3853 // gid.
3854 "usergroups" => {
3855 #[cfg(unix)]
3856 {
3857 if key == "@" || key == "*" {
3858 let mut gids: Vec<String> = Vec::new();
3859 unsafe {
3860 libc::setgrent();
3861 loop {
3862 let grp = libc::getgrent();
3863 if grp.is_null() {
3864 break;
3865 }
3866 let name = CStr::from_ptr((*grp).gr_name);
3867 gids.push(name.to_string_lossy().to_string());
3868 }
3869 libc::endgrent();
3870 }
3871 gids.sort();
3872 gids.dedup();
3873 return Some(gids.join(" "));
3874 }
3875 if let Ok(name) = CString::new(key) {
3876 unsafe {
3877 let grp = libc::getgrnam(name.as_ptr());
3878 if !grp.is_null() {
3879 return Some((*grp).gr_gid.to_string());
3880 }
3881 }
3882 }
3883 }
3884 Some(String::new())
3885 }
3886
3887 // === DIRECTORY STACK ===
3888 // Canonical `dirstack` lives in `modules/parameter.rs::DIRSTACK`
3889 // — mirror of the C `dirstack` global (`Src/builtin.c:1456`).
3890 "dirstack" => {
3891 let dirs = crate::ported::modules::parameter::DIRSTACK
3892 .lock()
3893 .map(|g| g.clone())
3894 .unwrap_or_default();
3895 if key == "@" || key == "*" {
3896 return Some(dirs.join(" "));
3897 }
3898 if let Ok(idx) = key.parse::<usize>() {
3899 Some(dirs.get(idx).cloned().unwrap_or_default())
3900 } else {
3901 Some(String::new())
3902 }
3903 }
3904
3905 // === JOBS ===
3906 "jobstates" => {
3907 if key == "@" || key == "*" {
3908 let states: Vec<String> = self
3909 .jobs
3910 .iter()
3911 .map(|(id, job)| format!("{}:{:?}", id, job.state))
3912 .collect();
3913 return Some(states.join(" "));
3914 }
3915 if let Ok(id) = key.parse::<usize>() {
3916 if let Some(job) = self.jobs.get(id) {
3917 return Some(format!("{:?}", job.state));
3918 }
3919 }
3920 Some(String::new())
3921 }
3922 "jobtexts" => {
3923 if key == "@" || key == "*" {
3924 let texts: Vec<String> = self
3925 .jobs
3926 .iter()
3927 .map(|(_, job)| job.command.clone())
3928 .collect();
3929 return Some(texts.join(" "));
3930 }
3931 if let Ok(id) = key.parse::<usize>() {
3932 if let Some(job) = self.jobs.get(id) {
3933 return Some(job.command.clone());
3934 }
3935 }
3936 Some(String::new())
3937 }
3938 "jobdirs" => {
3939 // ${jobdirs[N]}: cwd at the time job N was launched.
3940 // We don't yet capture per-job cwd at launch (would
3941 // need a JobInfo.cwd field plumbed through add_job),
3942 // so use the current PWD as a best-effort proxy. With
3943 // @/* expansion, return one entry per active job so
3944 // arr-length math (${#jobdirs}) matches ${#jobtexts}.
3945 let pwd = self
3946 .scalar("PWD")
3947 .or_else(|| env::var("PWD").ok())
3948 .unwrap_or_default();
3949 if key == "@" || key == "*" {
3950 let n = self.jobs.iter().count();
3951 return Some(vec![pwd; n].join(" "));
3952 }
3953 if let Ok(id) = key.parse::<usize>() {
3954 if self.jobs.get(id).is_some() {
3955 return Some(pwd);
3956 }
3957 }
3958 Some(String::new())
3959 }
3960
3961 // === HISTORY ===
3962 "history" => {
3963 if key == "@" || key == "*" {
3964 // Return recent history
3965 if let Some(ref engine) = self.history {
3966 if let Ok(entries) = engine.recent(100) {
3967 let cmds: Vec<String> =
3968 entries.iter().map(|e| e.command.clone()).collect();
3969 return Some(cmds.join("\n"));
3970 }
3971 }
3972 return Some(String::new());
3973 }
3974 if let Ok(num) = key.parse::<usize>() {
3975 if let Some(ref engine) = self.history {
3976 if let Ok(Some(entry)) = engine.get_by_offset(num.saturating_sub(1)) {
3977 return Some(entry.command);
3978 }
3979 }
3980 }
3981 Some(String::new())
3982 }
3983 "historywords" => {
3984 // $historywords: flat list of words from recent history
3985 // entries (zsh/Modules/parameter.c historywords_*).
3986 // Each command is split on whitespace; the words are
3987 // collected newest-first across the recent window.
3988 if let Some(ref engine) = self.history {
3989 if let Ok(entries) = engine.recent(100) {
3990 let words: Vec<String> = entries
3991 .iter()
3992 .flat_map(|e| {
3993 e.command
3994 .split_whitespace()
3995 .map(|s| s.to_string())
3996 .collect::<Vec<_>>()
3997 })
3998 .collect();
3999 if key == "@" || key == "*" {
4000 return Some(words.join(" "));
4001 }
4002 if let Ok(idx) = key.parse::<usize>() {
4003 if idx >= 1 && idx <= words.len() {
4004 return Some(words[idx - 1].clone());
4005 }
4006 }
4007 }
4008 }
4009 Some(String::new())
4010 }
4011
4012 // === MODULES ===
4013 // ${modules[name]} → "loaded" / "" per
4014 // zsh/Src/Modules/parameter.c modules_*. zshrs tracks
4015 // loaded modules via `_module_<name>` keys in
4016 // self.options (see bin_zmodload). Always-loaded
4017 // built-in modules are surfaced unconditionally so
4018 // compsys's `[[ ${+modules[zsh/zutil]} ]]` gating works.
4019 "modules" => {
4020 const ALWAYS_LOADED: &[&str] = &[
4021 "zsh/parameter",
4022 "zsh/zutil",
4023 "zsh/complete",
4024 "zsh/complist",
4025 "zsh/zle",
4026 "zsh/main",
4027 "zsh/files",
4028 ];
4029 let user_loaded: Vec<String> = crate::ported::options::opt_state_snapshot()
4030 .iter()
4031 .filter_map(|(k, v)| {
4032 if *v {
4033 k.strip_prefix("_module_").map(|s| s.to_string())
4034 } else {
4035 None
4036 }
4037 })
4038 .collect();
4039 if key == "@" || key == "*" {
4040 let mut all: Vec<String> = ALWAYS_LOADED
4041 .iter()
4042 .map(|s| s.to_string())
4043 .chain(user_loaded.iter().cloned())
4044 .collect();
4045 all.sort();
4046 all.dedup();
4047 return Some(all.join(" "));
4048 }
4049 if ALWAYS_LOADED.contains(&key)
4050 || crate::ported::options::opt_state_get(&format!("_module_{}", key))
4051 .unwrap_or(false)
4052 {
4053 Some("loaded".to_string())
4054 } else {
4055 Some(String::new())
4056 }
4057 }
4058
4059 // === RESERVED WORDS ===
4060 "reswords" => {
4061 let reswords = [
4062 "do",
4063 "done",
4064 "esac",
4065 "then",
4066 "elif",
4067 "else",
4068 "fi",
4069 "for",
4070 "case",
4071 "if",
4072 "while",
4073 "function",
4074 "repeat",
4075 "time",
4076 "until",
4077 "select",
4078 "coproc",
4079 "nocorrect",
4080 "foreach",
4081 "end",
4082 "in",
4083 ];
4084 if key == "@" || key == "*" {
4085 return Some(reswords.join(" "));
4086 }
4087 if let Ok(idx) = key.parse::<usize>() {
4088 Some(reswords.get(idx).map(|s| s.to_string()).unwrap_or_default())
4089 } else {
4090 Some(String::new())
4091 }
4092 }
4093
4094 // === PATCHARS (characters with special meaning in patterns) ===
4095 "patchars" => {
4096 let patchars = ["?", "*", "[", "]", "^", "#", "~", "(", ")", "|"];
4097 if key == "@" || key == "*" {
4098 return Some(patchars.join(" "));
4099 }
4100 if let Ok(idx) = key.parse::<usize>() {
4101 Some(patchars.get(idx).map(|s| s.to_string()).unwrap_or_default())
4102 } else {
4103 Some(String::new())
4104 }
4105 }
4106
4107 // === FUNCTION CALL STACK ===
4108 // $funcstack: array of function names in the current call
4109 // chain (innermost first). Already maintained by the
4110 // function-call code at exec.rs:7828-7835. Surface it here
4111 // so `${funcstack[1]}` / `${funcstack[@]}` reads work.
4112 // funcfiletrace / funcsourcetrace need separate tables (file
4113 // and definition tracking) which we don't yet wire; emit
4114 // empty for those until they're populated.
4115 "funcstack" => {
4116 if let Some(stack) = self.array("funcstack") {
4117 if key == "@" || key == "*" {
4118 return Some(stack.join(" "));
4119 }
4120 if let Ok(idx) = key.parse::<usize>() {
4121 // zsh subscripts are 1-based.
4122 if idx >= 1 && idx <= stack.len() {
4123 return Some(stack[idx - 1].clone());
4124 }
4125 }
4126 }
4127 Some(String::new())
4128 }
4129 "functrace" => {
4130 // $functrace: `caller_name:callsite_lineno` for each
4131 // frame. We don't yet track call-site line numbers, so
4132 // synthesize from funcstack with a `:0` placeholder
4133 // line. This still lets scripts that test
4134 // `[[ -n $functrace[1] ]]` work without false-empty.
4135 if let Some(stack) = self.array("funcstack") {
4136 let synth: Vec<String> = stack.iter().map(|n| format!("{}:0", n)).collect();
4137 if key == "@" || key == "*" {
4138 return Some(synth.join(" "));
4139 }
4140 if let Ok(idx) = key.parse::<usize>() {
4141 if idx >= 1 && idx <= synth.len() {
4142 return Some(synth[idx - 1].clone());
4143 }
4144 }
4145 }
4146 Some(String::new())
4147 }
4148 "funcfiletrace" | "funcsourcetrace" => {
4149 // Would need file:line where each function was called
4150 // from / defined in. Per-frame file tracking is not yet
4151 // wired — return empty.
4152 Some(String::new())
4153 }
4154
4155 // === DISABLED VARIANTS (dis_*) ===
4156 // ${dis_builtins[name]} → "defined" if the builtin was
4157 // disabled via `disable name`. Tracked through
4158 // self.options['_disabled_<name>']. The other dis_*
4159 // variants (aliases/functions/reswords/patchars) lose
4160 // their entries entirely on disable in zshrs's table
4161 // model (see do_enable_disable at exec.rs:31371) so the
4162 // disabled list isn't recoverable post-disable; emit
4163 // empty for those.
4164 "dis_builtins" => {
4165 let disabled: Vec<String> = crate::ported::options::opt_state_snapshot()
4166 .iter()
4167 .filter_map(|(k, v)| {
4168 if *v {
4169 k.strip_prefix("_disabled_").map(|s| s.to_string())
4170 } else {
4171 None
4172 }
4173 })
4174 .collect();
4175 if key == "@" || key == "*" {
4176 let mut sorted = disabled.clone();
4177 sorted.sort();
4178 return Some(sorted.join(" "));
4179 }
4180 if disabled.iter().any(|d| d == key) {
4181 Some("defined".to_string())
4182 } else {
4183 Some(String::new())
4184 }
4185 }
4186 "dis_aliases"
4187 | "dis_galiases"
4188 | "dis_saliases"
4189 | "dis_functions"
4190 | "dis_functions_source"
4191 | "dis_reswords"
4192 | "dis_patchars" => Some(String::new()),
4193
4194 // === ZLE WIDGETS ===
4195 // ${widgets[name]} → widget-type prefix per
4196 // zsh/Src/Zle/zleparameter.c widgets_*: "builtin",
4197 // "user:<funcname>", or "completion:<funcname>".
4198 // Distinguishes builtin vs user-defined so
4199 // ${(t)widgets[name]} works.
4200 "widgets" => {
4201 if key == "@" || key == "*" {
4202 let mut names = listwidgets();
4203 names.sort();
4204 return Some(names.join(" "));
4205 }
4206 if let Some(target) = getwidgettarget(key) {
4207 if target == key {
4208 Some("builtin".to_string())
4209 } else {
4210 Some(format!("user:{}", target))
4211 }
4212 } else {
4213 Some(String::new())
4214 }
4215 }
4216
4217 // === ZLE KEYMAPS ===
4218 // ${keymaps[N]} per zleparameter.c keymaps_*: list of
4219 // available keymap names. Single-key lookup returns 1
4220 // ("set") if the keymap exists, "" otherwise.
4221 "keymaps" => {
4222 const KEYMAPS: &[&str] = &[
4223 "main",
4224 "emacs",
4225 "viins",
4226 "vicmd",
4227 "isearch",
4228 "command",
4229 "menuselect",
4230 ];
4231 if key == "@" || key == "*" {
4232 return Some(KEYMAPS.join(" "));
4233 }
4234 if KEYMAPS.contains(&key) {
4235 Some("1".to_string())
4236 } else {
4237 Some(String::new())
4238 }
4239 }
4240
4241 // === SIGNAL NAMES ===
4242 // $signals: array indexed by signal number (1-based) where
4243 // each slot holds the bare signal name. Direct port of
4244 // zsh/Modules/parameter.c signals_*. zshrs uses libc signal
4245 // constants so the mapping matches the host platform
4246 // (macOS USR1=30, Linux USR1=10).
4247 "signals" => {
4248 let map: &[(i32, &str)] = &[
4249 (libc::SIGHUP, "HUP"),
4250 (libc::SIGINT, "INT"),
4251 (libc::SIGQUIT, "QUIT"),
4252 (libc::SIGILL, "ILL"),
4253 (libc::SIGTRAP, "TRAP"),
4254 (libc::SIGABRT, "ABRT"),
4255 #[cfg(target_os = "macos")]
4256 (libc::SIGEMT, "EMT"),
4257 (libc::SIGFPE, "FPE"),
4258 (libc::SIGKILL, "KILL"),
4259 (libc::SIGBUS, "BUS"),
4260 (libc::SIGSEGV, "SEGV"),
4261 (libc::SIGSYS, "SYS"),
4262 (libc::SIGPIPE, "PIPE"),
4263 (libc::SIGALRM, "ALRM"),
4264 (libc::SIGTERM, "TERM"),
4265 (libc::SIGURG, "URG"),
4266 (libc::SIGSTOP, "STOP"),
4267 (libc::SIGTSTP, "TSTP"),
4268 (libc::SIGCONT, "CONT"),
4269 (libc::SIGCHLD, "CHLD"),
4270 (libc::SIGTTIN, "TTIN"),
4271 (libc::SIGTTOU, "TTOU"),
4272 (libc::SIGIO, "IO"),
4273 (libc::SIGXCPU, "XCPU"),
4274 (libc::SIGXFSZ, "XFSZ"),
4275 (libc::SIGVTALRM, "VTALRM"),
4276 (libc::SIGPROF, "PROF"),
4277 (libc::SIGWINCH, "WINCH"),
4278 #[cfg(target_os = "macos")]
4279 (libc::SIGINFO, "INFO"),
4280 (libc::SIGUSR1, "USR1"),
4281 (libc::SIGUSR2, "USR2"),
4282 ];
4283 if key == "@" || key == "*" {
4284 // Return one entry per signal in numeric order (1..N).
4285 let max = map.iter().map(|(n, _)| *n).max().unwrap_or(0) as usize;
4286 let mut slots: Vec<String> = vec![String::new(); max];
4287 for (n, name) in map {
4288 if (*n as usize) >= 1 && (*n as usize) <= max {
4289 slots[*n as usize - 1] = (*name).to_string();
4290 }
4291 }
4292 return Some(slots.join(" "));
4293 }
4294 // Numeric subscript -> name; name -> empty (zsh's
4295 // $signals is keyed by number).
4296 if let Ok(n) = key.parse::<i32>() {
4297 for (sig_num, name) in map {
4298 if *sig_num == n {
4299 return Some((*name).to_string());
4300 }
4301 }
4302 }
4303 Some(String::new())
4304 }
4305
4306 // Not a special array
4307 _ => None,
4308 }
4309 }
4310 pub(crate) fn get_variable(&self, name: &str) -> String {
4311 // Handle special parameters
4312 match name {
4313 "" => String::new(), // Empty name returns empty
4314 "$" => std::process::id().to_string(),
4315 "@" | "*" => {
4316 // $* joins by the first char of $IFS (POSIX). Default
4317 // IFS is " \t\n\0" so the join char is " "; with a
4318 // custom IFS like `:` the joined string uses `:`.
4319 // $@ technically does the same in scalar context but
4320 // is usually quoted-spliced — both fall through here.
4321 let sep = self
4322 .scalar("IFS")
4323 .and_then(|s| s.chars().next())
4324 .unwrap_or(' ');
4325 self.pparams().join(&sep.to_string())
4326 }
4327 "#" | "#@" | "#*" => self.pparams().len().to_string(),
4328 // zsh alias: $ARGC also equals $#.
4329 "ARGC" => self.pparams().len().to_string(),
4330 "?" | "status" => self.last_status().to_string(),
4331 "!" => self
4332 .scalar("!")
4333 .unwrap_or_else(|| "0".to_string()),
4334 // `$-` returns the concatenated single-letter flags of options
4335 // currently set. zsh always emits a baseline "569X" prefix
4336 // (internal-letter options that are on by default in -f mode)
4337 // followed by user-controllable flags. Match the prefix
4338 // verbatim so existing scripts that do `[[ $- == *e* ]]` /
4339 // `case $- in *x*) … esac` see consistent letters.
4340 "-" => {
4341 let mut letters = String::from("569X");
4342 let opt = |n: &str| crate::ported::options::opt_state_get(n).unwrap_or(false);
4343 // `e` comes BEFORE `f` in zsh's letter ordering: `set -e`
4344 // in -f mode produces "569Xef", not "569Xfe".
4345 if opt("errexit") {
4346 letters.push('e');
4347 }
4348 if !opt("rcs") {
4349 letters.push('f');
4350 }
4351 if opt("login") {
4352 letters.push('l');
4353 }
4354 // i/m are present only when *truly* interactive; zsh's `-c`
4355 // path leaves them off, so we mirror that and don't surface
4356 // them just because `options.interactive` happens to be set
4357 // by the executor's default-options init.
4358 if opt("nounset") {
4359 letters.push('u');
4360 }
4361 if opt("xtrace") {
4362 letters.push('x');
4363 }
4364 if opt("verbose") {
4365 letters.push('v');
4366 }
4367 if opt("noexec") {
4368 letters.push('n');
4369 }
4370 if opt("hashall") {
4371 letters.push('h');
4372 }
4373 letters
4374 }
4375 "EUID" => unsafe { libc::geteuid() }.to_string(),
4376 "UID" => unsafe { libc::getuid() }.to_string(),
4377 "EGID" => unsafe { libc::getegid() }.to_string(),
4378 "GID" => unsafe { libc::getgid() }.to_string(),
4379 "PPID" => unsafe { libc::getppid() }.to_string(),
4380 "ZSH_SUBSHELL" => self
4381 .scalar("ZSH_SUBSHELL")
4382 .unwrap_or_else(|| "0".to_string()),
4383 "HOST" => {
4384 // libc gethostname → up to 256 bytes.
4385 let mut buf = [0u8; 256];
4386 let r = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut _, buf.len()) };
4387 if r == 0 {
4388 let nul = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
4389 String::from_utf8_lossy(&buf[..nul]).into_owned()
4390 } else {
4391 String::new()
4392 }
4393 }
4394 // OS / machine identity vars. zsh hardcodes these from build-time
4395 // detection; we synthesize at runtime from libc uname(). Without
4396 // these arms `$OSTYPE` returned empty even though zle_params wrote
4397 // them into the params table — the executor's get_variable bypasses
4398 // that table for special names.
4399 "OSTYPE" => {
4400 let mut u: libc::utsname = unsafe { std::mem::zeroed() };
4401 if unsafe { libc::uname(&mut u) } == 0 {
4402 let sysname = unsafe { std::ffi::CStr::from_ptr(u.sysname.as_ptr()) }
4403 .to_string_lossy()
4404 .to_lowercase();
4405 let release = unsafe { std::ffi::CStr::from_ptr(u.release.as_ptr()) }
4406 .to_string_lossy()
4407 .to_string();
4408 format!("{}{}", sysname, release)
4409 } else {
4410 std::env::consts::OS.to_string()
4411 }
4412 }
4413 "MACHTYPE" => {
4414 let mut u: libc::utsname = unsafe { std::mem::zeroed() };
4415 if unsafe { libc::uname(&mut u) } == 0 {
4416 let m = unsafe { std::ffi::CStr::from_ptr(u.machine.as_ptr()) }
4417 .to_string_lossy()
4418 .to_string();
4419 // zsh shortens common machines: aarch64 → arm, x86_64
4420 // stays x86_64. Mirror that for the common cases.
4421 if m == "aarch64" || m == "arm64" {
4422 "arm".to_string()
4423 } else {
4424 m
4425 }
4426 } else {
4427 std::env::consts::ARCH.to_string()
4428 }
4429 }
4430 "CPUTYPE" => {
4431 let mut u: libc::utsname = unsafe { std::mem::zeroed() };
4432 if unsafe { libc::uname(&mut u) } == 0 {
4433 unsafe { std::ffi::CStr::from_ptr(u.machine.as_ptr()) }
4434 .to_string_lossy()
4435 .to_string()
4436 } else {
4437 std::env::consts::ARCH.to_string()
4438 }
4439 }
4440 "VENDOR" => {
4441 // No portable libc query for vendor; pick by OS family.
4442 if cfg!(target_os = "macos") {
4443 "apple".to_string()
4444 } else if cfg!(target_os = "linux") {
4445 "unknown".to_string()
4446 } else {
4447 "pc".to_string()
4448 }
4449 }
4450 "HOSTNAME" => {
4451 let mut buf = [0u8; 256];
4452 let r = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut _, buf.len()) };
4453 if r == 0 {
4454 let nul = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
4455 String::from_utf8_lossy(&buf[..nul]).into_owned()
4456 } else {
4457 String::new()
4458 }
4459 }
4460 "RANDOM" => {
4461 // zsh/bash: pseudo-random unsigned 16-bit integer per
4462 // expansion. We use process+nano for a cheap, OS-portable
4463 // source — not cryptographically secure, but matches zsh's
4464 // "noise" semantics.
4465 let nanos = SystemTime::now()
4466 .duration_since(UNIX_EPOCH)
4467 .map(|d| d.subsec_nanos() as u64)
4468 .unwrap_or(0);
4469 let pid = std::process::id() as u64;
4470 let r = (nanos.wrapping_mul(2654435761).wrapping_add(pid)) as u32;
4471 ((r as u16) & 0x7fff).to_string()
4472 }
4473 "SECONDS" => {
4474 // Seconds since shell start. We approximate via the
4475 // tracked `shell_start_time` if present; otherwise 0.
4476 crate::ported::params::getsparam("SECONDS").unwrap_or_else(|| {
4477 let now = SystemTime::now()
4478 .duration_since(UNIX_EPOCH)
4479 .map(|d| d.as_secs())
4480 .unwrap_or(0);
4481 let start = self
4482 .scalar("__zshrs_start_secs")
4483 .and_then(|s| s.parse::<u64>().ok())
4484 .unwrap_or(now);
4485 now.saturating_sub(start).to_string()
4486 })
4487 }
4488 "EPOCHSECONDS" => {
4489 SystemTime::now()
4490 .duration_since(UNIX_EPOCH)
4491 .map(|d| d.as_secs().to_string())
4492 .unwrap_or_else(|_| "0".to_string())
4493 }
4494 "EPOCHREALTIME" => {
4495 // zsh/datetime: fractional seconds since the epoch with
4496 // microsecond resolution. Format: SECS.UUUUUU.
4497 match SystemTime::now().duration_since(UNIX_EPOCH) {
4498 Ok(d) => format!("{}.{:06}", d.as_secs(), d.subsec_micros()),
4499 Err(_) => "0.000000".to_string(),
4500 }
4501 }
4502 "argv" => self.pparams().join(" "),
4503 "HISTCMD" => {
4504 // zsh: HISTCMD = current history-event number. With -f
4505 // (no rc loading) and history-tracking off, zsh shows
4506 // 0. We mirror by returning the current session count
4507 // (or 0 when history isn't engaged).
4508 self.session_history_ids.len().to_string()
4509 }
4510 "TTY" => {
4511 // Path to the controlling terminal (`$TTY` in zsh).
4512 // ttyname(0) gives the device path. Returns "" if no tty.
4513 let ptr = unsafe { libc::ttyname(0) };
4514 if ptr.is_null() {
4515 String::new()
4516 } else {
4517 unsafe { std::ffi::CStr::from_ptr(ptr) }
4518 .to_string_lossy()
4519 .into_owned()
4520 }
4521 }
4522 "TTYIDLE" => {
4523 // Idle time of stdin TTY in seconds — stat the tty, return
4524 // (now - st_atime). Returns "-1" if not a tty per zsh docs.
4525 let ptr = unsafe { libc::ttyname(0) };
4526 if ptr.is_null() {
4527 return "-1".to_string();
4528 }
4529 let path = unsafe { std::ffi::CStr::from_ptr(ptr) };
4530 let path_str = path.to_string_lossy().into_owned();
4531 match std::fs::metadata(&path_str) {
4532 Ok(meta) => {
4533 if let Ok(atime) = meta.accessed() {
4534 let now = SystemTime::now();
4535 let idle = now.duration_since(atime).unwrap_or_default();
4536 return idle.as_secs().to_string();
4537 }
4538 "0".to_string()
4539 }
4540 Err(_) => "-1".to_string(),
4541 }
4542 }
4543 "TRY_BLOCK_ERROR" => {
4544 // Set by `{ … } always { … }` — last status of the try
4545 // block. Lives in self.variables under the same name when
4546 // the try arm assigns it; default 0.
4547 self.scalar("TRY_BLOCK_ERROR")
4548 .unwrap_or_else(|| "0".to_string())
4549 }
4550 "patchars" => "*?[]<>(){}|^&;".to_string(),
4551 "RANDOM_FILE" => {
4552 // Path to entropy source. Mainline zsh leaves empty
4553 // unless `zmodload zsh/random` set it; we expose
4554 // /dev/urandom as a useful default — matches the
4555 // platform's actual entropy source.
4556 if std::path::Path::new("/dev/urandom").exists() {
4557 "/dev/urandom".to_string()
4558 } else {
4559 String::new()
4560 }
4561 }
4562 "LINENO" => {
4563 // Tracked elsewhere; default to 1 if not populated.
4564 self.scalar("LINENO")
4565 .unwrap_or_else(|| "1".to_string())
4566 }
4567 "0" => self
4568 .scalar("0")
4569 .unwrap_or_else(|| env::args().next().unwrap_or_default()),
4570 n if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) => {
4571 let idx: usize = n.parse().unwrap_or(0);
4572 if idx == 0 {
4573 env::args().next().unwrap_or_default()
4574 } else {
4575 self.pparams()
4576 .get(idx - 1)
4577 .cloned()
4578 .unwrap_or_default()
4579 }
4580 }
4581 _ => {
4582 // Bare-assoc bypass: `declare -A h; h=(a 1 b 2); ${h}`
4583 // expects the joined values. The `declare -A` sets
4584 // variables["h"]="" as a side effect, which would
4585 // satisfy the variables lookup with empty. Skip the
4586 // variables lookup when an assoc with the same name
4587 // exists AND has entries.
4588 let assoc_has_entries = self
4589 .assoc(name)
4590 .map(|h| !h.is_empty())
4591 .unwrap_or(false);
4592 // GSU dispatch first — `$USERNAME` / `$IFS` / `$HOME`
4593 // / etc. route through their getfn callback. Mirrors
4594 // C zsh's `Param.gsu->getfn` lookup. Without this,
4595 // get_variable bypassed the GSU table entirely and
4596 // returned empty for usernamegetfn-backed reads.
4597 let resolved = lookup_special_var(name)
4598 .or_else(|| {
4599 if !assoc_has_entries {
4600 crate::ported::params::getsparam(name)
4601 } else {
4602 None
4603 }
4604 })
4605 .or_else(|| self.array(name).map(|a| a.join(" ")))
4606 .or_else(|| {
4607 self.assoc(name).map(|h| {
4608 if h.is_empty() {
4609 String::new()
4610 } else {
4611 h.values().cloned().collect::<Vec<_>>().join(" ")
4612 }
4613 })
4614 })
4615 .or_else(|| env::var(name).ok());
4616 match resolved {
4617 Some(v) => v,
4618 None => {
4619 // zsh stores the option as "unset" (default ON =
4620 // silently empty). `set -u` / `setopt nounset` /
4621 // `set -o nounset` all turn it OFF. Different
4622 // code paths in zshrs persist either key, so
4623 // honor either signal.
4624 let nounset_on = crate::ported::options::opt_state_get("nounset").unwrap_or(false)
4625 || !crate::ported::options::opt_state_get("unset").unwrap_or(true);
4626 if nounset_on {
4627 zerr(&format!("{}: parameter not set", name));
4628 std::process::exit(1);
4629 }
4630 String::new()
4631 }
4632 }
4633 }
4634 }
4635 }
4636 pub(crate) fn pre_resolve_array_subscripts(&self, expr: &str) -> String {
4637 let bytes: Vec<char> = expr.chars().collect();
4638 let mut out = String::with_capacity(expr.len());
4639 let mut i = 0;
4640 while i < bytes.len() {
4641 let c = bytes[i];
4642 // `$@`, `$*`, `$NAME` followed by `[…]` — zinit's
4643 // `(( $@[(I)-*] ))` and similar arith uses this. Strip
4644 // the leading `$` and route through the same name+[key]
4645 // resolver as bare identifiers. Without this the `$@`
4646 // gets variable-expanded to its joined form before
4647 // arith eval, dropping the subscript flag entirely.
4648 if c == '$' && i + 1 < bytes.len() {
4649 let next = bytes[i + 1];
4650 let is_special_at = next == '@' || next == '*';
4651 let is_ident_start = next.is_ascii_alphabetic() || next == '_';
4652 if (is_special_at || is_ident_start) && i + 2 < bytes.len() {
4653 // Look-ahead: must be followed by `[` to qualify
4654 // as a subscript form. Bare `$@` without `[` is
4655 // left alone (downstream substitution handles it).
4656 let mut probe = i + 1;
4657 if is_special_at {
4658 probe += 1;
4659 } else {
4660 while probe < bytes.len()
4661 && (bytes[probe].is_ascii_alphanumeric() || bytes[probe] == '_')
4662 {
4663 probe += 1;
4664 }
4665 }
4666 if probe < bytes.len() && bytes[probe] == '[' {
4667 // Drop the `$` and re-enter the bare-ident
4668 // path on the next iteration.
4669 i += 1;
4670 continue;
4671 }
4672 }
4673 }
4674 // Identifier start?
4675 if c.is_ascii_alphabetic() || c == '_' || c == '@' || c == '*' {
4676 let start = i;
4677 i += 1;
4678 if !(bytes[start] == '@' || bytes[start] == '*') {
4679 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == '_') {
4680 i += 1;
4681 }
4682 }
4683 let name: String = bytes[start..i].iter().collect();
4684 if i < bytes.len() && bytes[i] == '[' {
4685 // Collect balanced [...]
4686 i += 1;
4687 let key_start = i;
4688 let mut depth = 1;
4689 while i < bytes.len() && depth > 0 {
4690 match bytes[i] {
4691 '[' => depth += 1,
4692 ']' => {
4693 depth -= 1;
4694 if depth == 0 {
4695 break;
4696 }
4697 }
4698 _ => {}
4699 }
4700 i += 1;
4701 }
4702 let key_str: String = bytes[key_start..i].iter().collect();
4703 if i < bytes.len() {
4704 i += 1; // skip closing ]
4705 }
4706 // Resolve sub-key (it may itself be an arith expr or
4707 // string literal); strip surrounding quotes and
4708 // expand $-refs.
4709 let key_resolved: String = if key_str.starts_with('"') && key_str.ends_with('"')
4710 || key_str.starts_with('\'') && key_str.ends_with('\'')
4711 {
4712 key_str[1..key_str.len() - 1].to_string()
4713 } else {
4714 key_str.clone()
4715 };
4716 // Subscript-flag form `(I)pat` / `(i)pat` etc. —
4717 // route through array_subscript_flag so zinit's
4718 // `(( $@[(I)-*] ))` and `(( OPTS[opt_-h,…] ))`
4719 // patterns yield an index/key as zsh does.
4720 let trimmed_key = key_resolved.trim_start();
4721 let resolved = if trimmed_key.starts_with('(') {
4722 // getarg with the right storage gives back the
4723 // matched value or the all-matches join — see
4724 // params.c:1581-1719 inside getarg.
4725 let scalar_val = crate::ported::params::getsparam(&name);
4726 let result = if let Some(assoc) = self.assoc(&name) {
4727 getarg(trimmed_key, None, Some(&assoc), None)
4728 } else if name == "@" || name == "*" {
4729 let pos = self.pparams();
4730 getarg(trimmed_key, Some(&pos), None, None)
4731 } else if let Some(arr) = self.array(&name) {
4732 getarg(trimmed_key, Some(&arr), None, None)
4733 } else if let Some(ref s) = scalar_val {
4734 getarg(trimmed_key, None, None, Some(s.as_str()))
4735 } else {
4736 None
4737 };
4738 match result {
4739 Some(getarg_out::Value(v)) => v.to_str(),
4740 _ => "0".to_string(),
4741 }
4742 } else if let Some(assoc) = self.assoc(&name) {
4743 assoc
4744 .get(&key_resolved)
4745 .cloned()
4746 .unwrap_or_else(|| "0".to_string())
4747 } else if let Some(arr) = self.array(&name) {
4748 // Numeric subscript — can be a literal or an
4749 // expression. For simple int literals only here;
4750 // complex exprs are uncommon in real scripts.
4751 if let Ok(idx) = key_resolved.trim().parse::<i64>() {
4752 let len = arr.len() as i64;
4753 let pos = if idx < 0 { len + idx } else { idx - 1 };
4754 if pos >= 0 && (pos as usize) < arr.len() {
4755 arr[pos as usize].clone()
4756 } else {
4757 "0".to_string()
4758 }
4759 } else {
4760 "0".to_string()
4761 }
4762 } else {
4763 // Unrecognised — emit the original text so the
4764 // evaluator can complain naturally.
4765 format!("{}[{}]", name, key_str)
4766 };
4767 out.push_str(&resolved);
4768 } else {
4769 out.push_str(&name);
4770 }
4771 continue;
4772 }
4773 out.push(c);
4774 i += 1;
4775 }
4776 out
4777 }
4778}
4779
4780// =====================================================================
4781// MOVED FROM: src/ported/hist.rs
4782// =====================================================================
4783
4784impl crate::ported::exec::ShellExecutor {
4785 /// Split a command string into words for word designators, respecting quotes.
4786 pub(crate) fn history_split_words(cmd: &str) -> Vec<String> {
4787 let mut words = Vec::new();
4788 let mut current = String::new();
4789 let mut in_sq = false;
4790 let mut in_dq = false;
4791 let mut escaped = false;
4792
4793 for c in cmd.chars() {
4794 if escaped {
4795 current.push(c);
4796 escaped = false;
4797 continue;
4798 }
4799 if c == '\\' {
4800 current.push(c);
4801 escaped = true;
4802 continue;
4803 }
4804 if c == '\'' && !in_dq {
4805 in_sq = !in_sq;
4806 current.push(c);
4807 continue;
4808 }
4809 if c == '"' && !in_sq {
4810 in_dq = !in_dq;
4811 current.push(c);
4812 continue;
4813 }
4814 if c.is_whitespace() && !in_sq && !in_dq {
4815 if !current.is_empty() {
4816 words.push(std::mem::take(&mut current));
4817 }
4818 continue;
4819 }
4820 current.push(c);
4821 }
4822 if !current.is_empty() {
4823 words.push(current);
4824 }
4825 words
4826 }
4827 /// Parse a word range like 0, 1, ^, $, *, n-m, n-
4828 pub(crate) fn history_parse_word_range(
4829 &self,
4830 chars: &[char],
4831 mut i: usize,
4832 argc: usize,
4833 ) -> (Option<usize>, Option<usize>, usize) {
4834 if i >= chars.len() {
4835 return (None, None, i);
4836 }
4837
4838 // Check for modifiers that aren't word designators
4839 match chars[i] {
4840 'h' | 't' | 'r' | 'e' | 's' | 'S' | 'g' | 'p' | 'q' | 'Q' | 'l' | 'u' | 'a' | 'A'
4841 | '&' => {
4842 // This is a modifier, not a word designator — back up
4843 return (None, None, i - 1); // -1 to re-read the ':'
4844 }
4845 _ => {}
4846 }
4847
4848 let farg = if chars[i] == '^' {
4849 i += 1;
4850 Some(1usize)
4851 } else if chars[i] == '$' {
4852 i += 1;
4853 return (Some(argc), Some(argc), i);
4854 } else if chars[i] == '*' {
4855 i += 1;
4856 return (Some(1), Some(argc), i);
4857 } else if chars[i].is_ascii_digit() {
4858 let start = i;
4859 while i < chars.len() && chars[i].is_ascii_digit() {
4860 i += 1;
4861 }
4862 let n: usize = chars[start..i]
4863 .iter()
4864 .collect::<String>()
4865 .parse()
4866 .unwrap_or(0);
4867 Some(n)
4868 } else {
4869 None
4870 };
4871
4872 // Check for range: n-m or n-
4873 if i < chars.len() && chars[i] == '-' {
4874 i += 1;
4875 if i < chars.len() && chars[i] == '$' {
4876 i += 1;
4877 return (farg, Some(argc), i);
4878 } else if i < chars.len() && chars[i].is_ascii_digit() {
4879 let start = i;
4880 while i < chars.len() && chars[i].is_ascii_digit() {
4881 i += 1;
4882 }
4883 let m: usize = chars[start..i]
4884 .iter()
4885 .collect::<String>()
4886 .parse()
4887 .unwrap_or(0);
4888 return (farg, Some(m), i);
4889 } else {
4890 // n- means n to argc-1
4891 return (farg, Some(argc.saturating_sub(1)), i);
4892 }
4893 }
4894
4895 if farg.is_some() {
4896 (farg, farg, i)
4897 } else {
4898 (None, None, i)
4899 }
4900 }
4901}
4902
4903// =====================================================================
4904// MOVED FROM: src/ported/signals.rs
4905// =====================================================================
4906
4907impl crate::ported::exec::ShellExecutor {
4908 /// Execute trap handlers for a signal
4909 pub fn run_trap(&mut self, signal: &str) {
4910 if let Some(action) = self.traps.get(signal).cloned() {
4911 // Empty action = signal-ignore. Don't try to execute "".
4912 if !action.is_empty() {
4913 let _ = self.execute_script(&action);
4914 }
4915 }
4916 }
4917}
4918
4919// =====================================================================
4920// MOVED FROM: src/ported/prompt.rs
4921// =====================================================================
4922
4923impl crate::ported::exec::ShellExecutor {
4924 /// Expand prompt escape sequences using the full prompt module.
4925 /// `expand_prompt` itself now reads C globals (paramtab / LASTVAL /
4926 /// curhist / JOBTAB / scriptname) so no per-executor sync is
4927 /// needed — the executor's state already mirrors those globals.
4928 pub(crate) fn expand_prompt_string(&self, s: &str) -> String {
4929 expand_prompt(s)
4930 }
4931 pub(crate) fn apply_prompt_theme(&mut self, theme: &str, preview: bool) {
4932 let (ps1, rps1) = match theme {
4933 "minimal" => ("%# ", ""),
4934 "off" => ("$ ", ""),
4935 "adam1" => (
4936 "%B%F{cyan}%n@%m %F{blue}%~%f%b %# ",
4937 "%F{yellow}%D{%H:%M}%f",
4938 ),
4939 "redhat" => ("[%n@%m %~]$ ", ""),
4940 _ => ("%n@%m %~ %# ", ""),
4941 };
4942 if preview {
4943 println!("PS1={:?}", ps1);
4944 println!("RPS1={:?}", rps1);
4945 } else {
4946 self.set_scalar("PS1".to_string(), ps1.to_string());
4947 self.set_scalar("RPS1".to_string(), rps1.to_string());
4948 self.set_scalar("prompt_theme".to_string(), theme.to_string());
4949 }
4950 }
4951}
4952
4953// =====================================================================
4954// MOVED FROM: src/ported/glob.rs
4955// =====================================================================
4956
4957impl crate::ported::exec::ShellExecutor {
4958 /// Expand glob pattern to matching files
4959 pub fn expand_glob(&self, pattern: &str) -> Vec<String> {
4960 // Glob alternation `(a|b|c)` is a primary zsh feature
4961 // (no extendedglob needed, unlike `~` exclusion). Direct
4962 // port of zsh's pattern.c handling of P_BRANCH | inside
4963 // grouping parens — at the path level, `/etc/(passwd|
4964 // hostname)` matches multiple alternative paths. zshrs's
4965 // glob crate (and earlier hand-rolled code) didn't expand
4966 // the `(...|...)` form, so the literal parens reached the
4967 // OS glob and produced no matches.
4968 //
4969 // Pre-expand by splitting top-level `(...|...)` groups
4970 // into separate patterns and recursing — same shape as
4971 // brace expansion at this layer. Skip when extendedglob
4972 // is on AND the pattern is `(#flag)` (inline pattern flag,
4973 // handled by the regex compiler downstream).
4974 if let Some(alternatives) = expand_glob_alternation(pattern) {
4975 // For each alternative, treat as a GLOB pattern: if it
4976 // contains other glob chars, recurse through expand_glob
4977 // (which handles `*`/`?`/`[`/qualifier suffixes); if
4978 // it's a literal path, only include it if the path
4979 // EXISTS — zsh's pattern.c behavior is "alternation
4980 // produces matching paths, not literal alternatives".
4981 // Without the exists-check, `/etc/(passwd|nonexistent)`
4982 // would output both.
4983 let mut out: Vec<String> = Vec::new();
4984 for alt in alternatives {
4985 let has_meta = alt.chars().any(|c| matches!(c, '*' | '?' | '[' | '('));
4986 if has_meta {
4987 out.extend(self.expand_glob(&alt));
4988 } else if std::path::Path::new(&alt).exists() {
4989 out.push(alt);
4990 }
4991 }
4992 let mut seen = std::collections::HashSet::new();
4993 out.retain(|p| seen.insert(p.clone()));
4994 // zsh sorts glob results alphabetically by default.
4995 // Without sorting, the alternation order leaks
4996 // through (`/etc/(passwd|group)` would output
4997 // `passwd group` instead of zsh's `group passwd`).
4998 out.sort();
4999 if !out.is_empty() {
5000 return out;
5001 }
5002 // No matches — fall through to NOMATCH semantics
5003 // below (zsh: error if `nomatch` is on, else literal).
5004 }
5005 // extendedglob `~` exclusion: `*.txt~b.txt` matches `*.txt`
5006 // and excludes paths that also match `b.txt`. Detect a
5007 // top-level `~` (not inside brackets/parens) when extendedglob
5008 // is on and split. Recursively expand both halves and remove
5009 // the RHS matches from the LHS list.
5010 let extglob_on = crate::ported::options::opt_state_get("extendedglob").unwrap_or(false);
5011 if extglob_on {
5012 // extendedglob `^pat` (negation): match everything that
5013 // does NOT match `pat`. The lexer leaves `^` as a literal
5014 // char, so we detect a leading `^` here and convert to a
5015 // directory-walk-then-filter. Only applies at the start
5016 // of the LAST path component (zsh: `^pat` only negates
5017 // the basename portion).
5018 let last_seg_start = pattern.rfind('/').map(|i| i + 1).unwrap_or(0);
5019 let last_seg = &pattern[last_seg_start..];
5020 if last_seg.starts_with('^') && last_seg.len() > 1 {
5021 let prefix = &pattern[..last_seg_start];
5022 let neg = &last_seg[1..];
5023 let dir = if prefix.is_empty() {
5024 ".".to_string()
5025 } else {
5026 prefix.trim_end_matches('/').to_string()
5027 };
5028 let mut out = Vec::new();
5029 if let Ok(entries) = std::fs::read_dir(&dir) {
5030 for entry in entries.flatten() {
5031 let name = entry.file_name().to_string_lossy().to_string();
5032 if name.starts_with('.') {
5033 continue;
5034 }
5035 if !crate::exec::glob_match_static(&name, neg) {
5036 let path = if prefix.is_empty() {
5037 name
5038 } else {
5039 format!("{}{}", prefix, name)
5040 };
5041 out.push(path);
5042 }
5043 }
5044 }
5045 out.sort();
5046 if !out.is_empty() {
5047 return out;
5048 }
5049 let nullglob = crate::ported::options::opt_state_get("nullglob").unwrap_or(false);
5050 if nullglob {
5051 return Vec::new();
5052 }
5053 let nomatch = crate::ported::options::opt_state_get("nomatch").unwrap_or(true);
5054 if nomatch {
5055 zerr(&format!("no matches found: {}", pattern));
5056 std::process::exit(1);
5057 }
5058 return vec![pattern.to_string()];
5059 }
5060 // Find a top-level `~` outside brackets.
5061 let chars: Vec<char> = pattern.chars().collect();
5062 let mut depth_b = 0i32;
5063 let mut depth_p = 0i32;
5064 let mut split_at: Option<usize> = None;
5065 for (i, &c) in chars.iter().enumerate() {
5066 match c {
5067 '[' => depth_b += 1,
5068 ']' => depth_b -= 1,
5069 '(' => depth_p += 1,
5070 ')' => depth_p -= 1,
5071 '~' if depth_b == 0 && depth_p == 0 && i > 0 => {
5072 // Skip `~` at start (tilde expansion) and `~` adjacent
5073 // to space (zsh treats those as expansion).
5074 split_at = Some(i);
5075 break;
5076 }
5077 _ => {}
5078 }
5079 }
5080 if let Some(pos) = split_at {
5081 let lhs: String = chars[..pos].iter().collect();
5082 let rhs: String = chars[pos + 1..].iter().collect();
5083 let lhs_matches = self.expand_glob(&lhs);
5084 // zsh pattern.c: `~` is an exclusion operator that matches
5085 // RHS as a PATTERN against each LHS candidate, not a
5086 // separate glob expansion in CWD. Match RHS against each
5087 // result's basename and full path.
5088 let filtered: Vec<String> = lhs_matches
5089 .into_iter()
5090 .filter(|p| {
5091 let basename = p.rsplit('/').next().unwrap_or(p);
5092 !crate::exec::glob_match_static(basename, &rhs)
5093 && !crate::exec::glob_match_static(p, &rhs)
5094 })
5095 .collect();
5096 if !filtered.is_empty() {
5097 return filtered;
5098 }
5099 // Empty after exclusion — fall through so NOMATCH
5100 // semantics fire if no nullglob.
5101 let nullglob = crate::ported::options::opt_state_get("nullglob").unwrap_or(false);
5102 if nullglob {
5103 return Vec::new();
5104 }
5105 let nomatch = crate::ported::options::opt_state_get("nomatch").unwrap_or(true);
5106 if nomatch && Self::looks_like_glob(pattern) {
5107 zerr(&format!("no matches found: {}", pattern));
5108 std::process::exit(1);
5109 }
5110 return vec![pattern.to_string()];
5111 }
5112 }
5113 // Check for zsh glob qualifiers at end: *(.) *(/) *(@) etc.
5114 let (glob_pattern, qualifiers) = self.parse_glob_qualifiers(pattern);
5115 // Pre-process `[^...]` → `[!...]` so the `glob` crate (which
5116 // only accepts `!` for class negation per fnmatch) works for
5117 // zsh's `^` form too. Walk the pattern and only translate
5118 // inside `[...]` regions (so a literal `^` outside brackets
5119 // stays literal — extendedglob handles those separately).
5120 let glob_pattern = if glob_pattern.contains("[^") {
5121 let mut out = String::with_capacity(glob_pattern.len());
5122 let mut chars = glob_pattern.chars().peekable();
5123 while let Some(c) = chars.next() {
5124 if c == '[' {
5125 out.push('[');
5126 if chars.peek() == Some(&'^') {
5127 chars.next();
5128 out.push('!');
5129 }
5130 for cc in chars.by_ref() {
5131 out.push(cc);
5132 if cc == ']' {
5133 break;
5134 }
5135 }
5136 } else {
5137 out.push(c);
5138 }
5139 }
5140 out
5141 } else {
5142 glob_pattern
5143 };
5144
5145 // POSIX character classes: `[[:alpha:]]`, `[[:digit:]]` etc.
5146 // The `glob` crate doesn't recognise the `[:class:]` syntax —
5147 // convert each known class to its enumerated char range so
5148 // the underlying matcher sees a plain char-class. Done here
5149 // (not at the lexer) so the substitution survives all the
5150 // way to glob::glob_with(). Tracks: alnum, alpha, blank,
5151 // cntrl, digit, graph, lower, print, punct, space, upper,
5152 // xdigit. Each translates to ranges like `0-9`/`a-zA-Z`.
5153 let glob_pattern = if glob_pattern.contains("[:") {
5154 // Inline expansion of `[[:alpha:]]` → `[a-zA-Z]` etc.
5155 // Mirrors the inline `[:class:]` switch the C source does
5156 // in pattern.c::patmatchrange. Each known class translates
5157 // to its standard ASCII range; unknown classes pass through.
5158 let s = &glob_pattern;
5159 let mut out = String::with_capacity(s.len());
5160 let chars: Vec<char> = s.chars().collect();
5161 let mut i = 0;
5162 while i < chars.len() {
5163 if chars[i] == '[' && i + 2 < chars.len() && chars[i + 1] == ':' {
5164 let mut j = i + 2;
5165 while j + 1 < chars.len() && !(chars[j] == ':' && chars[j + 1] == ']') {
5166 j += 1;
5167 }
5168 if j + 1 < chars.len() && chars[j] == ':' && chars[j + 1] == ']' {
5169 let name: String = chars[i + 2..j].iter().collect();
5170 let range = match name.as_str() {
5171 "alpha" => "a-zA-Z",
5172 "alnum" => "a-zA-Z0-9",
5173 "digit" => "0-9",
5174 "xdigit" => "0-9a-fA-F",
5175 "lower" => "a-z",
5176 "upper" => "A-Z",
5177 "space" => " \\t\\n\\r\\v\\f",
5178 "blank" => " \\t",
5179 "cntrl" => "\\x00-\\x1f\\x7f",
5180 "print" => "\\x20-\\x7e",
5181 "graph" => "\\x21-\\x7e",
5182 "punct" => "!-/:-@\\[-`{-~",
5183 _ => "",
5184 };
5185 if !range.is_empty() {
5186 out.push_str(range);
5187 i = j + 2;
5188 continue;
5189 }
5190 }
5191 }
5192 out.push(chars[i]);
5193 i += 1;
5194 }
5195 out
5196 } else {
5197 glob_pattern
5198 };
5199
5200 // zsh numeric range glob `<N-M>`, `<N->`, `<-M>`, `<->`.
5201 // The `glob` crate has no equivalent — match by replacing the
5202 // range with `*` and post-filtering by extracting the digit
5203 // sequence at that position and verifying it falls in [N, M].
5204 // Only fires when the pattern actually contains a `<…-…>` shape
5205 // — guard with a fast contains() before the regex.
5206 let numeric_ranges = if glob_pattern.contains('<') {
5207 extract_numeric_ranges(&glob_pattern)
5208 } else {
5209 Vec::new()
5210 };
5211 let glob_pattern = if !numeric_ranges.is_empty() {
5212 numeric_ranges_to_star(&glob_pattern)
5213 } else {
5214 glob_pattern
5215 };
5216
5217 // Check for extended glob patterns: ?(pat), *(pat), +(pat), @(pat), !(pat)
5218 if self.has_extglob_pattern(&glob_pattern) {
5219 let expanded = self.expand_glob(&glob_pattern);
5220 return self.filter_by_qualifiers(expanded, &qualifiers);
5221 }
5222
5223 let nullglob = crate::ported::options::opt_state_get("nullglob").unwrap_or(false);
5224 // `(D)` glob qualifier — per-pattern dotglob. Same effect as
5225 // `setopt dotglob` but scoped to this expansion only.
5226 // Also: when the LAST path component starts with literal `.`,
5227 // treat as if dotglob was on (zsh: `.*` matches dotfiles even
5228 // without setopt dotglob, because the leading `.` is literal).
5229 let last_seg = glob_pattern.rsplit('/').next().unwrap_or(&glob_pattern);
5230 let pattern_starts_with_dot = last_seg.starts_with('.');
5231 // `globdots` is the zsh canonical name; `dotglob` is the bash
5232 // alias. Both end up stored under their own key by setopt — read
5233 // both so either spelling works.
5234 let dotglob = crate::ported::options::opt_state_get("dotglob").unwrap_or(false)
5235 || crate::ported::options::opt_state_get("globdots").unwrap_or(false)
5236 || qualifiers.contains('D')
5237 || pattern_starts_with_dot;
5238 // `setopt nocaseglob` normalizes to `caseglob=false` in the
5239 // options table (the `no` prefix is the negation marker).
5240 // Read both forms so user code that flips either key works:
5241 // - `caseglob=false` → case-INSENSITIVE
5242 // - `nocaseglob=true` → case-INSENSITIVE (legacy / direct)
5243 let nocaseglob = !crate::ported::options::opt_state_get("caseglob").unwrap_or(true)
5244 || crate::ported::options::opt_state_get("nocaseglob").unwrap_or(false);
5245
5246 // Parallel recursive glob: when pattern contains **/ we split the
5247 // directory walk across worker pool threads — one thread per top-level
5248 // subdirectory. zsh does this single-threaded via fork+exec which is
5249 // why `echo **/*.rs` is painfully slow on large trees.
5250 let mut expanded = if !numeric_ranges.is_empty() {
5251 // `<N-M>` numeric range glob — handle via direct directory
5252 // walk so the digit-count semantics survive (the glob crate
5253 // can't express "one or more digits" precisely).
5254 self.expand_glob_with_numeric_range(pattern, &numeric_ranges, dotglob, nocaseglob)
5255 } else if glob_pattern.contains("**/") {
5256 self.expand_glob_parallel(&glob_pattern, dotglob, nocaseglob)
5257 } else {
5258 let options = glob::MatchOptions {
5259 case_sensitive: !nocaseglob,
5260 require_literal_separator: false,
5261 require_literal_leading_dot: !dotglob,
5262 };
5263 match glob::glob_with(&glob_pattern, options) {
5264 Ok(paths) => paths
5265 .filter_map(|p| p.ok())
5266 .map(|p| p.to_string_lossy().to_string())
5267 .collect(),
5268 Err(_) => vec![],
5269 }
5270 };
5271
5272 // zsh always excludes "." and ".." from glob results, even
5273 // with `dotglob` set or when the pattern is `.*`. The Rust
5274 // glob crate includes them. `Path::file_name` returns None
5275 // for these (treats them as cur/parent-dir components), so
5276 // check the trailing path segment textually.
5277 expanded.retain(|p| {
5278 let last = p.rsplit('/').next().unwrap_or(p);
5279 last != "." && last != ".."
5280 });
5281
5282 let expanded = self.filter_by_qualifiers(expanded, &qualifiers);
5283 let mut expanded = expanded;
5284 // zsh: `echo */` outputs each directory with a trailing
5285 // slash. The Rust glob crate strips trailing slashes from
5286 // matches, so re-append when the pattern ended in `/`.
5287 if glob_pattern.ends_with('/') {
5288 for p in expanded.iter_mut() {
5289 if !p.ends_with('/') {
5290 p.push('/');
5291 }
5292 }
5293 }
5294 // Locale-aware sort: under a Unicode locale, zsh folds case
5295 // (`Aaa bbb Ccc Ddd` not `Aaa Ccc Ddd bbb`). Fallback to byte
5296 // order under C/POSIX. Sort by basename so directory components
5297 // don't dominate the comparison and produce ASCII-style output.
5298 // Skip when the qualifier requested an explicit sort (`o*`/`O*`)
5299 // — those reorder by mtime/size/etc and the alpha sort would
5300 // clobber the result.
5301 let user_sort = qualifiers.contains('o') || qualifiers.contains('O');
5302 if !user_sort {
5303 // For `**/...` recursive globs, sort by the FULL path so
5304 // depth-first / breadth-first walk order is preserved
5305 // (zsh's natural recursive order: `dir/f sub sub/g`, not
5306 // basename-sorted `f g sub`). For plain (non-recursive)
5307 // globs, sort by BASENAME to match zsh's locale-aware
5308 // case-folded output.
5309 // Locale-aware compare via canonical `zstrcmp` (sort.c:191).
5310 // C's `gmatchcmp` (glob.c:936) calls `zstrcmp(b->uname,
5311 // a->uname, gf_numsort ? SORTIT_NUMERICALLY : 0)` for the
5312 // GS_NAME arm — same path here at the callsite.
5313 if glob_pattern.contains("**/") {
5314 expanded.sort_by(|a, b| crate::ported::sort::zstrcmp(a, b, 0));
5315 } else {
5316 expanded.sort_by(|a, b| {
5317 let an = a.rsplit('/').next().unwrap_or(a);
5318 let bn = b.rsplit('/').next().unwrap_or(b);
5319 crate::ported::sort::zstrcmp(an, bn, 0)
5320 });
5321 }
5322 }
5323
5324 if expanded.is_empty() {
5325 // The `(N)` per-pattern qualifier is the local equivalent of
5326 // `setopt nullglob` — when present on this glob, no-match
5327 // collapses to an empty list (silent) instead of the literal
5328 // pattern. Mirrors zsh's `*(N)` semantics.
5329 if nullglob || qualifiers.contains('N') {
5330 return vec![];
5331 }
5332 // zsh's default is `setopt nomatch`: an unmatched glob
5333 // emits "no matches found" on stderr and aborts the command
5334 // (the shell exits in -c mode). bash-style "pass literal
5335 // through" is the opt-out via `unsetopt nomatch`.
5336 let nomatch = crate::ported::options::opt_state_get("nomatch").unwrap_or(true);
5337 if nomatch && Self::looks_like_glob(pattern) {
5338 zerr(&format!("no matches found: {}", pattern));
5339 // zsh: command is aborted (skipped) with status 1,
5340 // script continues. Set the flag the simple-command
5341 // dispatcher checks; it returns early before exec.
5342 self.current_command_glob_failed.set(true);
5343 return Vec::new();
5344 }
5345 vec![pattern.to_string()]
5346 } else {
5347 expanded
5348 }
5349 }
5350 /// True iff the literal `pattern` actually contains a glob metachar
5351 /// in a position that would have triggered globbing. Used to avoid
5352 /// spurious "no matches" errors when expand_glob is called on a
5353 /// plain path that happened to route through this code (e.g. some
5354 /// fast paths bridge unconditionally).
5355 pub(crate) fn looks_like_glob(pattern: &str) -> bool {
5356 // A trailing `(qualifier)` is itself a glob trigger — e.g.
5357 // `path(L+10)` should be treated as a glob even when the
5358 // body has no `*`/`?`/`[...]`.
5359 let has_qual_suffix = if let Some(open) = pattern.rfind('(') {
5360 pattern.ends_with(')') && open + 1 < pattern.len() - 1
5361 } else {
5362 false
5363 };
5364 // Strip trailing `(...)` qualifier so we test the pattern body.
5365 let body = if let Some(open) = pattern.rfind('(') {
5366 if pattern.ends_with(')') {
5367 &pattern[..open]
5368 } else {
5369 pattern
5370 }
5371 } else {
5372 pattern
5373 };
5374 // Walk character-by-character so escaped metachars (`\*`, `\?`,
5375 // `\[`) are NOT counted as glob triggers. zsh: `echo \*` prints
5376 // a literal `*`; without the unescaped check, looks_like_glob
5377 // returned true on the bare `*` and the runtime glob expansion
5378 // aborted with NOMATCH.
5379 let chars: Vec<char> = body.chars().collect();
5380 let mut i = 0;
5381 let mut has_unescaped_star = false;
5382 let mut has_unescaped_question = false;
5383 let mut has_unescaped_bracket_open: Option<usize> = None;
5384 while i < chars.len() {
5385 let c = chars[i];
5386 if c == '\\' && i + 1 < chars.len() {
5387 // Escaped char — skip both.
5388 i += 2;
5389 continue;
5390 }
5391 match c {
5392 '*' => has_unescaped_star = true,
5393 '?' => has_unescaped_question = true,
5394 '[' if has_unescaped_bracket_open.is_none() => {
5395 has_unescaped_bracket_open = Some(i);
5396 }
5397 _ => {}
5398 }
5399 i += 1;
5400 }
5401 // `[` only counts when there's a matching `]` after it.
5402 let has_bracket_class = has_unescaped_bracket_open
5403 .map(|i| body[i + 1..].contains(']'))
5404 .unwrap_or(false);
5405 // `<N-M>` numeric range glob is also a trigger — match shape
5406 // `<` + optional digits + `-` + optional digits + `>` outside
5407 // any bracket expression.
5408 let has_numeric_range =
5409 body.contains('<') && body.contains('>') && !extract_numeric_ranges(body).is_empty();
5410 has_unescaped_star
5411 || has_unescaped_question
5412 || has_bracket_class
5413 || has_qual_suffix
5414 || has_numeric_range
5415 }
5416 /// Direct directory walk for numeric-range glob `<N-M>`.
5417 ///
5418 /// Split the pattern at the last `/` so the dir component can stay
5419 /// concrete (or be globbed normally) and the basename gets a custom
5420 /// regex match. Numeric range groups capture `(\d+)` and each
5421 /// capture must fall inside its declared `[lo, hi]` range — open
5422 /// ends mean unbounded on that side.
5423 pub(crate) fn expand_glob_with_numeric_range(
5424 &self,
5425 pattern: &str,
5426 ranges: &[(usize, usize, Option<i64>, Option<i64>)],
5427 dotglob: bool,
5428 nocaseglob: bool,
5429 ) -> Vec<String> {
5430 let (dir_part, file_part) = match pattern.rfind('/') {
5431 Some(idx) => (&pattern[..idx], &pattern[idx + 1..]),
5432 None => ("", pattern),
5433 };
5434 // Build the basename regex: glob → regex, with each `<N-M>`
5435 // becoming a numbered capture group `(\d+)`.
5436 let mut rx = String::from("^");
5437 let chars: Vec<char> = file_part.chars().collect();
5438 let mut i = 0;
5439 let mut in_bracket = false;
5440 while i < chars.len() {
5441 let c = chars[i];
5442 if c == '[' && !in_bracket {
5443 in_bracket = true;
5444 rx.push('[');
5445 i += 1;
5446 continue;
5447 }
5448 if c == ']' && in_bracket {
5449 in_bracket = false;
5450 rx.push(']');
5451 i += 1;
5452 continue;
5453 }
5454 if in_bracket {
5455 rx.push(c);
5456 i += 1;
5457 continue;
5458 }
5459 if c == '<' {
5460 let mut j = i + 1;
5461 while j < chars.len() && chars[j].is_ascii_digit() {
5462 j += 1;
5463 }
5464 if j < chars.len() && chars[j] == '-' {
5465 j += 1;
5466 while j < chars.len() && chars[j].is_ascii_digit() {
5467 j += 1;
5468 }
5469 if j < chars.len() && chars[j] == '>' {
5470 rx.push_str("(\\d+)");
5471 i = j + 1;
5472 continue;
5473 }
5474 }
5475 }
5476 match c {
5477 '*' => rx.push_str(".*"),
5478 '?' => rx.push('.'),
5479 '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '{' | '}' => {
5480 rx.push('\\');
5481 rx.push(c);
5482 }
5483 _ => rx.push(c),
5484 }
5485 i += 1;
5486 }
5487 rx.push('$');
5488 let re = match if nocaseglob {
5489 regex::RegexBuilder::new(&rx).case_insensitive(true).build()
5490 } else {
5491 regex::Regex::new(&rx).map_err(|e| regex::Error::Syntax(e.to_string()))
5492 } {
5493 Ok(r) => r,
5494 Err(_) => return Vec::new(),
5495 };
5496
5497 // Resolve dir_part: it may itself contain glob chars (e.g.
5498 // `**/file<2-4>`). For now require the dir part to be either
5499 // empty (cwd) or a literal path; defer recursive ranges.
5500 let mut dirs: Vec<String> = if dir_part.is_empty() {
5501 vec![".".to_string()]
5502 } else if dir_part.contains('*')
5503 || dir_part.contains('?')
5504 || dir_part.contains('[')
5505 || dir_part.contains('<')
5506 {
5507 // Glob the dir component first, keeping only directories.
5508 let opts = glob::MatchOptions {
5509 case_sensitive: !nocaseglob,
5510 require_literal_separator: false,
5511 require_literal_leading_dot: !dotglob,
5512 };
5513 match glob::glob_with(dir_part, opts) {
5514 Ok(paths) => paths
5515 .filter_map(|p| p.ok())
5516 .filter(|p| p.is_dir())
5517 .map(|p| p.to_string_lossy().to_string())
5518 .collect(),
5519 Err(_) => return Vec::new(),
5520 }
5521 } else {
5522 vec![dir_part.to_string()]
5523 };
5524 if dirs.is_empty() {
5525 dirs.push(dir_part.to_string());
5526 }
5527
5528 let mut out = Vec::new();
5529 for dir in &dirs {
5530 let read = match std::fs::read_dir(if dir.is_empty() { "." } else { dir }) {
5531 Ok(r) => r,
5532 Err(_) => continue,
5533 };
5534 for entry in read.flatten() {
5535 let name = entry.file_name().to_string_lossy().to_string();
5536 if !dotglob && name.starts_with('.') && !file_part.starts_with('.') {
5537 continue;
5538 }
5539 let caps = match re.captures(&name) {
5540 Some(c) => c,
5541 None => continue,
5542 };
5543 let mut ok = true;
5544 for (idx, range) in ranges.iter().enumerate() {
5545 let cap = match caps.get(idx + 1) {
5546 Some(m) => m.as_str(),
5547 None => {
5548 ok = false;
5549 break;
5550 }
5551 };
5552 let val: i64 = match cap.parse() {
5553 Ok(v) => v,
5554 Err(_) => {
5555 ok = false;
5556 break;
5557 }
5558 };
5559 // Tuple shape: (_start, _end, lo, hi).
5560 if !numeric_range_contains(range.2, range.3, val) {
5561 ok = false;
5562 break;
5563 }
5564 }
5565 if !ok {
5566 continue;
5567 }
5568 let full = if dir == "." || dir.is_empty() {
5569 name
5570 } else if dir.ends_with('/') {
5571 format!("{}{}", dir, name)
5572 } else {
5573 format!("{}/{}", dir, name)
5574 };
5575 out.push(full);
5576 }
5577 }
5578 out.sort();
5579 out
5580 }
5581 /// Parallel recursive glob using the worker pool.
5582 ///
5583 /// Splits `base/**/file_pattern` into per-subdirectory walks, each
5584 /// running on a pool thread via walkdir. Results merge via channel.
5585 /// This is why `echo **/*.rs` will be 5-10x faster than zsh.
5586 pub(crate) fn expand_glob_parallel(&self, pattern: &str, dotglob: bool, nocaseglob: bool) -> Vec<String> {
5587
5588 // Split pattern at the first **/ into (base_dir, file_glob)
5589 // e.g. "src/**/*.rs" → ("src", "*.rs")
5590 // "**/*.rs" → (".", "*.rs")
5591 // "**/" → (".", "") with dirs_only=true
5592 // "**/*" → (".", "*") with both files+dirs
5593 let (base, file_glob) = if let Some(pos) = pattern.find("**/") {
5594 let base = if pos == 0 {
5595 "."
5596 } else {
5597 &pattern[..pos.saturating_sub(1)]
5598 };
5599 let rest = &pattern[pos + 3..]; // skip "**/", get "*.rs" or "foo/**/*.rs"
5600 (base.to_string(), rest.to_string())
5601 } else {
5602 return vec![];
5603 };
5604
5605 // Trailing-slash form `**/`: zsh enumerates matching directories
5606 // (with the trailing slash preserved). Empty file_glob means
5607 // "match every dir under base, no file mask".
5608 let dirs_only = file_glob.is_empty();
5609
5610 // If file_glob itself contains **/, fall back to single-threaded glob
5611 // (nested recursive patterns are rare, not worth the complexity)
5612 if file_glob.contains("**/") {
5613 let options = glob::MatchOptions {
5614 case_sensitive: !nocaseglob,
5615 require_literal_separator: false,
5616 require_literal_leading_dot: !dotglob,
5617 };
5618 return match glob::glob_with(pattern, options) {
5619 Ok(paths) => paths
5620 .filter_map(|p| p.ok())
5621 .map(|p| p.to_string_lossy().to_string())
5622 .collect(),
5623 Err(_) => vec![],
5624 };
5625 }
5626
5627 // Build the glob::Pattern for matching filenames. For
5628 // `dirs_only` (trailing-slash `**/`) we don't have a file mask
5629 // — every directory matches.
5630 let match_opts = glob::MatchOptions {
5631 case_sensitive: !nocaseglob,
5632 require_literal_separator: false,
5633 require_literal_leading_dot: !dotglob,
5634 };
5635 let file_pat = if dirs_only {
5636 None
5637 } else {
5638 match glob::Pattern::new(&file_glob) {
5639 Ok(p) => Some(p),
5640 Err(_) => return vec![],
5641 }
5642 };
5643 // For `**/*` (file_glob = "*"), zsh matches both files and
5644 // directories. For `**/foo` (specific file pattern), still
5645 // match either type — zsh doesn't restrict to file-type unless
5646 // a `(.)` qualifier is appended.
5647 let match_dirs_too = !dirs_only;
5648
5649 // Enumerate top-level entries in base dir to fan out across workers
5650 let top_entries: Vec<std::path::PathBuf> = match std::fs::read_dir(&base) {
5651 Ok(rd) => rd.filter_map(|e| e.ok()).map(|e| e.path()).collect(),
5652 Err(_) => return vec![],
5653 };
5654
5655 // Also check files (and dirs in dirs_only / match_dirs_too mode)
5656 // directly in base (not in subdirs).
5657 let mut results: Vec<String> = Vec::new();
5658 for entry in &top_entries {
5659 let is_dir = entry.is_dir();
5660 let is_file = entry.is_file() || entry.is_symlink();
5661 let want = if dirs_only {
5662 is_dir
5663 } else {
5664 is_file || (match_dirs_too && is_dir)
5665 };
5666 if want {
5667 if let Some(name) = entry.file_name().and_then(|n| n.to_str()) {
5668 let matches = match &file_pat {
5669 None => true,
5670 Some(p) => p.matches_with(name, match_opts),
5671 };
5672 if matches {
5673 let mut s = entry.to_string_lossy().to_string();
5674 if dirs_only {
5675 s.push('/');
5676 }
5677 results.push(s);
5678 }
5679 }
5680 }
5681 }
5682
5683 // Fan out subdirectory walks to worker pool
5684 let subdirs: Vec<std::path::PathBuf> = top_entries
5685 .into_iter()
5686 .filter(|p| p.is_dir())
5687 .filter(|p| {
5688 dotglob
5689 || !p
5690 .file_name()
5691 .and_then(|n| n.to_str())
5692 .map(|n| n.starts_with('.'))
5693 .unwrap_or(false)
5694 })
5695 .collect();
5696
5697 if subdirs.is_empty() {
5698 return results;
5699 }
5700
5701 let (tx, rx) = std::sync::mpsc::channel::<Vec<String>>();
5702
5703 for subdir in &subdirs {
5704 let tx = tx.clone();
5705 let subdir = subdir.clone();
5706 let file_pat = file_pat.clone();
5707 let skip_dot = !dotglob;
5708 let dirs_only_w = dirs_only;
5709 let match_dirs_too_w = match_dirs_too;
5710 self.worker_pool.submit(move || {
5711 let mut matches = Vec::new();
5712 let walker = WalkDir::new(&subdir)
5713 .follow_links(false)
5714 .into_iter()
5715 .filter_entry(move |e| {
5716 // Skip hidden dirs if !dotglob
5717 if skip_dot {
5718 if let Some(name) = e.file_name().to_str() {
5719 if name.starts_with('.') && e.depth() > 0 {
5720 return false;
5721 }
5722 }
5723 }
5724 true
5725 });
5726 for entry in walker.filter_map(|e| e.ok()) {
5727 let is_file = entry.file_type().is_file() || entry.file_type().is_symlink();
5728 let is_dir = entry.file_type().is_dir();
5729 // Skip the subdir root itself — it was already added
5730 // by the top-level loop.
5731 if entry.depth() == 0 {
5732 continue;
5733 }
5734 let want = if dirs_only_w {
5735 is_dir
5736 } else {
5737 is_file || (match_dirs_too_w && is_dir)
5738 };
5739 if want {
5740 if let Some(name) = entry.file_name().to_str() {
5741 let matches_pat = match &file_pat {
5742 None => true,
5743 Some(p) => p.matches_with(name, match_opts),
5744 };
5745 if matches_pat {
5746 let mut s = entry.path().to_string_lossy().to_string();
5747 if dirs_only_w {
5748 s.push('/');
5749 }
5750 matches.push(s);
5751 }
5752 }
5753 }
5754 }
5755 let _ = tx.send(matches);
5756 });
5757 }
5758
5759 // Drop our sender so rx knows when all workers are done
5760 drop(tx);
5761
5762 // Collect results from all workers
5763 for batch in rx {
5764 results.extend(batch);
5765 }
5766
5767 // When base was the implicit "." (the user wrote `**/...`,
5768 // not `./**/...`), zsh emits relative paths without the `./`
5769 // prefix. Strip it here for parity.
5770 if base == "." {
5771 results = results
5772 .into_iter()
5773 .map(|s| s.strip_prefix("./").map(|t| t.to_string()).unwrap_or(s))
5774 .collect();
5775 }
5776
5777 // zsh sorts the recursive-glob result lexicographically. Without
5778 // this, the parallel-walker order leaks through and `**/*`
5779 // returns paths in worker-completion order (`f sub/g sub`
5780 // instead of `f sub sub/g`).
5781 results.sort();
5782
5783 results
5784 }
5785 /// Parse zsh glob qualifiers from the end of a pattern
5786 /// Returns (pattern_without_qualifiers, qualifiers_string)
5787 pub(crate) fn parse_glob_qualifiers(&self, pattern: &str) -> (String, String) {
5788 // Check if pattern ends with (...) that looks like qualifiers
5789 // Qualifiers are single chars like . / @ * % or combinations
5790 if !pattern.ends_with(')') {
5791 return (pattern.to_string(), String::new());
5792 }
5793
5794 // Find matching opening paren
5795 let chars: Vec<char> = pattern.chars().collect();
5796 let mut depth = 0;
5797 let mut qual_start = None;
5798
5799 for i in (0..chars.len()).rev() {
5800 match chars[i] {
5801 ')' => depth += 1,
5802 '(' => {
5803 depth -= 1;
5804 if depth == 0 {
5805 qual_start = Some(i);
5806 break;
5807 }
5808 }
5809 _ => {}
5810 }
5811 }
5812
5813 if let Some(start) = qual_start {
5814 let qual_content: String = chars[start + 1..chars.len() - 1].iter().collect();
5815
5816 // Check if this looks like glob qualifiers (not extglob)
5817 // Qualifiers are things like: . / @ * % r w x ^ - etc.
5818 // Extglob would have | inside
5819 if !qual_content.contains('|') && self.looks_like_glob_qualifiers(&qual_content) {
5820 let base_pattern: String = chars[..start].iter().collect();
5821 return (base_pattern, qual_content);
5822 }
5823 }
5824
5825 (pattern.to_string(), String::new())
5826 }
5827 /// Check if string looks like glob qualifiers
5828 //WARNING FAKE AND MUST BE DELETED
5829 pub(crate) fn looks_like_glob_qualifiers(&self, s: &str) -> bool {
5830 if s.is_empty() {
5831 return false;
5832 }
5833 // Valid qualifier chars (zsh glob qualifier set):
5834 // type/perm: . / @ = p * % b r w x s A I E R W X
5835 // sort: o O n L l a m c d N
5836 // time qual: a m c — followed by unit (s h m M d w) and op (+ -)
5837 // user/grp: u g
5838 // nullglob: N
5839 // dotglob: D
5840 // T (path component)
5841 // numeric ranges and digits for depth/uid/gid: 0-9 + - , [ ] :
5842 // Previously missing: `h` (hours unit), `g` (group qualifier),
5843 // `H` (non-empty-dir alt), `U` (owned-by-user) — adding them
5844 // unlocks `(mh-N)`, `(g+N)`, `(U)`, etc.
5845 // `O` (reverse-sort prefix, complementing `o`) was missing —
5846 // `*(Om)` was being treated as a literal pattern instead of a
5847 // qualifier set, leaving the trailing `)` unmatched. Added.
5848 let valid_chars = "./@=p*%bghilrwxAIERWXsStfHedDLNnMmcaouUYHTk^-+:0123456789,[]FO";
5849 s.chars()
5850 .all(|c| valid_chars.contains(c) || c.is_whitespace())
5851 }
5852
5853 //WARNING FAKE AND MUST BE DELETED
5854 pub(crate) fn filter_by_qualifiers(&self, files: Vec<String>, qualifiers: &str) -> Vec<String> {
5855 if qualifiers.is_empty() {
5856 return files;
5857 }
5858
5859 // Top-level `,` in the qualifier list is OR (zsh: `*(.,/)`
5860 // = files OR dirs). Direct port of zsh's pattern.c
5861 // qualifier parsing — comma splits at clause boundary,
5862 // each clause runs its own AND filter, the results are
5863 // UNIONed and de-duplicated. Single-clause (no comma)
5864 // path is unchanged.
5865 let has_or = {
5866 let mut depth_b = 0;
5867 let mut depth_p = 0;
5868 let mut found = false;
5869 for c in qualifiers.chars() {
5870 match c {
5871 '[' => depth_b += 1,
5872 ']' if depth_b > 0 => depth_b -= 1,
5873 '(' if depth_b == 0 => depth_p += 1,
5874 ')' if depth_b == 0 && depth_p > 0 => depth_p -= 1,
5875 ',' if depth_b == 0 && depth_p == 0 => {
5876 found = true;
5877 break;
5878 }
5879 _ => {}
5880 }
5881 }
5882 found
5883 };
5884 if has_or {
5885 // Split at top-level commas, recurse for each clause,
5886 // union the results in original-file order. Each
5887 // clause re-runs the full filter so qualifier flags
5888 // (`L+0`, `om`, etc.) inside one clause stay scoped.
5889 let mut clauses: Vec<String> = Vec::new();
5890 let mut current = String::new();
5891 let mut depth_b = 0;
5892 let mut depth_p = 0;
5893 for c in qualifiers.chars() {
5894 match c {
5895 '[' => {
5896 depth_b += 1;
5897 current.push(c);
5898 }
5899 ']' if depth_b > 0 => {
5900 depth_b -= 1;
5901 current.push(c);
5902 }
5903 '(' if depth_b == 0 => {
5904 depth_p += 1;
5905 current.push(c);
5906 }
5907 ')' if depth_b == 0 && depth_p > 0 => {
5908 depth_p -= 1;
5909 current.push(c);
5910 }
5911 ',' if depth_b == 0 && depth_p == 0 => {
5912 clauses.push(std::mem::take(&mut current));
5913 }
5914 _ => current.push(c),
5915 }
5916 }
5917 clauses.push(current);
5918 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
5919 let mut out: Vec<String> = Vec::new();
5920 for clause in &clauses {
5921 let matched = self.filter_by_qualifiers(files.clone(), clause);
5922 for m in matched {
5923 if seen.insert(m.clone()) {
5924 out.push(m);
5925 }
5926 }
5927 }
5928 return out;
5929 }
5930
5931 // Parallel metadata prefetch — all stat syscalls happen on pool threads,
5932 // then filter/sort uses cached metadata with zero syscalls.
5933 let meta_cache = self.prefetch_metadata(&files);
5934
5935 let mut result = files;
5936 let mut negate = false;
5937 // (M) mark-dirs and (T) list-types qualifiers — direct port of
5938 // zsh/Src/glob.c:1557-1566. zsh appends a single char to each
5939 // output (or only to dirs for `M`). We collect the flags during
5940 // the filter loop and apply marking AFTER all filtering is done
5941 // so the suffix sticks on the final result, not midway. `^M`
5942 // disables (toggles negate to clear the flag) — same as zsh.
5943 let mut mark_dirs = false;
5944 let mut list_types = false;
5945 let mut chars = qualifiers.chars().peekable();
5946
5947 while let Some(c) = chars.next() {
5948 match c {
5949 // Negation
5950 '^' => negate = !negate,
5951 // (M) mark dirs with `/`. negate=true (`^M`) clears.
5952 'M' => {
5953 mark_dirs = !negate;
5954 negate = false;
5955 }
5956 // (T) list types (ls -F style: /, *, @, |, =, #, %).
5957 'T' => {
5958 list_types = !negate;
5959 negate = false;
5960 }
5961
5962 // History modifier `:r` / `:e` / `:t` / `:h` /
5963 // `:s/pat/repl/` etc. applied to each match. Direct
5964 // port of zsh's pattern.c qualifier modifier
5965 // handling — `:NAME` consumes through the next
5966 // qualifier-list-end (next `,` or `)`) and
5967 // dispatches each modifier to apply_history_modifiers
5968 // per element.
5969 ':' => {
5970 // Collect the modifier chain — consume until
5971 // we hit another qualifier-flag char or end.
5972 // For simplicity, consume to end since the
5973 // qualifier-end already strips the trailing
5974 // `)`. The apply_history_modifiers helper
5975 // tolerates a leading `:`.
5976 let mut mods = String::from(":");
5977 // Consume to end — qualifier-end already stripped
5978 // the trailing `)`, so no internal delimiter check
5979 // is needed (apply_history_modifiers tolerates the
5980 // leading `:`).
5981 while chars.peek().is_some() {
5982 mods.push(chars.next().unwrap());
5983 }
5984 let modref = mods.as_str();
5985 result = result
5986 .into_iter()
5987 .map(|p| crate::ported::hist::apply_history_modifiers(&p, modref))
5988 .collect();
5989 }
5990
5991 // File types — all use prefetched metadata cache
5992 '.' => {
5993 // zsh: `.` is "plain regular file" — excludes
5994 // symlinks (use `@` for those). The `-`
5995 // qualifier modifier (`(-.)`) inverts this:
5996 // follow the symlink before testing, so a link
5997 // to a regular file IS included. Direct port of
5998 // zsh pattern.c QUAL_NULL → stat-not-lstat
5999 // toggle.
6000 let follow_links = qualifiers.contains('-');
6001 result.retain(|f| {
6002 let is_plain_file = meta_cache
6003 .get(f)
6004 .map(|(m, sm)| {
6005 let is_link = sm
6006 .as_ref()
6007 .map(|m| m.file_type().is_symlink())
6008 .unwrap_or(false);
6009 let is_reg = m.as_ref().map(|m| m.is_file()).unwrap_or(false);
6010 if follow_links {
6011 is_reg
6012 } else {
6013 is_reg && !is_link
6014 }
6015 })
6016 .unwrap_or(false);
6017 if negate {
6018 !is_plain_file
6019 } else {
6020 is_plain_file
6021 }
6022 });
6023 negate = false;
6024 }
6025 '/' => {
6026 result.retain(|f| {
6027 let is_dir = meta_cache
6028 .get(f)
6029 .and_then(|(m, _)| m.as_ref())
6030 .map(|m| m.is_dir())
6031 .unwrap_or(false);
6032 if negate {
6033 !is_dir
6034 } else {
6035 is_dir
6036 }
6037 });
6038 negate = false;
6039 }
6040 '@' => {
6041 result.retain(|f| {
6042 let is_link = meta_cache
6043 .get(f)
6044 .and_then(|(_, sm)| sm.as_ref())
6045 .map(|m| m.file_type().is_symlink())
6046 .unwrap_or(false);
6047 if negate {
6048 !is_link
6049 } else {
6050 is_link
6051 }
6052 });
6053 negate = false;
6054 }
6055 '=' => {
6056 // Sockets
6057 result.retain(|f| {
6058 let is_socket = meta_cache
6059 .get(f)
6060 .and_then(|(_, sm)| sm.as_ref())
6061 .map(|m| m.file_type().is_socket())
6062 .unwrap_or(false);
6063 if negate {
6064 !is_socket
6065 } else {
6066 is_socket
6067 }
6068 });
6069 negate = false;
6070 }
6071 'p' => {
6072 // Named pipes (FIFOs)
6073 result.retain(|f| {
6074 let is_fifo = meta_cache
6075 .get(f)
6076 .and_then(|(_, sm)| sm.as_ref())
6077 .map(|m| m.file_type().is_fifo())
6078 .unwrap_or(false);
6079 if negate {
6080 !is_fifo
6081 } else {
6082 is_fifo
6083 }
6084 });
6085 negate = false;
6086 }
6087 '*' => {
6088 // Executable files
6089 result.retain(|f| {
6090 let is_exec = meta_cache
6091 .get(f)
6092 .and_then(|(m, _)| m.as_ref())
6093 .map(|m| m.is_file() && (m.permissions().mode() & 0o111) != 0)
6094 .unwrap_or(false);
6095 if negate {
6096 !is_exec
6097 } else {
6098 is_exec
6099 }
6100 });
6101 negate = false;
6102 }
6103 '%' => {
6104 // Device files
6105 let next = chars.peek().copied();
6106 result.retain(|f| {
6107 let is_device = meta_cache
6108 .get(f)
6109 .and_then(|(_, sm)| sm.as_ref())
6110 .map(|m| match next {
6111 Some('b') => m.file_type().is_block_device(),
6112 Some('c') => m.file_type().is_char_device(),
6113 _ => {
6114 m.file_type().is_block_device()
6115 || m.file_type().is_char_device()
6116 }
6117 })
6118 .unwrap_or(false);
6119 if negate {
6120 !is_device
6121 } else {
6122 is_device
6123 }
6124 });
6125 if next == Some('b') || next == Some('c') {
6126 chars.next();
6127 }
6128 negate = false;
6129 }
6130
6131 // L[+-]N[k|m|g|p] — size qualifier. Default unit is 512-byte
6132 // blocks; suffix 'k'/'K' = kilobytes, 'm'/'M' = megabytes,
6133 // 'g'/'G' = gigabytes, 'p'/'P' = bytes (POSIX). +N matches
6134 // larger, -N smaller, N matches exactly. e.g. L0 = exactly
6135 // 0 bytes; L+10k = larger than 10 KB.
6136 'L' => {
6137 let mut cmp = '=';
6138 if let Some(&peek) = chars.peek() {
6139 if peek == '+' || peek == '-' {
6140 cmp = peek;
6141 chars.next();
6142 }
6143 }
6144 let mut num_str = String::new();
6145 while let Some(&peek) = chars.peek() {
6146 if peek.is_ascii_digit() {
6147 num_str.push(peek);
6148 chars.next();
6149 } else {
6150 break;
6151 }
6152 }
6153 let n: u64 = num_str.parse().unwrap_or(0);
6154 let unit_mult: u64 = match chars.peek().copied() {
6155 Some('k') | Some('K') => {
6156 chars.next();
6157 1024
6158 }
6159 Some('m') | Some('M') => {
6160 chars.next();
6161 1024 * 1024
6162 }
6163 Some('g') | Some('G') => {
6164 chars.next();
6165 1024 * 1024 * 1024
6166 }
6167 Some('p') | Some('P') => {
6168 chars.next();
6169 1
6170 }
6171 // zsh's default for L is BYTES (not 512-byte
6172 // blocks). `(L+3)` means "more than 3 bytes".
6173 _ => 1,
6174 };
6175 let target = n * unit_mult;
6176 result.retain(|f| {
6177 // zsh's L qualifier uses lstat size —
6178 // for symlinks, that's the path-string
6179 // length (NOT the target's size).
6180 // Direct port: prefer the symlink
6181 // metadata `sm` when present, fall
6182 // back to the followed metadata.
6183 let size = meta_cache
6184 .get(f)
6185 .map(|(m, sm)| {
6186 sm.as_ref()
6187 .map(|m| m.len())
6188 .unwrap_or_else(|| m.as_ref().map(|m| m.len()).unwrap_or(0))
6189 })
6190 .unwrap_or(0);
6191 let pass = match cmp {
6192 '+' => size > target,
6193 '-' => size < target,
6194 _ => size == target,
6195 };
6196 if negate {
6197 !pass
6198 } else {
6199 pass
6200 }
6201 });
6202 negate = false;
6203 }
6204
6205 // l[+-]N — link-count qualifier. zsh: `*(l2)` = files
6206 // with exactly 2 hard links (e.g. one regular + one
6207 // hardlink). `+N` matches more, `-N` matches fewer.
6208 'l' => {
6209 let mut cmp = '=';
6210 if let Some(&peek) = chars.peek() {
6211 if peek == '+' || peek == '-' {
6212 cmp = peek;
6213 chars.next();
6214 }
6215 }
6216 let mut num_str = String::new();
6217 while let Some(&peek) = chars.peek() {
6218 if peek.is_ascii_digit() {
6219 num_str.push(peek);
6220 chars.next();
6221 } else {
6222 break;
6223 }
6224 }
6225 let target: u64 = num_str.parse().unwrap_or(0);
6226 result.retain(|f| {
6227 let nlink = meta_cache
6228 .get(f)
6229 .and_then(|(m, _)| m.as_ref())
6230 .map(|m| m.nlink())
6231 .unwrap_or(0);
6232 let matches = match cmp {
6233 '+' => nlink > target,
6234 '-' => nlink < target,
6235 _ => nlink == target,
6236 };
6237 if negate {
6238 !matches
6239 } else {
6240 matches
6241 }
6242 });
6243 negate = false;
6244 }
6245
6246 // Permission qualifiers — all use prefetched metadata cache
6247 'r' => {
6248 result = self.filter_by_permission(result, 0o400, negate, &meta_cache);
6249 negate = false;
6250 }
6251 'w' => {
6252 result = self.filter_by_permission(result, 0o200, negate, &meta_cache);
6253 negate = false;
6254 }
6255 'x' => {
6256 result = self.filter_by_permission(result, 0o100, negate, &meta_cache);
6257 negate = false;
6258 }
6259 'A' => {
6260 result = self.filter_by_permission(result, 0o040, negate, &meta_cache);
6261 negate = false;
6262 }
6263 'I' => {
6264 result = self.filter_by_permission(result, 0o020, negate, &meta_cache);
6265 negate = false;
6266 }
6267 'E' => {
6268 result = self.filter_by_permission(result, 0o010, negate, &meta_cache);
6269 negate = false;
6270 }
6271 'R' => {
6272 result = self.filter_by_permission(result, 0o004, negate, &meta_cache);
6273 negate = false;
6274 }
6275 'W' => {
6276 result = self.filter_by_permission(result, 0o002, negate, &meta_cache);
6277 negate = false;
6278 }
6279 'X' => {
6280 result = self.filter_by_permission(result, 0o001, negate, &meta_cache);
6281 negate = false;
6282 }
6283 's' => {
6284 result = self.filter_by_permission(result, 0o4000, negate, &meta_cache);
6285 negate = false;
6286 }
6287 'S' => {
6288 result = self.filter_by_permission(result, 0o2000, negate, &meta_cache);
6289 negate = false;
6290 }
6291 't' => {
6292 result = self.filter_by_permission(result, 0o1000, negate, &meta_cache);
6293 negate = false;
6294 }
6295
6296 // Full/empty directories
6297 'F' => {
6298 // Non-empty directories
6299 result.retain(|f| {
6300 let path = std::path::Path::new(f);
6301 let is_nonempty = path.is_dir()
6302 && std::fs::read_dir(path)
6303 .map(|mut d| d.next().is_some())
6304 .unwrap_or(false);
6305 if negate {
6306 !is_nonempty
6307 } else {
6308 is_nonempty
6309 }
6310 });
6311 negate = false;
6312 }
6313
6314 // Ownership — uses prefetched metadata cache
6315 'U' => {
6316 // Owned by effective UID
6317 let euid = unsafe { libc::geteuid() };
6318 result.retain(|f| {
6319 let is_owned = meta_cache
6320 .get(f)
6321 .and_then(|(m, _)| m.as_ref())
6322 .map(|m| m.uid() == euid)
6323 .unwrap_or(false);
6324 if negate {
6325 !is_owned
6326 } else {
6327 is_owned
6328 }
6329 });
6330 negate = false;
6331 }
6332 'G' => {
6333 // Owned by effective GID
6334 let egid = unsafe { libc::getegid() };
6335 result.retain(|f| {
6336 let is_owned = meta_cache
6337 .get(f)
6338 .and_then(|(m, _)| m.as_ref())
6339 .map(|m| m.gid() == egid)
6340 .unwrap_or(false);
6341 if negate {
6342 !is_owned
6343 } else {
6344 is_owned
6345 }
6346 });
6347 negate = false;
6348 }
6349
6350 // Sorting modifiers
6351 'o' => {
6352 // Sort by name (ascending) - already default
6353 if chars.peek() == Some(&'n') {
6354 chars.next();
6355 // Sort by name
6356 result.sort();
6357 } else if chars.peek() == Some(&'L') {
6358 chars.next();
6359 // Sort by size — uses prefetched metadata
6360 result.sort_by_key(|f| {
6361 meta_cache
6362 .get(f)
6363 .and_then(|(m, _)| m.as_ref())
6364 .map(|m| m.len())
6365 .unwrap_or(0)
6366 });
6367 } else if chars.peek() == Some(&'m') {
6368 chars.next();
6369 // zsh: `om` orders by modification time NEWEST
6370 // FIRST (the time qualifiers default to
6371 // descending; `Om` reverses to oldest-first).
6372 // Was sorting ascending which inverted output.
6373 result.sort_by_key(|f| {
6374 meta_cache
6375 .get(f)
6376 .and_then(|(m, _)| m.as_ref())
6377 .and_then(|m| m.modified().ok())
6378 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
6379 });
6380 result.reverse();
6381 } else if chars.peek() == Some(&'a') {
6382 chars.next();
6383 // Same time-default-descending for atime.
6384 result.sort_by_key(|f| {
6385 meta_cache
6386 .get(f)
6387 .and_then(|(m, _)| m.as_ref())
6388 .and_then(|m| m.accessed().ok())
6389 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
6390 });
6391 result.reverse();
6392 } else if chars.peek() == Some(&'c') {
6393 chars.next();
6394 // ctime — same default-descending semantics.
6395 result.sort_by_key(|f| {
6396 meta_cache
6397 .get(f)
6398 .and_then(|(m, _)| m.as_ref())
6399 .map(|m| {
6400 std::time::UNIX_EPOCH
6401 + std::time::Duration::from_secs(m.ctime() as u64)
6402 })
6403 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
6404 });
6405 result.reverse();
6406 }
6407 }
6408 'O' => {
6409 // Reverse sort — uses prefetched metadata
6410 if chars.peek() == Some(&'n') {
6411 chars.next();
6412 result.sort();
6413 result.reverse();
6414 } else if chars.peek() == Some(&'L') {
6415 chars.next();
6416 result.sort_by_key(|f| {
6417 meta_cache
6418 .get(f)
6419 .and_then(|(m, _)| m.as_ref())
6420 .map(|m| m.len())
6421 .unwrap_or(0)
6422 });
6423 result.reverse();
6424 } else if chars.peek() == Some(&'m') {
6425 chars.next();
6426 // `Om` flips the default time-descending — so
6427 // `Om` is oldest-first. Just sort ascending.
6428 result.sort_by_key(|f| {
6429 meta_cache
6430 .get(f)
6431 .and_then(|(m, _)| m.as_ref())
6432 .and_then(|m| m.modified().ok())
6433 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
6434 });
6435 } else {
6436 // Just reverse current order
6437 result.reverse();
6438 }
6439 }
6440
6441 // Subscript range [n] or [n,m]
6442 '[' => {
6443 let mut range_str = String::new();
6444 while let Some(&ch) = chars.peek() {
6445 if ch == ']' {
6446 chars.next();
6447 break;
6448 }
6449 range_str.push(chars.next().unwrap());
6450 }
6451
6452 if let Some((start, end)) = self.parse_subscript_range(&range_str, result.len())
6453 {
6454 result = result.into_iter().skip(start).take(end - start).collect();
6455 }
6456 }
6457
6458 // Depth limit (for **/)
6459 'D' => {
6460 // Include dotfiles (handled by dotglob)
6461 }
6462 'N' => {
6463 // Nullglob for this pattern
6464 }
6465
6466 // Time qualifiers `m` (mtime), `a` (atime), `c` (ctime).
6467 // Format: <qual><unit><op><N> e.g. `mh-100` =
6468 // mtime within last 100 hours. Units: s (sec), m (min,
6469 // default), h (hour), d (day, default for none),
6470 // w (week), M (month, 30d). Ops: `+N` = older than,
6471 // `-N` = newer than, no op = exactly N (within ±1 unit).
6472 'm' | 'a' | 'c' => {
6473 let qual_kind = c;
6474 // Unit (optional, default = days)
6475 let unit_secs: i64 = match chars.peek().copied() {
6476 Some('s') => {
6477 chars.next();
6478 1
6479 }
6480 Some('m') => {
6481 chars.next();
6482 60
6483 }
6484 Some('h') => {
6485 chars.next();
6486 3600
6487 }
6488 Some('d') => {
6489 chars.next();
6490 86400
6491 }
6492 Some('w') => {
6493 chars.next();
6494 7 * 86400
6495 }
6496 Some('M') => {
6497 chars.next();
6498 30 * 86400
6499 }
6500 _ => 86400,
6501 };
6502 // Op (optional, default = exact)
6503 let op = match chars.peek().copied() {
6504 Some('+') => {
6505 chars.next();
6506 '+'
6507 }
6508 Some('-') => {
6509 chars.next();
6510 '-'
6511 }
6512 _ => '=',
6513 };
6514 // Numeric value
6515 let mut nstr = String::new();
6516 while let Some(&nc) = chars.peek() {
6517 if nc.is_ascii_digit() {
6518 nstr.push(nc);
6519 chars.next();
6520 } else {
6521 break;
6522 }
6523 }
6524 let n: i64 = nstr.parse().unwrap_or(0);
6525 let cutoff = n * unit_secs;
6526 let now = std::time::SystemTime::now()
6527 .duration_since(std::time::UNIX_EPOCH)
6528 .map(|d| d.as_secs() as i64)
6529 .unwrap_or(0);
6530 result.retain(|f| {
6531 let m = match meta_cache.get(f).and_then(|(m, _)| m.as_ref()) {
6532 Some(m) => m,
6533 None => return false,
6534 };
6535 let ts = match qual_kind {
6536 'm' => m.mtime(),
6537 'a' => m.atime(),
6538 'c' => m.ctime(),
6539 _ => 0,
6540 };
6541 let age = now - ts;
6542 let pass = match op {
6543 '+' => age > cutoff,
6544 '-' => age < cutoff,
6545 _ => age >= cutoff && age < cutoff + unit_secs,
6546 };
6547 if negate {
6548 !pass
6549 } else {
6550 pass
6551 }
6552 });
6553 negate = false;
6554 }
6555
6556 // Unknown qualifier - ignore
6557 _ => {}
6558 }
6559 }
6560
6561 // Apply (M) / (T) marking AFTER all filters have run. Direct
6562 // port of zsh/Src/glob.c:355,372 — output emit consults
6563 // gf_markdirs / gf_listtypes set by case 'M' / case 'T'.
6564 if mark_dirs || list_types {
6565 result = result
6566 .into_iter()
6567 .map(|p| {
6568 let meta = match std::fs::symlink_metadata(&p) {
6569 Ok(m) => m,
6570 Err(_) => return p,
6571 };
6572 let ch = crate::glob::file_type(meta.permissions().mode());
6573 if list_types || (mark_dirs && ch == '/') {
6574 format!("{}{}", p, ch)
6575 } else {
6576 p
6577 }
6578 })
6579 .collect();
6580 }
6581
6582 result
6583 }
6584}
6585
6586// =====================================================================
6587// MOVED FROM: src/ported/glob.rs
6588// =====================================================================
6589
6590impl crate::ported::exec::ShellExecutor {
6591 /// Filter file list by glob qualifiers
6592 /// Prefetch file metadata in parallel across the worker pool.
6593 /// Returns a map from path → (metadata, symlink_metadata).
6594 /// Each batch of files is stat'd on a pool thread.
6595 pub(crate) fn prefetch_metadata(
6596 &self,
6597 files: &[String],
6598 ) -> HashMap<String, (Option<std::fs::Metadata>, Option<std::fs::Metadata>)> {
6599 // After fork(), the worker pool's threads don't survive (POSIX:
6600 // only the calling thread persists). Pipeline children would
6601 // submit work that never gets picked up, blocking forever or
6602 // returning empty. Detect via pid mismatch with the original
6603 // main pid; use serial when forked.
6604 // Worker-pool-fork detection: compare current pid with the
6605 // main shell pid cached at handler-install time. After fork()
6606 // the worker pool's threads don't survive (POSIX leaves only
6607 // the calling thread), so pipeline children must take the
6608 // serial path. This is Rust-runtime concern, not a C
6609 // construct, so the check stays here in `exec_shims.rs`.
6610 let in_forked_child = {
6611 static MAIN_PID: AtomicI32 = AtomicI32::new(0);
6612 let mut main = MAIN_PID.load(Ordering::Relaxed);
6613 if main == 0 {
6614 let cur = nix::unistd::getpid().as_raw();
6615 match MAIN_PID.compare_exchange(0, cur, Ordering::Relaxed, Ordering::Relaxed) {
6616 Ok(_) => main = cur,
6617 Err(prev) => main = prev,
6618 }
6619 }
6620 nix::unistd::getpid().as_raw() != main
6621 };
6622 if files.len() < 32 || in_forked_child {
6623 // Small list OR forked child — serial stat is the only
6624 // safe path.
6625 return files
6626 .iter()
6627 .map(|f| {
6628 let meta = std::fs::metadata(f).ok();
6629 let symlink_meta = std::fs::symlink_metadata(f).ok();
6630 (f.clone(), (meta, symlink_meta))
6631 })
6632 .collect();
6633 }
6634
6635 let pool_size = self.worker_pool.size();
6636 let chunk_size = files.len().div_ceil(pool_size);
6637 let (tx, rx) = std::sync::mpsc::channel();
6638
6639 for chunk in files.chunks(chunk_size) {
6640 let tx = tx.clone();
6641 let chunk: Vec<String> = chunk.to_vec();
6642 self.worker_pool.submit(move || {
6643 #[allow(clippy::type_complexity)]
6644 let batch: Vec<(
6645 String,
6646 (Option<std::fs::Metadata>, Option<std::fs::Metadata>),
6647 )> = chunk
6648 .into_iter()
6649 .map(|f| {
6650 let meta = std::fs::metadata(&f).ok();
6651 let symlink_meta = std::fs::symlink_metadata(&f).ok();
6652 (f, (meta, symlink_meta))
6653 })
6654 .collect();
6655 let _ = tx.send(batch);
6656 });
6657 }
6658 drop(tx);
6659
6660 let mut map = HashMap::with_capacity(files.len());
6661 for batch in rx {
6662 for (path, metas) in batch {
6663 map.insert(path, metas);
6664 }
6665 }
6666 map
6667 }
6668 /// Filter files by permission bits — uses prefetched metadata cache
6669 pub(crate) fn filter_by_permission(
6670 &self,
6671 files: Vec<String>,
6672 mode: u32,
6673 negate: bool,
6674 meta_cache: &HashMap<String, (Option<std::fs::Metadata>, Option<std::fs::Metadata>)>,
6675 ) -> Vec<String> {
6676 files
6677 .into_iter()
6678 .filter(|f| {
6679 let has_perm = meta_cache
6680 .get(f)
6681 .and_then(|(m, _)| m.as_ref())
6682 .map(|m| (m.permissions().mode() & mode) != 0)
6683 .unwrap_or(false);
6684 if negate {
6685 !has_perm
6686 } else {
6687 has_perm
6688 }
6689 })
6690 .collect()
6691 }
6692}
6693
6694// =====================================================================
6695// MOVED FROM: src/ported/utils.rs
6696// =====================================================================
6697
6698impl crate::ported::exec::ShellExecutor {
6699 pub(crate) fn copy_dir_recursive(src: &std::path::Path, dest: &std::path::Path) -> std::io::Result<()> {
6700 if !dest.exists() {
6701 std::fs::create_dir_all(dest)?;
6702 }
6703 for entry in std::fs::read_dir(src)? {
6704 let entry = entry?;
6705 let file_type = entry.file_type()?;
6706 let src_path = entry.path();
6707 let dest_path = dest.join(entry.file_name());
6708
6709 if file_type.is_dir() {
6710 Self::copy_dir_recursive(&src_path, &dest_path)?;
6711 } else {
6712 std::fs::copy(&src_path, &dest_path)?;
6713 }
6714 }
6715 Ok(())
6716 }
6717}
6718
6719// =====================================================================
6720// MOVED FROM: src/ported/zle/compcore.rs
6721// =====================================================================
6722
6723impl crate::ported::exec::ShellExecutor {
6724 // zsh compadd - add completion matches
6725 // zsh compset - modify completion prefix/suffix
6726}
6727
6728// =====================================================================
6729// MOVED FROM: src/ported/zle/computil.rs
6730// =====================================================================
6731
6732impl crate::ported::exec::ShellExecutor {
6733}
6734
6735// =====================================================================
6736// MOVED FROM: src/ported/zle/zle_main.rs
6737// =====================================================================
6738
6739impl crate::ported::exec::ShellExecutor {
6740 // `vared` shim — parses the `"AaceghM:m:p:r:i:f:"` BUILTIN spec
6741 // from zle_main.c:2186 into a real `options` struct, then invokes
6742 // the canonical free-fn port at
6743 // crate::ported::zle::zle_main::bin_vared which matches the C
6744 // signature `bin_vared(name, args, ops, func)` exactly.
6745}
6746
6747// =====================================================================
6748// MOVED FROM: src/ported/modules/cap.rs
6749// =====================================================================
6750
6751impl crate::ported::exec::ShellExecutor {
6752 /// `cap` builtin entry. Bridge to `bin_cap()` above.
6753 pub(crate) fn bin_cap(&self, args: &[String]) -> i32 {
6754 let ops = crate::ported::zsh_h::options { ind: [0u8; crate::ported::zsh_h::MAX_OPS], args: Vec::new(), argscount: 0, argsalloc: 0 };
6755 bin_cap("cap", args, &ops, 0)
6756 }
6757
6758 /// `getcap` builtin entry. Bridge to `bin_getcap()` above.
6759 pub(crate) fn bin_getcap(&self, args: &[String]) -> i32 {
6760 let ops = crate::ported::zsh_h::options { ind: [0u8; crate::ported::zsh_h::MAX_OPS], args: Vec::new(), argscount: 0, argsalloc: 0 };
6761 bin_getcap("getcap", args, &ops, 0)
6762 }
6763
6764 /// `setcap` builtin entry. Bridge to `bin_setcap()` above.
6765 pub(crate) fn bin_setcap(&self, args: &[String]) -> i32 {
6766 let ops = crate::ported::zsh_h::options { ind: [0u8; crate::ported::zsh_h::MAX_OPS], args: Vec::new(), argscount: 0, argsalloc: 0 };
6767 bin_setcap("setcap", args, &ops, 0)
6768 }
6769}
6770
6771// =====================================================================
6772// MOVED FROM: src/ported/modules/zpty.rs
6773// =====================================================================
6774
6775impl crate::ported::exec::ShellExecutor {
6776 // `zpty` builtin — delegates to canonical port at
6777 // `src/ported/modules/zpty.rs:367` (`bin_zpty()` from
6778 // `Src/Modules/zpty.c`). The named-pty table lives on
6779 // `ShellExecutor` so `zpty -w NAME ...` and `zpty -r NAME` can
6780 // reach a session started by an earlier `zpty NAME ...` call.
6781}
6782
6783// =====================================================================
6784// MOVED FROM: src/ported/modules/terminfo.rs
6785// =====================================================================
6786
6787impl crate::ported::exec::ShellExecutor {
6788 /// `echoti` shim — delegates to the canonical free-fn port at
6789 /// `crate::ported::modules::terminfo::bin_echoti` (matching C
6790 /// `bin_echoti(nam, args, ops, func)` per terminfo.c:64). The
6791 /// previous shim mapped terminfo→termcap and routed through
6792 /// bin_echotc; the canonical port now calls libcurses tigetstr/
6793 /// tigetnum/tigetflag + tparm directly per the C source.
6794 pub(crate) fn bin_echoti(&mut self, args: &[String]) -> i32 {
6795 let ops = options { ind: [0u8; MAX_OPS], args: Vec::new(),
6796 argscount: 0, argsalloc: 0 };
6797 crate::ported::modules::terminfo::bin_echoti("echoti", args, &ops, 0)
6798 }
6799}
6800
6801// =====================================================================
6802// MOVED FROM: src/ported/modules/watch.rs
6803// =====================================================================
6804
6805impl crate::ported::exec::ShellExecutor {
6806 // `log` builtin — delegates to canonical port at
6807 // `src/ported/modules/watch.rs` (`bin_log()` from
6808 // `Src/Modules/watch.c`). The watch state lives in
6809 // `thread_local!`s in the canonical port (mirroring C's
6810 // `Src/Modules/watch.c:150-156` file-statics) so login/logout
6811 // edge detection survives across calls without a struct on
6812 // `ShellExecutor`.
6813}
6814
6815// =====================================================================
6816// MOVED FROM: src/ported/modules/pcre.rs
6817// =====================================================================
6818
6819impl crate::ported::exec::ShellExecutor {
6820 // `pcre_compile` builtin — delegates to canonical port at
6821 // `src/ported/modules/pcre.rs` (`bin_pcre_compile()` from
6822 // `Src/Modules/pcre.c:70`). Builds an `options` struct from the
6823 // `-a/-i/-m/-s/-x` flags so the canonical port reads them via
6824 // `OPT_ISSET` exactly like C does.
6825 // `pcre_match` builtin — delegates to canonical port at
6826 // `src/ported/modules/pcre.rs` (`bin_pcre_match()` from
6827 // `Src/Modules/pcre.c:328`). Builds the `options` struct from
6828 // `-v`/`-a` argv (matching C's OPT_ARG reads) and writes the
6829 // returned capture data into the executor's variable/array
6830 // tables — that side-effect cannot live in the canonical port
6831 // because it doesn't own those tables.
6832 // pcre_study - optimize compiled PCRE (no-op in Rust regex)
6833}
6834
6835// =====================================================================
6836// MOVED FROM: src/ported/modules/tcp.rs
6837// =====================================================================
6838
6839impl crate::ported::exec::ShellExecutor {
6840}
6841
6842// =====================================================================
6843// MOVED FROM: src/ported/modules/db_gdbm.rs
6844// =====================================================================
6845
6846impl crate::ported::exec::ShellExecutor {
6847 // Tie a parameter to a GDBM database
6848 // Usage: ztie -d db/gdbm -f /path/to/db.gdbm [-r] PARAM_NAME
6849 // Untie a parameter from its GDBM database
6850 // Usage: zuntie [-u] PARAM_NAME...
6851 // Get the path of a tied GDBM database
6852 // Usage: zgdbmpath PARAM_NAME
6853 // Sets $REPLY to the path
6854}
6855
6856// =====================================================================
6857// MOVED FROM: src/ported/modules/termcap.rs
6858// =====================================================================
6859
6860impl crate::ported::exec::ShellExecutor {
6861 // `echotc` builtin shim — adapts `&[String]` argv to
6862 // `bin_echotc` over a `[bool; 256]` ops bitmask.
6863}
6864
6865// (FAKE `magic_assoc_keys` deleted per user instruction. Callers
6866// in fusevm_bridge.rs route through the canonical scanpm* dispatch
6867// in src/ported/modules/parameter.rs — each magic-assoc table has
6868// its own scanpm* fn that walks the live canonical hashtable
6869// instead of a unified hard-coded switch.)
6870
6871// =====================================================================
6872// Magic-assoc key dispatch — fusevm-bridge aggregator that fans a
6873// magic-assoc table NAME out into the right scanpm* port from
6874// src/ported/modules/parameter.rs.
6875// =====================================================================
6876//
6877// The C source (Src/Modules/parameter.c) doesn't have a single
6878// "scan-by-name" function — each magic-assoc registers its own
6879// per-table getfn/scanfn pointer in the paramdef[] table at
6880// c:825-..., and zsh's paramtab dispatch reaches them through that
6881// table. fusevm_bridge's magic_assoc_lookup needs name → keys
6882// lookup at the call site; that aggregator is THIS Rust-only
6883// convenience, parked outside src/ported/ per the rule that
6884// src/ported/ holds direct C ports only.
6885
6886use std::cell::RefCell;
6887thread_local! {
6888 static SCAN_KEYS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
6889}
6890
6891pub fn scan_magic_assoc_keys(name: &str) -> Option<Vec<String>> {
6892 fn collect<F: FnOnce(Option<crate::ported::zsh_h::ScanFunc>, i32)>(scan: F)
6893 -> Vec<String>
6894 {
6895 SCAN_KEYS.with(|k| k.borrow_mut().clear());
6896 fn cb(node: &crate::ported::zsh_h::HashNode, _flags: i32) {
6897 SCAN_KEYS.with(|k| k.borrow_mut().push(node.nam.clone()));
6898 }
6899 scan(Some(cb), 0);
6900 SCAN_KEYS.with(|k| k.borrow().clone())
6901 }
6902 let null_ht = std::ptr::null_mut();
6903 match name {
6904 "commands" => Some(collect(|f, fl| scanpmcommands(null_ht, f, fl))),
6905 "options" => Some(collect(|f, fl| scanpmoptions(null_ht, f, fl))),
6906 "builtins" => Some(collect(|f, fl| scanpmbuiltins(null_ht, f, fl))),
6907 "dis_builtins" => Some(collect(|f, fl| scanpmdisbuiltins(null_ht, f, fl))),
6908 "functions" => Some(collect(|f, fl| scanpmfunctions(null_ht, f, fl))),
6909 "dis_functions"=> Some(collect(|f, fl| scanpmdisfunctions(null_ht, f, fl))),
6910 "aliases" => Some(collect(|f, fl| scanpmraliases(null_ht, f, fl))),
6911 "dis_aliases" => Some(collect(|f, fl| scanpmdisraliases(null_ht, f, fl))),
6912 "galiases" => Some(collect(|f, fl| scanpmgaliases(null_ht, f, fl))),
6913 "dis_galiases" => Some(collect(|f, fl| scanpmdisgaliases(null_ht, f, fl))),
6914 "saliases" => Some(collect(|f, fl| scanpmsaliases(null_ht, f, fl))),
6915 "dis_saliases" => Some(collect(|f, fl| scanpmdissaliases(null_ht, f, fl))),
6916 "reswords" | "dis_reswords" |
6917 "modules" | "history" | "historywords" |
6918 "jobtexts" | "jobstates" | "jobdirs" |
6919 "nameddirs" | "userdirs" | "usergroups" |
6920 "parameters" | "errnos" | "sysparams" | "dirstack"
6921 => Some(Vec::new()),
6922 // `mapfile` — zsh/mapfile module's magic assoc. Keys are
6923 // discoverable via `scanpmmapfile` (Src/Modules/mapfile.c:241)
6924 // which walks `.` but uses an empty value list per the
6925 // comment "grotesquely wasteful to read every file into
6926 // memory." Routing through here lets `${mapfile[$path]}`
6927 // hit get_special_array_value's "mapfile" arm and call
6928 // get_contents() directly.
6929 "mapfile" => Some(
6930 crate::modules::mapfile::scanpmmapfile()
6931 .into_iter()
6932 .map(|(k, _v)| k)
6933 .collect(),
6934 ),
6935 _ => None,
6936 }
6937}
6938
6939// =====================================================================
6940// SubstState bridge — DELETED per user directive ("delete SubstState").
6941//
6942// `subst_state_from_executor` and `subst_state_commit_to_executor`
6943// were Rust-only plumbing that snapshotted executor state into a
6944// `SubstState` struct, then mutated it back out. Both the struct
6945// and the bridge are gone. subst.rs now reads/writes canonical
6946// globals (`utils::errflag`, `hist::hsubl/hsubr/hsubpatopt`,
6947// `options::opt_state_get/set`) and executor state directly via
6948// `fusevm_bridge::try_with_executor`. The single piece of state
6949// the bridge guarded — bumping `exec.last_status` on errflag — now
6950// lives at the per-call site in fusevm_bridge.rs subst_port arms.
6951// =====================================================================
6952
6953impl crate::ported::exec::ShellExecutor {
6954 pub fn enter_posix_mode(&mut self) {
6955 self.posix_mode = true;
6956 self.plugin_cache = None;
6957 self.compsys_cache = None;
6958 self.compinit_pending = None;
6959 self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
6960 let ops = crate::ported::zsh_h::options {
6961 ind: [0u8; crate::ported::zsh_h::MAX_OPS],
6962 args: Vec::new(), argscount: 0, argsalloc: 0,
6963 };
6964 crate::ported::builtin::bin_emulate("emulate",
6965 &["sh".to_string(), "-R".to_string()], &ops, 0);
6966 }
6967 pub fn enter_ksh_mode(&mut self) {
6968 self.plugin_cache = None;
6969 self.compsys_cache = None;
6970 self.compinit_pending = None;
6971 self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
6972 let ops = crate::ported::zsh_h::options {
6973 ind: [0u8; crate::ported::zsh_h::MAX_OPS],
6974 args: Vec::new(), argscount: 0, argsalloc: 0,
6975 };
6976 crate::ported::builtin::bin_emulate("emulate",
6977 &["ksh".to_string(), "-R".to_string()], &ops, 0);
6978 }
6979}
6980
6981// ─────────────────────────────────────────────────────────
6982// Static glob match — module-level free fn (no executor state).
6983// Extracted from impl ShellExecutor per the FAKE DUP audit.
6984// ─────────────────────────────────────────────────────────
6985/// Static glob match — same logic as glob_match but callable without &self,
6986/// needed for Rayon parallel iterators that can't capture &self.
6987///
6988/// plus extendedglob preprocessing (`^pat` negation, `(a|b)~(x)`
6989/// exclusion). The preprocessing is shell-canonical but lives here
6990/// in exec.rs instead of inside patcompile or a wrapper. To dissolve:
6991/// move the extendedglob `^` / `~` arm into patcompile (or a thin
6992/// canonical pre-walker) so callers use patmatch directly.
6993pub fn glob_match_static(s: &str, pattern: &str) -> bool {
6994 // Extendedglob `^pat` negation: when extendedglob is on AND
6995 // the pattern starts with a literal `^`, strip it and invert
6996 // the match of the remainder. Already done in
6997 // `extendedglob_match` for the param-filter path; do it here
6998 // too so `[[ str = ^pat ]]` works via the cond `=` matcher.
6999 let extendedglob_on =
7000 with_executor(|e| crate::ported::options::opt_state_get("extendedglob").unwrap_or(false));
7001 if extendedglob_on {
7002 if let Some(rest) = pattern.strip_prefix('^') {
7003 return !crate::exec::glob_match_static(s, rest);
7004 }
7005 // Extendedglob `~` exclusion: `pat1~pat2` matches strings
7006 // matching `pat1` AND NOT matching `pat2`. Direct port of
7007 // zsh's pattern.c P_EXCLUDE handling (line 155 onward) for
7008 // the top-level case — the canonical implementation also
7009 // handles nested exclusions (`(a~b)c`) but the top-level
7010 // form is what `*.txt~README*` and similar idioms produce.
7011 // Walk the pattern looking for a `~` that's NOT inside
7012 // `[...]` or `(...)` so nested specials stay literal.
7013 if let Some(idx) = find_top_level_tilde(pattern) {
7014 let lhs = &pattern[..idx];
7015 let rhs = &pattern[idx + 1..];
7016 return crate::exec::glob_match_static(s, lhs)
7017 && !crate::exec::glob_match_static(s, rhs);
7018 }
7019 }
7020
7021 // ksh-style negation `!(p)` (gated on `setopt kshglob`): when
7022 // the entire pattern is `!(<body>)`, match anything that does
7023 // NOT match `<body>`. This handles the standalone case (the
7024 // overwhelmingly common form); embedded `!()` inside a larger
7025 // pattern still falls through and is left literal — full
7026 // zsh-style negation needs lookahead which `regex` lacks.
7027 let kshglob_on = with_executor(|e| crate::ported::options::opt_state_get("kshglob").unwrap_or(false));
7028 if kshglob_on {
7029 if let Some(body) = pattern.strip_prefix("!(").and_then(|r| r.strip_suffix(')')) {
7030 // Don't recurse if body itself contains an unmatched
7031 // `(` that would change the meaning.
7032 let mut depth = 0;
7033 let mut balanced = true;
7034 for c in body.chars() {
7035 match c {
7036 '(' => depth += 1,
7037 ')' => {
7038 if depth == 0 {
7039 balanced = false;
7040 break;
7041 }
7042 depth -= 1;
7043 }
7044 _ => {}
7045 }
7046 }
7047 if balanced && depth == 0 {
7048 return !crate::exec::glob_match_static(s, body);
7049 }
7050 }
7051 }
7052
7053 // Inline pattern flags `(#i)` / `(#I)` / `(#l)` / `(#a<n>)` per
7054 // zshexpn(1) "Globbing Flags". They prefix a pattern and modify
7055 // matching semantics for the rest.
7056 // (#i) — case insensitive
7057 // (#I) — case sensitive (turn (#i) back off)
7058 // (#l) — lowercase pattern char matches both cases in input;
7059 // uppercase pattern char is exact-match
7060 // (#a<n>) — approximate match: up to <n> errors (Levenshtein
7061 // distance, insert/delete/substitute)
7062 // Inline (#i)/(#l)/(#aN) flag pre-parse — direct call to
7063 // patgetglobflags + bit-mask extraction, matching how C's
7064 // patcompile inlines the same parsing at pattern.c:1066+.
7065 let (pattern, case_insensitive, l_flag, approx_n) =
7066 if let Some((bits, _assert, consumed)) =
7067 crate::ported::pattern::patgetglobflags(pattern)
7068 {
7069 let ci = (bits & crate::ported::zsh_h::GF_IGNCASE) != 0;
7070 let l = (bits & crate::ported::zsh_h::GF_LCMATCHUC) != 0;
7071 let errs = bits & 0xff;
7072 let approx = if errs != 0 { Some(errs as u32) } else { None };
7073 (pattern[consumed..].to_string(), ci, l, approx)
7074 } else {
7075 (pattern.to_string(), false, false, None)
7076 };
7077
7078 if let Some(n) = approx_n {
7079 // Inline (#aN) approximate-match — direct port of the
7080 // Levenshtein-distance check inside patmatch (Src/pattern.c)
7081 // when PAT_APPROX is set. m/k bound check skips early when
7082 // the strings differ in length by more than the budget;
7083 // otherwise standard 2-row DP table.
7084 let s_chars: Vec<char> = s.chars().collect();
7085 let p_chars: Vec<char> = pattern.chars().collect();
7086 let m = s_chars.len();
7087 let k = p_chars.len();
7088 if m.abs_diff(k) as u32 > n {
7089 return false;
7090 }
7091 let mut prev: Vec<usize> = (0..=k).collect();
7092 let mut curr: Vec<usize> = vec![0; k + 1];
7093 for i in 1..=m {
7094 curr[0] = i;
7095 for j in 1..=k {
7096 let cost = if s_chars[i - 1] == p_chars[j - 1] { 0 } else { 1 };
7097 curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
7098 }
7099 std::mem::swap(&mut prev, &mut curr);
7100 }
7101 return prev[k] as u32 <= n;
7102 }
7103
7104 // Build the regex. For (#l) we need to inflate lowercase chars
7105 // to character classes that match either case. Also detect
7106 // zsh's numeric-range glob `<a-b>` (or `<->` for any number,
7107 // `<a->` / `<-b>` for one-sided ranges) — translate to a
7108 // capture group and remember the bounds for a post-match check.
7109 let mut regex_pattern = String::from("^");
7110 // Numeric ranges paired with the regex capture-group index they
7111 // correspond to. Required because user-written `(...)` groups
7112 // in the pattern (esp. alternation `(a|b)`) shift capture
7113 // indices, so we can't assume each `<N-M>` is at numeric_ranges
7114 // index + 1. Direct port of the bookkeeping zsh's pattern.c
7115 // does via `pat_captures` — each numeric atom remembers its
7116 // own group offset. Without this, `[[ 5.9 == (5.<1->*|<6->.*) ]]`
7117 // applied the lo/hi check against the OUTER alternation's
7118 // capture (the literal "5.9") and parse-as-int failed.
7119 let mut numeric_ranges: Vec<(usize, Option<i64>, Option<i64>)> = Vec::new();
7120 // Track the capture-group index. Increments on every `(` that
7121 // OPENS a new group in the emitted regex. Starts at 0 because
7122 // the outer `^...$` anchors don't add a group.
7123 let mut capture_group_count: usize = 0;
7124 let mut chars = pattern.chars().peekable();
7125 // Helper: after emitting any atom, check for zsh extendedglob
7126 // postfix `#` (zero-or-more) / `##` (one-or-more) and append
7127 // the equivalent regex quantifier. Direct port of zsh's
7128 // pattern.c (`Pound` / `POUND2` cases in `patcompswitch`).
7129 // Only fires when extendedglob is enabled.
7130 let consume_extglob_postfix =
7131 |chars: &mut std::iter::Peekable<std::str::Chars>| -> Option<&'static str> {
7132 if !extendedglob_on {
7133 return None;
7134 }
7135 if chars.peek() != Some(&'#') {
7136 return None;
7137 }
7138 chars.next();
7139 if chars.peek() == Some(&'#') {
7140 chars.next();
7141 Some("+")
7142 } else {
7143 Some("*")
7144 }
7145 };
7146 while let Some(c) = chars.next() {
7147 match c {
7148 // ksh-style extglob: ?(p) *(p) +(p) @(p) — translate to
7149 // (?:p)? (?:p)* (?:p)+ (?:p) respectively. Gated on
7150 // the `kshglob` option (zsh's default is off). The
7151 // !(p) (negative) form needs lookahead which the
7152 // `regex` crate doesn't support; left literal.
7153 '?' | '*' | '+' | '@'
7154 if chars.peek() == Some(&'(')
7155 && with_executor(|e| {
7156 crate::ported::options::opt_state_get("kshglob").unwrap_or(false)
7157 }) =>
7158 {
7159 let op = c;
7160 chars.next(); // consume '('
7161 // Capture body until matching ')'. Track depth so
7162 // nested parens work.
7163 let mut depth = 1;
7164 let mut body = String::new();
7165 while let Some(&pc) = chars.peek() {
7166 chars.next();
7167 if pc == '(' {
7168 depth += 1;
7169 body.push(pc);
7170 } else if pc == ')' {
7171 depth -= 1;
7172 if depth == 0 {
7173 break;
7174 }
7175 body.push(pc);
7176 } else {
7177 body.push(pc);
7178 }
7179 }
7180 // Inline ksh-extglob body -> regex translator.
7181 // Direct port of the tiny per-char dispatch zsh's
7182 // pattern.c does inside its extglob handler — no
7183 // anchors, no (#flags), just glob -> regex chars.
7184 let body_re = {
7185 let mut out = String::new();
7186 let mut chars = body.chars().peekable();
7187 while let Some(c) = chars.next() {
7188 match c {
7189 '|' => out.push('|'),
7190 '*' => out.push_str(".*"),
7191 '?' => out.push('.'),
7192 '[' => {
7193 out.push('[');
7194 for cc in chars.by_ref() {
7195 if cc == ']' {
7196 out.push(']');
7197 break;
7198 }
7199 out.push(cc);
7200 }
7201 }
7202 '.' | '+' | '^' | '$' | '\\' | '{' | '}' | '(' | ')' => {
7203 out.push('\\');
7204 out.push(c);
7205 }
7206 _ => out.push(c),
7207 }
7208 }
7209 out
7210 };
7211 let suffix = match op {
7212 '?' => "?",
7213 '*' => "*",
7214 '+' => "+",
7215 '@' => "",
7216 _ => "",
7217 };
7218 regex_pattern.push_str(&format!("(?:{}){}", body_re, suffix));
7219 }
7220 '*' => regex_pattern.push_str(".*"),
7221 '?' => {
7222 regex_pattern.push('.');
7223 if let Some(q) = consume_extglob_postfix(&mut chars) {
7224 regex_pattern.push_str(q);
7225 }
7226 }
7227 '<' => {
7228 // Try to parse `<lo-hi>`. If the form doesn't
7229 // match, fall back to literal `<`. Direct port of
7230 // zsh's numeric-range glob handler — speculative
7231 // scan for the closing `>`, split on `-`, parse
7232 // optional bounds. Matches `<5-10>`, `<5->`,
7233 // `<-10>`, `<->`.
7234 let parsed: Option<(Option<i64>, Option<i64>, usize)> = (|| {
7235 let mut buf = String::new();
7236 let peek_iter = chars.clone();
7237 for c in peek_iter {
7238 buf.push(c);
7239 if c == '>' { break; }
7240 if buf.len() > 64 { return None; }
7241 }
7242 if !buf.ends_with('>') {
7243 return None;
7244 }
7245 let inner = &buf[..buf.len() - 1];
7246 let (lo_str, hi_str) = inner.split_once('-')?;
7247 let lo: Option<i64> = if lo_str.is_empty() {
7248 None
7249 } else {
7250 Some(lo_str.parse().ok()?)
7251 };
7252 let hi: Option<i64> = if hi_str.is_empty() {
7253 None
7254 } else {
7255 Some(hi_str.parse().ok()?)
7256 };
7257 let n = buf.chars().count();
7258 for _ in 0..n { chars.next(); }
7259 Some((lo, hi, n))
7260 })();
7261 if let Some((lo, hi, consumed)) = parsed {
7262 regex_pattern.push_str("(\\d+)");
7263 capture_group_count += 1;
7264 numeric_ranges.push((capture_group_count, lo, hi));
7265 let _ = consumed;
7266 } else {
7267 regex_pattern.push('<');
7268 }
7269 }
7270 '[' => {
7271 // Direct port of zsh's character-class compile
7272 // (pattern.c, see `patcompcls` and the `[`
7273 // handling in `patcompswitch`):
7274 // - `[!...]` and `[^...]` both negate (POSIX +
7275 // zsh both accept; only `^` is canonical
7276 // regex). Translate `!` -> `^` so the regex
7277 // crate sees the right form. Was being
7278 // copied verbatim, so `[!a]` matched `!` or
7279 // `a` instead of "anything but a".
7280 // - POSIX character classes `[:alpha:]` /
7281 // `[:digit:]` etc. inside `[...]` already
7282 // pass through the regex crate, but the
7283 // trailing `]` of the class would be misread
7284 // as the closing of the outer bracket. Walk
7285 // past `[:NAME:]` as a unit so the next `]`
7286 // after the class isn't taken as the close.
7287 // - Backslash-escaped `]` (`[\\]]`) keeps the
7288 // `]` as a literal class member.
7289 regex_pattern.push('[');
7290 let mut first = true;
7291 while let Some(cc) = chars.next() {
7292 if first && cc == '!' {
7293 regex_pattern.push('^');
7294 first = false;
7295 continue;
7296 }
7297 first = false;
7298 if cc == ']' {
7299 regex_pattern.push(']');
7300 break;
7301 }
7302 if cc == '\\' {
7303 // Pass escape + next char through.
7304 regex_pattern.push('\\');
7305 if let Some(nx) = chars.next() {
7306 regex_pattern.push(nx);
7307 }
7308 continue;
7309 }
7310 if cc == '[' && chars.peek() == Some(&':') {
7311 // POSIX class `[:NAME:]`. Read until
7312 // `:]` then push the class verbatim.
7313 regex_pattern.push('[');
7314 let mut prev_colon = false;
7315 for ic in chars.by_ref() {
7316 regex_pattern.push(ic);
7317 if prev_colon && ic == ']' {
7318 break;
7319 }
7320 prev_colon = ic == ':';
7321 }
7322 continue;
7323 }
7324 regex_pattern.push(cc);
7325 }
7326 // After a closed `[...]`, the bracket is a single
7327 // regex atom — apply extendedglob `#`/`##`
7328 // postfix as `*`/`+` directly.
7329 if let Some(q) = consume_extglob_postfix(&mut chars) {
7330 regex_pattern.push_str(q);
7331 }
7332 }
7333 '(' => {
7334 // `(#cN)` and `(#cN,M)` post-subpattern repetition
7335 // qualifiers: the previous element gets a `{N}` or
7336 // `{N,M}` regex quantifier. Detect by peeking for
7337 // `#c` after the opening `(`.
7338 let peek_iter = chars.clone();
7339 let mut probe: Vec<char> = Vec::new();
7340 let p = peek_iter;
7341 for pc in p {
7342 probe.push(pc);
7343 if pc == ')' || probe.len() > 32 {
7344 break;
7345 }
7346 }
7347 let probe_str: String = probe.iter().collect();
7348 if probe_str.starts_with("#c") && probe_str.ends_with(')') {
7349 let body = &probe_str[2..probe_str.len() - 1];
7350 let quant = if let Some((lo, hi)) = body.split_once(',') {
7351 format!("{{{},{}}}", lo, hi)
7352 } else {
7353 format!("{{{}}}", body)
7354 };
7355 regex_pattern.push_str(&quant);
7356 // Advance the real iterator past the consumed chars.
7357 for _ in 0..probe.len() {
7358 chars.next();
7359 }
7360 } else if probe_str == "#e)" {
7361 // `(#e)` — match end-of-string anchor. Direct
7362 // port of zsh's pattern.c P_EOL token (zsh's
7363 // "globbing flag" `(#e)` per zshexpn(1)).
7364 // Emits regex `$` to anchor the match at the
7365 // end of the input. Used by zinit's
7366 // `(#b)((*)\\(#e)|(*))` to detect a trailing
7367 // `\` in each element.
7368 regex_pattern.push('$');
7369 for _ in 0..probe.len() {
7370 chars.next();
7371 }
7372 } else if probe_str == "#s)" {
7373 // `(#s)` — match start-of-string anchor.
7374 // zshexpn(1): "matches at the start of the
7375 // test string". Emits regex `^`.
7376 regex_pattern.push('^');
7377 for _ in 0..probe.len() {
7378 chars.next();
7379 }
7380 } else {
7381 regex_pattern.push('(');
7382 capture_group_count += 1;
7383 }
7384 }
7385 ')' => {
7386 regex_pattern.push(')');
7387 // Closed group is an atom — extendedglob `#`/`##`
7388 // postfix applies to the whole group.
7389 if let Some(q) = consume_extglob_postfix(&mut chars) {
7390 regex_pattern.push_str(q);
7391 }
7392 }
7393 '|' => regex_pattern.push('|'),
7394 '\\' => {
7395 // Special-case: `\(#e)` / `\(#s)` — literal
7396 // backslash followed by extendedglob end/start
7397 // anchor. Emit `\\$` / `\\^` so the pattern matches
7398 // a literal trailing/leading `\`. Without this the
7399 // `(` of `(#e)` got consumed as the escaped char,
7400 // dropping the anchor entirely. Direct port of
7401 // pattern.c P_EOL/P_BOL recognition after a `\`.
7402 // Only fires under extendedglob — without the
7403 // option, `(#e)` is not a token at all.
7404 if extendedglob_on {
7405 let mut peek = chars.clone();
7406 let p1 = peek.next();
7407 let p2 = peek.next();
7408 let p3 = peek.next();
7409 let p4 = peek.next();
7410 if p1 == Some('(')
7411 && p2 == Some('#')
7412 && (p3 == Some('e') || p3 == Some('s'))
7413 && p4 == Some(')')
7414 {
7415 regex_pattern.push_str("\\\\");
7416 regex_pattern.push(if p3 == Some('e') { '$' } else { '^' });
7417 chars.next(); chars.next(); chars.next(); chars.next();
7418 continue;
7419 }
7420 }
7421 // Backslash escapes the next char — treat literally.
7422 if let Some(next) = chars.next() {
7423 if matches!(
7424 next,
7425 '.' | '+'
7426 | '^'
7427 | '$'
7428 | '\\'
7429 | '{'
7430 | '}'
7431 | '*'
7432 | '?'
7433 | '('
7434 | ')'
7435 | '|'
7436 | '['
7437 | ']'
7438 ) {
7439 regex_pattern.push('\\');
7440 }
7441 regex_pattern.push(next);
7442 } else {
7443 regex_pattern.push_str("\\\\");
7444 }
7445 }
7446 '.' | '+' | '^' | '$' | '{' | '}' => {
7447 regex_pattern.push('\\');
7448 regex_pattern.push(c);
7449 }
7450 _ => {
7451 if l_flag && c.is_ascii_lowercase() {
7452 regex_pattern.push('[');
7453 regex_pattern.push(c);
7454 regex_pattern.push(c.to_ascii_uppercase());
7455 regex_pattern.push(']');
7456 } else {
7457 regex_pattern.push(c);
7458 }
7459 // After a literal/(#l)-class atom, extendedglob
7460 // `#`/`##` postfix maps to regex `*`/`+` and
7461 // binds to that single atom. Same as zsh's
7462 // pattern.c Pound/POUND2 handling on the atom
7463 // just compiled.
7464 if let Some(q) = consume_extglob_postfix(&mut chars) {
7465 regex_pattern.push_str(q);
7466 }
7467 }
7468 }
7469 }
7470 regex_pattern.push('$');
7471 let final_pattern = if case_insensitive {
7472 format!("(?i){}", regex_pattern)
7473 } else {
7474 regex_pattern
7475 };
7476 if !numeric_ranges.is_empty() {
7477 // Need captures + per-group numeric range checks.
7478 let re = match regex::Regex::new(&final_pattern) {
7479 Ok(re) => re,
7480 Err(_) => return false,
7481 };
7482 let caps = match re.captures(s) {
7483 Some(c) => c,
7484 None => return false,
7485 };
7486 for (group_idx, lo, hi) in numeric_ranges.iter() {
7487 // A numeric-range `<N-M>` inside an alternation branch
7488 // that didn't fire (e.g. branch B of `(A|B)` when A
7489 // matched) won't have a populated capture. Skip the
7490 // bounds check for those — the alternation's match
7491 // already commits to the branch that DID fire.
7492 let cap_str = match caps.get(*group_idx) {
7493 Some(m) => m.as_str(),
7494 None => continue,
7495 };
7496 let n: i64 = match cap_str.parse() {
7497 Ok(n) => n,
7498 Err(_) => return false,
7499 };
7500 if let Some(l) = lo {
7501 if n < *l {
7502 return false;
7503 }
7504 }
7505 if let Some(h) = hi {
7506 if n > *h {
7507 return false;
7508 }
7509 }
7510 }
7511 return true;
7512 }
7513 regex::Regex::new(&final_pattern)
7514 .map(|re| re.is_match(s))
7515 .unwrap_or(false)
7516}
7517
7518
7519// =============================================================================
7520// Direct ports from Src/exec.c — fns whose Rust home was misplaced in
7521// builtin.rs because they're called from `bin_autoload` but actually
7522// defined in Src/exec.c.
7523// =============================================================================
7524
7525/// Direct port of `Shfunc loadautofn(Shfunc shf, int ks, int test_only,
7526/// int ignore_loaddir)` from `Src/exec.c:5050`. Walks `$fpath` for a
7527/// file named `shf->node.nam`, reads it, installs the text body on
7528/// the corresponding `shfunctab` entry, and clears `PM_UNDEFINED`.
7529///
7530/// C body (abridged):
7531/// 1. `name = shf->node.nam`
7532/// 2. `getfpfunc(name, &dir_path, NULL, 0)` → resolved file path
7533/// 3. If !test_only && file found: parse → store eprog on
7534/// `shf->funcdef`; clear PM_UNDEFINED; set `shf->filename`.
7535/// 4. Returns shf on success, NULL on failure.
7536///
7537/// Rust port: returns 0 = success, 1 = failure (matches the
7538/// existing call-site convention in `bin_functions -c`). Stores
7539/// raw file text on `ShFunc.body` (the Rust-side ShFunc in
7540/// `hashtable.rs:362`); the parser pass that converts text →
7541/// Eprog runs lazily at first call site.
7542/// Port of `loadautofn(Shfunc shf, int fksh, int autol, int current_fpath)` from `Src/exec.c:5682`.
7543pub fn loadautofn(shf: *mut crate::ported::zsh_h::shfunc, // c:5682 (Src/exec.c)
7544 _ks: i32, test_only: i32, _ignore_loaddir: i32) -> i32 {
7545 if shf.is_null() {
7546 return 1;
7547 }
7548 // c:5054 — `name = shf->node.nam`.
7549 let name = unsafe { (*shf).node.nam.clone() };
7550 // c:5070 — `path = getfpfunc(name, &dir_path, NULL, 0)`.
7551 let mut dir_path: Option<String> = None;
7552 let path = match getfpfunc(&name, &mut dir_path, None, 0) {
7553 Some(p) => p,
7554 None => return 1, // c:5074 not found
7555 };
7556 if test_only != 0 { // c:5096
7557 return 0; // test passes — file exists
7558 }
7559 // c:5100-5140 — read the file. C uses zopen + read + parse_string +
7560 // execsave; Rust port stores raw text on the ShFunc and defers
7561 // parse-to-Eprog until the first call.
7562 let body = match std::fs::read_to_string(&path) {
7563 Ok(t) => t,
7564 Err(_) => return 1,
7565 };
7566 // c:5142 — `shf->filename = ztrdup(dir_path)`.
7567 unsafe {
7568 (*shf).filename = dir_path.clone().or(Some(path.clone()));
7569 }
7570 // c:5148 — `shf->node.flags &= ~PM_UNDEFINED`.
7571 unsafe {
7572 (*shf).node.flags &= !(PM_UNDEFINED as i32);
7573 }
7574 // Sync the body string into the Rust-side ShFunc table so the
7575 // lazy-parse path can find it later.
7576 if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
7577 if let Some(existing) = tab.get_mut(&name) {
7578 existing.body = Some(body);
7579 existing.filename = dir_path;
7580 } else {
7581 tab.add(crate::ported::hashtable::ShFunc {
7582 node: crate::ported::zsh_h::hashnode {
7583 next: None,
7584 nam: name.clone(),
7585 flags: 0,
7586 },
7587 filename: dir_path,
7588 lineno: 0,
7589 funcdef: None,
7590 redir: None,
7591 sticky: None,
7592 body: Some(body),
7593 });
7594 }
7595 }
7596 0
7597}
7598
7599/// Port of `getfpfunc(char *s, int *ksh, char **fdir, char **alt_path, int test_only)` from Src/exec.c:5260. Walks `$fpath` (or the
7600/// supplied `spec_path` slice) for a file named `name` and writes the
7601/// resolved directory through `*dir_path_out` (matching the C `char **dir_path`).
7602/// Returns `Some(file_contents_path)` on success, `None` when not found.
7603pub fn getfpfunc(name: &str, dir_path_out: &mut Option<String>, // c:5260 (Src/exec.c)
7604 spec_path: Option<&[String]>, _all_loaded: i32) -> Option<String> {
7605 let dirs: Vec<String> = match spec_path {
7606 Some(s) => s.to_vec(),
7607 None => std::env::var("FPATH").or_else(|_| std::env::var("fpath"))
7608 .ok().map(|v| v.split(':').map(String::from).collect())
7609 .unwrap_or_default(),
7610 };
7611 for dir in &dirs {
7612 if dir.is_empty() { continue; }
7613 let path = format!("{}/{}", dir, name);
7614 if std::path::Path::new(&path).exists() {
7615 *dir_path_out = Some(dir.clone());
7616 return Some(path);
7617 }
7618 }
7619 None
7620}