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